:3
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using Mocha2023.Classes.DBs;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = false)]
|
||||
public sealed class ApiProtectionAttribute : Attribute, IAsyncResourceFilter, IOrderedFilter
|
||||
{
|
||||
private sealed class TokenBucket
|
||||
{
|
||||
public readonly object Sync = new();
|
||||
public double Tokens;
|
||||
public DateTime LastRefillUtc;
|
||||
public DateTime LastSeenUtc;
|
||||
|
||||
public TokenBucket(double capacity, DateTime now)
|
||||
{
|
||||
Tokens = capacity;
|
||||
LastRefillUtc = now;
|
||||
LastSeenUtc = now;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct Policy(
|
||||
string Name,
|
||||
int BurstCapacity,
|
||||
double BurstRefillPerSecond,
|
||||
int SustainedCapacity,
|
||||
double SustainedRefillPerSecond,
|
||||
int MaxConcurrent,
|
||||
long MaxBodyBytes);
|
||||
|
||||
private static readonly ConcurrentDictionary<string, TokenBucket> Buckets = new();
|
||||
private static readonly ConcurrentDictionary<string, int> ActiveRequests = new();
|
||||
private static readonly ConcurrentDictionary<string, int> GlobalActiveRequests = new();
|
||||
private const string EacChallengeResponse = "AQAAAGd9O3h2ynQW6Y/1MhdZC8VoHygxyTzmiRvAfpiRtJEBQ+NVaXMStRTsYQk42H1hbB7NGKhIpgfShk+ADtRW9EU/YF5320eGmINJZAqkm3pyX9QF/w1QT4IB2EQfOqTfpryQ3QFchXYqDgg/SbX+/X9mgzssbflIw3OAK+c=";
|
||||
private static long _requestCounter;
|
||||
|
||||
public int Order => int.MinValue + 100;
|
||||
|
||||
public async Task OnResourceExecutionAsync(
|
||||
ResourceExecutingContext context,
|
||||
ResourceExecutionDelegate next)
|
||||
{
|
||||
HttpRequest request = context.HttpContext.Request;
|
||||
ApplySecurityHeaders(context.HttpContext.Response);
|
||||
Policy policy = ResolvePolicy(request);
|
||||
string requestPath = request.Path.Value?.ToLowerInvariant() ?? string.Empty;
|
||||
IPAddress? clientAddress = ClientNetwork.GetClientIp(request);
|
||||
|
||||
if (clientAddress != null &&
|
||||
!ShouldSkipIpBan(requestPath) &&
|
||||
RecNetDB.TryGetIpBan(clientAddress, out RecNetDB.IpBan? ipBan))
|
||||
{
|
||||
context.HttpContext.Response.Headers["X-Mocha-Blocked"] = "ip-ban";
|
||||
context.Result = Error(
|
||||
StatusCodes.Status403Forbidden,
|
||||
"ip_banned",
|
||||
string.IsNullOrWhiteSpace(ipBan?.Reason)
|
||||
? "This network is blocked from Mocha."
|
||||
: ipBan.Reason);
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientAddress != null &&
|
||||
RecNetDB.IsVpnBlockingEnabled() &&
|
||||
ShouldCheckVpn(requestPath) &&
|
||||
await VpnDetectionService.IsVpnOrProxyAsync(
|
||||
clientAddress,
|
||||
context.HttpContext.RequestAborted))
|
||||
{
|
||||
context.HttpContext.Response.Headers["X-Mocha-Blocked"] = "anonymous-network";
|
||||
context.Result = Error(
|
||||
StatusCodes.Status403Forbidden,
|
||||
"vpn_or_proxy_blocked",
|
||||
"VPN, proxy, Tor, and hosting-provider connections are not allowed on Mocha.");
|
||||
return;
|
||||
}
|
||||
|
||||
if ((request.QueryString.Value?.Length ?? 0) > 8_192)
|
||||
{
|
||||
context.Result = Error(
|
||||
StatusCodes.Status414UriTooLong,
|
||||
"query_too_large",
|
||||
"The query string is too large.");
|
||||
return;
|
||||
}
|
||||
|
||||
var maxBodyFeature = context.HttpContext.Features.Get<
|
||||
Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
|
||||
if (maxBodyFeature != null &&
|
||||
!maxBodyFeature.IsReadOnly &&
|
||||
(!maxBodyFeature.MaxRequestBodySize.HasValue ||
|
||||
maxBodyFeature.MaxRequestBodySize.Value > policy.MaxBodyBytes))
|
||||
{
|
||||
|
||||
maxBodyFeature.MaxRequestBodySize = policy.MaxBodyBytes;
|
||||
}
|
||||
|
||||
if (request.ContentLength.HasValue &&
|
||||
request.ContentLength.Value > policy.MaxBodyBytes)
|
||||
{
|
||||
context.Result = Error(
|
||||
StatusCodes.Status413PayloadTooLarge,
|
||||
"payload_too_large",
|
||||
"The request body is too large for this endpoint.");
|
||||
return;
|
||||
}
|
||||
|
||||
string clientKey = GetClientKey(request);
|
||||
|
||||
string routeKey = $"{clientKey}:{GetRateLimitRouteKey(request, policy.Name)}";
|
||||
|
||||
bool burstAllowed = TryConsume(
|
||||
routeKey + ":burst",
|
||||
policy.BurstCapacity,
|
||||
policy.BurstRefillPerSecond,
|
||||
out int burstRetryAfter);
|
||||
bool sustainedAllowed = TryConsume(
|
||||
routeKey + ":sustained",
|
||||
policy.SustainedCapacity,
|
||||
policy.SustainedRefillPerSecond,
|
||||
out int sustainedRetryAfter);
|
||||
|
||||
if (!burstAllowed || !sustainedAllowed)
|
||||
{
|
||||
int retryAfter = Math.Max(1, Math.Max(burstRetryAfter, sustainedRetryAfter));
|
||||
context.HttpContext.Response.Headers["Retry-After"] = retryAfter.ToString();
|
||||
context.Result = Error(
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
"rate_limited",
|
||||
"Too many requests. Slow down and try again.",
|
||||
new { retryAfterSeconds = retryAfter });
|
||||
return;
|
||||
}
|
||||
|
||||
int active = ActiveRequests.AddOrUpdate(routeKey, 1, (_, current) => current + 1);
|
||||
if (active > policy.MaxConcurrent)
|
||||
{
|
||||
DecrementActive(routeKey, ActiveRequests);
|
||||
context.HttpContext.Response.Headers["Retry-After"] = "1";
|
||||
context.Result = Error(
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
"too_many_concurrent_requests",
|
||||
"Too many requests are already running for this client.",
|
||||
new { retryAfterSeconds = 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
int globalLimit = GetGlobalConcurrencyLimit(policy.Name);
|
||||
int globalActive = GlobalActiveRequests.AddOrUpdate(
|
||||
policy.Name,
|
||||
1,
|
||||
(_, current) => current + 1);
|
||||
if (globalActive > globalLimit)
|
||||
{
|
||||
DecrementActive(policy.Name, GlobalActiveRequests);
|
||||
DecrementActive(routeKey, ActiveRequests);
|
||||
context.HttpContext.Response.Headers["Retry-After"] = "1";
|
||||
context.Result = Error(
|
||||
StatusCodes.Status503ServiceUnavailable,
|
||||
"server_busy",
|
||||
"The server is busy. Try again in a moment.",
|
||||
new { retryAfterSeconds = 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (requestPath.Equals("/auth/eac/challenge", StringComparison.Ordinal))
|
||||
{
|
||||
context.HttpContext.Response.Headers["Cache-Control"] = "no-store";
|
||||
context.HttpContext.Response.Headers["Pragma"] = "no-cache";
|
||||
context.Result = new JsonResult(EacChallengeResponse)
|
||||
{
|
||||
ContentType = "application/json; charset=utf-8",
|
||||
StatusCode = StatusCodes.Status200OK
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
finally
|
||||
{
|
||||
DecrementActive(policy.Name, GlobalActiveRequests);
|
||||
DecrementActive(routeKey, ActiveRequests);
|
||||
CleanupOccasionally();
|
||||
}
|
||||
}
|
||||
|
||||
private static Policy ResolvePolicy(HttpRequest request)
|
||||
{
|
||||
string path = request.Path.Value?.ToLowerInvariant() ?? string.Empty;
|
||||
bool isWrite = HttpMethods.IsPost(request.Method) ||
|
||||
HttpMethods.IsPut(request.Method) ||
|
||||
HttpMethods.IsPatch(request.Method) ||
|
||||
HttpMethods.IsDelete(request.Method);
|
||||
|
||||
if (path.Equals("/api/images/v4/uploadsaved", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"image-upload",
|
||||
BurstCapacity: 12,
|
||||
BurstRefillPerSecond: 12d / 60d,
|
||||
SustainedCapacity: 90,
|
||||
SustainedRefillPerSecond: 90d / 3600d,
|
||||
MaxConcurrent: 3,
|
||||
MaxBodyBytes: 12L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Equals("/upload", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"room-blob-upload",
|
||||
BurstCapacity: 30,
|
||||
BurstRefillPerSecond: 30d / 60d,
|
||||
SustainedCapacity: 360,
|
||||
SustainedRefillPerSecond: 360d / 3600d,
|
||||
MaxConcurrent: 3,
|
||||
MaxBodyBytes: 52L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Contains("community-board/media", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"admin-media-upload",
|
||||
BurstCapacity: 6,
|
||||
BurstRefillPerSecond: 6d / 60d,
|
||||
SustainedCapacity: 30,
|
||||
SustainedRefillPerSecond: 30d / 3600d,
|
||||
MaxConcurrent: 1,
|
||||
MaxBodyBytes: 106L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (isWrite &&
|
||||
(path.Equals("/recnet/api/admin/rooms/import", StringComparison.Ordinal) ||
|
||||
path.Equals("/recnet/api/admin/rooms", StringComparison.Ordinal)))
|
||||
{
|
||||
return new Policy(
|
||||
"admin-room-import",
|
||||
BurstCapacity: 3,
|
||||
BurstRefillPerSecond: 3d / 300d,
|
||||
SustainedCapacity: 8,
|
||||
SustainedRefillPerSecond: 8d / 3600d,
|
||||
MaxConcurrent: 1,
|
||||
MaxBodyBytes: 512L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Equals("/recnet/api/auth/register", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"account-creation",
|
||||
BurstCapacity: 5,
|
||||
BurstRefillPerSecond: 5d / 600d,
|
||||
SustainedCapacity: 12,
|
||||
SustainedRefillPerSecond: 12d / 3600d,
|
||||
MaxConcurrent: 2,
|
||||
MaxBodyBytes: 64L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Equals("/photon/authenticate", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"photon-callback",
|
||||
BurstCapacity: 1_000,
|
||||
BurstRefillPerSecond: 100,
|
||||
SustainedCapacity: 100_000,
|
||||
SustainedRefillPerSecond: 100_000d / 3600d,
|
||||
MaxConcurrent: 64,
|
||||
MaxBodyBytes: 128L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Equals("/auth/connect/token", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"token-exchange",
|
||||
BurstCapacity: 300,
|
||||
BurstRefillPerSecond: 15,
|
||||
SustainedCapacity: 20_000,
|
||||
SustainedRefillPerSecond: 20_000d / 3600d,
|
||||
MaxConcurrent: 24,
|
||||
MaxBodyBytes: 2L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (path.StartsWith("/auth/role/", StringComparison.Ordinal) ||
|
||||
path.Equals("/auth/eac/challenge", StringComparison.Ordinal) ||
|
||||
path.Equals("/api/photon/config", StringComparison.Ordinal) ||
|
||||
path.Equals("/photon/config", StringComparison.Ordinal) ||
|
||||
path.Equals("/voice/config", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"game-auth-read",
|
||||
BurstCapacity: 1_500,
|
||||
BurstRefillPerSecond: 75,
|
||||
SustainedCapacity: 100_000,
|
||||
SustainedRefillPerSecond: 100_000d / 3600d,
|
||||
MaxConcurrent: 48,
|
||||
MaxBodyBytes: 1L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Equals("/data/heartbeat", StringComparison.Ordinal) ||
|
||||
path.Equals("/match/player/exclusivelogin", StringComparison.Ordinal) ||
|
||||
path.Contains("heartbeat", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"game-session",
|
||||
BurstCapacity: 2_000,
|
||||
BurstRefillPerSecond: 100,
|
||||
SustainedCapacity: 200_000,
|
||||
SustainedRefillPerSecond: 200_000d / 3600d,
|
||||
MaxConcurrent: 64,
|
||||
MaxBodyBytes: 2L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
if (path.StartsWith("/cdn/", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"cdn-read",
|
||||
BurstCapacity: 2_000,
|
||||
BurstRefillPerSecond: 100,
|
||||
SustainedCapacity: 100_000,
|
||||
SustainedRefillPerSecond: 100_000d / 3600d,
|
||||
MaxConcurrent: 48,
|
||||
MaxBodyBytes: 16 * 1024);
|
||||
}
|
||||
|
||||
if (path.StartsWith("/imageserver", StringComparison.Ordinal))
|
||||
{
|
||||
bool transformsImage = request.Query.ContainsKey("width") ||
|
||||
request.Query.ContainsKey("height") ||
|
||||
request.Query.ContainsKey("cropsquare");
|
||||
bool signsImage = string.Equals(
|
||||
request.Query["sig"].FirstOrDefault()?.Trim(),
|
||||
"p1",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
if (transformsImage)
|
||||
{
|
||||
return new Policy(
|
||||
"image-transform",
|
||||
BurstCapacity: 300,
|
||||
BurstRefillPerSecond: 10,
|
||||
SustainedCapacity: 4_000,
|
||||
SustainedRefillPerSecond: 4_000d / 3600d,
|
||||
MaxConcurrent: 8,
|
||||
MaxBodyBytes: 16 * 1024);
|
||||
}
|
||||
|
||||
if (signsImage)
|
||||
{
|
||||
return new Policy(
|
||||
"image-signed-read",
|
||||
BurstCapacity: 1_000,
|
||||
BurstRefillPerSecond: 50,
|
||||
SustainedCapacity: 50_000,
|
||||
SustainedRefillPerSecond: 50_000d / 3600d,
|
||||
MaxConcurrent: 32,
|
||||
MaxBodyBytes: 16 * 1024);
|
||||
}
|
||||
|
||||
return new Policy(
|
||||
"image-read",
|
||||
BurstCapacity: 2_000,
|
||||
BurstRefillPerSecond: 100,
|
||||
SustainedCapacity: 100_000,
|
||||
SustainedRefillPerSecond: 100_000d / 3600d,
|
||||
MaxConcurrent: 48,
|
||||
MaxBodyBytes: 16 * 1024);
|
||||
}
|
||||
|
||||
if (path.Contains("cloudvariables", StringComparison.Ordinal) ||
|
||||
path.Contains("roomvariables", StringComparison.Ordinal) ||
|
||||
path.Contains("/playerdata/", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"cloud-variable",
|
||||
BurstCapacity: 2_000,
|
||||
BurstRefillPerSecond: 100,
|
||||
SustainedCapacity: 200_000,
|
||||
SustainedRefillPerSecond: 200_000d / 3600d,
|
||||
MaxConcurrent: 32,
|
||||
MaxBodyBytes: 256L * 1024L);
|
||||
}
|
||||
|
||||
if (path.Equals("/recnet/api/auth/login", StringComparison.Ordinal))
|
||||
{
|
||||
return new Policy(
|
||||
"recnet-login",
|
||||
BurstCapacity: 20,
|
||||
BurstRefillPerSecond: 1,
|
||||
SustainedCapacity: 300,
|
||||
SustainedRefillPerSecond: 300d / 3600d,
|
||||
MaxConcurrent: 4,
|
||||
MaxBodyBytes: 128L * 1024L);
|
||||
}
|
||||
|
||||
if (isWrite)
|
||||
{
|
||||
return new Policy(
|
||||
"game-write",
|
||||
BurstCapacity: 2_000,
|
||||
BurstRefillPerSecond: 100,
|
||||
SustainedCapacity: 200_000,
|
||||
SustainedRefillPerSecond: 200_000d / 3600d,
|
||||
MaxConcurrent: 48,
|
||||
MaxBodyBytes: 20L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
return new Policy(
|
||||
"game-read",
|
||||
BurstCapacity: 4_000,
|
||||
BurstRefillPerSecond: 200,
|
||||
SustainedCapacity: 400_000,
|
||||
SustainedRefillPerSecond: 400_000d / 3600d,
|
||||
MaxConcurrent: 96,
|
||||
MaxBodyBytes: 1L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
private static string GetRateLimitRouteKey(HttpRequest request, string policyName)
|
||||
{
|
||||
string rawPath = request.Path.Value ?? "/";
|
||||
string[] segments = rawPath.Split(
|
||||
'/',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
for (int index = 0; index < segments.Length; index++)
|
||||
{
|
||||
string segment = segments[index];
|
||||
if (long.TryParse(segment, out _) ||
|
||||
Guid.TryParse(segment, out _) ||
|
||||
LooksLikeOpaqueIdentifier(segment))
|
||||
{
|
||||
segments[index] = "{id}";
|
||||
}
|
||||
else
|
||||
{
|
||||
segments[index] = segment.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
string normalizedPath = segments.Length == 0
|
||||
? "/"
|
||||
: "/" + string.Join('/', segments);
|
||||
return $"{policyName}:{request.Method.ToUpperInvariant()}:{normalizedPath}";
|
||||
}
|
||||
|
||||
private static bool LooksLikeOpaqueIdentifier(string value)
|
||||
{
|
||||
if (value.Length < 24)
|
||||
return false;
|
||||
|
||||
foreach (char character in value)
|
||||
{
|
||||
if (!char.IsLetterOrDigit(character) &&
|
||||
character != '-' &&
|
||||
character != '_')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryConsume(
|
||||
string key,
|
||||
int capacity,
|
||||
double refillPerSecond,
|
||||
out int retryAfterSeconds)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
TokenBucket bucket = Buckets.GetOrAdd(key, _ => new TokenBucket(capacity, now));
|
||||
|
||||
lock (bucket.Sync)
|
||||
{
|
||||
double elapsedSeconds = Math.Max(
|
||||
0,
|
||||
(now - bucket.LastRefillUtc).TotalSeconds);
|
||||
bucket.Tokens = Math.Min(
|
||||
capacity,
|
||||
bucket.Tokens + (elapsedSeconds * refillPerSecond));
|
||||
bucket.LastRefillUtc = now;
|
||||
bucket.LastSeenUtc = now;
|
||||
|
||||
if (bucket.Tokens >= 1)
|
||||
{
|
||||
bucket.Tokens -= 1;
|
||||
retryAfterSeconds = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
retryAfterSeconds = refillPerSecond <= 0
|
||||
? 60
|
||||
: Math.Max(1, (int)Math.Ceiling((1 - bucket.Tokens) / refillPerSecond));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetClientKey(HttpRequest request)
|
||||
{
|
||||
IPAddress? address = ClientNetwork.GetClientIp(request);
|
||||
return "ip:" + (address?.ToString() ?? "unknown");
|
||||
}
|
||||
|
||||
private static void ApplySecurityHeaders(HttpResponse response)
|
||||
{
|
||||
response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
response.Headers["X-Frame-Options"] = "DENY";
|
||||
response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
||||
response.Headers["Permissions-Policy"] =
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=()";
|
||||
}
|
||||
|
||||
private static bool ShouldSkipIpBan(string path) =>
|
||||
path is "/recnet" or "/recnet/" or "/recnet/api/session" or
|
||||
"/photon/authenticate" ||
|
||||
(path.StartsWith("/recnet/", StringComparison.Ordinal) &&
|
||||
!path.StartsWith("/recnet/api/", StringComparison.Ordinal)) ||
|
||||
path.StartsWith("/recnet/api/admin/ip-bans", StringComparison.Ordinal);
|
||||
|
||||
private static bool ShouldCheckVpn(string path)
|
||||
{
|
||||
if (path is "/" or "/nameserver" or "/favicon.ico" or
|
||||
"/privacy" or "/tos" or "/recnet" or "/recnet/" or
|
||||
"/photon/authenticate" ||
|
||||
path.StartsWith("/recnet/api/admin/", StringComparison.Ordinal) ||
|
||||
path.StartsWith("/cdn/", StringComparison.Ordinal) ||
|
||||
path.StartsWith("/imageserver", StringComparison.Ordinal) ||
|
||||
(path.StartsWith("/recnet/", StringComparison.Ordinal) &&
|
||||
!path.StartsWith("/recnet/api/", StringComparison.Ordinal)) ||
|
||||
path is "/recnet/api/status" or "/recnet/api/session")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ObjectResult Error(
|
||||
int statusCode,
|
||||
string error,
|
||||
string message,
|
||||
object? extra = null)
|
||||
{
|
||||
object payload = extra == null
|
||||
? new { success = false, error, message }
|
||||
: new { success = false, error, message, details = extra };
|
||||
return new ObjectResult(payload) { StatusCode = statusCode };
|
||||
}
|
||||
|
||||
private static int GetGlobalConcurrencyLimit(string policyName) =>
|
||||
policyName switch
|
||||
{
|
||||
"admin-media-upload" => 2,
|
||||
"image-upload" => 12,
|
||||
"room-blob-upload" => 12,
|
||||
"image-transform" => 32,
|
||||
"recnet-login" or "account-creation" => 32,
|
||||
"token-exchange" => 256,
|
||||
"photon-callback" => 512,
|
||||
"game-auth-read" or "game-session" => 1_024,
|
||||
"game-write" or "cloud-variable" => 1_024,
|
||||
"image-signed-read" or "image-read" or "cdn-read" => 1_024,
|
||||
_ => 2_048
|
||||
};
|
||||
|
||||
private static void DecrementActive(
|
||||
string key,
|
||||
ConcurrentDictionary<string, int> counters)
|
||||
{
|
||||
while (counters.TryGetValue(key, out int current))
|
||||
{
|
||||
if (current <= 1)
|
||||
{
|
||||
if (counters.TryRemove(key, out _))
|
||||
return;
|
||||
}
|
||||
else if (counters.TryUpdate(key, current - 1, current))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static int GetGlobalActiveRequests()
|
||||
{
|
||||
int total = 0;
|
||||
foreach (var kvp in GlobalActiveRequests)
|
||||
total += kvp.Value;
|
||||
return total;
|
||||
}
|
||||
|
||||
private static void CleanupOccasionally()
|
||||
{
|
||||
long count = Interlocked.Increment(ref _requestCounter);
|
||||
if ((count & 4095) != 0)
|
||||
return;
|
||||
|
||||
DateTime cutoff = DateTime.UtcNow.AddHours(-2);
|
||||
foreach (var pair in Buckets)
|
||||
{
|
||||
if (pair.Value.LastSeenUtc < cutoff)
|
||||
Buckets.TryRemove(pair.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class AgeVerificationDB
|
||||
{
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "AgeVerification.db"));
|
||||
|
||||
private static readonly ILiteCollection<AgeVerificationRequest> Requests =
|
||||
Database.GetCollection<AgeVerificationRequest>("Requests");
|
||||
|
||||
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
private static readonly Random Rng = new();
|
||||
private static readonly object Sync = new();
|
||||
|
||||
static AgeVerificationDB()
|
||||
{
|
||||
LiteDbMaintenance.StartPeriodicCheckpoint("AgeVerification.db", Database);
|
||||
Requests.EnsureIndex(value => value.AccountId);
|
||||
Requests.EnsureIndex(value => value.Status);
|
||||
}
|
||||
|
||||
public sealed class AgeVerificationRequest
|
||||
{
|
||||
[BsonId]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
|
||||
public string Method { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "Pending";
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
public long? ReviewedByAccountId { get; set; }
|
||||
public string? RejectionReason { get; set; }
|
||||
}
|
||||
|
||||
public static string GenerateCode(long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
|
||||
foreach (AgeVerificationRequest stale in Requests
|
||||
.Find(value => value.AccountId == accountId &&
|
||||
(value.Status == "Pending" || value.Status == "UnderReview"))
|
||||
.ToList())
|
||||
{
|
||||
Requests.Delete(stale.Code);
|
||||
}
|
||||
|
||||
string code;
|
||||
do
|
||||
{
|
||||
code = NewCode();
|
||||
} while (Requests.Exists(value => value.Code == code));
|
||||
|
||||
Requests.Insert(new AgeVerificationRequest
|
||||
{
|
||||
Code = code,
|
||||
AccountId = accountId,
|
||||
Status = "Pending"
|
||||
});
|
||||
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NewCode()
|
||||
{
|
||||
char[] buffer = new char[6];
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
buffer[i] = CodeAlphabet[Rng.Next(CodeAlphabet.Length)];
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
public static AgeVerificationRequest? GetByCode(string? code)
|
||||
{
|
||||
string normalized = (code ?? string.Empty).Trim().ToUpperInvariant();
|
||||
return normalized.Length == 0 ? null : Requests.FindById(normalized);
|
||||
}
|
||||
|
||||
public static AgeVerificationRequest? GetActiveForAccount(long accountId)
|
||||
{
|
||||
return Requests.FindAll()
|
||||
.Where(value => value.AccountId == accountId &&
|
||||
(value.Status == "Pending" || value.Status == "UnderReview"))
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public static bool MarkUnderReview(string code, long accountId, string method)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
AgeVerificationRequest? request = GetByCode(code);
|
||||
if (request == null ||
|
||||
request.AccountId != accountId ||
|
||||
request.Status != "Pending")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
request.Method = method;
|
||||
request.Status = "UnderReview";
|
||||
request.SubmittedAt = DateTime.UtcNow;
|
||||
return Requests.Update(request);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<AgeVerificationRequest> GetForReview()
|
||||
{
|
||||
return Requests.Find(value => value.Status == "UnderReview")
|
||||
.OrderBy(value => value.SubmittedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static bool Review(string code, bool approve, long reviewerId, string? reason)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
AgeVerificationRequest? request = GetByCode(code);
|
||||
if (request == null || request.Status != "UnderReview")
|
||||
return false;
|
||||
|
||||
request.Status = approve ? "Approved" : "Rejected";
|
||||
request.ReviewedAt = DateTime.UtcNow;
|
||||
request.ReviewedByAccountId = reviewerId;
|
||||
request.RejectionReason = reason;
|
||||
return Requests.Update(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs;
|
||||
|
||||
public static class AnticheatLogDB
|
||||
{
|
||||
public class AnticheatEntry
|
||||
{
|
||||
[BsonId]
|
||||
public long Id { get; set; }
|
||||
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
public string IpAddress { get; set; } = "unknown";
|
||||
public long? AccountId { get; set; }
|
||||
public string? SteamId { get; set; }
|
||||
public string? Build { get; set; }
|
||||
public string Flags { get; set; } = "";
|
||||
public string? UserAgent { get; set; }
|
||||
}
|
||||
|
||||
private static readonly LiteDatabase DB = new(
|
||||
Path.Combine(Program.dataDir, "DBs", "AnticheatLogs.db"));
|
||||
|
||||
public static readonly ILiteCollection<AnticheatEntry> Entries =
|
||||
DB.GetCollection<AnticheatEntry>("Entries");
|
||||
|
||||
private const int MaxStored = 20_000;
|
||||
private const int TrimBatchSize = 1_000;
|
||||
|
||||
public static void Log(
|
||||
string ipAddress,
|
||||
long? accountId,
|
||||
string? steamId,
|
||||
string? build,
|
||||
string flags,
|
||||
string? userAgent)
|
||||
{
|
||||
Entries.Insert(new AnticheatEntry
|
||||
{
|
||||
IpAddress = ipAddress,
|
||||
AccountId = accountId,
|
||||
SteamId = steamId,
|
||||
Build = build,
|
||||
Flags = flags,
|
||||
UserAgent = userAgent
|
||||
});
|
||||
|
||||
long count = Entries.LongCount();
|
||||
if (count > MaxStored)
|
||||
{
|
||||
List<long> oldestIds = Entries.Query()
|
||||
.OrderBy(a => a.Id)
|
||||
.Limit(TrimBatchSize)
|
||||
.ToList()
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (long id in oldestIds)
|
||||
Entries.Delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using LiteDB;
|
||||
using SystemJsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class ChatDB
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "Chat.db"));
|
||||
|
||||
private static readonly ILiteCollection<ChatThread> Threads =
|
||||
Database.GetCollection<ChatThread>("Threads", BsonAutoId.Int64);
|
||||
|
||||
private static readonly ILiteCollection<ChatMessage> Messages =
|
||||
Database.GetCollection<ChatMessage>("Messages", BsonAutoId.Int64);
|
||||
|
||||
private static readonly ILiteCollection<ChatReadState> ReadStates =
|
||||
Database.GetCollection<ChatReadState>("ReadStates");
|
||||
|
||||
static ChatDB()
|
||||
{
|
||||
Threads.EnsureIndex(value => value.UpdatedAt);
|
||||
Messages.EnsureIndex(value => value.ThreadId);
|
||||
Messages.EnsureIndex(value => value.CreatedAt);
|
||||
ReadStates.EnsureIndex(value => value.ThreadId);
|
||||
ReadStates.EnsureIndex(value => value.AccountId);
|
||||
}
|
||||
|
||||
public static int DeveloperThreadCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Sync)
|
||||
return Threads.Count();
|
||||
}
|
||||
}
|
||||
|
||||
public static int DeveloperMessageCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Sync)
|
||||
return Messages.Count();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ChatThread
|
||||
{
|
||||
[BsonId]
|
||||
public long ThreadId { get; set; }
|
||||
public int Type { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public long CreatedByAccountId { get; set; }
|
||||
public List<long> MemberIds { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class ChatMessage
|
||||
{
|
||||
[BsonId]
|
||||
public long MessageId { get; set; }
|
||||
public long ThreadId { get; set; }
|
||||
public long SenderAccountId { get; set; }
|
||||
public string Body { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class ChatReadState
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long ThreadId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public long LastReadMessageId { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static List<ChatThread> GetThreadsForPlayer(long accountId, int maxCount)
|
||||
{
|
||||
maxCount = Math.Clamp(maxCount, 1, 100);
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
List<ChatThread> threads = Threads.FindAll()
|
||||
.Where(value => value.MemberIds?.Contains(accountId) == true)
|
||||
.OrderByDescending(value => value.UpdatedAt)
|
||||
.Take(maxCount)
|
||||
.ToList();
|
||||
|
||||
foreach (ChatThread thread in threads)
|
||||
EnsureStarterMessageNoLock(thread);
|
||||
|
||||
return threads;
|
||||
}
|
||||
}
|
||||
|
||||
public static ChatThread? GetThread(long threadId, long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? thread = Threads.FindById(threadId);
|
||||
if (thread?.MemberIds?.Contains(accountId) != true)
|
||||
return null;
|
||||
|
||||
EnsureStarterMessageNoLock(thread);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
|
||||
public static ChatThread? FindThreadWithMembers(
|
||||
long requestingAccountId,
|
||||
IEnumerable<long> requestedMemberIds)
|
||||
{
|
||||
long[] normalized = NormalizeMembers(
|
||||
requestingAccountId,
|
||||
requestedMemberIds);
|
||||
|
||||
if (normalized.Length < 2)
|
||||
return null;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
return Threads.FindAll().FirstOrDefault(thread =>
|
||||
NormalizeMembers(0, thread.MemberIds).SequenceEqual(normalized));
|
||||
}
|
||||
}
|
||||
|
||||
public static ChatThread GetOrCreateThread(
|
||||
long requestingAccountId,
|
||||
IEnumerable<long> requestedMemberIds,
|
||||
string? name = null,
|
||||
int type = 0)
|
||||
{
|
||||
long[] normalized = NormalizeMembers(
|
||||
requestingAccountId,
|
||||
requestedMemberIds);
|
||||
|
||||
if (normalized.Length < 2)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A chat thread requires at least two distinct members.",
|
||||
nameof(requestedMemberIds));
|
||||
}
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? existing = Threads.FindAll().FirstOrDefault(thread =>
|
||||
NormalizeMembers(0, thread.MemberIds).SequenceEqual(normalized));
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
EnsureStarterMessageNoLock(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var thread = new ChatThread
|
||||
{
|
||||
ThreadId = GetNextThreadIdNoLock(),
|
||||
Type = type,
|
||||
Name = string.IsNullOrWhiteSpace(name) ? null : name.Trim(),
|
||||
CreatedByAccountId = requestingAccountId,
|
||||
MemberIds = normalized.ToList(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Threads.Insert(thread);
|
||||
EnsureStarterMessageNoLock(thread);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<ChatMessage> GetMessages(
|
||||
long threadId,
|
||||
long requestingAccountId,
|
||||
int maxCount = 50,
|
||||
long? beforeMessageId = null)
|
||||
{
|
||||
maxCount = Math.Clamp(maxCount, 1, 100);
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? thread = Threads.FindById(threadId);
|
||||
if (thread?.MemberIds?.Contains(requestingAccountId) != true)
|
||||
return new List<ChatMessage>();
|
||||
|
||||
IEnumerable<ChatMessage> query =
|
||||
Messages.Find(value => value.ThreadId == threadId);
|
||||
|
||||
if (beforeMessageId.HasValue && beforeMessageId.Value > 0)
|
||||
query = query.Where(value => value.MessageId < beforeMessageId.Value);
|
||||
|
||||
return query
|
||||
.OrderByDescending(value => value.MessageId)
|
||||
.Take(maxCount)
|
||||
.OrderBy(value => value.MessageId)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static ChatMessage? GetLastMessage(long threadId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
return Messages.Find(value => value.ThreadId == threadId)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<ChatMessage> GetMessagesForDeveloper(
|
||||
int maxCount = 100,
|
||||
long? beforeMessageId = null)
|
||||
{
|
||||
maxCount = Math.Clamp(maxCount, 1, 250);
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
IEnumerable<ChatMessage> query = Messages.FindAll();
|
||||
if (beforeMessageId.HasValue && beforeMessageId.Value > 0)
|
||||
query = query.Where(value =>
|
||||
value.MessageId < beforeMessageId.Value);
|
||||
|
||||
return query
|
||||
.OrderByDescending(value => value.MessageId)
|
||||
.Take(maxCount)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static Dictionary<long, ChatThread> GetThreadsForDeveloper(
|
||||
IEnumerable<long> threadIds)
|
||||
{
|
||||
long[] ids = threadIds
|
||||
.Where(value => value > 0)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
return Threads.FindAll()
|
||||
.Where(value => ids.Contains(value.ThreadId))
|
||||
.ToDictionary(value => value.ThreadId);
|
||||
}
|
||||
}
|
||||
|
||||
public static long GetLastReadMessageId(long threadId, long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? thread = Threads.FindById(threadId);
|
||||
if (thread?.MemberIds?.Contains(accountId) != true)
|
||||
return 0;
|
||||
|
||||
return ReadStates.FindById(BuildReadStateId(threadId, accountId))
|
||||
?.LastReadMessageId ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool MarkMessageRead(
|
||||
long threadId,
|
||||
long messageId,
|
||||
long accountId)
|
||||
{
|
||||
if (threadId <= 0 || messageId <= 0 || accountId <= 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? thread = Threads.FindById(threadId);
|
||||
if (thread?.MemberIds?.Contains(accountId) != true)
|
||||
return false;
|
||||
|
||||
ChatMessage? message = Messages.FindById(messageId);
|
||||
if (message == null || message.ThreadId != threadId)
|
||||
return false;
|
||||
|
||||
SetLastReadNoLock(threadId, accountId, messageId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static ChatMessage? AddMessage(
|
||||
long threadId,
|
||||
long senderAccountId,
|
||||
string body)
|
||||
{
|
||||
body = body?.Trim() ?? string.Empty;
|
||||
if (body.Length is < 1 or > 2000 || body.Any(char.IsControl))
|
||||
return null;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? thread = Threads.FindById(threadId);
|
||||
if (thread?.MemberIds?.Contains(senderAccountId) != true)
|
||||
return null;
|
||||
|
||||
var message = new ChatMessage
|
||||
{
|
||||
MessageId = GetNextMessageIdNoLock(),
|
||||
ThreadId = threadId,
|
||||
SenderAccountId = senderAccountId,
|
||||
Body = body,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Messages.Insert(message);
|
||||
thread.UpdatedAt = message.CreatedAt;
|
||||
Threads.Update(thread);
|
||||
SetLastReadNoLock(threadId, senderAccountId, message.MessageId);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool LeaveThread(long threadId, long requestingAccountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? thread = Threads.FindById(threadId);
|
||||
if (thread?.MemberIds?.Contains(requestingAccountId) != true)
|
||||
return false;
|
||||
|
||||
if (thread.MemberIds.Count <= 2)
|
||||
{
|
||||
Messages.DeleteMany(value => value.ThreadId == threadId);
|
||||
ReadStates.DeleteMany(value => value.ThreadId == threadId);
|
||||
return Threads.Delete(threadId);
|
||||
}
|
||||
|
||||
thread.MemberIds.Remove(requestingAccountId);
|
||||
ReadStates.Delete(BuildReadStateId(threadId, requestingAccountId));
|
||||
thread.UpdatedAt = DateTime.UtcNow;
|
||||
return Threads.Update(thread);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool DeleteThread(long threadId, long requestingAccountId) =>
|
||||
LeaveThread(threadId, requestingAccountId);
|
||||
|
||||
private static string BuildReadStateId(long threadId, long accountId) =>
|
||||
$"{threadId}:{accountId}";
|
||||
|
||||
private static void SetLastReadNoLock(
|
||||
long threadId,
|
||||
long accountId,
|
||||
long messageId)
|
||||
{
|
||||
string id = BuildReadStateId(threadId, accountId);
|
||||
ChatReadState? state = ReadStates.FindById(id);
|
||||
|
||||
if (state == null)
|
||||
{
|
||||
state = new ChatReadState
|
||||
{
|
||||
Id = id,
|
||||
ThreadId = threadId,
|
||||
AccountId = accountId,
|
||||
LastReadMessageId = messageId,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
state.LastReadMessageId = Math.Max(
|
||||
state.LastReadMessageId,
|
||||
messageId);
|
||||
state.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
ReadStates.Upsert(state);
|
||||
}
|
||||
|
||||
private static ChatMessage EnsureStarterMessageNoLock(ChatThread thread)
|
||||
{
|
||||
ChatMessage? existing = Messages
|
||||
.Find(value => value.ThreadId == thread.ThreadId)
|
||||
.OrderBy(value => value.MessageId)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
long creatorId = thread.CreatedByAccountId > 0
|
||||
? thread.CreatedByAccountId
|
||||
: thread.MemberIds.FirstOrDefault();
|
||||
|
||||
string contents = SystemJsonSerializer.Serialize(new
|
||||
{
|
||||
Type = 0,
|
||||
Version = 1,
|
||||
Data = $"Player <@U{creatorId}> started a chat"
|
||||
});
|
||||
|
||||
var starter = new ChatMessage
|
||||
{
|
||||
MessageId = GetNextMessageIdNoLock(),
|
||||
ThreadId = thread.ThreadId,
|
||||
SenderAccountId = -5,
|
||||
Body = contents,
|
||||
CreatedAt = thread.CreatedAt == default
|
||||
? DateTime.UtcNow
|
||||
: thread.CreatedAt
|
||||
};
|
||||
|
||||
Messages.Insert(starter);
|
||||
|
||||
if (creatorId > 0)
|
||||
SetLastReadNoLock(thread.ThreadId, creatorId, starter.MessageId);
|
||||
|
||||
if (thread.UpdatedAt < starter.CreatedAt)
|
||||
{
|
||||
thread.UpdatedAt = starter.CreatedAt;
|
||||
Threads.Update(thread);
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"[CHAT] backfilled starter message={starter.MessageId} thread={thread.ThreadId}");
|
||||
|
||||
return starter;
|
||||
}
|
||||
|
||||
private static long[] NormalizeMembers(
|
||||
long requestingAccountId,
|
||||
IEnumerable<long>? memberIds)
|
||||
{
|
||||
IEnumerable<long> values = memberIds ?? Array.Empty<long>();
|
||||
|
||||
if (requestingAccountId > 0)
|
||||
values = values.Append(requestingAccountId);
|
||||
|
||||
return values
|
||||
.Where(value => value > 0)
|
||||
.Distinct()
|
||||
.OrderBy(value => value)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static long GetNextThreadIdNoLock() =>
|
||||
Threads.Count() == 0
|
||||
? 1
|
||||
: Threads.Max(value => value.ThreadId) + 1;
|
||||
|
||||
private static long GetNextMessageIdNoLock() =>
|
||||
Messages.Count() == 0
|
||||
? 1
|
||||
: Messages.Max(value => value.MessageId) + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
using LiteDB;
|
||||
using static Mocha2023.Classes.DBs.DBClasses.ClubDBClasses;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
public static class ClubDB
|
||||
{
|
||||
private const int MaxCreatedClubsPerAccount = 100;
|
||||
private static readonly object Sync = new();
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "Clubs.db"));
|
||||
|
||||
public static readonly ILiteCollection<ClubRecord> Clubs =
|
||||
Database.GetCollection<ClubRecord>("Clubs");
|
||||
public static readonly ILiteCollection<ClubMembershipRecord> Memberships =
|
||||
Database.GetCollection<ClubMembershipRecord>("Memberships");
|
||||
public static readonly ILiteCollection<HomeClubRecord> HomeClubs =
|
||||
Database.GetCollection<HomeClubRecord>("HomeClubs");
|
||||
|
||||
public static readonly IReadOnlyList<string> CategoryTags = new[]
|
||||
{
|
||||
"Social",
|
||||
"Creative",
|
||||
"Competitive",
|
||||
"Casual",
|
||||
"Educational",
|
||||
"Entertainment",
|
||||
"Lifestyle"
|
||||
};
|
||||
|
||||
private static bool _legacyDataRepaired;
|
||||
|
||||
static ClubDB()
|
||||
{
|
||||
Clubs.EnsureIndex(value => value.CreatorAccountId);
|
||||
Clubs.EnsureIndex(value => value.Name);
|
||||
Clubs.EnsureIndex(value => value.State);
|
||||
Memberships.EnsureIndex(value => value.ClubId);
|
||||
Memberships.EnsureIndex(value => value.AccountId);
|
||||
Memberships.EnsureIndex(value => value.MembershipType);
|
||||
|
||||
RepairLegacyData();
|
||||
}
|
||||
|
||||
public static void RepairLegacyData()
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
if (_legacyDataRepaired)
|
||||
return;
|
||||
|
||||
int clubsChanged = 0;
|
||||
int membershipsChanged = 0;
|
||||
int malformedMembershipsRemoved = 0;
|
||||
|
||||
foreach (ClubMembershipRecord membership in Memberships.FindAll().ToList())
|
||||
{
|
||||
if (membership.ClubId <= 0 || membership.AccountId <= 0)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(membership.Id))
|
||||
Memberships.Delete(membership.Id);
|
||||
malformedMembershipsRemoved++;
|
||||
continue;
|
||||
}
|
||||
|
||||
string expectedId = MembershipId(membership.ClubId, membership.AccountId);
|
||||
if (string.Equals(membership.Id, expectedId, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
ClubMembershipRecord? existing = Memberships.FindById(expectedId);
|
||||
ClubMembershipType mergedType = existing == null
|
||||
? membership.MembershipType
|
||||
: (ClubMembershipType)Math.Max(
|
||||
(int)existing.MembershipType,
|
||||
(int)membership.MembershipType);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(membership.Id))
|
||||
Memberships.Delete(membership.Id);
|
||||
|
||||
UpsertMembershipUnsafe(
|
||||
membership.ClubId,
|
||||
membership.AccountId,
|
||||
mergedType);
|
||||
membershipsChanged++;
|
||||
}
|
||||
|
||||
foreach (ClubRecord club in Clubs.FindAll().ToList())
|
||||
{
|
||||
bool changed = false;
|
||||
|
||||
club.Name ??= string.Empty;
|
||||
club.Description ??= string.Empty;
|
||||
club.MainImageName = NormalizeImageName(club.MainImageName);
|
||||
club.AdditionalImages ??= new List<string>();
|
||||
club.CategoryTags ??= new List<string>();
|
||||
|
||||
string resolvedPrimary = ResolveLegacyPrimaryCategory(
|
||||
club.PrimaryCategory,
|
||||
club.CategoryTags);
|
||||
List<string> normalizedTags = NormalizeTags(
|
||||
new[] { resolvedPrimary }
|
||||
.Concat(club.CategoryTags))
|
||||
.ToList();
|
||||
|
||||
if (normalizedTags.Count == 0)
|
||||
normalizedTags.Add(resolvedPrimary);
|
||||
|
||||
if (!string.Equals(
|
||||
club.PrimaryCategory,
|
||||
resolvedPrimary,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[CLUB CATEGORY REPAIR] club={club.ClubId} " +
|
||||
$"old={club.PrimaryCategory ?? "null"} new={resolvedPrimary} " +
|
||||
$"tags={string.Join(',', club.CategoryTags)}");
|
||||
club.PrimaryCategory = resolvedPrimary;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!club.CategoryTags.SequenceEqual(
|
||||
normalizedTags,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
club.CategoryTags = normalizedTags;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
ClubMembershipRecord? creatorMembership =
|
||||
Memberships.FindById(MembershipId(
|
||||
club.ClubId,
|
||||
club.CreatorAccountId));
|
||||
|
||||
if (club.CreatorAccountId > 0 &&
|
||||
creatorMembership?.MembershipType != ClubMembershipType.Creator)
|
||||
{
|
||||
UpsertMembershipUnsafe(
|
||||
club.ClubId,
|
||||
club.CreatorAccountId,
|
||||
ClubMembershipType.Creator);
|
||||
membershipsChanged++;
|
||||
}
|
||||
|
||||
int actualMemberCount = Memberships.Count(value =>
|
||||
value.ClubId == club.ClubId &&
|
||||
value.MembershipType >= ClubMembershipType.Member);
|
||||
|
||||
if (club.MemberCount != actualMemberCount)
|
||||
{
|
||||
club.MemberCount = actualMemberCount;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
club.UpdatedAt = DateTime.UtcNow;
|
||||
Clubs.Update(club);
|
||||
clubsChanged++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (HomeClubRecord home in HomeClubs.FindAll().ToList())
|
||||
{
|
||||
ClubRecord? club = Clubs.FindById(home.ClubId);
|
||||
if (club == null)
|
||||
{
|
||||
HomeClubs.Delete(home.AccountId);
|
||||
continue;
|
||||
}
|
||||
|
||||
ClubMembershipRecord? membership =
|
||||
Memberships.FindById(MembershipId(
|
||||
home.ClubId,
|
||||
home.AccountId));
|
||||
|
||||
if (membership == null ||
|
||||
membership.MembershipType < ClubMembershipType.Member)
|
||||
{
|
||||
ClubMembershipType type =
|
||||
club.CreatorAccountId == home.AccountId
|
||||
? ClubMembershipType.Creator
|
||||
: ClubMembershipType.Member;
|
||||
UpsertMembershipUnsafe(home.ClubId, home.AccountId, type);
|
||||
membershipsChanged++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ClubRecord club in Clubs.FindAll().ToList())
|
||||
{
|
||||
int actualMemberCount = Memberships.Count(value =>
|
||||
value.ClubId == club.ClubId &&
|
||||
value.MembershipType >= ClubMembershipType.Member);
|
||||
|
||||
if (club.MemberCount == actualMemberCount)
|
||||
continue;
|
||||
|
||||
club.MemberCount = actualMemberCount;
|
||||
club.UpdatedAt = DateTime.UtcNow;
|
||||
Clubs.Update(club);
|
||||
clubsChanged++;
|
||||
}
|
||||
|
||||
_legacyDataRepaired = true;
|
||||
|
||||
Console.WriteLine(
|
||||
$"[CLUB REPAIR] clubs={clubsChanged} " +
|
||||
$"memberships={membershipsChanged} " +
|
||||
$"malformedRemoved={malformedMembershipsRemoved}");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryCreate(
|
||||
long creatorAccountId,
|
||||
string name,
|
||||
string description,
|
||||
string? mainImageName,
|
||||
ClubVisibility visibility,
|
||||
ClubJoinability joinability,
|
||||
bool allowJuniors,
|
||||
int minLevel,
|
||||
ClubType clubType,
|
||||
IEnumerable<string>? categoryTags,
|
||||
out ClubRecord? club,
|
||||
out string? error)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
club = null;
|
||||
error = null;
|
||||
|
||||
string normalizedName = name.Trim();
|
||||
|
||||
ClubRecord? existingOwnedClub = Clubs.FindAll().FirstOrDefault(value =>
|
||||
value.CreatorAccountId == creatorAccountId &&
|
||||
value.State != ClubState.MarkedForDelete &&
|
||||
string.Equals(
|
||||
value.Name,
|
||||
normalizedName,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingOwnedClub != null)
|
||||
{
|
||||
UpsertMembershipUnsafe(
|
||||
existingOwnedClub.ClubId,
|
||||
creatorAccountId,
|
||||
ClubMembershipType.Creator);
|
||||
|
||||
club = existingOwnedClub;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Clubs.Count(value =>
|
||||
value.CreatorAccountId == creatorAccountId &&
|
||||
value.State != ClubState.MarkedForDelete) >=
|
||||
MaxCreatedClubsPerAccount)
|
||||
{
|
||||
error = "club_creation_limit";
|
||||
return false;
|
||||
}
|
||||
|
||||
List<string> tags = NormalizeTags(categoryTags).ToList();
|
||||
if (tags.Count == 0)
|
||||
tags.Add("Social");
|
||||
|
||||
club = new ClubRecord
|
||||
{
|
||||
ClubId = NextClubId(),
|
||||
Name = normalizedName,
|
||||
Description = description.Trim(),
|
||||
MainImageName = NormalizeImageName(mainImageName),
|
||||
MemberCount = 1,
|
||||
PrimaryCategory = tags[0],
|
||||
Visibility = visibility,
|
||||
Joinability = joinability,
|
||||
AllowJuniors = allowJuniors,
|
||||
MinLevel = Math.Clamp(minLevel, 0, 50),
|
||||
ClubType = clubType,
|
||||
CreatorAccountId = creatorAccountId,
|
||||
CategoryTags = tags,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Clubs.Insert(club);
|
||||
UpsertMembershipUnsafe(
|
||||
club.ClubId,
|
||||
creatorAccountId,
|
||||
ClubMembershipType.Creator);
|
||||
HomeClubs.Upsert(new HomeClubRecord
|
||||
{
|
||||
AccountId = creatorAccountId,
|
||||
ClubId = club.ClubId,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static ClubRecord? Get(long clubId)
|
||||
{
|
||||
lock (Sync)
|
||||
return Clubs.FindById(clubId);
|
||||
}
|
||||
|
||||
public static List<ClubRecord> GetCreatedBy(long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
return Clubs.Find(value =>
|
||||
value.CreatorAccountId == accountId &&
|
||||
value.State != ClubState.MarkedForDelete)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static List<ClubRecord> GetMemberClubs(long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
HashSet<long> clubIds = Memberships.Find(value =>
|
||||
value.AccountId == accountId &&
|
||||
value.MembershipType >= ClubMembershipType.Member)
|
||||
.Select(value => value.ClubId)
|
||||
.ToHashSet();
|
||||
|
||||
long? homeClubId = HomeClubs.FindById(accountId)?.ClubId;
|
||||
|
||||
return Clubs.Find(value => value.State == ClubState.Active)
|
||||
.Where(value =>
|
||||
clubIds.Contains(value.ClubId) ||
|
||||
value.CreatorAccountId == accountId ||
|
||||
homeClubId == value.ClubId)
|
||||
.OrderByDescending(value => value.MemberCount)
|
||||
.ThenBy(value => value.Name)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<ClubRecord> Search(
|
||||
string? category,
|
||||
string? query,
|
||||
int sort,
|
||||
int skip,
|
||||
int count) =>
|
||||
SearchWithTotal(category, query, sort, skip, count).Results;
|
||||
|
||||
public static (List<ClubRecord> Results, int TotalResults) SearchWithTotal(
|
||||
string? category,
|
||||
string? query,
|
||||
int sort,
|
||||
int skip,
|
||||
int count)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
IEnumerable<ClubRecord> clubs = Clubs.Find(value =>
|
||||
value.State == ClubState.Active &&
|
||||
value.Visibility == ClubVisibility.Public);
|
||||
|
||||
string? normalizedCategory = NormalizeCategory(category);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedCategory))
|
||||
{
|
||||
clubs = clubs.Where(value =>
|
||||
ClubMatchesCategory(value, normalizedCategory));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
string trimmedQuery = query.Trim();
|
||||
clubs = clubs.Where(value =>
|
||||
(value.Name ?? string.Empty).Contains(
|
||||
trimmedQuery,
|
||||
StringComparison.OrdinalIgnoreCase) ||
|
||||
(value.Description ?? string.Empty).Contains(
|
||||
trimmedQuery,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
clubs = sort switch
|
||||
{
|
||||
1 => clubs.OrderByDescending(value => value.CreatedAt),
|
||||
2 => clubs.OrderBy(value => value.CreatedAt),
|
||||
_ => clubs.OrderByDescending(value => value.MemberCount)
|
||||
.ThenBy(value => value.Name)
|
||||
};
|
||||
|
||||
List<ClubRecord> all = clubs.ToList();
|
||||
List<ClubRecord> page = all
|
||||
.Skip(Math.Max(0, skip))
|
||||
.Take(Math.Clamp(count, 1, 100))
|
||||
.ToList();
|
||||
|
||||
return (page, all.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public static ClubMembershipRecord? GetMembership(long clubId, long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
ClubMembershipRecord? membership =
|
||||
Memberships.FindById(MembershipId(clubId, accountId));
|
||||
|
||||
if (membership != null)
|
||||
return membership;
|
||||
|
||||
ClubRecord? club = Clubs.FindById(clubId);
|
||||
if (club?.CreatorAccountId != accountId)
|
||||
return null;
|
||||
|
||||
UpsertMembershipUnsafe(
|
||||
clubId,
|
||||
accountId,
|
||||
ClubMembershipType.Creator);
|
||||
return Memberships.FindById(MembershipId(clubId, accountId));
|
||||
}
|
||||
}
|
||||
|
||||
public static List<ClubMembershipRecord> GetMemberships(
|
||||
long clubId,
|
||||
bool includePending = false)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
return Memberships.Find(value => value.ClubId == clubId &&
|
||||
(includePending || value.MembershipType >= ClubMembershipType.Member))
|
||||
.OrderByDescending(value => value.MembershipType)
|
||||
.ThenBy(value => value.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SetMembership(
|
||||
long clubId,
|
||||
long accountId,
|
||||
ClubMembershipType membershipType)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
if (Clubs.FindById(clubId) == null)
|
||||
return false;
|
||||
UpsertMembershipUnsafe(clubId, accountId, membershipType);
|
||||
RefreshMemberCountUnsafe(clubId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool RemoveMembership(long clubId, long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
ClubRecord? club = Clubs.FindById(clubId);
|
||||
if (club == null || club.CreatorAccountId == accountId)
|
||||
return false;
|
||||
Memberships.Delete(MembershipId(clubId, accountId));
|
||||
if (HomeClubs.FindById(accountId)?.ClubId == clubId)
|
||||
HomeClubs.Delete(accountId);
|
||||
RefreshMemberCountUnsafe(clubId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SetHomeClub(long accountId, long? clubId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
if (!clubId.HasValue || clubId.Value <= 0)
|
||||
return HomeClubs.Delete(accountId);
|
||||
|
||||
ClubMembershipRecord? membership = GetMembership(clubId.Value, accountId);
|
||||
if (membership == null ||
|
||||
membership.MembershipType < ClubMembershipType.Member)
|
||||
return false;
|
||||
|
||||
HomeClubs.Upsert(new HomeClubRecord
|
||||
{
|
||||
AccountId = accountId,
|
||||
ClubId = clubId.Value,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static ClubRecord? GetHomeClub(long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
HomeClubRecord? home = HomeClubs.FindById(accountId);
|
||||
return home == null ? null : Clubs.FindById(home.ClubId);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Update(ClubRecord club)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
club.Name = (club.Name ?? string.Empty).Trim();
|
||||
club.Description = (club.Description ?? string.Empty).Trim();
|
||||
club.MainImageName = NormalizeImageName(club.MainImageName);
|
||||
|
||||
string primary = NormalizeCategory(club.PrimaryCategory) ??
|
||||
NormalizeTags(club.CategoryTags).FirstOrDefault() ??
|
||||
"Social";
|
||||
List<string> normalizedTags = NormalizeTags(
|
||||
new[] { primary }
|
||||
.Concat(club.CategoryTags ?? new List<string>()))
|
||||
.ToList();
|
||||
if (normalizedTags.Count == 0)
|
||||
normalizedTags.Add(primary);
|
||||
|
||||
club.PrimaryCategory = primary;
|
||||
club.CategoryTags = normalizedTags;
|
||||
club.MinLevel = Math.Clamp(club.MinLevel, 0, 50);
|
||||
club.UpdatedAt = DateTime.UtcNow;
|
||||
return Clubs.Update(club);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Delete(long clubId, long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
ClubRecord? club = Clubs.FindById(clubId);
|
||||
if (club == null || club.CreatorAccountId != accountId)
|
||||
return false;
|
||||
|
||||
Clubs.Delete(clubId);
|
||||
foreach (ClubMembershipRecord membership in
|
||||
Memberships.Find(value => value.ClubId == clubId).ToList())
|
||||
{
|
||||
Memberships.Delete(membership.Id);
|
||||
}
|
||||
foreach (HomeClubRecord home in
|
||||
HomeClubs.Find(value => value.ClubId == clubId).ToList())
|
||||
{
|
||||
HomeClubs.Delete(home.AccountId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static ClubSummaryDto ToSummary(ClubRecord club) =>
|
||||
ToSummary(club, null);
|
||||
|
||||
public static ClubSummaryDto ToSummary(
|
||||
ClubRecord club,
|
||||
long? viewerAccountId)
|
||||
{
|
||||
ClubMembershipType membershipType = ClubMembershipType.None;
|
||||
if (viewerAccountId.HasValue)
|
||||
{
|
||||
membershipType = GetMembership(
|
||||
club.ClubId,
|
||||
viewerAccountId.Value)?.MembershipType ??
|
||||
(club.CreatorAccountId == viewerAccountId.Value
|
||||
? ClubMembershipType.Creator
|
||||
: ClubMembershipType.None);
|
||||
}
|
||||
|
||||
string primary = NormalizeCategory(club.PrimaryCategory) ??
|
||||
NormalizeTags(club.CategoryTags).FirstOrDefault() ??
|
||||
"Social";
|
||||
List<string> normalizedTags = NormalizeTags(
|
||||
new[] { primary }
|
||||
.Concat(club.CategoryTags ?? new List<string>()))
|
||||
.ToList();
|
||||
if (normalizedTags.Count == 0)
|
||||
normalizedTags.Add(primary);
|
||||
|
||||
return new ClubSummaryDto
|
||||
{
|
||||
ClubId = club.ClubId,
|
||||
Name = club.Name ?? string.Empty,
|
||||
Description = club.Description ?? string.Empty,
|
||||
MainImageName = NormalizeImageName(club.MainImageName),
|
||||
State = (int)club.State,
|
||||
MemberCount = club.MemberCount,
|
||||
PrimaryCategory = primary,
|
||||
CategoryTags = normalizedTags,
|
||||
Visibility = (int)club.Visibility,
|
||||
Joinability = (int)club.Joinability,
|
||||
AllowJuniors = club.AllowJuniors,
|
||||
MinLevel = club.MinLevel,
|
||||
ClubChatEnabled = club.ClubChatEnabled,
|
||||
ClubhouseRoomId = club.ClubhouseRoomId,
|
||||
ClubType = (int)club.ClubType,
|
||||
CreatorAccountId = club.CreatorAccountId,
|
||||
ClubMembershipType = (int)membershipType,
|
||||
CreatedAt = club.CreatedAt,
|
||||
UpdatedAt = club.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public static ClubMembershipDto ToMembership(ClubMembershipRecord membership) => new()
|
||||
{
|
||||
AccountId = membership.AccountId,
|
||||
ClubId = membership.ClubId,
|
||||
ClubMembershipType = (int)membership.MembershipType,
|
||||
CreatedAt = membership.CreatedAt
|
||||
};
|
||||
|
||||
public static ClubDetailsDto ToDetails(ClubRecord club, long? viewerAccountId)
|
||||
{
|
||||
int myType = viewerAccountId.HasValue
|
||||
? (int)(GetMembership(club.ClubId, viewerAccountId.Value)?.MembershipType ??
|
||||
ClubMembershipType.None)
|
||||
: (int)ClubMembershipType.None;
|
||||
|
||||
return new ClubDetailsDto
|
||||
{
|
||||
Club = ToSummary(club, viewerAccountId),
|
||||
CategoryTags = ToSummary(club).CategoryTags,
|
||||
AdditionalImages = (club.AdditionalImages ?? new List<string>())
|
||||
.Select((imageName, imageIndex) => new ClubImageDto
|
||||
{
|
||||
ImageName = imageName,
|
||||
ImageIndex = imageIndex
|
||||
})
|
||||
.ToList(),
|
||||
CreatorPermissions = Permissions(club.ClubId, ClubMembershipType.Creator),
|
||||
CoOwnerPermissions = Permissions(club.ClubId, ClubMembershipType.CoOwner),
|
||||
ModeratorPermissions = Permissions(club.ClubId, ClubMembershipType.Moderator),
|
||||
MyMembershipType = myType
|
||||
};
|
||||
}
|
||||
|
||||
public static ClubPermissionsDto Permissions(
|
||||
long clubId,
|
||||
ClubMembershipType membershipType)
|
||||
{
|
||||
bool moderator = membershipType >= ClubMembershipType.Moderator;
|
||||
bool coOwner = membershipType >= ClubMembershipType.CoOwner;
|
||||
return new ClubPermissionsDto
|
||||
{
|
||||
ClubId = clubId,
|
||||
MembershipType = (int)membershipType,
|
||||
CanInvite = moderator,
|
||||
CanKick = moderator,
|
||||
CanBan = moderator,
|
||||
CanModify = coOwner,
|
||||
CanManageRoles = coOwner,
|
||||
CanManageClubhouse = coOwner
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveLegacyPrimaryCategory(
|
||||
string? storedPrimary,
|
||||
IEnumerable<string>? storedTags)
|
||||
{
|
||||
string? primary = NormalizeCategory(storedPrimary);
|
||||
List<string> tags = NormalizeTags(storedTags).ToList();
|
||||
|
||||
if (primary == null)
|
||||
return tags.FirstOrDefault() ?? "Social";
|
||||
|
||||
if (string.Equals(primary, "Social", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string? recovered = tags.FirstOrDefault(value =>
|
||||
!string.Equals(value, "Social", StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(recovered))
|
||||
return recovered;
|
||||
}
|
||||
|
||||
return primary;
|
||||
}
|
||||
|
||||
private static bool ClubMatchesCategory(
|
||||
ClubRecord club,
|
||||
string normalizedCategory)
|
||||
{
|
||||
string primary = NormalizeCategory(club.PrimaryCategory) ?? "Social";
|
||||
if (string.Equals(primary, normalizedCategory,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return (club.CategoryTags ?? new List<string>())
|
||||
.Select(NormalizeCategory)
|
||||
.Any(tag => string.Equals(
|
||||
tag,
|
||||
normalizedCategory,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static long NextClubId() =>
|
||||
Clubs.Count() == 0 ? 1 : Clubs.Max(value => value.ClubId) + 1;
|
||||
|
||||
private static string MembershipId(long clubId, long accountId) =>
|
||||
$"{clubId}:{accountId}";
|
||||
|
||||
private static void UpsertMembershipUnsafe(
|
||||
long clubId,
|
||||
long accountId,
|
||||
ClubMembershipType membershipType)
|
||||
{
|
||||
string id = MembershipId(clubId, accountId);
|
||||
ClubMembershipRecord record = Memberships.FindById(id) ??
|
||||
new ClubMembershipRecord
|
||||
{
|
||||
Id = id,
|
||||
ClubId = clubId,
|
||||
AccountId = accountId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
record.Id = id;
|
||||
record.ClubId = clubId;
|
||||
record.AccountId = accountId;
|
||||
record.MembershipType = membershipType;
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
Memberships.Upsert(record);
|
||||
}
|
||||
|
||||
private static void RefreshMemberCountUnsafe(long clubId)
|
||||
{
|
||||
ClubRecord? club = Clubs.FindById(clubId);
|
||||
if (club == null)
|
||||
return;
|
||||
club.MemberCount = Memberships.Count(value =>
|
||||
value.ClubId == clubId &&
|
||||
value.MembershipType >= ClubMembershipType.Member);
|
||||
club.UpdatedAt = DateTime.UtcNow;
|
||||
Clubs.Update(club);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> NormalizeTags(IEnumerable<string>? tags) =>
|
||||
(tags ?? Array.Empty<string>())
|
||||
.Select(NormalizeCategory)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(3);
|
||||
|
||||
private static string? NormalizeCategory(string? category)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category))
|
||||
return null;
|
||||
|
||||
return category.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"social" => "Social",
|
||||
"hangout" => "Social",
|
||||
"creative" => "Creative",
|
||||
"art" => "Creative",
|
||||
"competitive" => "Competitive",
|
||||
"casual" => "Casual",
|
||||
"games" => "Casual",
|
||||
"roleplay" => "Casual",
|
||||
"educational" => "Educational",
|
||||
"education" => "Educational",
|
||||
"entertainment" => "Entertainment",
|
||||
"quest" => "Entertainment",
|
||||
"music" => "Entertainment",
|
||||
"lifestyle" => "Lifestyle",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeImageName(string? imageName)
|
||||
{
|
||||
string normalized = (imageName ?? string.Empty).Trim().Replace('\\', '/');
|
||||
if (string.IsNullOrWhiteSpace(normalized) ||
|
||||
normalized.Length > 260 ||
|
||||
normalized.Contains("..", StringComparison.Ordinal) ||
|
||||
Uri.TryCreate(normalized, UriKind.Absolute, out _))
|
||||
return "DefaultClubImage2k.jpg";
|
||||
return normalized.TrimStart('/');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1981 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class CreatorFeatureDB
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly LiteDatabase Database = OpenDatabase();
|
||||
|
||||
private static readonly ILiteCollection<InventionRecord> Inventions =
|
||||
Database.GetCollection<InventionRecord>("Inventions");
|
||||
|
||||
private const long MaxClientInventionId = 1_000_000_000;
|
||||
|
||||
private static readonly ILiteCollection<InventionCheerRecord> InventionCheers =
|
||||
Database.GetCollection<InventionCheerRecord>("InventionCheers");
|
||||
private static readonly ILiteCollection<InventionAliasRecord> InventionAliases =
|
||||
Database.GetCollection<InventionAliasRecord>("InventionAliases");
|
||||
private static readonly ILiteCollection<InventionPurchaseRecord> InventionPurchases =
|
||||
Database.GetCollection<InventionPurchaseRecord>("InventionPurchases");
|
||||
private static readonly ILiteCollection<MeetupCodeRecord> MeetupCodes =
|
||||
Database.GetCollection<MeetupCodeRecord>("MeetupCodes");
|
||||
private static readonly ILiteCollection<CloudVariableRecord> CloudVariables =
|
||||
Database.GetCollection<CloudVariableRecord>("CloudVariables");
|
||||
private static readonly ILiteCollection<RoomPlayerDataRecord> RoomPlayerData =
|
||||
Database.GetCollection<RoomPlayerDataRecord>("RoomPlayerData");
|
||||
|
||||
private static LiteDatabase OpenDatabase()
|
||||
{
|
||||
string directory = Path.Combine(Program.dataDir, "DBs");
|
||||
Directory.CreateDirectory(directory);
|
||||
return new LiteDatabase(Path.Combine(directory, "CreatorFeatures.db"));
|
||||
}
|
||||
|
||||
static CreatorFeatureDB()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(Program.dataDir, "DBs"));
|
||||
LiteDbMaintenance.StartPeriodicCheckpoint("CreatorFeatures.db", Database);
|
||||
|
||||
Inventions.EnsureIndex(value => value.CreatorAccountId);
|
||||
Inventions.EnsureIndex(value => value.RoomId);
|
||||
Inventions.EnsureIndex(value => value.UpdatedAtUtc);
|
||||
InventionCheers.EnsureIndex(value => value.InventionId);
|
||||
InventionCheers.EnsureIndex(value => value.AccountId);
|
||||
InventionAliases.EnsureIndex(value => value.CurrentInventionId);
|
||||
InventionPurchases.EnsureIndex(value => value.InventionId);
|
||||
InventionPurchases.EnsureIndex(value => value.AccountId);
|
||||
MeetupCodes.EnsureIndex(value => value.CreatorAccountId);
|
||||
MeetupCodes.EnsureIndex(value => value.RoomInstanceId);
|
||||
MeetupCodes.EnsureIndex(value => value.ExpiresAtUtc);
|
||||
CloudVariables.EnsureIndex(value => value.RoomId);
|
||||
CloudVariables.EnsureIndex(value => value.AccountId);
|
||||
CloudVariables.EnsureIndex(value => value.UpdatedAtUtc);
|
||||
RoomPlayerData.EnsureIndex(value => value.RoomId);
|
||||
RoomPlayerData.EnsureIndex(value => value.AccountId);
|
||||
RoomPlayerData.EnsureIndex(value => value.UpdatedAtUtc);
|
||||
|
||||
MigrateLegacyInventionFiles();
|
||||
RepairOversizedInventionIds();
|
||||
BackfillInventionSaveMetadata();
|
||||
RepairStoredInventionBlobs();
|
||||
}
|
||||
|
||||
public sealed class InventionRecord
|
||||
{
|
||||
[BsonId]
|
||||
public long InventionId { get; set; }
|
||||
public long CreatorAccountId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
public string DataBlob { get; set; } = string.Empty;
|
||||
public string DataBlobHash { get; set; } = string.Empty;
|
||||
public long DataBlobSize { get; set; }
|
||||
|
||||
public int InstantiationCost { get; set; }
|
||||
public int LightsCost { get; set; }
|
||||
public int ChipsCost { get; set; }
|
||||
public int CloudVariablesCost { get; set; }
|
||||
public int AiCost { get; set; }
|
||||
public List<long> ReferencedInventionIds { get; set; } = new();
|
||||
public int CreatorAccountRole { get; set; }
|
||||
public List<string> Tags { get; set; } = new();
|
||||
public int Version { get; set; } = 1;
|
||||
public bool IsPublished { get; set; } = true;
|
||||
public int Uses { get; set; }
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public string RawPayloadJson { get; set; } = "{}";
|
||||
}
|
||||
|
||||
public sealed class InventionAliasRecord
|
||||
{
|
||||
[BsonId]
|
||||
public long LegacyInventionId { get; set; }
|
||||
public long CurrentInventionId { get; set; }
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class InventionPurchaseRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long InventionId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public int PricePaid { get; set; }
|
||||
public DateTime PurchasedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class InventionCheerRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long InventionId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class MeetupCodeRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public long CreatorAccountId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public long SubRoomId { get; set; }
|
||||
public long RoomInstanceId { get; set; }
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ExpiresAtUtc { get; set; } = DateTime.UtcNow.AddHours(6);
|
||||
public int UseCount { get; set; }
|
||||
public int MaxUses { get; set; } = 100;
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class CloudVariableRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long RoomId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string ValueJson { get; set; } = "null";
|
||||
public int Version { get; set; } = 1;
|
||||
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public long UpdatedByAccountId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RoomPlayerDataRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long RoomId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public string DataJson { get; set; } = "{}";
|
||||
public int Version { get; set; } = 1;
|
||||
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public long UpdatedByAccountId { get; set; }
|
||||
}
|
||||
|
||||
public static InventionRecord SaveInvention(
|
||||
long accountId,
|
||||
JsonElement payload,
|
||||
long? forcedInventionId = null)
|
||||
{
|
||||
if (accountId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(accountId));
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
bool isForcedImport = forcedInventionId.GetValueOrDefault() > 0;
|
||||
long requestedId = isForcedImport
|
||||
? forcedInventionId!.Value
|
||||
: FindLong(payload,
|
||||
"InventionId", "inventionId", "Id", "id") ?? 0;
|
||||
|
||||
InventionRecord? existing = requestedId > 0
|
||||
? FindInventionNoLock(requestedId)
|
||||
: null;
|
||||
|
||||
if (existing != null && existing.CreatorAccountId != accountId)
|
||||
throw new UnauthorizedAccessException(
|
||||
"An invention can only be edited by its creator.");
|
||||
|
||||
bool canPreserveImportedId = isForcedImport &&
|
||||
requestedId > 0 &&
|
||||
requestedId <= MaxClientInventionId &&
|
||||
Inventions.FindById(requestedId) == null;
|
||||
long inventionId = existing?.InventionId ??
|
||||
(canPreserveImportedId ? requestedId : NextInventionIdNoLock());
|
||||
DateTime now = DateTime.UtcNow;
|
||||
List<string> tags = ReadStringList(payload,
|
||||
"Tags", "tags", "TagNames", "tagNames");
|
||||
|
||||
var record = existing ?? new InventionRecord
|
||||
{
|
||||
InventionId = inventionId,
|
||||
CreatorAccountId = accountId,
|
||||
CreatedAtUtc = now
|
||||
};
|
||||
|
||||
record.RoomId = FindLong(payload,
|
||||
"CreationRoomId", "creationRoomId",
|
||||
"RoomId", "roomId", "SourceRoomId", "sourceRoomId")
|
||||
?? record.RoomId;
|
||||
record.InstantiationCost = ReadNonNegativeInt(
|
||||
payload,
|
||||
record.InstantiationCost,
|
||||
"InstantiationCost", "instantiationCost", "InstantiateCost", "instantiateCost");
|
||||
record.LightsCost = ReadNonNegativeInt(
|
||||
payload,
|
||||
record.LightsCost,
|
||||
"LightsCost", "lightsCost");
|
||||
record.ChipsCost = ReadNonNegativeInt(
|
||||
payload,
|
||||
record.ChipsCost,
|
||||
"ChipsCost", "chipsCost");
|
||||
record.CloudVariablesCost = ReadNonNegativeInt(
|
||||
payload,
|
||||
record.CloudVariablesCost,
|
||||
"CloudVariablesCost", "cloudVariablesCost", "CloudVariableCost", "cloudVariableCost");
|
||||
record.AiCost = ReadNonNegativeInt(
|
||||
payload,
|
||||
record.AiCost,
|
||||
"AiCost", "aiCost", "AICost");
|
||||
record.CreatorAccountRole = ReadNonNegativeInt(
|
||||
payload,
|
||||
record.CreatorAccountRole,
|
||||
"CreatorAccountRole", "creatorAccountRole");
|
||||
List<long> referencedInventions = ReadLongList(
|
||||
payload,
|
||||
"ReferencedInventions", "referencedInventions",
|
||||
"ReferencedInventionIds", "referencedInventionIds");
|
||||
if (referencedInventions.Count > 0)
|
||||
record.ReferencedInventionIds = referencedInventions.Take(512).ToList();
|
||||
record.Name = Limit(
|
||||
FindString(payload,
|
||||
"Name", "name", "Title", "title",
|
||||
"InventionName", "inventionName")
|
||||
?? record.Name,
|
||||
128);
|
||||
if (string.IsNullOrWhiteSpace(record.Name))
|
||||
record.Name = $"Invention {inventionId}";
|
||||
|
||||
record.Description = Limit(
|
||||
FindString(payload, "Description", "description")
|
||||
?? record.Description,
|
||||
1_000);
|
||||
record.ImageName = Limit(
|
||||
FindString(payload,
|
||||
"ImageName", "imageName", "Image", "image")
|
||||
?? record.ImageName,
|
||||
512);
|
||||
record.DataBlob = Limit(
|
||||
FindString(payload,
|
||||
"ObjectDataFilename", "objectDataFilename",
|
||||
"ObjectDataBlob", "objectDataBlob",
|
||||
"SubRoomDataFilename", "subRoomDataFilename",
|
||||
"DataBlob", "dataBlob", "DataBlobPath", "dataBlobPath",
|
||||
"Filename", "filename", "FileName", "fileName",
|
||||
"DataBlobName", "dataBlobName", "BlobName", "blobName")
|
||||
?? record.DataBlob,
|
||||
512);
|
||||
|
||||
string? requestedBlobHash = FindString(payload,
|
||||
"DataBlobHash", "dataBlobHash",
|
||||
"ObjectDataBlobHash", "objectDataBlobHash",
|
||||
"ObjectDataHash", "objectDataHash", "Hash", "hash");
|
||||
if (!string.IsNullOrWhiteSpace(requestedBlobHash))
|
||||
record.DataBlobHash = Limit(requestedBlobHash, 256);
|
||||
|
||||
long? requestedBlobSize = FindLong(payload,
|
||||
"DataBlobSize", "dataBlobSize", "ObjectDataSize", "objectDataSize",
|
||||
"ContentLength", "contentLength", "Length", "length");
|
||||
if (requestedBlobSize is >= 0)
|
||||
record.DataBlobSize = requestedBlobSize.Value;
|
||||
|
||||
if (tags.Count > 0)
|
||||
record.Tags = tags.Take(30).ToList();
|
||||
|
||||
bool? requestedPublished = FindBool(payload,
|
||||
"IsPublished", "isPublished", "Published", "published");
|
||||
if (requestedPublished.HasValue)
|
||||
record.IsPublished = requestedPublished.Value;
|
||||
|
||||
int requestedVersion = (int)Math.Clamp(
|
||||
FindLong(payload, "Version", "version") ?? 0,
|
||||
0,
|
||||
int.MaxValue);
|
||||
record.Version = Math.Max(
|
||||
Math.Max(1, record.Version + (existing == null ? 0 : 1)),
|
||||
requestedVersion);
|
||||
record.UpdatedAtUtc = now;
|
||||
record.RawPayloadJson = payload.GetRawText();
|
||||
RepairInventionBlobMetadataNoLock(record);
|
||||
|
||||
Inventions.Upsert(record);
|
||||
|
||||
if (existing == null && requestedId > 0 && requestedId != inventionId)
|
||||
{
|
||||
InventionAliases.Upsert(new InventionAliasRecord
|
||||
{
|
||||
LegacyInventionId = requestedId,
|
||||
CurrentInventionId = inventionId,
|
||||
CreatedAtUtc = now
|
||||
});
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public static InventionRecord? GetInvention(long inventionId)
|
||||
{
|
||||
if (inventionId <= 0)
|
||||
return null;
|
||||
|
||||
lock (Sync)
|
||||
return FindInventionNoLock(inventionId);
|
||||
}
|
||||
|
||||
public static long ResolveInventionId(long inventionId)
|
||||
{
|
||||
InventionRecord? record = GetInvention(inventionId);
|
||||
return record?.InventionId ?? inventionId;
|
||||
}
|
||||
|
||||
public static bool RecordInventionPurchase(
|
||||
long accountId,
|
||||
long inventionId,
|
||||
int pricePaid = 0)
|
||||
{
|
||||
if (accountId <= 0 || inventionId <= 0 || pricePaid < 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
InventionRecord? invention = FindInventionNoLock(inventionId);
|
||||
if (invention == null ||
|
||||
(!invention.IsPublished && invention.CreatorAccountId != accountId) ||
|
||||
!HasValidInventionBlob(invention))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
InventionPurchases.Upsert(new InventionPurchaseRecord
|
||||
{
|
||||
Id = InventionPurchaseId(invention.InventionId, accountId),
|
||||
InventionId = invention.InventionId,
|
||||
AccountId = accountId,
|
||||
PricePaid = pricePaid,
|
||||
PurchasedAtUtc = DateTime.UtcNow
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasInventionAccess(long accountId, long inventionId)
|
||||
{
|
||||
if (accountId <= 0 || inventionId <= 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
InventionRecord? invention = FindInventionNoLock(inventionId);
|
||||
if (invention == null)
|
||||
return false;
|
||||
return invention.CreatorAccountId == accountId ||
|
||||
InventionPurchases.Exists(value =>
|
||||
value.Id == InventionPurchaseId(invention.InventionId, accountId));
|
||||
}
|
||||
}
|
||||
|
||||
public static InventionRecord? SetInventionTags(
|
||||
long inventionId,
|
||||
long accountId,
|
||||
IEnumerable<string> tags)
|
||||
{
|
||||
if (inventionId <= 0)
|
||||
return null;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
InventionRecord? record = Inventions.FindById(inventionId);
|
||||
if (record == null)
|
||||
return null;
|
||||
if (record.CreatorAccountId != accountId)
|
||||
{
|
||||
throw new UnauthorizedAccessException(
|
||||
"An invention can only be edited by its creator.");
|
||||
}
|
||||
|
||||
record.Tags = tags
|
||||
.Select(value => Limit(value.Trim(), 32))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(30)
|
||||
.ToList();
|
||||
record.Version = Math.Max(1, record.Version + 1);
|
||||
record.UpdatedAtUtc = DateTime.UtcNow;
|
||||
Inventions.Update(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public static InventionRecord? IncrementInventionUse(long inventionId)
|
||||
{
|
||||
if (inventionId <= 0)
|
||||
return null;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
InventionRecord? record = Inventions.FindById(inventionId);
|
||||
if (record == null)
|
||||
return null;
|
||||
record.Uses = Math.Max(0, record.Uses) + 1;
|
||||
record.UpdatedAtUtc = DateTime.UtcNow;
|
||||
Inventions.Update(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool DeleteInvention(long inventionId, long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
InventionRecord? record = Inventions.FindById(inventionId);
|
||||
if (record == null || record.CreatorAccountId != accountId ||
|
||||
!Inventions.Delete(inventionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
InventionCheers.DeleteMany(value => value.InventionId == inventionId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SetInventionCheer(
|
||||
long inventionId,
|
||||
long accountId,
|
||||
bool cheered)
|
||||
{
|
||||
if (inventionId <= 0 || accountId <= 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
if (Inventions.FindById(inventionId) == null)
|
||||
return false;
|
||||
|
||||
string id = InventionCheerId(inventionId, accountId);
|
||||
if (!cheered)
|
||||
{
|
||||
InventionCheers.Delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
InventionCheers.Upsert(new InventionCheerRecord
|
||||
{
|
||||
Id = id,
|
||||
InventionId = inventionId,
|
||||
AccountId = accountId,
|
||||
CreatedAtUtc = DateTime.UtcNow
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsInventionCheered(long inventionId, long accountId)
|
||||
{
|
||||
if (inventionId <= 0 || accountId <= 0)
|
||||
return false;
|
||||
return InventionCheers.Exists(value =>
|
||||
value.Id == InventionCheerId(inventionId, accountId));
|
||||
}
|
||||
|
||||
public static int GetInventionCheerCount(long inventionId) =>
|
||||
inventionId <= 0
|
||||
? 0
|
||||
: InventionCheers.Count(value => value.InventionId == inventionId);
|
||||
|
||||
private static string InventionCheerId(long inventionId, long accountId) =>
|
||||
$"{accountId}:{inventionId}";
|
||||
|
||||
public static List<InventionRecord> SearchInventions(
|
||||
string? query = null,
|
||||
long? creatorAccountId = null,
|
||||
long? roomId = null,
|
||||
string? tag = null,
|
||||
bool includeUnpublished = false,
|
||||
int skip = 0,
|
||||
int take = 100)
|
||||
{
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
string q = query?.Trim() ?? string.Empty;
|
||||
string wantedTag = tag?.Trim() ?? string.Empty;
|
||||
|
||||
IEnumerable<InventionRecord> rows = Inventions.FindAll();
|
||||
|
||||
if (!includeUnpublished)
|
||||
rows = rows.Where(HasValidInventionBlob);
|
||||
if (!includeUnpublished)
|
||||
rows = rows.Where(value => value.IsPublished);
|
||||
if (creatorAccountId.HasValue && creatorAccountId.Value > 0)
|
||||
rows = rows.Where(value => value.CreatorAccountId == creatorAccountId.Value);
|
||||
if (roomId.HasValue && roomId.Value > 0)
|
||||
rows = rows.Where(value => value.RoomId == roomId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(wantedTag))
|
||||
{
|
||||
rows = rows.Where(value => value.Tags?.Any(item =>
|
||||
string.Equals(item, wantedTag, StringComparison.OrdinalIgnoreCase)) == true);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
rows = rows.Where(value =>
|
||||
value.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Description.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Tags.Any(item =>
|
||||
item.Contains(q, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
return rows
|
||||
.OrderByDescending(value => value.Uses)
|
||||
.ThenByDescending(value => value.UpdatedAtUtc)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static int CountInventions(
|
||||
string? query = null,
|
||||
long? creatorAccountId = null,
|
||||
long? roomId = null,
|
||||
string? tag = null,
|
||||
bool includeUnpublished = false)
|
||||
{
|
||||
string q = query?.Trim() ?? string.Empty;
|
||||
string wantedTag = tag?.Trim() ?? string.Empty;
|
||||
IEnumerable<InventionRecord> rows = Inventions.FindAll();
|
||||
if (!includeUnpublished)
|
||||
rows = rows.Where(HasValidInventionBlob);
|
||||
if (!includeUnpublished)
|
||||
rows = rows.Where(value => value.IsPublished);
|
||||
if (creatorAccountId.HasValue && creatorAccountId.Value > 0)
|
||||
rows = rows.Where(value => value.CreatorAccountId == creatorAccountId.Value);
|
||||
if (roomId.HasValue && roomId.Value > 0)
|
||||
rows = rows.Where(value => value.RoomId == roomId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(wantedTag))
|
||||
{
|
||||
rows = rows.Where(value => value.Tags?.Any(item =>
|
||||
string.Equals(item, wantedTag, StringComparison.OrdinalIgnoreCase)) == true);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
rows = rows.Where(value =>
|
||||
value.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Description.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Tags.Any(item => item.Contains(q, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
return rows.Count();
|
||||
}
|
||||
|
||||
public static object? ToClientInventionVersion(InventionRecord value)
|
||||
{
|
||||
if (!TryGetInventionBlob(
|
||||
value,
|
||||
out _,
|
||||
out string dataBlobPath,
|
||||
out string dataBlobName,
|
||||
out string dataBlobHash,
|
||||
out long dataBlobSize))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int versionNumber = Math.Max(1, value.Version);
|
||||
return new
|
||||
{
|
||||
InventionId = value.InventionId,
|
||||
Id = value.InventionId,
|
||||
ReplicationId = StableReplicationId(
|
||||
"invention-version",
|
||||
value.InventionId,
|
||||
versionNumber),
|
||||
VersionNumber = versionNumber,
|
||||
Version = versionNumber,
|
||||
InventionVersionId = value.InventionId,
|
||||
VersionId = value.InventionId,
|
||||
BlobName = dataBlobName,
|
||||
DataBlob = dataBlobName,
|
||||
DataBlobName = dataBlobName,
|
||||
DataBlobPath = dataBlobPath,
|
||||
ObjectDataFilename = dataBlobName,
|
||||
BlobHash = dataBlobHash,
|
||||
DataBlobHash = dataBlobHash,
|
||||
BlobSize = dataBlobSize,
|
||||
DataBlobSize = dataBlobSize,
|
||||
BlobUrl = "/cdn/" + dataBlobPath.TrimStart('/'),
|
||||
DownloadUrl = "/cdn/" + dataBlobPath.TrimStart('/'),
|
||||
InstantiationCost = Math.Max(0, value.InstantiationCost),
|
||||
LightsCost = Math.Max(0, value.LightsCost),
|
||||
ChipsCost = Math.Max(0, value.ChipsCost),
|
||||
CloudVariablesCost = Math.Max(0, value.CloudVariablesCost),
|
||||
AiCost = Math.Max(0, value.AiCost),
|
||||
CanSpawn = true
|
||||
};
|
||||
}
|
||||
|
||||
public static object ToClientInvention(InventionRecord value)
|
||||
{
|
||||
bool hasBlob = TryGetInventionBlob(
|
||||
value,
|
||||
out _,
|
||||
out string dataBlobPath,
|
||||
out string dataBlobName,
|
||||
out string dataBlobHash,
|
||||
out long dataBlobSize);
|
||||
|
||||
if (hasBlob &&
|
||||
(!string.Equals(value.DataBlob, dataBlobPath, StringComparison.Ordinal) ||
|
||||
!string.Equals(value.DataBlobHash, dataBlobHash, StringComparison.Ordinal) ||
|
||||
value.DataBlobSize != dataBlobSize))
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
value.DataBlob = dataBlobPath;
|
||||
value.DataBlobHash = dataBlobHash;
|
||||
value.DataBlobSize = dataBlobSize;
|
||||
Inventions.Upsert(value);
|
||||
}
|
||||
}
|
||||
|
||||
int currentVersionNumber = Math.Max(1, value.Version);
|
||||
string inventionReplicationId = StableReplicationId(
|
||||
"invention",
|
||||
value.InventionId,
|
||||
0);
|
||||
object? currentVersion = hasBlob
|
||||
? ToClientInventionVersion(value)
|
||||
: null;
|
||||
|
||||
return new
|
||||
{
|
||||
InventionId = value.InventionId,
|
||||
Id = value.InventionId,
|
||||
ReplicationId = inventionReplicationId,
|
||||
Name = value.Name,
|
||||
InventionName = value.Name,
|
||||
Description = value.Description,
|
||||
CreatorAccountId = value.CreatorAccountId,
|
||||
CreatorPlayerId = value.CreatorAccountId,
|
||||
CreatorId = value.CreatorAccountId,
|
||||
RoomId = value.RoomId,
|
||||
ImageName = value.ImageName,
|
||||
Image = value.ImageName,
|
||||
PreviewImageName = value.ImageName,
|
||||
DataBlob = hasBlob ? dataBlobName : string.Empty,
|
||||
DataBlobPath = hasBlob ? dataBlobPath : string.Empty,
|
||||
DataBlobName = hasBlob ? dataBlobName : string.Empty,
|
||||
Filename = hasBlob ? dataBlobName : string.Empty,
|
||||
ObjectDataFilename = hasBlob ? dataBlobName : string.Empty,
|
||||
ObjectDataBlob = hasBlob ? dataBlobName : string.Empty,
|
||||
DataBlobHash = hasBlob ? dataBlobHash : string.Empty,
|
||||
ObjectDataBlobHash = hasBlob ? dataBlobHash : string.Empty,
|
||||
DataBlobSize = hasBlob ? dataBlobSize : 0,
|
||||
InstantiationCost = Math.Max(0, value.InstantiationCost),
|
||||
LightsCost = Math.Max(0, value.LightsCost),
|
||||
ChipsCost = Math.Max(0, value.ChipsCost),
|
||||
CloudVariablesCost = Math.Max(0, value.CloudVariablesCost),
|
||||
AiCost = Math.Max(0, value.AiCost),
|
||||
ReferencedInventions = value.ReferencedInventionIds ?? new List<long>(),
|
||||
ReferencedInventionIds = value.ReferencedInventionIds ?? new List<long>(),
|
||||
DataBlobUrl = hasBlob
|
||||
? "/cdn/" + dataBlobPath.TrimStart('/')
|
||||
: string.Empty,
|
||||
HasValidObjects = hasBlob,
|
||||
CanSpawn = hasBlob,
|
||||
Tags = value.Tags ?? new List<string>(),
|
||||
TagNames = value.Tags ?? new List<string>(),
|
||||
CurrentVersionNumber = currentVersionNumber,
|
||||
CurrentVersion = currentVersion,
|
||||
InventionVersion = currentVersion,
|
||||
LatestVersion = currentVersion,
|
||||
Version = currentVersionNumber,
|
||||
Accessibility = 1,
|
||||
CreationRoomId = value.RoomId,
|
||||
CreatorPermission = 100,
|
||||
|
||||
GeneralPermission = 20,
|
||||
IsFeatured = false,
|
||||
IsAGInvention = false,
|
||||
IsCertifiedInvention = false,
|
||||
|
||||
Price = 0,
|
||||
AllowTrial = true,
|
||||
HideFromPlayer = false,
|
||||
IsPublished = value.IsPublished,
|
||||
Published = value.IsPublished,
|
||||
Uses = value.Uses,
|
||||
NumDownloads = value.Uses,
|
||||
NumPlayersHaveUsedInRoom = value.Uses,
|
||||
CheerCount = GetInventionCheerCount(value.InventionId),
|
||||
CreatedAt = value.CreatedAtUtc,
|
||||
FirstPublishedAt = value.CreatedAtUtc,
|
||||
CreationDate = value.CreatedAtUtc,
|
||||
ModifiedAt = value.UpdatedAtUtc,
|
||||
UpdatedAt = value.UpdatedAtUtc,
|
||||
LastUpdated = value.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
public static bool HasValidInventionBlob(InventionRecord value) =>
|
||||
TryGetInventionBlob(
|
||||
value,
|
||||
out _,
|
||||
out _,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
public static bool TryGetInventionBlob(
|
||||
InventionRecord value,
|
||||
out string fullPath,
|
||||
out string relativePath,
|
||||
out string filename,
|
||||
out string hashBase64,
|
||||
out long size)
|
||||
{
|
||||
fullPath = string.Empty;
|
||||
relativePath = string.Empty;
|
||||
filename = string.Empty;
|
||||
hashBase64 = string.Empty;
|
||||
size = 0;
|
||||
|
||||
var candidates = new List<(string Source, string Value)>();
|
||||
if (!string.IsNullOrWhiteSpace(value.DataBlob))
|
||||
candidates.Add(("storedDataBlob", value.DataBlob));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(value.RawPayloadJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(value.RawPayloadJson);
|
||||
CollectInventionBlobCandidates(document.RootElement, candidates);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
string cdnRoot = Path.GetFullPath(Path.Combine(Program.dataDir, "CDN"));
|
||||
string inventionDirectory = Path.Combine(cdnRoot, "invention");
|
||||
string roomDirectory = Path.Combine(cdnRoot, "room");
|
||||
Directory.CreateDirectory(inventionDirectory);
|
||||
|
||||
(string FullPath, string Name, long Size, int Score)? best = null;
|
||||
foreach ((string source, string rawValue) in candidates)
|
||||
{
|
||||
string normalized = NormalizeCandidateBlobValue(rawValue);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
continue;
|
||||
|
||||
string safeName = Path.GetFileName(normalized);
|
||||
if (safeName.Length == 0 || safeName.Length > 180 ||
|
||||
safeName.Any(character =>
|
||||
!char.IsLetterOrDigit(character) &&
|
||||
character is not '-' and not '_' and not '.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var blobNames = new List<string> { safeName };
|
||||
if (safeName.EndsWith(".inv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string extensionless = safeName[..^4];
|
||||
if (!string.IsNullOrWhiteSpace(extensionless))
|
||||
blobNames.Add(extensionless);
|
||||
}
|
||||
else
|
||||
{
|
||||
string baseName = Path.GetFileNameWithoutExtension(safeName);
|
||||
if (string.IsNullOrWhiteSpace(baseName))
|
||||
baseName = safeName;
|
||||
blobNames.Add(baseName + ".inv");
|
||||
}
|
||||
|
||||
var paths = new List<string>();
|
||||
if (normalized.Contains('/'))
|
||||
paths.Add(Path.Combine(cdnRoot, normalized.Replace('/', Path.DirectorySeparatorChar)));
|
||||
foreach (string blobName in blobNames.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
paths.Add(Path.Combine(inventionDirectory, blobName));
|
||||
paths.Add(Path.Combine(roomDirectory, blobName));
|
||||
paths.Add(Path.Combine(cdnRoot, blobName));
|
||||
}
|
||||
|
||||
foreach (string candidatePath in paths.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
string resolved;
|
||||
try
|
||||
{
|
||||
resolved = Path.GetFullPath(candidatePath);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!resolved.StartsWith(cdnRoot + Path.DirectorySeparatorChar,
|
||||
StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(resolved))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = new FileInfo(resolved);
|
||||
if (info.Length <= 0 || LooksLikeImageFile(resolved))
|
||||
continue;
|
||||
|
||||
int score = ScoreInventionBlobCandidate(source, resolved, info.Length);
|
||||
if (best == null || score > best.Value.Score)
|
||||
best = (resolved, info.Name, info.Length, score);
|
||||
}
|
||||
}
|
||||
|
||||
if (best == null)
|
||||
return false;
|
||||
|
||||
string canonicalBaseName = Path.GetFileNameWithoutExtension(best.Value.Name);
|
||||
if (string.IsNullOrWhiteSpace(canonicalBaseName))
|
||||
canonicalBaseName = best.Value.Name;
|
||||
string canonicalName = best.Value.Name.EndsWith(
|
||||
".inv",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? best.Value.Name
|
||||
: canonicalBaseName + ".inv";
|
||||
string canonicalPath = Path.Combine(inventionDirectory, canonicalName);
|
||||
if (!string.Equals(best.Value.FullPath, canonicalPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(canonicalPath))
|
||||
File.Copy(best.Value.FullPath, canonicalPath, overwrite: false);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
fullPath = File.Exists(canonicalPath) ? canonicalPath : best.Value.FullPath;
|
||||
var finalInfo = new FileInfo(fullPath);
|
||||
if (!finalInfo.Exists || finalInfo.Length <= 0 || LooksLikeImageFile(fullPath))
|
||||
return false;
|
||||
|
||||
filename = finalInfo.Name;
|
||||
relativePath = string.Equals(
|
||||
Path.GetDirectoryName(fullPath),
|
||||
inventionDirectory,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? "invention/" + filename
|
||||
: "room/" + filename;
|
||||
size = finalInfo.Length;
|
||||
if (!string.IsNullOrWhiteSpace(value.DataBlobHash) &&
|
||||
value.DataBlobSize == size)
|
||||
{
|
||||
hashBase64 = value.DataBlobHash;
|
||||
}
|
||||
else
|
||||
{
|
||||
using FileStream stream = File.OpenRead(fullPath);
|
||||
hashBase64 = Convert.ToBase64String(SHA256.HashData(stream));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void RepairInventionBlobMetadataNoLock(InventionRecord value)
|
||||
{
|
||||
if (!TryGetInventionBlob(
|
||||
value,
|
||||
out _,
|
||||
out string relativePath,
|
||||
out _,
|
||||
out string hashBase64,
|
||||
out long size))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
value.DataBlob = relativePath;
|
||||
value.DataBlobHash = hashBase64;
|
||||
value.DataBlobSize = size;
|
||||
}
|
||||
|
||||
private static void BackfillInventionSaveMetadata()
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
int repaired = 0;
|
||||
foreach (InventionRecord record in Inventions.FindAll().ToList())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.RawPayloadJson))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(record.RawPayloadJson);
|
||||
JsonElement payload = document.RootElement;
|
||||
bool changed = false;
|
||||
|
||||
long roomId = FindLong(payload,
|
||||
"CreationRoomId", "creationRoomId",
|
||||
"RoomId", "roomId", "SourceRoomId", "sourceRoomId") ?? 0;
|
||||
if (record.RoomId <= 0 && roomId > 0)
|
||||
{
|
||||
record.RoomId = roomId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed |= TryBackfillNonNegativeInt(
|
||||
payload,
|
||||
record.InstantiationCost,
|
||||
value => record.InstantiationCost = value,
|
||||
"InstantiationCost", "instantiationCost", "InstantiateCost", "instantiateCost");
|
||||
changed |= TryBackfillNonNegativeInt(
|
||||
payload,
|
||||
record.LightsCost,
|
||||
value => record.LightsCost = value,
|
||||
"LightsCost", "lightsCost");
|
||||
changed |= TryBackfillNonNegativeInt(
|
||||
payload,
|
||||
record.ChipsCost,
|
||||
value => record.ChipsCost = value,
|
||||
"ChipsCost", "chipsCost");
|
||||
changed |= TryBackfillNonNegativeInt(
|
||||
payload,
|
||||
record.CloudVariablesCost,
|
||||
value => record.CloudVariablesCost = value,
|
||||
"CloudVariablesCost", "cloudVariablesCost", "CloudVariableCost", "cloudVariableCost");
|
||||
changed |= TryBackfillNonNegativeInt(
|
||||
payload,
|
||||
record.AiCost,
|
||||
value => record.AiCost = value,
|
||||
"AiCost", "aiCost", "AICost");
|
||||
changed |= TryBackfillNonNegativeInt(
|
||||
payload,
|
||||
record.CreatorAccountRole,
|
||||
value => record.CreatorAccountRole = value,
|
||||
"CreatorAccountRole", "creatorAccountRole");
|
||||
|
||||
List<long> references = ReadLongList(payload,
|
||||
"ReferencedInventions", "referencedInventions",
|
||||
"ReferencedInventionIds", "referencedInventionIds");
|
||||
if ((record.ReferencedInventionIds == null || record.ReferencedInventionIds.Count == 0) &&
|
||||
references.Count > 0)
|
||||
{
|
||||
record.ReferencedInventionIds = references.Take(512).ToList();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
continue;
|
||||
|
||||
Inventions.Update(record);
|
||||
repaired++;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (repaired > 0)
|
||||
Console.WriteLine($"[INVENTION METADATA REPAIR] repaired={repaired}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryBackfillNonNegativeInt(
|
||||
JsonElement payload,
|
||||
int currentValue,
|
||||
Action<int> setter,
|
||||
params string[] names)
|
||||
{
|
||||
long? parsed = FindLong(payload, names);
|
||||
if (!parsed.HasValue)
|
||||
return false;
|
||||
|
||||
int value = (int)Math.Clamp(parsed.Value, 0, int.MaxValue);
|
||||
if (currentValue == value)
|
||||
return false;
|
||||
|
||||
setter(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int ReadNonNegativeInt(
|
||||
JsonElement payload,
|
||||
int fallback,
|
||||
params string[] names)
|
||||
{
|
||||
long? parsed = FindLong(payload, names);
|
||||
return parsed.HasValue
|
||||
? (int)Math.Clamp(parsed.Value, 0, int.MaxValue)
|
||||
: Math.Max(0, fallback);
|
||||
}
|
||||
|
||||
private static void RepairStoredInventionBlobs()
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
int repaired = 0;
|
||||
foreach (InventionRecord record in Inventions.FindAll().ToList())
|
||||
{
|
||||
string oldBlob = record.DataBlob;
|
||||
string oldHash = record.DataBlobHash;
|
||||
long oldSize = record.DataBlobSize;
|
||||
RepairInventionBlobMetadataNoLock(record);
|
||||
if (!string.Equals(oldBlob, record.DataBlob, StringComparison.Ordinal) ||
|
||||
!string.Equals(oldHash, record.DataBlobHash, StringComparison.Ordinal) ||
|
||||
oldSize != record.DataBlobSize)
|
||||
{
|
||||
Inventions.Update(record);
|
||||
repaired++;
|
||||
}
|
||||
}
|
||||
|
||||
if (repaired > 0)
|
||||
Console.WriteLine($"[INVENTION BLOB REPAIR] repaired={repaired}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectInventionBlobCandidates(
|
||||
JsonElement element,
|
||||
ICollection<(string Source, string Value)> output)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
string lowerName = property.Name.ToLowerInvariant();
|
||||
bool looksLikeBlobField =
|
||||
lowerName.Contains("blob", StringComparison.Ordinal) ||
|
||||
lowerName.Contains("filename", StringComparison.Ordinal) ||
|
||||
lowerName is "file" or "datafile" or "objectdata";
|
||||
bool looksLikePreview =
|
||||
lowerName.Contains("image", StringComparison.Ordinal) ||
|
||||
lowerName.Contains("preview", StringComparison.Ordinal) ||
|
||||
lowerName.Contains("thumbnail", StringComparison.Ordinal) ||
|
||||
lowerName.Contains("icon", StringComparison.Ordinal);
|
||||
|
||||
if (looksLikeBlobField && !looksLikePreview)
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
string? text = property.Value.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
output.Add((property.Name, text));
|
||||
}
|
||||
else if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
{
|
||||
CollectInventionBlobCandidates(property.Value, output);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
CollectInventionBlobCandidates(property.Value, output);
|
||||
}
|
||||
|
||||
if (TryParseEmbeddedJson(property.Value, out JsonElement embedded))
|
||||
CollectInventionBlobCandidates(embedded, output);
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
CollectInventionBlobCandidates(item, output);
|
||||
}
|
||||
else if (TryParseEmbeddedJson(element, out JsonElement embedded))
|
||||
{
|
||||
CollectInventionBlobCandidates(embedded, output);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeCandidateBlobValue(string? value)
|
||||
{
|
||||
string normalized = (value ?? string.Empty).Replace('\\', '/').Trim();
|
||||
if (normalized.StartsWith("/cdn/", StringComparison.OrdinalIgnoreCase))
|
||||
normalized = normalized[5..];
|
||||
normalized = normalized.TrimStart('/');
|
||||
if (normalized.Contains("..", StringComparison.Ordinal))
|
||||
return string.Empty;
|
||||
|
||||
if (normalized.StartsWith("room/invention/", StringComparison.OrdinalIgnoreCase))
|
||||
normalized = normalized[5..];
|
||||
if (normalized.StartsWith("invention/room/", StringComparison.OrdinalIgnoreCase))
|
||||
normalized = normalized[10..];
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static int ScoreInventionBlobCandidate(
|
||||
string source,
|
||||
string fullPath,
|
||||
long length)
|
||||
{
|
||||
string lower = source.ToLowerInvariant();
|
||||
int score = 0;
|
||||
if (lower.Contains("object", StringComparison.Ordinal)) score += 500;
|
||||
if (lower.Contains("data", StringComparison.Ordinal)) score += 300;
|
||||
if (lower.Contains("blob", StringComparison.Ordinal)) score += 200;
|
||||
if (lower.Contains("filename", StringComparison.Ordinal)) score += 100;
|
||||
if (fullPath.Contains(
|
||||
Path.DirectorySeparatorChar + "invention" + Path.DirectorySeparatorChar,
|
||||
StringComparison.OrdinalIgnoreCase)) score += 50;
|
||||
score += (int)Math.Min(length / 1024, 100);
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool LooksLikeImageFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Span<byte> header = stackalloc byte[12];
|
||||
using FileStream stream = File.OpenRead(path);
|
||||
int read = stream.Read(header);
|
||||
if (read >= 8 &&
|
||||
header[0] == 0x89 && header[1] == 0x50 &&
|
||||
header[2] == 0x4E && header[3] == 0x47)
|
||||
return true;
|
||||
if (read >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF)
|
||||
return true;
|
||||
if (read >= 6 &&
|
||||
header[0] == (byte)'G' && header[1] == (byte)'I' &&
|
||||
header[2] == (byte)'F')
|
||||
return true;
|
||||
if (read >= 12 &&
|
||||
header[0] == (byte)'R' && header[1] == (byte)'I' &&
|
||||
header[2] == (byte)'F' && header[3] == (byte)'F' &&
|
||||
header[8] == (byte)'W' && header[9] == (byte)'E' &&
|
||||
header[10] == (byte)'B' && header[11] == (byte)'P')
|
||||
return true;
|
||||
return read >= 2 && header[0] == (byte)'B' && header[1] == (byte)'M';
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static MeetupCodeRecord CreateMeetupCode(
|
||||
long creatorAccountId,
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
long roomInstanceId,
|
||||
int maxUses = 100,
|
||||
int lifetimeMinutes = 360)
|
||||
{
|
||||
if (creatorAccountId <= 0 || roomId <= 0 || roomInstanceId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(roomInstanceId));
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredMeetupCodesNoLock();
|
||||
|
||||
MeetupCodeRecord? existing = MeetupCodes.Find(value =>
|
||||
value.CreatorAccountId == creatorAccountId &&
|
||||
value.RoomInstanceId == roomInstanceId &&
|
||||
value.IsActive)
|
||||
.OrderByDescending(value => value.CreatedAtUtc)
|
||||
.FirstOrDefault(value => value.ExpiresAtUtc > DateTime.UtcNow);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
string code;
|
||||
do
|
||||
{
|
||||
code = GenerateMeetupCode();
|
||||
}
|
||||
while (MeetupCodes.Exists(value => value.Code == code));
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
var record = new MeetupCodeRecord
|
||||
{
|
||||
Code = code,
|
||||
CreatorAccountId = creatorAccountId,
|
||||
RoomId = roomId,
|
||||
SubRoomId = subRoomId,
|
||||
RoomInstanceId = roomInstanceId,
|
||||
CreatedAtUtc = now,
|
||||
ExpiresAtUtc = now.AddMinutes(Math.Clamp(lifetimeMinutes, 5, 1_440)),
|
||||
MaxUses = Math.Clamp(maxUses, 1, 1_000),
|
||||
IsActive = true
|
||||
};
|
||||
MeetupCodes.Insert(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public static MeetupCodeRecord? GetMeetupCode(string? code)
|
||||
{
|
||||
string normalized = NormalizeMeetupCode(code);
|
||||
if (normalized.Length == 0)
|
||||
return null;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredMeetupCodesNoLock();
|
||||
MeetupCodeRecord? record = MeetupCodes.FindById(normalized);
|
||||
return record != null && record.IsActive &&
|
||||
record.ExpiresAtUtc > DateTime.UtcNow &&
|
||||
record.UseCount < record.MaxUses
|
||||
? record
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecordMeetupCodeUse(string code)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
MeetupCodeRecord? record = MeetupCodes.FindById(NormalizeMeetupCode(code));
|
||||
if (record == null)
|
||||
return;
|
||||
record.UseCount++;
|
||||
if (record.UseCount >= record.MaxUses)
|
||||
record.IsActive = false;
|
||||
MeetupCodes.Update(record);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool RevokeMeetupCode(string code, long callerAccountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
MeetupCodeRecord? record = MeetupCodes.FindById(NormalizeMeetupCode(code));
|
||||
if (record == null || record.CreatorAccountId != callerAccountId)
|
||||
return false;
|
||||
record.IsActive = false;
|
||||
return MeetupCodes.Update(record);
|
||||
}
|
||||
}
|
||||
|
||||
public static object ToClientMeetupCode(MeetupCodeRecord value) => new
|
||||
{
|
||||
Code = value.Code,
|
||||
MeetupCode = value.Code,
|
||||
CreatorAccountId = value.CreatorAccountId,
|
||||
RoomId = value.RoomId,
|
||||
SubRoomId = value.SubRoomId,
|
||||
RoomInstanceId = value.RoomInstanceId,
|
||||
CreatedAt = value.CreatedAtUtc,
|
||||
ExpiresAt = value.ExpiresAtUtc,
|
||||
UseCount = value.UseCount,
|
||||
MaxUses = value.MaxUses,
|
||||
IsActive = value.IsActive
|
||||
};
|
||||
|
||||
public static CloudVariableRecord SetCloudVariable(
|
||||
long roomId,
|
||||
long accountId,
|
||||
string key,
|
||||
string valueJson,
|
||||
long updatedByAccountId)
|
||||
{
|
||||
if (roomId <= 0 || accountId < 0 || updatedByAccountId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(roomId));
|
||||
|
||||
string normalizedKey = NormalizeCloudKey(key);
|
||||
if (normalizedKey.Length == 0)
|
||||
throw new ArgumentException("A cloud variable key is required.", nameof(key));
|
||||
if (Encoding.UTF8.GetByteCount(valueJson) > 16 * 1024)
|
||||
throw new ArgumentException("Cloud variable values are limited to 16 KiB.", nameof(valueJson));
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
string id = BuildCloudVariableId(roomId, accountId, normalizedKey);
|
||||
CloudVariableRecord? existing = CloudVariables.FindById(id);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
int count = CloudVariables.Count(value =>
|
||||
value.RoomId == roomId && value.AccountId == accountId);
|
||||
if (count >= 512)
|
||||
throw new InvalidOperationException(
|
||||
"This room/player scope already has 512 cloud variables.");
|
||||
}
|
||||
|
||||
var record = existing ?? new CloudVariableRecord
|
||||
{
|
||||
Id = id,
|
||||
RoomId = roomId,
|
||||
AccountId = accountId,
|
||||
Key = normalizedKey,
|
||||
Version = 0
|
||||
};
|
||||
|
||||
record.ValueJson = string.IsNullOrWhiteSpace(valueJson) ? "null" : valueJson;
|
||||
record.Version = Math.Max(1, record.Version + 1);
|
||||
record.UpdatedAtUtc = DateTime.UtcNow;
|
||||
record.UpdatedByAccountId = updatedByAccountId;
|
||||
CloudVariables.Upsert(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public static CloudVariableRecord? GetCloudVariable(
|
||||
long roomId,
|
||||
long accountId,
|
||||
string key)
|
||||
{
|
||||
if (roomId <= 0 || accountId < 0)
|
||||
return null;
|
||||
return CloudVariables.FindById(
|
||||
BuildCloudVariableId(roomId, accountId, NormalizeCloudKey(key)));
|
||||
}
|
||||
|
||||
public static List<CloudVariableRecord> GetCloudVariables(
|
||||
long roomId,
|
||||
long accountId,
|
||||
bool includeShared = true)
|
||||
{
|
||||
if (roomId <= 0 || accountId < 0)
|
||||
return new List<CloudVariableRecord>();
|
||||
|
||||
return CloudVariables.Find(value =>
|
||||
value.RoomId == roomId &&
|
||||
(value.AccountId == accountId ||
|
||||
(includeShared && value.AccountId == 0)))
|
||||
.OrderBy(value => value.Key)
|
||||
.ThenBy(value => value.AccountId)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static bool DeleteCloudVariable(
|
||||
long roomId,
|
||||
long accountId,
|
||||
string key) =>
|
||||
CloudVariables.Delete(
|
||||
BuildCloudVariableId(roomId, accountId, NormalizeCloudKey(key)));
|
||||
|
||||
public static RoomPlayerDataRecord SaveRoomPlayerData(
|
||||
long roomId,
|
||||
long accountId,
|
||||
string dataJson,
|
||||
long updatedByAccountId)
|
||||
{
|
||||
if (roomId <= 0 || accountId <= 0 || updatedByAccountId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(roomId));
|
||||
|
||||
string normalizedJson = string.IsNullOrWhiteSpace(dataJson)
|
||||
? "{}"
|
||||
: dataJson;
|
||||
if (Encoding.UTF8.GetByteCount(normalizedJson) > 256 * 1024)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Room player data is limited to 256 KiB.",
|
||||
nameof(dataJson));
|
||||
}
|
||||
|
||||
using (JsonDocument.Parse(normalizedJson))
|
||||
{
|
||||
}
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
string id = BuildRoomPlayerDataId(roomId, accountId);
|
||||
RoomPlayerDataRecord? existing = RoomPlayerData.FindById(id);
|
||||
var record = existing ?? new RoomPlayerDataRecord
|
||||
{
|
||||
Id = id,
|
||||
RoomId = roomId,
|
||||
AccountId = accountId,
|
||||
Version = 0
|
||||
};
|
||||
|
||||
record.DataJson = normalizedJson;
|
||||
record.Version = Math.Max(1, record.Version + 1);
|
||||
record.UpdatedAtUtc = DateTime.UtcNow;
|
||||
record.UpdatedByAccountId = updatedByAccountId;
|
||||
RoomPlayerData.Upsert(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public static RoomPlayerDataRecord? GetRoomPlayerData(
|
||||
long roomId,
|
||||
long accountId)
|
||||
{
|
||||
if (roomId <= 0 || accountId <= 0)
|
||||
return null;
|
||||
return RoomPlayerData.FindById(BuildRoomPlayerDataId(roomId, accountId));
|
||||
}
|
||||
|
||||
public static bool DeleteRoomPlayerData(long roomId, long accountId)
|
||||
{
|
||||
if (roomId <= 0 || accountId <= 0)
|
||||
return false;
|
||||
return RoomPlayerData.Delete(BuildRoomPlayerDataId(roomId, accountId));
|
||||
}
|
||||
|
||||
public static object ToClientCloudVariable(CloudVariableRecord value)
|
||||
{
|
||||
object? parsedValue;
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(value.ValueJson);
|
||||
parsedValue = document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
parsedValue = value.ValueJson;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
Key = value.Key,
|
||||
Name = value.Key,
|
||||
VariableName = value.Key,
|
||||
CloudVariableName = value.Key,
|
||||
Value = parsedValue,
|
||||
VariableValue = parsedValue,
|
||||
CloudVariableValue = parsedValue,
|
||||
ValueJson = value.ValueJson,
|
||||
RoomId = value.RoomId,
|
||||
AccountId = value.AccountId,
|
||||
PlayerId = value.AccountId,
|
||||
IsShared = value.AccountId == 0,
|
||||
IsPlayerVariable = value.AccountId != 0,
|
||||
Scope = value.AccountId == 0 ? "room" : "player",
|
||||
Version = value.Version,
|
||||
UpdatedAt = value.UpdatedAtUtc,
|
||||
UpdatedByAccountId = value.UpdatedByAccountId
|
||||
};
|
||||
}
|
||||
|
||||
private static void MigrateLegacyInventionFiles()
|
||||
{
|
||||
string root = Path.Combine(Program.dataDir, "Inventions");
|
||||
if (!Directory.Exists(root))
|
||||
return;
|
||||
|
||||
int imported = 0;
|
||||
foreach (string accountDirectory in Directory.EnumerateDirectories(root))
|
||||
{
|
||||
if (!long.TryParse(Path.GetFileName(accountDirectory), out long accountId) ||
|
||||
accountId <= 0)
|
||||
continue;
|
||||
|
||||
foreach (string file in Directory.EnumerateFiles(
|
||||
accountDirectory,
|
||||
"*.json",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(
|
||||
File.ReadAllText(file));
|
||||
long fileId = long.TryParse(
|
||||
Path.GetFileNameWithoutExtension(file),
|
||||
out long parsedId)
|
||||
? parsedId
|
||||
: 0;
|
||||
if (fileId > 0 && Inventions.FindById(fileId) != null)
|
||||
continue;
|
||||
|
||||
JsonElement payload = document.RootElement.Clone();
|
||||
SaveInvention(
|
||||
accountId,
|
||||
payload,
|
||||
fileId > 0 ? fileId : null);
|
||||
imported++;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[INVENTION MIGRATION] skipped={file} error={exception.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imported > 0)
|
||||
Console.WriteLine($"[INVENTION MIGRATION] imported={imported}");
|
||||
}
|
||||
|
||||
private static InventionRecord? FindInventionNoLock(long inventionId)
|
||||
{
|
||||
InventionRecord? direct = Inventions.FindById(inventionId);
|
||||
if (direct != null)
|
||||
return direct;
|
||||
|
||||
InventionAliasRecord? alias = InventionAliases.FindById(inventionId);
|
||||
return alias == null
|
||||
? null
|
||||
: Inventions.FindById(alias.CurrentInventionId);
|
||||
}
|
||||
|
||||
private static string InventionPurchaseId(long inventionId, long accountId) =>
|
||||
$"{accountId}:{inventionId}";
|
||||
|
||||
private static long NextInventionIdNoLock()
|
||||
{
|
||||
long max = Inventions.FindAll()
|
||||
.Where(value => value.InventionId > 0 &&
|
||||
value.InventionId <= MaxClientInventionId)
|
||||
.Select(value => value.InventionId)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
|
||||
long candidate = Math.Max(1, max + 1);
|
||||
while (candidate <= MaxClientInventionId &&
|
||||
Inventions.FindById(candidate) != null)
|
||||
{
|
||||
candidate++;
|
||||
}
|
||||
|
||||
if (candidate > MaxClientInventionId)
|
||||
throw new InvalidOperationException("No client-compatible invention IDs remain.");
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static void RepairOversizedInventionIds()
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
List<InventionRecord> oversized = Inventions.FindAll()
|
||||
.Where(value => value.InventionId > MaxClientInventionId)
|
||||
.OrderBy(value => value.CreatedAtUtc)
|
||||
.ThenBy(value => value.InventionId)
|
||||
.ToList();
|
||||
if (oversized.Count == 0)
|
||||
return;
|
||||
|
||||
int repaired = 0;
|
||||
foreach (InventionRecord record in oversized)
|
||||
{
|
||||
long oldId = record.InventionId;
|
||||
long newId = NextInventionIdNoLock();
|
||||
|
||||
record.InventionId = newId;
|
||||
Inventions.Insert(record);
|
||||
Inventions.Delete(oldId);
|
||||
|
||||
foreach (InventionCheerRecord cheer in InventionCheers
|
||||
.Find(value => value.InventionId == oldId)
|
||||
.ToList())
|
||||
{
|
||||
string oldCheerId = cheer.Id;
|
||||
cheer.InventionId = newId;
|
||||
cheer.Id = InventionCheerId(newId, cheer.AccountId);
|
||||
InventionCheers.Upsert(cheer);
|
||||
if (!string.Equals(oldCheerId, cheer.Id, StringComparison.Ordinal))
|
||||
InventionCheers.Delete(oldCheerId);
|
||||
}
|
||||
|
||||
foreach (InventionPurchaseRecord purchase in InventionPurchases
|
||||
.Find(value => value.InventionId == oldId)
|
||||
.ToList())
|
||||
{
|
||||
string oldPurchaseId = purchase.Id;
|
||||
purchase.InventionId = newId;
|
||||
purchase.Id = InventionPurchaseId(newId, purchase.AccountId);
|
||||
InventionPurchases.Upsert(purchase);
|
||||
if (!string.Equals(oldPurchaseId, purchase.Id, StringComparison.Ordinal))
|
||||
InventionPurchases.Delete(oldPurchaseId);
|
||||
}
|
||||
|
||||
foreach (InventionAliasRecord existingAlias in InventionAliases
|
||||
.Find(value => value.CurrentInventionId == oldId)
|
||||
.ToList())
|
||||
{
|
||||
existingAlias.CurrentInventionId = newId;
|
||||
InventionAliases.Update(existingAlias);
|
||||
}
|
||||
|
||||
InventionAliases.Upsert(new InventionAliasRecord
|
||||
{
|
||||
LegacyInventionId = oldId,
|
||||
CurrentInventionId = newId,
|
||||
CreatedAtUtc = DateTime.UtcNow
|
||||
});
|
||||
repaired++;
|
||||
Console.WriteLine(
|
||||
$"[INVENTION ID REPAIR] old={oldId} new={newId} name={record.Name}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"[INVENTION ID REPAIR] repaired={repaired}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateMeetupCode()
|
||||
{
|
||||
const string alphabet = "23456789abcdefghjkmnpqrstuvwxyz";
|
||||
byte[] bytes = RandomNumberGenerator.GetBytes(8);
|
||||
var chars = new char[8];
|
||||
for (int i = 0; i < chars.Length; i++)
|
||||
chars[i] = alphabet[bytes[i] % alphabet.Length];
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
private static string NormalizeMeetupCode(string? code) =>
|
||||
new((code ?? string.Empty)
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.Select(char.ToLowerInvariant)
|
||||
.Take(16)
|
||||
.ToArray());
|
||||
|
||||
private static void DeleteExpiredMeetupCodesNoLock()
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
MeetupCodes.DeleteMany(value =>
|
||||
!value.IsActive ||
|
||||
value.ExpiresAtUtc <= now ||
|
||||
value.UseCount >= value.MaxUses);
|
||||
}
|
||||
|
||||
private static string NormalizeCloudKey(string? key)
|
||||
{
|
||||
string value = (key ?? string.Empty).Trim();
|
||||
if (value.Length > 128)
|
||||
value = value[..128];
|
||||
return value.Replace('\0', '_');
|
||||
}
|
||||
|
||||
public static void MigrateRoomId(long oldRoomId, long newRoomId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
foreach (InventionRecord invention in Inventions.Find(value => value.RoomId == oldRoomId).ToList())
|
||||
{
|
||||
invention.RoomId = newRoomId;
|
||||
Inventions.Update(invention);
|
||||
}
|
||||
|
||||
foreach (CloudVariableRecord variable in CloudVariables.Find(value => value.RoomId == oldRoomId).ToList())
|
||||
{
|
||||
CloudVariables.Delete(variable.Id);
|
||||
variable.Id = BuildCloudVariableId(newRoomId, variable.AccountId, variable.Key);
|
||||
variable.RoomId = newRoomId;
|
||||
CloudVariables.Upsert(variable);
|
||||
}
|
||||
|
||||
foreach (RoomPlayerDataRecord data in RoomPlayerData.Find(value => value.RoomId == oldRoomId).ToList())
|
||||
{
|
||||
RoomPlayerData.Delete(data.Id);
|
||||
data.Id = BuildRoomPlayerDataId(newRoomId, data.AccountId);
|
||||
data.RoomId = newRoomId;
|
||||
RoomPlayerData.Upsert(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildCloudVariableId(
|
||||
long roomId,
|
||||
long accountId,
|
||||
string key)
|
||||
{
|
||||
byte[] digest = SHA256.HashData(
|
||||
Encoding.UTF8.GetBytes(key.ToLowerInvariant()));
|
||||
return $"{roomId}:{accountId}:{Convert.ToHexString(digest)}";
|
||||
}
|
||||
|
||||
private static string BuildRoomPlayerDataId(long roomId, long accountId) =>
|
||||
$"{roomId}:{accountId}";
|
||||
|
||||
private static string Limit(string? value, int maxLength)
|
||||
{
|
||||
string result = value?.Trim() ?? string.Empty;
|
||||
return result.Length <= maxLength ? result : result[..maxLength];
|
||||
}
|
||||
|
||||
private static string StableReplicationId(
|
||||
string scope,
|
||||
long inventionId,
|
||||
int version)
|
||||
{
|
||||
byte[] source = Encoding.UTF8.GetBytes(
|
||||
$"{scope}:{inventionId}:{version}");
|
||||
byte[] hash = SHA256.HashData(source);
|
||||
var guidBytes = new byte[16];
|
||||
Array.Copy(hash, guidBytes, guidBytes.Length);
|
||||
return new Guid(guidBytes).ToString();
|
||||
}
|
||||
|
||||
private static long? FindLong(JsonElement element, params string[] names)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
if (names.Any(name => string.Equals(
|
||||
name,
|
||||
property.Name,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.Number &&
|
||||
property.Value.TryGetInt64(out long number))
|
||||
return number;
|
||||
if (long.TryParse(property.Value.ToString(), out number))
|
||||
return number;
|
||||
}
|
||||
|
||||
long? nested = FindLong(property.Value, names);
|
||||
if (nested.HasValue)
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
long? nested = FindLong(item, names);
|
||||
if (nested.HasValue)
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
else if (TryParseEmbeddedJson(element, out JsonElement embedded))
|
||||
{
|
||||
return FindLong(embedded, names);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindString(JsonElement element, params string[] names)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
if (names.Any(name => string.Equals(
|
||||
name,
|
||||
property.Name,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.String)
|
||||
return property.Value.GetString();
|
||||
if (property.Value.ValueKind is JsonValueKind.Number or
|
||||
JsonValueKind.True or JsonValueKind.False)
|
||||
return property.Value.ToString();
|
||||
}
|
||||
|
||||
string? nested = FindString(property.Value, names);
|
||||
if (nested != null)
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
string? nested = FindString(item, names);
|
||||
if (nested != null)
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
else if (TryParseEmbeddedJson(element, out JsonElement embedded))
|
||||
{
|
||||
return FindString(embedded, names);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? FindBool(JsonElement element, params string[] names)
|
||||
{
|
||||
string? raw = FindString(element, names);
|
||||
if (bool.TryParse(raw, out bool boolean))
|
||||
return boolean;
|
||||
if (int.TryParse(raw, out int number))
|
||||
return number != 0;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
if (!names.Any(name => string.Equals(
|
||||
name,
|
||||
property.Name,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
if (property.Value.ValueKind == JsonValueKind.True)
|
||||
return true;
|
||||
if (property.Value.ValueKind == JsonValueKind.False)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseEmbeddedJson(
|
||||
JsonElement element,
|
||||
out JsonElement parsed)
|
||||
{
|
||||
parsed = default;
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
return false;
|
||||
|
||||
string? raw = element.GetString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(raw) ||
|
||||
(raw[0] != '{' && raw[0] != '['))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(raw);
|
||||
parsed = document.RootElement.Clone();
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<long> ReadLongList(
|
||||
JsonElement element,
|
||||
params string[] names)
|
||||
{
|
||||
var output = new List<long>();
|
||||
if (TryParseEmbeddedJson(element, out JsonElement embeddedRoot))
|
||||
return ReadLongList(embeddedRoot, names);
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out long number))
|
||||
{
|
||||
if (number > 0)
|
||||
output.Add(number);
|
||||
}
|
||||
else if (long.TryParse(item.ToString(), out number) && number > 0)
|
||||
{
|
||||
output.Add(number);
|
||||
}
|
||||
else if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
{
|
||||
output.AddRange(ReadLongList(item, names));
|
||||
}
|
||||
}
|
||||
return output.Distinct().ToList();
|
||||
}
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
return output;
|
||||
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
bool requested = names.Any(name => string.Equals(
|
||||
name,
|
||||
property.Name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (requested)
|
||||
{
|
||||
JsonElement value = property.Value;
|
||||
if (TryParseEmbeddedJson(value, out JsonElement embedded))
|
||||
value = embedded;
|
||||
|
||||
if (value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
output.AddRange(ReadLongList(value, names));
|
||||
}
|
||||
else if (value.ValueKind == JsonValueKind.Number &&
|
||||
value.TryGetInt64(out long number) && number > 0)
|
||||
{
|
||||
output.Add(number);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (string part in value.ToString().Split(
|
||||
new[] { ',', ';' },
|
||||
StringSplitOptions.RemoveEmptyEntries |
|
||||
StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (long.TryParse(part, out number) && number > 0)
|
||||
output.Add(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
{
|
||||
output.AddRange(ReadLongList(property.Value, names));
|
||||
}
|
||||
else if (TryParseEmbeddedJson(property.Value, out JsonElement embedded))
|
||||
{
|
||||
output.AddRange(ReadLongList(embedded, names));
|
||||
}
|
||||
}
|
||||
|
||||
return output.Distinct().ToList();
|
||||
}
|
||||
|
||||
private static List<string> ReadStringList(
|
||||
JsonElement element,
|
||||
params string[] names)
|
||||
{
|
||||
var output = new List<string>();
|
||||
if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
output.AddRange(ReadStringList(item, names));
|
||||
return output
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (TryParseEmbeddedJson(element, out JsonElement embeddedRoot))
|
||||
return ReadStringList(embeddedRoot, names);
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
return output;
|
||||
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
bool isRequestedProperty = names.Any(name => string.Equals(
|
||||
name,
|
||||
property.Name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (!isRequestedProperty)
|
||||
{
|
||||
if (property.Value.ValueKind is JsonValueKind.Object or
|
||||
JsonValueKind.Array)
|
||||
{
|
||||
output.AddRange(ReadStringList(property.Value, names));
|
||||
}
|
||||
else if (TryParseEmbeddedJson(
|
||||
property.Value,
|
||||
out JsonElement embedded))
|
||||
{
|
||||
output.AddRange(ReadStringList(embedded, names));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonElement tagValue = property.Value;
|
||||
if (TryParseEmbeddedJson(tagValue, out JsonElement parsedTags))
|
||||
tagValue = parsedTags;
|
||||
|
||||
if (tagValue.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in tagValue.EnumerateArray())
|
||||
{
|
||||
string? tag = item.ValueKind == JsonValueKind.String
|
||||
? item.GetString()
|
||||
: item.ValueKind == JsonValueKind.Object
|
||||
? FindString(item, "Tag", "tag", "Name", "name")
|
||||
: item.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(tag))
|
||||
output.Add(Limit(tag, 32));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (string item in tagValue.ToString().Split(
|
||||
new[] { ',', ';' },
|
||||
StringSplitOptions.RemoveEmptyEntries |
|
||||
StringSplitOptions.TrimEntries))
|
||||
{
|
||||
output.Add(Limit(item, 32));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs.DBClasses
|
||||
{
|
||||
public static class ClubDBClasses
|
||||
{
|
||||
public enum ClubState
|
||||
{
|
||||
Active = 0,
|
||||
PendingJunior = 11,
|
||||
ModerationPendingReview = 100,
|
||||
ModerationClosed = 101,
|
||||
MarkedForDelete = 1000
|
||||
}
|
||||
|
||||
public enum ClubVisibility
|
||||
{
|
||||
Private = 0,
|
||||
Public = 1
|
||||
}
|
||||
|
||||
public enum ClubJoinability
|
||||
{
|
||||
Open = 0,
|
||||
InviteOnly = 1,
|
||||
AskToJoin = 2
|
||||
}
|
||||
|
||||
public enum ClubType
|
||||
{
|
||||
Generic = 0,
|
||||
Creator = 1
|
||||
}
|
||||
|
||||
public enum ClubMembershipType
|
||||
{
|
||||
Banned = -1,
|
||||
None = 0,
|
||||
PendingRequested = 1,
|
||||
PendingInvited = 2,
|
||||
PendingDenied = 3,
|
||||
Member = 10,
|
||||
Moderator = 20,
|
||||
CoOwner = 30,
|
||||
Creator = 100
|
||||
}
|
||||
|
||||
public sealed class ClubRecord
|
||||
{
|
||||
[BsonId]
|
||||
public long ClubId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string MainImageName { get; set; } = "DefaultClubImage2k.jpg";
|
||||
public ClubState State { get; set; } = ClubState.Active;
|
||||
public int MemberCount { get; set; }
|
||||
public string PrimaryCategory { get; set; } = "Social";
|
||||
public ClubVisibility Visibility { get; set; } = ClubVisibility.Public;
|
||||
public ClubJoinability Joinability { get; set; } = ClubJoinability.Open;
|
||||
public bool AllowJuniors { get; set; } = true;
|
||||
public int MinLevel { get; set; }
|
||||
public bool ClubChatEnabled { get; set; } = true;
|
||||
public long? ClubhouseRoomId { get; set; }
|
||||
public ClubType ClubType { get; set; } = ClubType.Generic;
|
||||
public long CreatorAccountId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public List<string> CategoryTags { get; set; } = new();
|
||||
public List<string> AdditionalImages { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ClubMembershipRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long ClubId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public ClubMembershipType MembershipType { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class HomeClubRecord
|
||||
{
|
||||
[BsonId]
|
||||
public long AccountId { get; set; }
|
||||
public long ClubId { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class ClubSummaryDto
|
||||
{
|
||||
public long ClubId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string MainImageName { get; set; } = string.Empty;
|
||||
public string ImageName => MainImageName;
|
||||
public int State { get; set; }
|
||||
public int MemberCount { get; set; }
|
||||
public string PrimaryCategory { get; set; } = string.Empty;
|
||||
public string PrimaryTag => PrimaryCategory;
|
||||
public string Category => PrimaryCategory;
|
||||
public string ClubCategory => PrimaryCategory;
|
||||
public List<string> CategoryTags { get; set; } = new();
|
||||
public List<string> CustomTags => CategoryTags;
|
||||
public List<string> Tags => CategoryTags;
|
||||
public int Visibility { get; set; }
|
||||
public int Joinability { get; set; }
|
||||
public bool AllowJuniors { get; set; }
|
||||
public int MinLevel { get; set; }
|
||||
public bool ClubChatEnabled { get; set; }
|
||||
public long? ClubhouseRoomId { get; set; }
|
||||
public int ClubType { get; set; }
|
||||
public long CreatorAccountId { get; set; }
|
||||
public long CreatorPlayerId => CreatorAccountId;
|
||||
public int ClubMembershipType { get; set; }
|
||||
public int MembershipType => ClubMembershipType;
|
||||
public int MyMembershipType => ClubMembershipType;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClubMembershipDto
|
||||
{
|
||||
public long AccountId { get; set; }
|
||||
public long ClubId { get; set; }
|
||||
public int ClubMembershipType { get; set; }
|
||||
public int MembershipType => ClubMembershipType;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClubPermissionsDto
|
||||
{
|
||||
public long ClubId { get; set; }
|
||||
public int MembershipType { get; set; }
|
||||
public bool CanInvite { get; set; }
|
||||
public bool CanKick { get; set; }
|
||||
public bool CanBan { get; set; }
|
||||
public bool CanModify { get; set; }
|
||||
public bool CanManageRoles { get; set; }
|
||||
public bool CanManageClubhouse { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClubImageDto
|
||||
{
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
public int ImageIndex { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClubDetailsDto
|
||||
{
|
||||
public ClubSummaryDto Club { get; set; } = new();
|
||||
public ClubSummaryDto ClubModel => Club;
|
||||
|
||||
public long ClubId => Club.ClubId;
|
||||
public string Name => Club.Name;
|
||||
public string Description => Club.Description;
|
||||
public string ImageName => Club.ImageName;
|
||||
public string MainImageName => Club.MainImageName;
|
||||
public int State => Club.State;
|
||||
public int MemberCount => Club.MemberCount;
|
||||
public string PrimaryCategory => Club.PrimaryCategory;
|
||||
public int Visibility => Club.Visibility;
|
||||
public int Joinability => Club.Joinability;
|
||||
public bool AllowJuniors => Club.AllowJuniors;
|
||||
public int MinLevel => Club.MinLevel;
|
||||
public int ClubType => Club.ClubType;
|
||||
public List<string> CategoryTags { get; set; } = new();
|
||||
public List<string> CustomTags => CategoryTags;
|
||||
public List<ClubImageDto> AdditionalImages { get; set; } = new();
|
||||
public List<ClubImageDto> Images => AdditionalImages;
|
||||
public ClubPermissionsDto CreatorPermissions { get; set; } = new();
|
||||
public ClubPermissionsDto CoOwnerPermissions { get; set; } = new();
|
||||
public ClubPermissionsDto ModeratorPermissions { get; set; } = new();
|
||||
public int MyMembershipType { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Mocha2023.Classes;
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs.DBClasses
|
||||
{
|
||||
public class PlayerDBClasses
|
||||
{
|
||||
public class FullPlayer
|
||||
{
|
||||
[BsonId]
|
||||
public long PlayerId { get; set; }
|
||||
public List<mPlatformID> PlatformIds { get; set; } = new();
|
||||
[JsonIgnore]
|
||||
public List<string>? DeviceIds { get; set; } = new();
|
||||
[JsonIgnore]
|
||||
public string? AuthToken { get; set; }
|
||||
[JsonIgnore]
|
||||
public string? Password { get; set; }
|
||||
public List<PlayerRoles> PlayerRoles { get; set; } = new();
|
||||
public Player? Player { get; set; }
|
||||
}
|
||||
|
||||
public class Player
|
||||
{
|
||||
public string? Username { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Bio { get; set; }
|
||||
public int AvailableUsernameChanges { get; set; } = 3;
|
||||
public bool? IsJunior { get; set; }
|
||||
public bool IsAgeVerified { get; set; }
|
||||
public DateTime? AgeVerifiedAt { get; set; }
|
||||
public int Level { get; set; } = 1;
|
||||
public int XP { get; set; } = 0;
|
||||
public string? ProfileImage { get; set; }
|
||||
public string? BannerImage { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime LastLoginAt { get; set; }
|
||||
public DateTime? Birthday { get; set; }
|
||||
public CurrentAuthSession? CurrentAuthSession { get; set; } = new CurrentAuthSession();
|
||||
public Reputation Reputation { get; set; } = new Reputation();
|
||||
public List<long> VisitedRooms { get; set; } = new();
|
||||
public List<long> CheeredRooms { get; set; } = new List<long>();
|
||||
public List<long> FavoritedRooms { get; set; } = new List<long>();
|
||||
public List<PlayerRelationship> Relationships { get; set; } = new();
|
||||
public List<PlayerCheerRecord> ReceivedCheers { get; set; } = new();
|
||||
public List<long> SubscribedAccountIds { get; set; } = new();
|
||||
public int SupportedInfluencerId { get; set; }
|
||||
public string? InfluencerCode { get; set; }
|
||||
public PlayerExtra PlayerExtra { get; set; } = new PlayerExtra();
|
||||
public string DisplayEmoji { get; set; } = "";
|
||||
public int PersonalPronouns { get; set; }
|
||||
|
||||
public int IdentityFlags { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerRelationship
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerID { get; set; }
|
||||
public RelationshipType RelationshipType { get; set; }
|
||||
public bool Favorited { get; set; }
|
||||
public bool Muted { get; set; }
|
||||
public bool Ignored { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClientRelationshipDTO
|
||||
{
|
||||
[JsonPropertyName("PlayerID")]
|
||||
public int PlayerID { get; set; }
|
||||
|
||||
[JsonPropertyName("RelationshipType")]
|
||||
public int RelationshipType { get; set; }
|
||||
|
||||
[JsonPropertyName("Favorited")]
|
||||
public int Favorited { get; set; }
|
||||
|
||||
[JsonPropertyName("Muted")]
|
||||
public int Muted { get; set; }
|
||||
|
||||
[JsonPropertyName("Ignored")]
|
||||
public int Ignored { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerCheerRecord
|
||||
{
|
||||
public long CheerId { get; set; }
|
||||
public long NotificationId { get; set; }
|
||||
public long GiverPlayerId { get; set; }
|
||||
public long ReceiverPlayerId { get; set; }
|
||||
public CheerCategory CheerCategory { get; set; }
|
||||
public bool Anonymous { get; set; }
|
||||
public CheerStatus Status { get; set; } = CheerStatus.Pending;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? HandledAt { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerDTOBase
|
||||
{
|
||||
public long accountId { get; set; }
|
||||
public DateTime createdAt { get; set; }
|
||||
public string? displayName { get; set; }
|
||||
public bool? isJunior { get; set; }
|
||||
public int platforms { get; set; }
|
||||
public string? profileImage { get; set; }
|
||||
public string? bannerImage { get; set; }
|
||||
public string? username { get; set; }
|
||||
public int personalPronouns { get; set; }
|
||||
public int identityFlags { get; set; }
|
||||
public string? displayEmoji { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerDTO : PlayerDTOBase { }
|
||||
public class PlayerMeDTO : PlayerDTOBase
|
||||
{
|
||||
public int availableUsernameChanges { get; set; } = 3;
|
||||
public DateTime? birthday { get; set; }
|
||||
public string? email { get; set; }
|
||||
public string? phone { get; set; }
|
||||
}
|
||||
|
||||
public class CurrentAuthSession { }
|
||||
|
||||
public class PlayerExtra
|
||||
{
|
||||
public Avatar Avatar { get; set; } = new Avatar();
|
||||
public List<string> AvatarItems { get; set; } = new();
|
||||
public List<SavedOutfit> SavedAvatars { get; set; } = new();
|
||||
public ModerationBlockDetails? ModerationBlockDetails { get; set; } = new ModerationBlockDetails();
|
||||
public List<Setting> Settings { get; set; } = new();
|
||||
public Heartbeat Heartbeat { get; set; } = new Heartbeat();
|
||||
public List<PlayerCurrency> Currencies { get; set; } = new();
|
||||
public List<RoomVisit> RoomVisits { get; set; } = new();
|
||||
public List<EquipmentItem> Equipment { get; set; } = new();
|
||||
public List<GiftPackage> PendingGiftPackages { get; set; } = new();
|
||||
|
||||
[JsonIgnore]
|
||||
public long FriendotronLastSentUtcDay { get; set; }
|
||||
|
||||
public long LevelingRoomInstanceId { get; set; }
|
||||
public int LevelingRoomActiveSeconds { get; set; }
|
||||
public long LevelingLastHeartbeatUnixTime { get; set; }
|
||||
|
||||
public long PartyId { get; set; }
|
||||
public long PartyLeaderPlayerId { get; set; }
|
||||
public List<long> PartyMemberPlayerIds { get; set; } = new();
|
||||
public List<PartyInviteRecord> PendingPartyInvites { get; set; } = new();
|
||||
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
public List<string> ItemWishlist { get; set; } = new();
|
||||
}
|
||||
|
||||
public enum GiftContext
|
||||
{
|
||||
None = -1,
|
||||
Default,
|
||||
First_Activity,
|
||||
Game_Drop,
|
||||
All_Daily_Challenges_Complete,
|
||||
All_Weekly_Challenge_Complete,
|
||||
Daily_Challenge_Complete,
|
||||
Weekly_Challenge_Complete,
|
||||
Unassigned_Equipment = 10,
|
||||
Unassigned_Avatar,
|
||||
Unassigned_Consumable,
|
||||
Reacquisition = 20,
|
||||
Membership,
|
||||
NUX_TokensAndDressUp = 30,
|
||||
NUX_Experiment1,
|
||||
NUX_Experiment2,
|
||||
NUX_Experiment3,
|
||||
NUX_Experiment4,
|
||||
NUX_Experiment5,
|
||||
GameRewards = 50,
|
||||
GameRewards_Tokens,
|
||||
LevelUp = 100,
|
||||
Purchased_Gift_A = 500,
|
||||
Purchased_Gift_B,
|
||||
Purchased_Gift_C,
|
||||
Purchased_Gift_D,
|
||||
Holiday = 1000,
|
||||
Contest,
|
||||
Promotion,
|
||||
SubscribersOnly,
|
||||
Deprecated = 1100,
|
||||
RecRoyale = 1200,
|
||||
DEPRECATED_Paintball_ClearCut = 2000,
|
||||
DEPRECATED_Paintball_Homestead,
|
||||
DEPRECATED_Paintball_Quarry,
|
||||
DEPRECATED_Paintball_River,
|
||||
DEPRECATED_Paintball_Dam,
|
||||
DEPRECATED_Paintball_DriveIn,
|
||||
Paintball_ClearCut = 2010,
|
||||
Paintball_Homestead,
|
||||
Paintball_Quarry,
|
||||
Paintball_River,
|
||||
Paintball_Dam,
|
||||
Paintball_DriveIn,
|
||||
DEPRECATED_Discgolf_Propulsion = 3000,
|
||||
DEPRECATED_Discgolf_Lake,
|
||||
Discgolf_Propulsion = 3010,
|
||||
Discgolf_Lake,
|
||||
Discgolf_Mode_CoopCatch = 3500,
|
||||
Quest_Goblin_A = 4000,
|
||||
Quest_Goblin_B,
|
||||
Quest_Goblin_C,
|
||||
Quest_Goblin_S,
|
||||
Quest_Goblin_Consumable,
|
||||
Quest_Cauldron_A = 4010,
|
||||
Quest_Cauldron_B,
|
||||
Quest_Cauldron_C,
|
||||
Quest_Cauldron_S,
|
||||
Quest_Cauldron_Consumable,
|
||||
Quest_Pirate1_A = 4100,
|
||||
Quest_Pirate1_B,
|
||||
Quest_Pirate1_C,
|
||||
Quest_Pirate1_S,
|
||||
Quest_Pirate1_X,
|
||||
Quest_Pirate1_Consumable,
|
||||
Quest_Dracula1_A = 4200,
|
||||
Quest_Dracula1_B,
|
||||
Quest_Dracula1_C,
|
||||
Quest_Dracula1_S,
|
||||
Quest_Dracula1_X,
|
||||
Quest_Dracula1_Consumable,
|
||||
Quest_Dracula1_SS,
|
||||
Quest_SciFi_A = 4500,
|
||||
Quest_SciFi_B,
|
||||
Quest_SciFi_C,
|
||||
Quest_SciFi_S,
|
||||
Quest_Scifi_Consumable,
|
||||
DEPRECATED_Charades = 5000,
|
||||
Charades,
|
||||
DEPRECATED_Soccer = 6000,
|
||||
Soccer,
|
||||
DEPRECATED_Paddleball = 7000,
|
||||
Paddleball,
|
||||
DEPRECATED_Dodgeball = 8000,
|
||||
Dodgeball,
|
||||
DEPRECATED_Lasertag = 9000,
|
||||
Lasertag,
|
||||
DEPRECATED_Bowling = 10000,
|
||||
Bowling,
|
||||
StuntRunner_TheMainEvent_A = 11000,
|
||||
StuntRunner_TheMainEvent_B,
|
||||
StuntRunner_TheMainEvent_C,
|
||||
StuntRunner_TheMainEvent_D,
|
||||
StuntRunner_TheMainEvent_S,
|
||||
StuntRunner_TheMainEvent_X,
|
||||
StuntRunner_TheMainEvent_Consumable,
|
||||
StuntRunner_TheMainEvent_SS,
|
||||
Store_LaserTag = 100000,
|
||||
Store_RecCenter = 100010,
|
||||
Consumable = 110000,
|
||||
Token = 110100,
|
||||
Punchcard_Challenge_Complete = 110200,
|
||||
All_Punchcard_Challenges_Complete,
|
||||
Commerce_Purchase = 200000
|
||||
}
|
||||
|
||||
public sealed class GiftPackage
|
||||
{
|
||||
public long GiftPackageId { get; set; }
|
||||
public int FromPlayerId { get; set; }
|
||||
public int PlayerId { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string AvatarItemDesc { get; set; } = string.Empty;
|
||||
public string ConsumableItemDesc { get; set; } = string.Empty;
|
||||
public int ConsumableQuantity { get; set; } = 1;
|
||||
public string EquipmentPrefabName { get; set; } = string.Empty;
|
||||
public string EquipmentModificationGuid { get; set; } = string.Empty;
|
||||
public string FriendlyName { get; set; } = string.Empty;
|
||||
public string Tooltip { get; set; } = string.Empty;
|
||||
public string ThumbnailImage { get; set; } = string.Empty;
|
||||
public int CurrencyType { get; set; } = 2;
|
||||
public int Currency { get; set; }
|
||||
public int XP { get; set; }
|
||||
public int GiftContext { get; set; }
|
||||
public int Rarity { get; set; }
|
||||
public int Platform { get; set; } = -1;
|
||||
public int PlatformMask { get; set; } = -1;
|
||||
public int? BalanceType { get; set; } = -1;
|
||||
public bool IsQuery { get; set; }
|
||||
public bool Unique { get; set; } = true;
|
||||
}
|
||||
|
||||
public class PartyInviteRecord
|
||||
{
|
||||
public long PartyId { get; set; }
|
||||
public long InviterPlayerId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class PartySnapshot
|
||||
{
|
||||
public long PartyId { get; set; }
|
||||
public long LeaderPlayerId { get; set; }
|
||||
public List<long> MemberPlayerIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public class EquipmentItem
|
||||
{
|
||||
public string PrefabName { get; set; } = "";
|
||||
public string ModificationGuid { get; set; } = "";
|
||||
public int UnlockedLevel { get; set; } = 0;
|
||||
public bool Favorited { get; set; } = false;
|
||||
public int PlatformMask { get; set; } = -1;
|
||||
public string FriendlyName { get; set; } = "";
|
||||
public string Tooltip { get; set; } = "";
|
||||
public int Rarity { get; set; } = 0;
|
||||
public string ThumbnailImage { get; set; } = "";
|
||||
}
|
||||
|
||||
public class RoomVisit
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public DateTime VisitedAt { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerCurrency
|
||||
{
|
||||
public int Balance { get; set; }
|
||||
public CurrencyType CurrencyType { get; set; }
|
||||
public BalanceType BalanceType { get; set; }
|
||||
}
|
||||
|
||||
public class Avatar
|
||||
{
|
||||
public string OutfitSelections { get; set; } = "";
|
||||
public string FaceFeatures { get; set; } = "";
|
||||
public string SkinColor { get; set; } = "";
|
||||
public string HairColor { get; set; } = "";
|
||||
}
|
||||
|
||||
public class Heartbeat
|
||||
{
|
||||
public string appVersion { get; set; } = ServerConfig.GameVersion.ToString();
|
||||
public DeviceClasses? deviceClass { get; set; } = DeviceClasses.Unknown;
|
||||
public MatchmakingErrorCode? errorCode { get; set; } = null;
|
||||
public bool isOnline { get; set; } = false;
|
||||
public long playerId { get; set; } = 0;
|
||||
public RoomInstance? roomInstance { get; set; } = null;
|
||||
public StatusVisibility statusVisibility { get; set; } = StatusVisibility.Online;
|
||||
public int vrMovementMode { get; set; } = 0;
|
||||
|
||||
[JsonIgnore]
|
||||
public long lastHeartbeatUnixTime { get; set; } = 0;
|
||||
}
|
||||
|
||||
public class RoomInstance
|
||||
{
|
||||
public bool encryptVoiceChat { get; set; }
|
||||
public long clubId { get; set; } = 0;
|
||||
public string? dataBlob { get; set; }
|
||||
public long eventId { get; set; } = 0;
|
||||
public bool isFull { get; set; }
|
||||
public bool isInProgress { get; set; }
|
||||
public bool isPrivate { get; set; }
|
||||
public string location { get; set; }
|
||||
public int maxCapacity { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string photonRegion { get; set; }
|
||||
public string photonRegionId { get; set; }
|
||||
public string photonRoomId { get; set; }
|
||||
public string roomCode { get; set; } = "";
|
||||
public long roomId { get; set; }
|
||||
public long roomInstanceId { get; set; }
|
||||
public RoomInstanceType roomInstanceType { get; set; }
|
||||
public long subRoomId { get; set; }
|
||||
public long subRoomDataSaveId { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public DateTime createdAt { get; set; }
|
||||
}
|
||||
|
||||
public class Reputation
|
||||
{
|
||||
public long AccountId { get; set; }
|
||||
public bool IsCheerful { get; set; }
|
||||
public double Noteriety { get; set; }
|
||||
public CheerCategory SelectedCheer { get; set; }
|
||||
public int CheerCredit { get; set; } = 15;
|
||||
|
||||
[JsonIgnore]
|
||||
public long CheerCreditRefreshAtUnixTime { get; set; }
|
||||
public int CheerGeneral { get; set; }
|
||||
public int CheerHelpful { get; set; }
|
||||
public int CheerCreative { get; set; }
|
||||
public int CheerGreatHost { get; set; }
|
||||
public int CheerSportsman { get; set; }
|
||||
public int SubscriberCount { get; set; }
|
||||
public int SubscribedCount { get; set; }
|
||||
}
|
||||
public class ModerationBlockDetails
|
||||
{
|
||||
public ReportCategory ReportCategory { get; set; } = ReportCategory.Moderator;
|
||||
public int Duration { get; set; } = 0;
|
||||
public long GameSessionId { get; set; } = 0;
|
||||
public bool? IsBan { get; set; } = false;
|
||||
public bool? IsHostKick { get; set; } = false;
|
||||
public string? Message { get; set; } = "";
|
||||
public ulong? PlayerIdReporter { get; set; } = null;
|
||||
[JsonIgnore]
|
||||
public long ModerationSetUnixTime { get; set; } = 0;
|
||||
[JsonIgnore]
|
||||
public ulong BannedByPlayerId { get; set; } = 0;
|
||||
[JsonIgnore]
|
||||
public string? AppealCode { get; set; }
|
||||
[JsonIgnore]
|
||||
public bool AppealSubmitted { get; set; }
|
||||
[JsonIgnore]
|
||||
public DateTime? AppealSubmittedAt { get; set; }
|
||||
}
|
||||
|
||||
public class Setting
|
||||
{
|
||||
public required string Key { get; set; }
|
||||
public required string Value { get; set; }
|
||||
}
|
||||
|
||||
public class mPlatformID
|
||||
{
|
||||
public Platforms Platform { get; set; }
|
||||
public ulong PlatformId { get; set; }
|
||||
}
|
||||
|
||||
public class CachedLogins
|
||||
{
|
||||
public Platforms platform { get; set; }
|
||||
public string? platformId { get; set; }
|
||||
public long accountId { get; set; }
|
||||
public DateTime? lastLoginTime { get; set; }
|
||||
public bool requirePassword { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerProgressionDTO
|
||||
{
|
||||
public long PlayerId { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int XP { get; set; }
|
||||
}
|
||||
|
||||
public enum Platforms
|
||||
{
|
||||
All = -1,
|
||||
Steam,
|
||||
Oculus,
|
||||
PlayStation,
|
||||
Xbox,
|
||||
HeadlessBot,
|
||||
IOS,
|
||||
GooglePlay
|
||||
}
|
||||
|
||||
public enum PlayerRoles
|
||||
{
|
||||
Screenshare,
|
||||
Moderator,
|
||||
Developer,
|
||||
Influencer,
|
||||
RRPlus,
|
||||
Keepsake,
|
||||
|
||||
CommunityTeam
|
||||
}
|
||||
|
||||
public enum ReportCategory
|
||||
{
|
||||
Moderator = -1,
|
||||
Unknown,
|
||||
DEPRECATED_MicrophoneAbuse,
|
||||
Harassment,
|
||||
Cheating,
|
||||
DEPRECATED_ImmatureBehavior,
|
||||
AFK,
|
||||
Misc,
|
||||
Underage,
|
||||
VoteKick = 10,
|
||||
MisleadingPurchases,
|
||||
CoC_Underage = 100,
|
||||
CoC_Sexual,
|
||||
CoC_Discrimination,
|
||||
CoC_Trolling,
|
||||
CoC_NameOrProfile,
|
||||
IssuingInaccurateReports = 1000
|
||||
}
|
||||
|
||||
public enum CheerCategory
|
||||
{
|
||||
General,
|
||||
Helpful = 10,
|
||||
Sportmanship = 20,
|
||||
GreatHost = 30,
|
||||
Creative = 40,
|
||||
|
||||
RecRoomDeveloper = 9000,
|
||||
RecRoomCommunityTeam = 9001
|
||||
}
|
||||
|
||||
public enum CheerStatus
|
||||
{
|
||||
Pending,
|
||||
Accepted,
|
||||
Dismissed
|
||||
}
|
||||
|
||||
public enum RelationshipType
|
||||
{
|
||||
None,
|
||||
OutgoingFriendRequest,
|
||||
IncomingFriendRequest,
|
||||
Friend
|
||||
}
|
||||
|
||||
public enum MatchmakingErrorCode
|
||||
{
|
||||
UnknownError = -1,
|
||||
Success,
|
||||
NoSuchGame,
|
||||
PlayerNotOnline,
|
||||
InsufficientSpace,
|
||||
EventNotStarted,
|
||||
EventAlreadyFinished,
|
||||
BlockedFromRoom = 7,
|
||||
JuniorNotAllowed = 11,
|
||||
Banned,
|
||||
AlreadyInBestInstance,
|
||||
InsufficientRelationship,
|
||||
UpdateRequired = 16,
|
||||
AlreadyInTargetInstance,
|
||||
UGCNotAllowed = 19,
|
||||
NoSuchRoom,
|
||||
RoomIsNotActive = 22,
|
||||
RoomBlockedByCreator,
|
||||
RoomIsPrivate = 25,
|
||||
RoomInstanceIsPrivate,
|
||||
DeviceClassNotSupported = 30,
|
||||
DeviceClassNotSupportedByRoomOwner,
|
||||
MovementModeNotSupportedByRoomOwner,
|
||||
EventIsPrivate = 35,
|
||||
RoomInviteExpired = 40,
|
||||
NoAvailableRegion = 45,
|
||||
NotorietyTooPoor = 50,
|
||||
BannedFromRoom = 55,
|
||||
NoSuchRoomPlaylist = 60,
|
||||
RoomPlaylistIsNotActive,
|
||||
RoomPlaylistIsPrivate,
|
||||
NoSuchClub = 70,
|
||||
ClubHasNoClubhouse,
|
||||
ClubIsNotActive = 73,
|
||||
NotAMemberOfClub,
|
||||
BannedFromClub,
|
||||
InstanceJoinNotPermitted,
|
||||
LevelTooLow
|
||||
}
|
||||
|
||||
public enum DeviceClasses
|
||||
{
|
||||
Unknown,
|
||||
VR,
|
||||
Screen,
|
||||
Mobile,
|
||||
VRLow,
|
||||
Quest2
|
||||
}
|
||||
|
||||
public enum CurrencyType
|
||||
{
|
||||
Invalid,
|
||||
LaserTagTickets,
|
||||
RecCenterTokens,
|
||||
LostSkullsGold = 100,
|
||||
DraculaSilver,
|
||||
RecRoyale_Season1 = 200,
|
||||
RoomCurrency = 300
|
||||
}
|
||||
|
||||
public enum BalanceType
|
||||
{
|
||||
NonPurchasedNotUsableInP2P = -2,
|
||||
NonPurchasedDefault,
|
||||
SteamPurchased,
|
||||
OculusPurchased,
|
||||
PlayStationPurchased,
|
||||
MicrosoftPurchased,
|
||||
IOSPurchased = 5,
|
||||
GooglePlayPurchased,
|
||||
PlayStationNonPurchasedP2P = 100,
|
||||
NonPlayStationNonPurchasedP2P,
|
||||
NonPurchasedEarnedByP2P = 1000
|
||||
}
|
||||
|
||||
public enum StatusVisibility
|
||||
{
|
||||
Online,
|
||||
Away,
|
||||
Offline,
|
||||
Unknown = 100
|
||||
}
|
||||
|
||||
public enum RoomInstanceType
|
||||
{
|
||||
Public,
|
||||
Private,
|
||||
Dormroom,
|
||||
Event,
|
||||
Meetup,
|
||||
Clubhouse
|
||||
}
|
||||
|
||||
public class SavedOutfit : Avatar
|
||||
{
|
||||
public int Slot { get; set; }
|
||||
public string? PreviewImageName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public static class RoleUtils
|
||||
{
|
||||
public static bool TryParseRole(string roleName, out PlayerDBClasses.PlayerRoles role)
|
||||
{
|
||||
role = default;
|
||||
if (string.IsNullOrWhiteSpace(roleName))
|
||||
return false;
|
||||
|
||||
switch (roleName.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "developer":
|
||||
case "dev":
|
||||
role = PlayerDBClasses.PlayerRoles.Developer;
|
||||
return true;
|
||||
case "communityteam":
|
||||
case "community-team":
|
||||
case "community_team":
|
||||
case "community":
|
||||
case "ct":
|
||||
role = PlayerDBClasses.PlayerRoles.CommunityTeam;
|
||||
return true;
|
||||
case "moderator":
|
||||
role = PlayerDBClasses.PlayerRoles.Moderator;
|
||||
return true;
|
||||
case "screenshare":
|
||||
role = PlayerDBClasses.PlayerRoles.Screenshare;
|
||||
return true;
|
||||
case "influencer":
|
||||
role = PlayerDBClasses.PlayerRoles.Influencer;
|
||||
return true;
|
||||
case "rrplus":
|
||||
case "rr+":
|
||||
role = PlayerDBClasses.PlayerRoles.RRPlus;
|
||||
return true;
|
||||
case "keepsake":
|
||||
role = PlayerDBClasses.PlayerRoles.Keepsake;
|
||||
return true;
|
||||
default:
|
||||
return Enum.TryParse(roleName, true, out role);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs.DBClasses
|
||||
{
|
||||
public class RoomDBClasses
|
||||
{
|
||||
public const string RoomsCollectionName = "Rooms";
|
||||
public const string SubRoomDataSavesCollectionName = "SubRoomDataSaves";
|
||||
public const string RoomBansCollectionName = "RoomBans";
|
||||
|
||||
public class Room
|
||||
{
|
||||
[BsonId]
|
||||
public long RoomId { get; set; }
|
||||
|
||||
public bool IsDorm { get; set; }
|
||||
|
||||
public int MaxPlayerCalculationMode { get; set; }
|
||||
|
||||
public int MaxPlayers { get; set; }
|
||||
|
||||
public bool CloningAllowed { get; set; }
|
||||
|
||||
public bool DisableMicAutoMute { get; set; }
|
||||
|
||||
public bool DisableRoomComments { get; set; }
|
||||
|
||||
public bool EncryptVoiceChat { get; set; }
|
||||
|
||||
public bool ToxmodEnabled { get; set; }
|
||||
|
||||
public bool LoadScreenLocked { get; set; }
|
||||
|
||||
public int PersistenceVersion { get; set; }
|
||||
|
||||
public bool AutoLocalizeRoom { get; set; }
|
||||
|
||||
public bool IsDeveloperOwned { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
public WarningMaskType WarningMask { get; set; }
|
||||
|
||||
public string? CustomWarning { get; set; }
|
||||
|
||||
public long CreatorAccountId { get; set; }
|
||||
|
||||
public RoomState? State { get; set; }
|
||||
|
||||
public RoomAccessibility Accessibility { get; set; }
|
||||
|
||||
public bool SupportsLevelVoting { get; set; }
|
||||
|
||||
public bool IsRRO { get; set; }
|
||||
|
||||
public bool IsBaseRoom { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool CreativeToolsBetaEnabled { get; set; }
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("CreativeToolsBetaEnabled")]
|
||||
public bool CreativeToolsBetaEnabledForClient
|
||||
{
|
||||
get => CreativeToolsBetaEnabled;
|
||||
set => CreativeToolsBetaEnabled = value;
|
||||
}
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("SupportsBetaContent")]
|
||||
public bool SupportsBetaContentForClient
|
||||
{
|
||||
get => CreativeToolsBetaEnabled;
|
||||
set => CreativeToolsBetaEnabled = value;
|
||||
}
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("IsBeta")]
|
||||
public bool IsBetaForClient
|
||||
{
|
||||
get => CreativeToolsBetaEnabled;
|
||||
set => CreativeToolsBetaEnabled = value;
|
||||
}
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("BetaContentEnabled")]
|
||||
public bool BetaContentEnabledForClient
|
||||
{
|
||||
get => CreativeToolsBetaEnabled;
|
||||
set => CreativeToolsBetaEnabled = value;
|
||||
}
|
||||
|
||||
public bool SupportsScreens { get; set; }
|
||||
|
||||
public bool SupportsWalkVR { get; set; }
|
||||
|
||||
public bool SupportsTeleportVR { get; set; }
|
||||
|
||||
public bool SupportsVRLow { get; set; }
|
||||
|
||||
public bool SupportsQuest2 { get; set; }
|
||||
|
||||
public bool SupportsMobile { get; set; }
|
||||
|
||||
public bool SupportsJuniors { get; set; }
|
||||
|
||||
public int MinLevel { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public Stats Stats { get; set; } = new();
|
||||
|
||||
public string? RankedEntityId { get; set; }
|
||||
|
||||
public string? RankingContext { get; set; }
|
||||
|
||||
public List<SubRooms> SubRooms { get; set; } = new();
|
||||
|
||||
public List<Roles> Roles { get; set; } = new();
|
||||
|
||||
public string? DataBlob { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public int UgcVersion { get; set; }
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("Version")]
|
||||
public int VersionForClient
|
||||
{
|
||||
get => Math.Max(1, UgcVersion);
|
||||
set => UgcVersion = Math.Max(1, value);
|
||||
}
|
||||
|
||||
public List<Tags> Tags { get; set; } = new();
|
||||
|
||||
public List<string> PromoImages { get; set; } = new();
|
||||
|
||||
public List<PromoExternalContent> PromoExternalContent { get; set; }
|
||||
= new();
|
||||
|
||||
public List<LoadScreens> LoadScreens { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SubRooms
|
||||
{
|
||||
public long SubRoomId { get; set; }
|
||||
|
||||
public long RoomId { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? DataBlob { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public long SubRoomDataSaveId { get; set; }
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("CurrentSave")]
|
||||
public SubRoomDataSave? SubRoomDataSave { get; set; }
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("DataSavedAt")]
|
||||
public DateTime? DataSavedAtForClient =>
|
||||
SubRoomDataSave?.CreatedAt;
|
||||
|
||||
public bool IsSandbox { get; set; }
|
||||
|
||||
public int MaxPlayers { get; set; }
|
||||
|
||||
public RoomAccessibility Accessibility { get; set; }
|
||||
|
||||
public string UnitySceneId { get; set; } = string.Empty;
|
||||
|
||||
public List<SubRoomPermission> Permissions { get; set; } = new();
|
||||
|
||||
[JsonIgnore]
|
||||
public long SavedByAccountId { get; set; }
|
||||
}
|
||||
|
||||
public class SubRoomPermission
|
||||
{
|
||||
public bool Override { get; set; }
|
||||
|
||||
public string Permission { get; set; } = string.Empty;
|
||||
|
||||
public int Role { get; set; }
|
||||
|
||||
public int Type { get; set; }
|
||||
|
||||
public string Value { get; set; } = "True";
|
||||
}
|
||||
|
||||
public class SubRoomDataSave
|
||||
{
|
||||
public sealed class SubRoomSaveResponse
|
||||
{
|
||||
[JsonPropertyName("Success")]
|
||||
public bool Success { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("RoomId")]
|
||||
public long RoomId { get; set; }
|
||||
|
||||
[JsonPropertyName("SubRoomId")]
|
||||
public long SubRoomId { get; set; }
|
||||
|
||||
[JsonPropertyName("SubRoomDataSaveId")]
|
||||
public long SubRoomDataSaveId { get; set; }
|
||||
|
||||
[JsonPropertyName("SavedByAccountId")]
|
||||
public long SavedByAccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("DataBlob")]
|
||||
public string DataBlob { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("SubRoomDataFilename")]
|
||||
public string SubRoomDataFilename { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("DataBlobHash")]
|
||||
public string? DataBlobHash { get; set; }
|
||||
|
||||
[JsonPropertyName("SubRoomDataHash")]
|
||||
public string? SubRoomDataHash { get; set; }
|
||||
|
||||
[JsonPropertyName("UnityAssetId")]
|
||||
public string? UnityAssetId { get; set; }
|
||||
|
||||
[JsonPropertyName("ReferencedUnityAssetIds")]
|
||||
public List<string> ReferencedUnityAssetIds { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("BakedUnityAssets")]
|
||||
public List<BakedUnityAsset> BakedUnityAssets { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("PersistenceVersion")]
|
||||
public int PersistenceVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("SavedOnPlatform")]
|
||||
public int? SavedOnPlatform { get; set; }
|
||||
|
||||
[JsonPropertyName("SavedOnDeviceClass")]
|
||||
public int? SavedOnDeviceClass { get; set; }
|
||||
|
||||
[JsonPropertyName("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("DataSavedAt")]
|
||||
public DateTime DataSavedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("CurrentSave")]
|
||||
public SubRoomDataSave CurrentSave { get; set; } = new();
|
||||
}
|
||||
[BsonId]
|
||||
[JsonPropertyName("SubRoomDataSaveId")]
|
||||
public long SubRoomDataSaveId { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public long RoomId { get; set; }
|
||||
|
||||
[JsonPropertyName("SubRoomId")]
|
||||
public long SubRoomId { get; set; }
|
||||
|
||||
[JsonPropertyName("SavedByAccountId")]
|
||||
public long SavedByAccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("DataBlob")]
|
||||
public string DataBlob { get; set; } = string.Empty;
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("SubRoomDataFilename")]
|
||||
public string SubRoomDataFilename => DataBlob;
|
||||
|
||||
[JsonIgnore]
|
||||
public string? RoomDataBlob { get; set; }
|
||||
|
||||
[JsonPropertyName("DataBlobHash")]
|
||||
public string? DataBlobHash { get; set; }
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("SubRoomDataHash")]
|
||||
public string? SubRoomDataHash => DataBlobHash;
|
||||
|
||||
[JsonPropertyName("PersistenceVersion")]
|
||||
public int? PersistenceVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("SavedOnPlatform")]
|
||||
public int? SavedOnPlatform { get; set; }
|
||||
|
||||
[JsonPropertyName("SavedOnDeviceClass")]
|
||||
public int? SavedOnDeviceClass { get; set; }
|
||||
|
||||
[JsonPropertyName("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("UnityAssetId")]
|
||||
public string? UnityAssetId { get; set; }
|
||||
|
||||
[JsonPropertyName("ReferencedUnityAssetIds")]
|
||||
public List<string> ReferencedUnityAssetIds { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("OMVersion")]
|
||||
public int OMVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("UgcSubVersion")]
|
||||
public int UgcSubVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("ModerationState")]
|
||||
public int ModerationState { get; set; }
|
||||
|
||||
[JsonPropertyName("Tags")]
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("BakedUnityAssets")]
|
||||
public List<BakedUnityAsset> BakedUnityAssets { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsPublished { get; set; }
|
||||
}
|
||||
|
||||
public class BakedUnityAsset
|
||||
{
|
||||
[JsonPropertyName("UnityAssetId")]
|
||||
public string UnityAssetId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Target")]
|
||||
public int Target { get; set; }
|
||||
|
||||
[JsonPropertyName("Version")]
|
||||
public int Version { get; set; }
|
||||
|
||||
[JsonPropertyName("Filename")]
|
||||
public string Filename { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Hash")]
|
||||
public string? Hash { get; set; }
|
||||
|
||||
[JsonPropertyName("UnityVersion")]
|
||||
public string? UnityVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("IsAvailable")]
|
||||
public bool IsAvailable { get; set; }
|
||||
}
|
||||
|
||||
public static void HydrateCurrentSaves(
|
||||
LiteDatabase database,
|
||||
Room room)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
ArgumentNullException.ThrowIfNull(room);
|
||||
|
||||
room.SubRooms ??= new List<SubRooms>();
|
||||
|
||||
var saves = database.GetCollection<SubRoomDataSave>(
|
||||
SubRoomDataSavesCollectionName,
|
||||
BsonAutoId.Int64
|
||||
);
|
||||
|
||||
SubRoomDataSave? newestRoomSave = null;
|
||||
|
||||
foreach (var subRoom in room.SubRooms)
|
||||
{
|
||||
SubRoomDataSave? currentSave = null;
|
||||
|
||||
if (subRoom.SubRoomDataSaveId > 0)
|
||||
{
|
||||
currentSave = saves.FindById(
|
||||
subRoom.SubRoomDataSaveId
|
||||
);
|
||||
|
||||
if (currentSave != null &&
|
||||
(currentSave.RoomId != room.RoomId ||
|
||||
currentSave.SubRoomId != subRoom.SubRoomId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM LOAD WARNING] Stale save pointer " +
|
||||
$"room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId} " +
|
||||
$"saveId={subRoom.SubRoomDataSaveId}"
|
||||
);
|
||||
|
||||
currentSave = null;
|
||||
}
|
||||
}
|
||||
|
||||
currentSave ??= FindNewestSubRoomSave(
|
||||
saves,
|
||||
room.RoomId,
|
||||
subRoom.SubRoomId
|
||||
);
|
||||
|
||||
if (currentSave == null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM LOAD] No save found " +
|
||||
$"room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId}"
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
ApplyCurrentSave(subRoom, currentSave);
|
||||
|
||||
if (newestRoomSave == null ||
|
||||
currentSave.CreatedAt > newestRoomSave.CreatedAt)
|
||||
{
|
||||
newestRoomSave = currentSave;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ROOM LOAD] " +
|
||||
$"room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId} " +
|
||||
$"saveId={currentSave.SubRoomDataSaveId} " +
|
||||
$"superRoomBlob={currentSave.RoomDataBlob ?? "null"} " +
|
||||
$"subRoomBlob={currentSave.DataBlob} " +
|
||||
$"persistenceVersion=" +
|
||||
$"{currentSave.PersistenceVersion?.ToString() ?? "null"}"
|
||||
);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(newestRoomSave?.RoomDataBlob))
|
||||
{
|
||||
room.DataBlob = newestRoomSave.RoomDataBlob;
|
||||
}
|
||||
|
||||
if (newestRoomSave?.PersistenceVersion is int persistenceVersion)
|
||||
{
|
||||
room.PersistenceVersion = persistenceVersion;
|
||||
}
|
||||
|
||||
room.UgcVersion = Math.Max(1, room.UgcVersion);
|
||||
}
|
||||
|
||||
public static SubRoomDataSave? FindNewestSubRoomSave(
|
||||
ILiteCollection<SubRoomDataSave> saves,
|
||||
long roomId,
|
||||
long subRoomId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(saves);
|
||||
|
||||
var query = Query.And(
|
||||
Query.EQ(
|
||||
nameof(SubRoomDataSave.RoomId),
|
||||
roomId
|
||||
),
|
||||
Query.EQ(
|
||||
nameof(SubRoomDataSave.SubRoomId),
|
||||
subRoomId
|
||||
)
|
||||
);
|
||||
|
||||
return saves
|
||||
.Find(query)
|
||||
.OrderByDescending(save => save.CreatedAt)
|
||||
.ThenByDescending(save => save.SubRoomDataSaveId)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public static void ApplyCurrentSave(
|
||||
SubRooms subRoom,
|
||||
SubRoomDataSave currentSave)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subRoom);
|
||||
ArgumentNullException.ThrowIfNull(currentSave);
|
||||
|
||||
subRoom.SubRoomDataSaveId =
|
||||
currentSave.SubRoomDataSaveId;
|
||||
|
||||
subRoom.SubRoomDataSave =
|
||||
currentSave;
|
||||
|
||||
subRoom.DataBlob =
|
||||
currentSave.DataBlob;
|
||||
|
||||
subRoom.SavedByAccountId =
|
||||
currentSave.SavedByAccountId;
|
||||
}
|
||||
|
||||
public class Tags
|
||||
{
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
|
||||
public TagType Type { get; set; }
|
||||
}
|
||||
|
||||
public class PromoExternalContent
|
||||
{
|
||||
public PromoExternalContentType Type { get; set; }
|
||||
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class LoadScreens
|
||||
{
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string Subtitle { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class Stats
|
||||
{
|
||||
public int CheerCount { get; set; }
|
||||
|
||||
public int FavoriteCount { get; set; }
|
||||
|
||||
public int VisitorCount { get; set; }
|
||||
|
||||
public int VisitCount { get; set; }
|
||||
}
|
||||
|
||||
public class Roles
|
||||
{
|
||||
public long AccountId { get; set; }
|
||||
|
||||
public Role Role { get; set; }
|
||||
|
||||
public Role InvitedRole { get; set; }
|
||||
}
|
||||
|
||||
public class RoomEditPermission
|
||||
{
|
||||
public bool CanEditRoom { get; set; }
|
||||
|
||||
public string Error { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class RoomBan
|
||||
{
|
||||
[BsonId]
|
||||
public long RoomBanId { get; set; }
|
||||
|
||||
public long RoomId { get; set; }
|
||||
|
||||
public long AccountId { get; set; }
|
||||
|
||||
public long BannedByAccountId { get; set; }
|
||||
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public DateTime BannedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum Role : byte
|
||||
{
|
||||
None = 0,
|
||||
Banned = 1,
|
||||
|
||||
Host = 10,
|
||||
Moderator = 20,
|
||||
CoOwner = 30,
|
||||
TemporaryCoOwner = 31,
|
||||
|
||||
Creator = 255
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum WarningMaskType
|
||||
{
|
||||
None = 0,
|
||||
Scary = 1,
|
||||
Mature = 2,
|
||||
FlashingLights = 4,
|
||||
IntenseMotion = 8,
|
||||
Violence = 16,
|
||||
Custom = 32,
|
||||
Reports = 64
|
||||
}
|
||||
|
||||
public enum RoomAccessibility
|
||||
{
|
||||
Private = 0,
|
||||
Public = 1,
|
||||
Unlisted = 2
|
||||
}
|
||||
|
||||
public enum RoomState
|
||||
{
|
||||
Active = 0,
|
||||
|
||||
PendingJunior = 11,
|
||||
|
||||
Moderation_PendingReview = 100,
|
||||
Moderation_Closed = 101,
|
||||
|
||||
MarkedForDelete = 1000
|
||||
}
|
||||
|
||||
public enum TagType
|
||||
{
|
||||
General = 0,
|
||||
Auto = 1,
|
||||
AGOnly = 2,
|
||||
Banned = 3
|
||||
}
|
||||
|
||||
public enum PromoExternalContentType
|
||||
{
|
||||
YouTube = 0
|
||||
}
|
||||
|
||||
public enum JoinMode
|
||||
{
|
||||
PublicMatchmaking = 0,
|
||||
PublicNewInstance = 1,
|
||||
PrivateNewInstance = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class LiteDbMaintenance
|
||||
{
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(2);
|
||||
|
||||
private static readonly List<Timer> ActiveTimers = new();
|
||||
|
||||
public static void StartPeriodicCheckpoint(string name, LiteDatabase database)
|
||||
{
|
||||
var timer = new Timer(_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
database.Checkpoint();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine($"[LITEDB CHECKPOINT] {name} failed: {exception.Message}");
|
||||
}
|
||||
}, null, Interval, Interval);
|
||||
|
||||
lock (ActiveTimers)
|
||||
ActiveTimers.Add(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Mocha2023.Classes.DBs;
|
||||
|
||||
public static class MassImportJobs
|
||||
{
|
||||
public sealed class RoomOutcome
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? SourceUrl { get; init; }
|
||||
public bool Success { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
public int SubRoomsImported { get; set; }
|
||||
public int SavesImported { get; set; }
|
||||
public int BakedAssetsImported { get; set; }
|
||||
public int AssetBundlesCopied { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Job
|
||||
{
|
||||
public string Id { get; } = Guid.NewGuid().ToString("N");
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
public DateTime StartedAt { get; } = DateTime.UtcNow;
|
||||
public DateTime? FinishedAt { get; set; }
|
||||
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? FatalError { get; set; }
|
||||
public int TotalFound { get; set; }
|
||||
public string? CurrentRoomName { get; set; }
|
||||
|
||||
private readonly object listLock = new();
|
||||
private readonly List<RoomOutcome> results = new();
|
||||
|
||||
public void AddResult(RoomOutcome outcome)
|
||||
{
|
||||
lock (listLock)
|
||||
results.Add(outcome);
|
||||
}
|
||||
|
||||
public List<RoomOutcome> SnapshotResults()
|
||||
{
|
||||
lock (listLock)
|
||||
return new List<RoomOutcome>(results);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Job> Jobs = new();
|
||||
|
||||
private static readonly TimeSpan Retention = TimeSpan.FromHours(6);
|
||||
|
||||
public static Job Create(string kind)
|
||||
{
|
||||
var job = new Job { Kind = kind };
|
||||
Jobs[job.Id] = job;
|
||||
|
||||
DateTime cutoff = DateTime.UtcNow - Retention;
|
||||
foreach (var pair in Jobs)
|
||||
{
|
||||
if (pair.Value.FinishedAt is DateTime finishedAt && finishedAt < cutoff)
|
||||
Jobs.TryRemove(pair.Key, out _);
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
public static Job? Get(string id) => Jobs.TryGetValue(id, out Job? job) ? job : null;
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
using LiteDB;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class NotificationDB
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly TimeSpan AcknowledgedInviteGrace =
|
||||
TimeSpan.FromMinutes(5);
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "Notifications.db"));
|
||||
private static readonly ILiteCollection<StoredNotification> Messages =
|
||||
Database.GetCollection<StoredNotification>("Messages", BsonAutoId.Int64);
|
||||
private static readonly ILiteCollection<StoredRoomInviteGrant> RoomInviteGrants =
|
||||
Database.GetCollection<StoredRoomInviteGrant>("RoomInviteGrants");
|
||||
|
||||
static NotificationDB()
|
||||
{
|
||||
Messages.EnsureIndex(value => value.RecipientPlayerId);
|
||||
Messages.EnsureIndex(value => value.CreatedAt);
|
||||
RoomInviteGrants.EnsureIndex(value => value.RecipientPlayerId);
|
||||
RoomInviteGrants.EnsureIndex(value => value.RoomInstanceId);
|
||||
RoomInviteGrants.EnsureIndex(value => value.ExpirationTime);
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
foreach (StoredNotification message in Messages
|
||||
.FindAll()
|
||||
.Where(value =>
|
||||
IsRoomInviteMessageType(value.Type))
|
||||
.ToList())
|
||||
{
|
||||
if (message.FromPlayerId <= 0 ||
|
||||
message.RecipientPlayerId <= 0 ||
|
||||
(message.RoomId ?? 0) <= 0 ||
|
||||
!TryReadRoomInviteId(message, out long roomInstanceId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string wireData = roomInstanceId.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (message.Type != (int)MessageType.GameInvite ||
|
||||
!string.Equals(
|
||||
message.Message,
|
||||
wireData,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
message.Type = (int)MessageType.GameInvite;
|
||||
message.Message = wireData;
|
||||
Messages.Update(message);
|
||||
}
|
||||
|
||||
RoomInviteGrants.Upsert(new StoredRoomInviteGrant
|
||||
{
|
||||
MessageId = message.Id,
|
||||
RecipientPlayerId = message.RecipientPlayerId,
|
||||
InviterPlayerId = message.FromPlayerId,
|
||||
RoomId = message.RoomId!.Value,
|
||||
RoomInstanceId = roomInstanceId,
|
||||
CreatedAt = message.CreatedAt,
|
||||
ExpirationTime = message.ExpirationTime
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum MessageType
|
||||
{
|
||||
GameInvite = 0,
|
||||
GameInviteDeclined = 1,
|
||||
GameJoinFailed = 2,
|
||||
PartyActivitySwitch = 3,
|
||||
FriendInvite = 4,
|
||||
VoteToKick = 5,
|
||||
GameInviteV2 = 6,
|
||||
PartyActivitySwitchV2 = 7,
|
||||
RequestGameInvite = 10,
|
||||
RequestGameInviteDeclined = 11,
|
||||
FriendStatusOnline = 20,
|
||||
TextMessage = 30,
|
||||
FriendRequestAccepted = 40,
|
||||
PlayerCheer = 50,
|
||||
PlayerCheerAnonymous = 51,
|
||||
PartyUpRequest = 120,
|
||||
FriendIntroduction = 130
|
||||
}
|
||||
|
||||
public sealed class StoredNotification
|
||||
{
|
||||
[BsonId]
|
||||
public long Id { get; set; }
|
||||
public long RecipientPlayerId { get; set; }
|
||||
public int FromPlayerId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public int Type { get; set; }
|
||||
public string Message { get; set; } = "{}";
|
||||
public long? RoomId { get; set; }
|
||||
public long? RoomInstanceId { get; set; }
|
||||
public long? PlayerEventId { get; set; }
|
||||
public long? ClubId { get; set; }
|
||||
public DateTime ExpirationTime { get; set; } = DateTime.UtcNow.AddDays(7);
|
||||
}
|
||||
|
||||
public sealed class StoredRoomInviteGrant
|
||||
{
|
||||
[BsonId]
|
||||
public long MessageId { get; set; }
|
||||
public long RecipientPlayerId { get; set; }
|
||||
public long InviterPlayerId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public long RoomInstanceId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ExpirationTime { get; set; } = DateTime.UtcNow.AddHours(2);
|
||||
}
|
||||
|
||||
public sealed class ClientNotification
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public int FromPlayerId { get; set; }
|
||||
public long ToPlayerId { get; set; }
|
||||
|
||||
[JsonPropertyName("SentTime")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public int Type { get; set; }
|
||||
|
||||
[JsonPropertyName("Data")]
|
||||
public string Message { get; set; } = "{}";
|
||||
|
||||
[JsonIgnore]
|
||||
public long? FromAccountId { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? RoomId { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public long? RoomInstanceId { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? PlayerEventId { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public long? ClubId { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public DateTime ExpirationTime { get; set; }
|
||||
}
|
||||
|
||||
public static ClientNotification CreateRoomInvite(
|
||||
long inviterPlayerId,
|
||||
long receiverPlayerId,
|
||||
long roomId,
|
||||
long roomInstanceId,
|
||||
string? photonRoomId)
|
||||
{
|
||||
|
||||
var inviterInstance = PlayerDB
|
||||
.GetPlayerHeartbeat(inviterPlayerId)
|
||||
.roomInstance;
|
||||
bool isExactInviterInstance =
|
||||
inviterInstance != null &&
|
||||
inviterInstance.roomId == roomId &&
|
||||
inviterInstance.roomInstanceId == roomInstanceId;
|
||||
string roomName = RoomDB.GetRoom(roomId)?.Name ?? "Room";
|
||||
return CreateRoomInvite(
|
||||
inviterPlayerId,
|
||||
receiverPlayerId,
|
||||
roomId,
|
||||
roomInstanceId,
|
||||
roomName,
|
||||
inviteMode:
|
||||
isExactInviterInstance &&
|
||||
Mocha2023.Classes.Sessions.IsRestrictedInstance(
|
||||
inviterInstance)
|
||||
? 1
|
||||
: 0,
|
||||
photonRoomId: isExactInviterInstance
|
||||
? inviterInstance!.photonRoomId
|
||||
: photonRoomId);
|
||||
}
|
||||
|
||||
public static ClientNotification CreateRoomInvite(
|
||||
long inviterPlayerId,
|
||||
long receiverPlayerId,
|
||||
long roomId,
|
||||
long roomInstanceId,
|
||||
string? roomName,
|
||||
int inviteMode,
|
||||
string? photonRoomId = null)
|
||||
{
|
||||
if (inviterPlayerId is <= 0 or > int.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(inviterPlayerId));
|
||||
if (receiverPlayerId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(receiverPlayerId));
|
||||
if (roomId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(roomId));
|
||||
if (roomInstanceId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(roomInstanceId));
|
||||
|
||||
var stored = new StoredNotification
|
||||
{
|
||||
RecipientPlayerId = receiverPlayerId,
|
||||
FromPlayerId = checked((int)inviterPlayerId),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Type = (int)MessageType.GameInvite,
|
||||
Message = roomInstanceId.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
RoomId = roomId,
|
||||
RoomInstanceId = roomInstanceId,
|
||||
PlayerEventId = null,
|
||||
ExpirationTime = DateTime.UtcNow.AddHours(2)
|
||||
};
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
|
||||
List<StoredRoomInviteGrant> replacedGrants = RoomInviteGrants
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == receiverPlayerId &&
|
||||
value.InviterPlayerId == inviterPlayerId &&
|
||||
value.RoomInstanceId == roomInstanceId)
|
||||
.ToList();
|
||||
foreach (StoredRoomInviteGrant grant in replacedGrants)
|
||||
{
|
||||
Messages.Delete(grant.MessageId);
|
||||
RoomInviteGrants.Delete(grant.MessageId);
|
||||
}
|
||||
|
||||
Messages.Insert(stored);
|
||||
|
||||
RoomInviteGrants.Upsert(new StoredRoomInviteGrant
|
||||
{
|
||||
MessageId = stored.Id,
|
||||
RecipientPlayerId = receiverPlayerId,
|
||||
InviterPlayerId = inviterPlayerId,
|
||||
RoomId = roomId,
|
||||
RoomInstanceId = roomInstanceId,
|
||||
CreatedAt = stored.CreatedAt,
|
||||
ExpirationTime = stored.ExpirationTime
|
||||
});
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ROOM INVITE GRANT] issued message={stored.Id} " +
|
||||
$"from={inviterPlayerId} to={receiverPlayerId} " +
|
||||
$"room={roomId} instance={roomInstanceId} " +
|
||||
$"replaced={replacedGrants.Count}");
|
||||
|
||||
return ToClient(stored);
|
||||
}
|
||||
}
|
||||
|
||||
public static ClientNotification CreatePartyInvite(
|
||||
long inviterPlayerId,
|
||||
long receiverPlayerId,
|
||||
long partyId)
|
||||
{
|
||||
var messageData = new
|
||||
{
|
||||
FromAccountId = checked((int)inviterPlayerId),
|
||||
InviterPlayerId = inviterPlayerId,
|
||||
PartyId = partyId
|
||||
};
|
||||
|
||||
return Insert(
|
||||
inviterPlayerId,
|
||||
receiverPlayerId,
|
||||
MessageType.PartyUpRequest,
|
||||
messageData);
|
||||
}
|
||||
|
||||
public static ClientNotification CreatePlayerMessage(
|
||||
long senderPlayerId,
|
||||
long receiverPlayerId,
|
||||
MessageType type,
|
||||
string? message,
|
||||
long? roomId = null)
|
||||
{
|
||||
return InsertMessage(
|
||||
senderPlayerId,
|
||||
receiverPlayerId,
|
||||
type,
|
||||
message ?? string.Empty,
|
||||
roomId);
|
||||
}
|
||||
|
||||
public static ClientNotification CreateFriendInvite(
|
||||
long senderPlayerId,
|
||||
long receiverPlayerId)
|
||||
{
|
||||
return Insert(
|
||||
senderPlayerId,
|
||||
receiverPlayerId,
|
||||
MessageType.FriendInvite,
|
||||
new
|
||||
{
|
||||
FromAccountId = checked((int)senderPlayerId),
|
||||
FromPlayerId = checked((int)senderPlayerId),
|
||||
FriendPlayerId = checked((int)senderPlayerId)
|
||||
});
|
||||
}
|
||||
|
||||
public static ClientNotification CreateFriendAccepted(
|
||||
long acceptingPlayerId,
|
||||
long receiverPlayerId)
|
||||
{
|
||||
return Insert(
|
||||
acceptingPlayerId,
|
||||
receiverPlayerId,
|
||||
MessageType.FriendRequestAccepted,
|
||||
new
|
||||
{
|
||||
FromAccountId = checked((int)acceptingPlayerId),
|
||||
FromPlayerId = checked((int)acceptingPlayerId),
|
||||
FriendPlayerId = checked((int)acceptingPlayerId)
|
||||
});
|
||||
}
|
||||
|
||||
public static ClientNotification CreateFriendIntroduction(
|
||||
long senderPlayerId,
|
||||
long receiverPlayerId,
|
||||
long aboutPlayerId)
|
||||
{
|
||||
if (aboutPlayerId is <= 0 or > int.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(aboutPlayerId));
|
||||
|
||||
int aboutId = checked((int)aboutPlayerId);
|
||||
return Insert(
|
||||
senderPlayerId,
|
||||
receiverPlayerId,
|
||||
MessageType.FriendIntroduction,
|
||||
new
|
||||
{
|
||||
FromAccountId = checked((int)senderPlayerId),
|
||||
FromPlayerId = checked((int)senderPlayerId),
|
||||
AboutAccountId = aboutId,
|
||||
AboutPlayerId = aboutId,
|
||||
IntroducedPlayerId = aboutId
|
||||
});
|
||||
}
|
||||
|
||||
public static ClientNotification CreatePlayerCheer(
|
||||
long senderPlayerId,
|
||||
long receiverPlayerId,
|
||||
int category,
|
||||
bool anonymous)
|
||||
{
|
||||
return InsertMessage(
|
||||
senderPlayerId,
|
||||
receiverPlayerId,
|
||||
anonymous
|
||||
? MessageType.PlayerCheerAnonymous
|
||||
: MessageType.PlayerCheer,
|
||||
category.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
hideSender: anonymous);
|
||||
}
|
||||
|
||||
public static List<ClientNotification> GetMessages(long recipientPlayerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
return Messages.Find(value => value.RecipientPlayerId == recipientPlayerId)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.Select(ToClient)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static int DeleteMessages(
|
||||
long recipientPlayerId,
|
||||
IEnumerable<long> messageIds)
|
||||
{
|
||||
HashSet<long> ids = messageIds
|
||||
.Where(value => value > 0)
|
||||
.ToHashSet();
|
||||
if (ids.Count == 0)
|
||||
return 0;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
DateTime graceExpiration =
|
||||
DateTime.UtcNow.Add(AcknowledgedInviteGrace);
|
||||
int deleted = 0;
|
||||
var acknowledgedGrantIds = new HashSet<long>();
|
||||
foreach (StoredNotification message in Messages
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.ToList())
|
||||
{
|
||||
bool matchesMessageId = ids.Contains(message.Id);
|
||||
bool matchesInviteId =
|
||||
IsRoomInviteMessageType(message.Type) &&
|
||||
TryReadRoomInviteId(message, out long roomInviteId) &&
|
||||
ids.Contains(roomInviteId);
|
||||
|
||||
if ((matchesMessageId || matchesInviteId) &&
|
||||
Messages.Delete(message.Id))
|
||||
{
|
||||
deleted++;
|
||||
|
||||
if (IsRoomInviteMessageType(message.Type))
|
||||
acknowledgedGrantIds.Add(message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (StoredRoomInviteGrant grant in RoomInviteGrants
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.Where(value =>
|
||||
acknowledgedGrantIds.Contains(value.MessageId) ||
|
||||
ids.Contains(value.MessageId) ||
|
||||
ids.Contains(value.RoomInstanceId))
|
||||
.ToList())
|
||||
{
|
||||
if (grant.ExpirationTime > graceExpiration)
|
||||
{
|
||||
grant.ExpirationTime = graceExpiration;
|
||||
RoomInviteGrants.Update(grant);
|
||||
}
|
||||
|
||||
acknowledgedGrantIds.Add(grant.MessageId);
|
||||
}
|
||||
|
||||
if (acknowledgedGrantIds.Count > 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM INVITE ACK] recipient={recipientPlayerId} " +
|
||||
$"grants={acknowledgedGrantIds.Count} " +
|
||||
$"graceSeconds={(int)AcknowledgedInviteGrace.TotalSeconds}");
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
public static int ConsumeRoomInvite(
|
||||
long recipientPlayerId,
|
||||
long inviteId)
|
||||
{
|
||||
if (recipientPlayerId <= 0 || inviteId <= 0)
|
||||
return 0;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
|
||||
List<StoredRoomInviteGrant> grants = RoomInviteGrants
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.Where(value =>
|
||||
value.MessageId == inviteId ||
|
||||
value.RoomInstanceId == inviteId)
|
||||
.ToList();
|
||||
|
||||
HashSet<long> messageIds = grants
|
||||
.Select(value => value.MessageId)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (StoredNotification message in Messages
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.Where(value =>
|
||||
IsRoomInviteMessageType(value.Type))
|
||||
.ToList())
|
||||
{
|
||||
if (message.Id == inviteId ||
|
||||
(TryReadRoomInviteId(message, out long roomInviteId) &&
|
||||
roomInviteId == inviteId))
|
||||
{
|
||||
messageIds.Add(message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
int removed = 0;
|
||||
foreach (long messageId in messageIds)
|
||||
{
|
||||
if (Messages.Delete(messageId))
|
||||
removed++;
|
||||
RoomInviteGrants.Delete(messageId);
|
||||
}
|
||||
|
||||
foreach (StoredRoomInviteGrant grant in grants)
|
||||
RoomInviteGrants.Delete(grant.MessageId);
|
||||
|
||||
return removed + grants.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetRoomInvite(
|
||||
long recipientPlayerId,
|
||||
long inviteId,
|
||||
out long inviterPlayerId,
|
||||
out long roomId,
|
||||
out long roomInstanceId)
|
||||
{
|
||||
inviterPlayerId = 0;
|
||||
roomId = 0;
|
||||
roomInstanceId = 0;
|
||||
|
||||
if (recipientPlayerId <= 0 || inviteId <= 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
|
||||
StoredNotification? message = Messages
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.Where(value =>
|
||||
IsRoomInviteMessageType(value.Type))
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefault(value =>
|
||||
value.Id == inviteId ||
|
||||
(TryReadRoomInviteId(value, out long candidateInviteId) &&
|
||||
candidateInviteId == inviteId));
|
||||
|
||||
if (message != null)
|
||||
{
|
||||
inviterPlayerId = message.FromPlayerId;
|
||||
roomId = message.RoomId ?? 0;
|
||||
|
||||
if (!TryReadRoomInviteId(message, out roomInstanceId))
|
||||
return false;
|
||||
|
||||
RoomInviteGrants.Upsert(new StoredRoomInviteGrant
|
||||
{
|
||||
MessageId = message.Id,
|
||||
RecipientPlayerId = message.RecipientPlayerId,
|
||||
InviterPlayerId = message.FromPlayerId,
|
||||
RoomId = roomId,
|
||||
RoomInstanceId = roomInstanceId,
|
||||
CreatedAt = message.CreatedAt,
|
||||
ExpirationTime = message.ExpirationTime
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
StoredRoomInviteGrant? grant = RoomInviteGrants
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefault(value =>
|
||||
value.MessageId == inviteId ||
|
||||
value.RoomInstanceId == inviteId);
|
||||
|
||||
if (grant == null)
|
||||
return false;
|
||||
|
||||
inviterPlayerId = grant.InviterPlayerId;
|
||||
roomId = grant.RoomId;
|
||||
roomInstanceId = grant.RoomInstanceId;
|
||||
}
|
||||
|
||||
return inviterPlayerId > 0 &&
|
||||
roomId > 0 &&
|
||||
roomInstanceId > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetLatestRoomInviteForRoom(
|
||||
long recipientPlayerId,
|
||||
long roomId,
|
||||
out long inviteId,
|
||||
out long inviterPlayerId,
|
||||
out long roomInstanceId)
|
||||
{
|
||||
inviteId = 0;
|
||||
inviterPlayerId = 0;
|
||||
roomInstanceId = 0;
|
||||
|
||||
if (recipientPlayerId <= 0 || roomId <= 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
|
||||
StoredRoomInviteGrant? grant = RoomInviteGrants
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId &&
|
||||
value.RoomId == roomId)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (grant == null)
|
||||
return false;
|
||||
|
||||
inviteId = grant.MessageId;
|
||||
inviterPlayerId = grant.InviterPlayerId;
|
||||
roomInstanceId = grant.RoomInstanceId;
|
||||
return inviteId > 0 &&
|
||||
inviterPlayerId > 0 &&
|
||||
roomInstanceId > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasActiveRoomInvite(
|
||||
long recipientPlayerId,
|
||||
long inviterPlayerId,
|
||||
long roomInstanceId,
|
||||
long? expectedRoomId = null)
|
||||
{
|
||||
if (recipientPlayerId <= 0 ||
|
||||
inviterPlayerId <= 0 ||
|
||||
roomInstanceId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryGetRoomInvite(
|
||||
recipientPlayerId,
|
||||
roomInstanceId,
|
||||
out long resolvedInviterPlayerId,
|
||||
out long resolvedRoomId,
|
||||
out long resolvedRoomInstanceId) &&
|
||||
resolvedInviterPlayerId == inviterPlayerId &&
|
||||
resolvedRoomInstanceId == roomInstanceId &&
|
||||
(!expectedRoomId.HasValue ||
|
||||
resolvedRoomId == expectedRoomId.Value);
|
||||
}
|
||||
|
||||
private static bool TryReadRoomInviteId(
|
||||
StoredNotification message,
|
||||
out long roomInstanceId)
|
||||
{
|
||||
roomInstanceId = message.RoomInstanceId ?? 0;
|
||||
if (roomInstanceId > 0)
|
||||
return true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message.Message))
|
||||
return false;
|
||||
|
||||
if (long.TryParse(
|
||||
message.Message,
|
||||
System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out roomInstanceId) &&
|
||||
roomInstanceId > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document =
|
||||
JsonDocument.Parse(message.Message);
|
||||
JsonElement root = document.RootElement;
|
||||
|
||||
foreach (string propertyName in new[]
|
||||
{
|
||||
"InviteId",
|
||||
"inviteId",
|
||||
"RoomInstanceId",
|
||||
"roomInstanceId",
|
||||
"TargetRoomInstanceId",
|
||||
"targetRoomInstanceId"
|
||||
})
|
||||
{
|
||||
if (!root.TryGetProperty(
|
||||
propertyName,
|
||||
out JsonElement value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.Number &&
|
||||
value.TryGetInt64(out long numericValue) &&
|
||||
numericValue > 0)
|
||||
{
|
||||
roomInstanceId = numericValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String &&
|
||||
long.TryParse(value.GetString(), out long stringValue) &&
|
||||
stringValue > 0)
|
||||
{
|
||||
roomInstanceId = stringValue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static int DeletePlayerCheerMessages(long recipientPlayerId)
|
||||
{
|
||||
if (recipientPlayerId <= 0)
|
||||
return 0;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
int deleted = 0;
|
||||
foreach (StoredNotification message in Messages
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.Where(value =>
|
||||
value.Type == (int)MessageType.PlayerCheer ||
|
||||
value.Type ==
|
||||
(int)MessageType.PlayerCheerAnonymous)
|
||||
.ToList())
|
||||
{
|
||||
if (Messages.Delete(message.Id))
|
||||
deleted++;
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
private static ClientNotification Insert(
|
||||
long fromPlayerId,
|
||||
long recipientPlayerId,
|
||||
MessageType type,
|
||||
object messageData,
|
||||
long? roomId = null,
|
||||
long? playerEventId = null,
|
||||
long? clubId = null)
|
||||
{
|
||||
return InsertMessage(
|
||||
fromPlayerId,
|
||||
recipientPlayerId,
|
||||
type,
|
||||
JsonSerializer.Serialize(messageData),
|
||||
roomId,
|
||||
playerEventId,
|
||||
clubId);
|
||||
}
|
||||
|
||||
private static ClientNotification InsertMessage(
|
||||
long fromPlayerId,
|
||||
long recipientPlayerId,
|
||||
MessageType type,
|
||||
string message,
|
||||
long? roomId = null,
|
||||
long? playerEventId = null,
|
||||
long? clubId = null,
|
||||
bool hideSender = false)
|
||||
{
|
||||
if (fromPlayerId is <= 0 or > int.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(fromPlayerId));
|
||||
if (recipientPlayerId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(recipientPlayerId));
|
||||
|
||||
var stored = new StoredNotification
|
||||
{
|
||||
RecipientPlayerId = recipientPlayerId,
|
||||
FromPlayerId = hideSender
|
||||
? 0
|
||||
: checked((int)fromPlayerId),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Type = (int)type,
|
||||
Message = message,
|
||||
RoomId = roomId,
|
||||
PlayerEventId = playerEventId,
|
||||
ClubId = clubId,
|
||||
ExpirationTime = DateTime.UtcNow.AddDays(7)
|
||||
};
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
Messages.Insert(stored);
|
||||
return ToClient(stored);
|
||||
}
|
||||
}
|
||||
|
||||
private static ClientNotification ToClient(StoredNotification value) => new()
|
||||
{
|
||||
Id = value.Id,
|
||||
FromPlayerId = value.FromPlayerId,
|
||||
ToPlayerId = value.RecipientPlayerId,
|
||||
CreatedAt = value.CreatedAt,
|
||||
Type = value.Type,
|
||||
Message = value.Message,
|
||||
FromAccountId = value.FromPlayerId,
|
||||
RoomId = value.RoomId,
|
||||
RoomInstanceId = value.RoomInstanceId,
|
||||
PlayerEventId = value.PlayerEventId,
|
||||
ClubId = value.ClubId,
|
||||
ExpirationTime = value.ExpirationTime
|
||||
};
|
||||
|
||||
public static bool IsRoomInviteMessageType(int type) =>
|
||||
type is
|
||||
(int)MessageType.GameInvite or
|
||||
(int)MessageType.GameInviteV2;
|
||||
|
||||
private static void DeleteExpiredNoLock()
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
foreach (StoredNotification message in Messages.Find(value =>
|
||||
value.ExpirationTime < now).ToList())
|
||||
{
|
||||
Messages.Delete(message.Id);
|
||||
}
|
||||
|
||||
foreach (StoredRoomInviteGrant grant in RoomInviteGrants.Find(value =>
|
||||
value.ExpirationTime < now).ToList())
|
||||
{
|
||||
RoomInviteGrants.Delete(grant.MessageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3732 @@
|
||||
using System;
|
||||
using LiteDB;
|
||||
using Mocha2023.Classes;
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
using Mocha2023.Controllers;
|
||||
using static Mocha2023.Classes.DBs.DBClasses.PlayerDBClasses;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
public class PlayerDB
|
||||
{
|
||||
private static readonly object SocialLock = new();
|
||||
private static readonly object ProgressionLock = new();
|
||||
private static readonly object PartyLock = new();
|
||||
private static readonly object GiftLock = new();
|
||||
private static long PartySequence = DateTime.UtcNow.Ticks & long.MaxValue;
|
||||
|
||||
private static long GiftSequence =
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
private static long CheerSequence = DateTime.UtcNow.Ticks & long.MaxValue;
|
||||
private const int RoomLevelSeconds = 15 * 60;
|
||||
private const int MaximumLevel = 50;
|
||||
private const int MaximumCreditableHeartbeatGapSeconds = 120;
|
||||
private const int PresenceFreshnessSeconds = 120;
|
||||
private const int MaximumPendingGiftPackages = 200;
|
||||
public const int CheerCreditLimit = 15;
|
||||
private const long CheerCreditRefreshSeconds = 5 * 60 * 60;
|
||||
|
||||
public static LiteDatabase PlayerDBFile =
|
||||
new LiteDatabase(Path.Combine(Program.dataDir, "DBs", "Players.db"));
|
||||
|
||||
public static readonly ILiteCollection<FullPlayer> Players =
|
||||
PlayerDBFile.GetCollection<FullPlayer>("Players");
|
||||
|
||||
private const string RRPlusSettingKey = "RecRoomPlus.IsActive";
|
||||
private const string InitialTokensSettingKey =
|
||||
"Mocha.InitialTokensGranted";
|
||||
private const string AlpacaShirtOwnershipResetSettingKey =
|
||||
"Mocha.AlpacaShirtOwnershipResetV1";
|
||||
public const long AlpacaShirtAvatarItemId = 1521;
|
||||
public const string AlpacaShirtAvatarItemDescriptor =
|
||||
"d0a9262f-5504-46a7-bb10-7507503db58e,941c046e-4e95-49f8-a7d7-19071fcc3c94,0440f08f-ef1d-49d8-942b-523056e8bb45,703ff56b-560d-4ff4-8c63-d195e879a328";
|
||||
public const int InitialTokenBalance = 1_000;
|
||||
private const string PlaceholderDisplayName =
|
||||
"Displaynamecouldbeverylong";
|
||||
private static readonly Random LevelBoxRng = new();
|
||||
|
||||
private static int GetLevelBoxRarity(int level) => level switch
|
||||
{
|
||||
<= 19 => 10,
|
||||
<= 29 => 20,
|
||||
<= 39 => 30,
|
||||
<= 49 => 40,
|
||||
_ => 50
|
||||
};
|
||||
|
||||
static PlayerDB()
|
||||
{
|
||||
LiteDbMaintenance.StartPeriodicCheckpoint("Players.db", PlayerDBFile);
|
||||
|
||||
try
|
||||
{
|
||||
int repaired = RepairPlaceholderDisplayNames();
|
||||
if (repaired > 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ACCOUNT DISPLAYNAME MIGRATION] repaired={repaired}");
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ACCOUNT DISPLAYNAME MIGRATION] failed={exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int RepairPlaceholderDisplayNames()
|
||||
{
|
||||
int repaired = 0;
|
||||
|
||||
foreach (FullPlayer account in Players.FindAll().ToList())
|
||||
{
|
||||
Player? player = account?.Player;
|
||||
if (player == null)
|
||||
continue;
|
||||
|
||||
string currentDisplayName =
|
||||
player.DisplayName?.Trim() ?? string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(currentDisplayName) &&
|
||||
!IsPlaceholderDisplayName(currentDisplayName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string username =
|
||||
player.Username?.Trim().TrimStart('@') ?? string.Empty;
|
||||
string replacement = string.IsNullOrWhiteSpace(username)
|
||||
? $"Player{account.PlayerId}"
|
||||
: username;
|
||||
|
||||
if (string.Equals(
|
||||
player.DisplayName,
|
||||
replacement,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
player.DisplayName = replacement;
|
||||
if (Players.Update(account))
|
||||
repaired++;
|
||||
}
|
||||
|
||||
return repaired;
|
||||
}
|
||||
|
||||
private static bool IsPlaceholderDisplayName(string? value)
|
||||
{
|
||||
return string.Equals(
|
||||
value?.Trim(),
|
||||
PlaceholderDisplayName,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static FullPlayer CreateAccount(
|
||||
Platforms platform,
|
||||
ulong platformId,
|
||||
bool isJunior,
|
||||
long? accountId = null,
|
||||
bool completeAccountCreation = true)
|
||||
{
|
||||
string username = NameGen.GetRandomName();
|
||||
|
||||
var newPlayerData = new Player
|
||||
{
|
||||
Username = username,
|
||||
DisplayName = username,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastLoginAt = DateTime.UtcNow,
|
||||
ProfileImage = "DefaultPFP.png",
|
||||
AvailableUsernameChanges = 3,
|
||||
IsJunior = isJunior,
|
||||
Level = 1,
|
||||
XP = 0,
|
||||
|
||||
PlayerExtra = new PlayerExtra
|
||||
{
|
||||
Currencies = new List<PlayerCurrency>
|
||||
{
|
||||
new PlayerCurrency
|
||||
{
|
||||
CurrencyType = CurrencyType.RecCenterTokens,
|
||||
BalanceType = BalanceType.NonPurchasedDefault,
|
||||
Balance = InitialTokenBalance
|
||||
}
|
||||
},
|
||||
Settings = new List<Setting>
|
||||
{
|
||||
new Setting
|
||||
{
|
||||
Key = InitialTokensSettingKey,
|
||||
Value = "True"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = AlpacaShirtOwnershipResetSettingKey,
|
||||
Value = "True"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = "Recroom.AccountCreation.HasStarted",
|
||||
Value = "True"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = "Recroom.AccountCreation.HasChosenUsername",
|
||||
Value = completeAccountCreation ? "True" : "False"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = "Recroom.AccountCreation.HasCreatedPassword",
|
||||
Value = completeAccountCreation ? "True" : "False"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = "Recroom.AccountCreation.HasFinished",
|
||||
Value = completeAccountCreation ? "True" : "False"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = "TUTORIAL_COMPLETE_MASK",
|
||||
Value = completeAccountCreation ? "57" : "0"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = "HAS_COMPLETED_ORIENTATION",
|
||||
Value = "False"
|
||||
},
|
||||
|
||||
new Setting
|
||||
{
|
||||
Key = RRPlusSettingKey,
|
||||
Value = "False"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var newFullPlayer = new FullPlayer
|
||||
{
|
||||
PlayerId = accountId ?? 0,
|
||||
|
||||
PlatformIds = new List<mPlatformID>
|
||||
{
|
||||
new mPlatformID
|
||||
{
|
||||
Platform = platform,
|
||||
PlatformId = platformId
|
||||
}
|
||||
},
|
||||
|
||||
Player = newPlayerData,
|
||||
|
||||
PlayerRoles = new List<PlayerRoles>(),
|
||||
|
||||
AuthToken = Guid.NewGuid().ToString()
|
||||
};
|
||||
|
||||
Players.Insert(newFullPlayer);
|
||||
EquipmentInventoryStore.EnsureInitialized(newFullPlayer.PlayerId);
|
||||
PlayerInventoryStore.EnsureInitialized(
|
||||
newFullPlayer.PlayerId,
|
||||
newFullPlayer.Player.PlayerExtra?.AvatarItems);
|
||||
RoomDB.GetOrCreatePlayerDorm(newFullPlayer.PlayerId);
|
||||
|
||||
return newFullPlayer;
|
||||
}
|
||||
|
||||
public static bool HasRRPlus(long playerId)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
var setting = player.Player.PlayerExtra?.Settings?
|
||||
.FirstOrDefault(x => string.Equals(
|
||||
x.Key,
|
||||
RRPlusSettingKey,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return setting != null &&
|
||||
string.Equals(setting.Value, "True", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool SetRRPlus(long playerId, bool enabled)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Settings ??= new List<Setting>();
|
||||
|
||||
var setting = player.Player.PlayerExtra.Settings
|
||||
.FirstOrDefault(x => string.Equals(
|
||||
x.Key,
|
||||
RRPlusSettingKey,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (setting == null)
|
||||
{
|
||||
player.Player.PlayerExtra.Settings.Add(new Setting
|
||||
{
|
||||
Key = RRPlusSettingKey,
|
||||
Value = enabled ? "True" : "False"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
setting.Key = RRPlusSettingKey;
|
||||
setting.Value = enabled ? "True" : "False";
|
||||
}
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static int ForceRRPlusForAllPlayers()
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[RR+ MEMBERSHIP] ForceRRPlusForAllPlayers called but is disabled - no players were updated.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static bool ChangeAccountId(
|
||||
long currentAccountId,
|
||||
long newAccountId)
|
||||
{
|
||||
if (currentAccountId <= 0 ||
|
||||
newAccountId <= 0 ||
|
||||
currentAccountId == newAccountId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Players.FindById(newAccountId) != null)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(currentAccountId);
|
||||
|
||||
if (player == null)
|
||||
return false;
|
||||
|
||||
Players.Delete(currentAccountId);
|
||||
|
||||
player.PlayerId = newAccountId;
|
||||
|
||||
Players.Insert(player);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool GetLogins(
|
||||
Platforms platform,
|
||||
ulong platformId,
|
||||
out List<CachedLogins> accounts)
|
||||
{
|
||||
var results = Players
|
||||
.Find(x => x.PlatformIds
|
||||
.Select(p => p.PlatformId)
|
||||
.Any(id => id == platformId))
|
||||
.Where(p => p.PlatformIds.Any(
|
||||
pid => pid.Platform == platform))
|
||||
.OrderByDescending(x => x.Player.LastLoginAt)
|
||||
.ToList();
|
||||
|
||||
accounts = results
|
||||
.Select(p => new CachedLogins
|
||||
{
|
||||
accountId = p.PlayerId,
|
||||
lastLoginTime = p.Player.LastLoginAt,
|
||||
platform = platform,
|
||||
platformId = platformId.ToString(),
|
||||
|
||||
requirePassword = false
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return accounts.Count > 0;
|
||||
}
|
||||
|
||||
private static PlayerDTOBase MapToDTO(
|
||||
FullPlayer player,
|
||||
bool accountMe)
|
||||
{
|
||||
int platformFlags = player.PlatformIds?.Aggregate(
|
||||
0,
|
||||
(acc, pid) => acc | (int)pid.Platform) ?? 0;
|
||||
|
||||
var p = player.Player ?? new Player();
|
||||
|
||||
PlayerDTOBase dto = accountMe
|
||||
? new PlayerMeDTO()
|
||||
: new PlayerDTO();
|
||||
|
||||
dto.accountId = player.PlayerId;
|
||||
dto.username = p.Username;
|
||||
dto.displayEmoji = p.DisplayEmoji ?? "";
|
||||
dto.displayName = GetClientDisplayName(p, player.PlayerId);
|
||||
dto.profileImage = p.ProfileImage;
|
||||
dto.bannerImage = p.BannerImage;
|
||||
dto.isJunior = p.IsJunior;
|
||||
dto.createdAt = p.CreatedAt;
|
||||
dto.platforms = platformFlags;
|
||||
dto.personalPronouns = p.PersonalPronouns;
|
||||
dto.identityFlags = p.IdentityFlags;
|
||||
|
||||
if (accountMe && dto is PlayerMeDTO meDto)
|
||||
{
|
||||
meDto.availableUsernameChanges =
|
||||
p.AvailableUsernameChanges;
|
||||
|
||||
meDto.birthday = p.Birthday;
|
||||
meDto.email = p.Email;
|
||||
meDto.phone = null;
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Player player, long playerId)
|
||||
{
|
||||
string displayName = player.DisplayName?.Trim() ?? string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(displayName) &&
|
||||
!IsPlaceholderDisplayName(displayName))
|
||||
{
|
||||
return displayName;
|
||||
}
|
||||
|
||||
string username = player.Username?.Trim().TrimStart('@') ??
|
||||
string.Empty;
|
||||
return string.IsNullOrWhiteSpace(username)
|
||||
? $"Player{playerId}"
|
||||
: username;
|
||||
}
|
||||
|
||||
public static List<PlayerDTOBase> GetAccountsBulk(
|
||||
List<long> playerIds,
|
||||
long? callerId = null)
|
||||
{
|
||||
var players = Players
|
||||
.Find(x => playerIds.Contains(x.PlayerId))
|
||||
.ToList();
|
||||
|
||||
var results = players
|
||||
.Select(p => MapToDTO(
|
||||
p,
|
||||
callerId.HasValue &&
|
||||
p.PlayerId == callerId.Value))
|
||||
.ToList();
|
||||
|
||||
if (playerIds.Contains(1) &&
|
||||
!results.Any(account => account.accountId == 1))
|
||||
{
|
||||
results.Add(BuildCoachSystemAccount());
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderBy(a => a.accountId)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static PlayerDTOBase BuildCoachSystemAccount() => new PlayerDTO
|
||||
{
|
||||
accountId = 1,
|
||||
username = "Coach",
|
||||
displayName = "Coach",
|
||||
profileImage = "DefaultPFP.png",
|
||||
bannerImage = null,
|
||||
displayEmoji = string.Empty,
|
||||
isJunior = false,
|
||||
platforms = 0,
|
||||
personalPronouns = 0,
|
||||
identityFlags = 0,
|
||||
createdAt = new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
public static PlayerMeDTO? GetAccountMe(long accountId)
|
||||
{
|
||||
var player = Players.FindById(accountId);
|
||||
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
return MapToDTO(player, true) as PlayerMeDTO;
|
||||
}
|
||||
|
||||
public static bool UpdateUsername(
|
||||
long playerId,
|
||||
string username,
|
||||
bool initialAccountSetup = false)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
username = username.Trim().TrimStart('@');
|
||||
if (username.Length is < 3 or > 20 ||
|
||||
username.Any(ch => !char.IsLetterOrDigit(ch) && ch != '_'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Players.FindAll().Any(candidate =>
|
||||
candidate.PlayerId != playerId &&
|
||||
string.Equals(
|
||||
candidate.Player?.Username,
|
||||
username,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!initialAccountSetup && player.Player.AvailableUsernameChanges <= 0)
|
||||
return false;
|
||||
|
||||
player.Player.Username = username;
|
||||
if (initialAccountSetup)
|
||||
{
|
||||
player.Player.DisplayName = username;
|
||||
SetSettingValue(
|
||||
player,
|
||||
"Recroom.AccountCreation.HasChosenUsername",
|
||||
"True");
|
||||
}
|
||||
else
|
||||
{
|
||||
player.Player.AvailableUsernameChanges--;
|
||||
}
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool SetPassword(
|
||||
long playerId,
|
||||
string newPassword,
|
||||
string? oldPassword = null)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null || string.IsNullOrEmpty(newPassword) ||
|
||||
newPassword.Length < PasswordSecurity.MinPasswordLength ||
|
||||
newPassword.Length > PasswordSecurity.MaxPasswordLength)
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(player.Password) &&
|
||||
!PasswordSecurity.Verify(oldPassword, player.Password, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
player.Password = PasswordSecurity.Hash(newPassword);
|
||||
SetSettingValue(
|
||||
player,
|
||||
"Recroom.AccountCreation.HasCreatedPassword",
|
||||
"True");
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool HasPassword(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
return !string.IsNullOrEmpty(player?.Password);
|
||||
}
|
||||
|
||||
public static bool UpdateBirthday(long playerId, DateTime birthday)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null || birthday.Date > DateTime.UtcNow.Date)
|
||||
return false;
|
||||
|
||||
player.Player.Birthday = birthday.Date;
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
int age = today.Year - birthday.Year;
|
||||
if (birthday.Date > today.AddYears(-age))
|
||||
age--;
|
||||
player.Player.IsJunior = age < 13;
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
private static void SetSettingValue(
|
||||
FullPlayer player,
|
||||
string key,
|
||||
string value)
|
||||
{
|
||||
if (player.Player == null)
|
||||
return;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Settings ??= new List<Setting>();
|
||||
|
||||
var setting = player.Player.PlayerExtra.Settings.FirstOrDefault(item =>
|
||||
string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||
if (setting == null)
|
||||
{
|
||||
player.Player.PlayerExtra.Settings.Add(new Setting
|
||||
{
|
||||
Key = key,
|
||||
Value = value
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
setting.Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool UpdateDisplayName(
|
||||
long playerId,
|
||||
string displayName)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
displayName = displayName?.Trim() ?? string.Empty;
|
||||
player.Player.DisplayName = string.IsNullOrWhiteSpace(displayName) ||
|
||||
IsPlaceholderDisplayName(displayName)
|
||||
? GetClientDisplayName(player.Player, playerId)
|
||||
: displayName;
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool UpdatePersonalPronouns(long playerId, int personalPronouns)
|
||||
{
|
||||
|
||||
if (personalPronouns < 0 || (personalPronouns & ~0x3F) != 0)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PersonalPronouns = personalPronouns;
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool UpdateIdentityFlags(
|
||||
long playerId,
|
||||
int identityFlags)
|
||||
{
|
||||
if (identityFlags < 0)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.IdentityFlags = identityFlags;
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<int> GetWishlist(long accountId)
|
||||
{
|
||||
var player = Players.FindById(accountId);
|
||||
string? stored = player?.Player?.PlayerExtra?.Settings?
|
||||
.FirstOrDefault(setting => string.Equals(
|
||||
setting.Key,
|
||||
"Store.Wishlist",
|
||||
StringComparison.OrdinalIgnoreCase))?.Value;
|
||||
if (string.IsNullOrWhiteSpace(stored))
|
||||
return Array.Empty<int>();
|
||||
|
||||
return stored.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(value => int.TryParse(value, out int id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(500)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static bool SetWishlistItem(long accountId, int purchasableItemId, bool wished)
|
||||
{
|
||||
if (purchasableItemId <= 0)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(accountId);
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
var ids = GetWishlist(accountId).ToHashSet();
|
||||
if (wished)
|
||||
{
|
||||
if (ids.Count >= 500 && !ids.Contains(purchasableItemId))
|
||||
return false;
|
||||
ids.Add(purchasableItemId);
|
||||
}
|
||||
else
|
||||
{
|
||||
ids.Remove(purchasableItemId);
|
||||
}
|
||||
|
||||
SetSettingValue(
|
||||
player,
|
||||
"Store.Wishlist",
|
||||
string.Join(',', ids.OrderBy(value => value)));
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool SetAvatar(
|
||||
long accountId,
|
||||
Avatar avatar)
|
||||
{
|
||||
var player = Players.FindById(accountId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Avatar = avatar;
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static void SetPlayerSetting(
|
||||
string key,
|
||||
string value,
|
||||
long playerId)
|
||||
{
|
||||
key = key?.Trim() ?? string.Empty;
|
||||
value ??= string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(key) || key.Length > 128 ||
|
||||
value.Length > 4_096 || key.Any(char.IsControl) ||
|
||||
value.Any(ch => ch == '\0') || playerId <= 0)
|
||||
return;
|
||||
|
||||
if (key is
|
||||
"SplitTestAssignedSegments" or
|
||||
"Growth.LastEmailPromptTime")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Settings ??= new List<Setting>();
|
||||
|
||||
var settings = player.Player.PlayerExtra.Settings;
|
||||
|
||||
var existingSetting = settings.FirstOrDefault(
|
||||
s => s.Key == key);
|
||||
|
||||
if (existingSetting != null)
|
||||
{
|
||||
existingSetting.Value = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
settings.Add(new Setting
|
||||
{
|
||||
Key = key,
|
||||
Value = value
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
key,
|
||||
"HAS_COMPLETED_ORIENTATION",
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
bool.TryParse(value, out bool completedOrientation) &&
|
||||
completedOrientation)
|
||||
{
|
||||
SetSettingValue(
|
||||
player,
|
||||
"Recroom.AccountCreation.HasFinished",
|
||||
"True");
|
||||
SetSettingValue(player, "TUTORIAL_COMPLETE_MASK", "57");
|
||||
}
|
||||
|
||||
Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool NeedsOrientation(FullPlayer? player)
|
||||
{
|
||||
var settings = player?.Player?.PlayerExtra?.Settings;
|
||||
if (settings == null)
|
||||
return false;
|
||||
|
||||
var orientation = settings.FirstOrDefault(setting =>
|
||||
string.Equals(
|
||||
setting.Key,
|
||||
"HAS_COMPLETED_ORIENTATION",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (orientation != null)
|
||||
{
|
||||
return !bool.TryParse(orientation.Value, out bool completed) ||
|
||||
!completed;
|
||||
}
|
||||
|
||||
var accountCreation = settings.FirstOrDefault(setting =>
|
||||
string.Equals(
|
||||
setting.Key,
|
||||
"Recroom.AccountCreation.HasFinished",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
return accountCreation != null &&
|
||||
!string.Equals(
|
||||
accountCreation.Value,
|
||||
"True",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static List<PlayerProgressionDTO> GetProgressionBulk(
|
||||
List<long> playerIds)
|
||||
{
|
||||
var players = Players
|
||||
.Find(x => playerIds.Contains(x.PlayerId))
|
||||
.ToList();
|
||||
|
||||
return players
|
||||
.Select(p => new PlayerProgressionDTO
|
||||
{
|
||||
PlayerId = p.PlayerId,
|
||||
Level = p.Player?.Level ?? 1,
|
||||
XP = p.Player?.XP ?? 0
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static PlayerProgressionDTO? SetProgression(
|
||||
long playerId,
|
||||
int level,
|
||||
int xp)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return null;
|
||||
|
||||
int oldLevel;
|
||||
PlayerProgressionDTO result;
|
||||
|
||||
lock (ProgressionLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
oldLevel = player.Player.Level;
|
||||
player.Player.Level = Math.Clamp(level, 1, 50);
|
||||
player.Player.XP = Math.Max(0, xp);
|
||||
|
||||
if (!Players.Update(player))
|
||||
return null;
|
||||
|
||||
result = new PlayerProgressionDTO
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Level = player.Player.Level,
|
||||
XP = player.Player.XP
|
||||
};
|
||||
}
|
||||
|
||||
_ = NotiController.NotifyProgressionAsync(
|
||||
playerId,
|
||||
oldLevel,
|
||||
result.Level,
|
||||
result.XP);
|
||||
|
||||
if (result.Level > oldLevel)
|
||||
GrantLevelUpBox(playerId, oldLevel, result.Level);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PlayerProgressionDTO? AddExperience(
|
||||
long playerId,
|
||||
int xpToAdd,
|
||||
int? resultingLevel = null)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
int level = resultingLevel ?? player.Player.Level;
|
||||
int xp = Math.Max(0, player.Player.XP + xpToAdd);
|
||||
return SetProgression(playerId, level, xp);
|
||||
}
|
||||
|
||||
public static List<GiftPackage> GetPendingGiftPackages(long playerId)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return new List<GiftPackage>();
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return new List<GiftPackage>();
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.PendingGiftPackages ??=
|
||||
new List<GiftPackage>();
|
||||
|
||||
bool repaired = RepairPendingGiftIdsNoLock(
|
||||
player.Player.PlayerExtra.PendingGiftPackages);
|
||||
if (repaired)
|
||||
Players.Update(player);
|
||||
|
||||
return player.Player.PlayerExtra.PendingGiftPackages
|
||||
.OrderBy(gift => gift.GiftPackageId)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static ClearOutgoingGiftsResult ClearPendingGiftsFromSender(
|
||||
long fromPlayerId)
|
||||
{
|
||||
var result = new ClearOutgoingGiftsResult();
|
||||
if (fromPlayerId <= 0)
|
||||
return result;
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
foreach (var account in Players.FindAll())
|
||||
{
|
||||
if (account?.Player?.PlayerExtra?.PendingGiftPackages == null ||
|
||||
account.Player.PlayerExtra.PendingGiftPackages.Count == 0)
|
||||
continue;
|
||||
|
||||
int removed = account.Player.PlayerExtra.PendingGiftPackages
|
||||
.RemoveAll(gift => gift.FromPlayerId == fromPlayerId);
|
||||
|
||||
if (removed <= 0)
|
||||
continue;
|
||||
|
||||
if (Players.Update(account))
|
||||
{
|
||||
result.RemovedBoxes += removed;
|
||||
result.AffectedPlayers++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public class ClearOutgoingGiftsResult
|
||||
{
|
||||
public int RemovedBoxes { get; set; }
|
||||
public int AffectedPlayers { get; set; }
|
||||
}
|
||||
|
||||
public static bool RemoveGiftPackage(long playerId, long giftPackageId)
|
||||
{
|
||||
if (playerId <= 0 || giftPackageId <= 0)
|
||||
return false;
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
var pending = player?.Player?.PlayerExtra?.PendingGiftPackages;
|
||||
if (pending == null)
|
||||
return false;
|
||||
|
||||
int removed = pending.RemoveAll(gift => gift.GiftPackageId == giftPackageId);
|
||||
return removed > 0 && Players.Update(player!);
|
||||
}
|
||||
}
|
||||
|
||||
public static int ClearPendingGiftPackages(long playerId)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return 0;
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
var pending = player?.Player?.PlayerExtra?.PendingGiftPackages;
|
||||
if (pending == null || pending.Count == 0)
|
||||
return 0;
|
||||
|
||||
int removed = pending.Count;
|
||||
pending.Clear();
|
||||
return Players.Update(player!) ? removed : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static GiftPackage? QueueGiftPackage(
|
||||
long playerId,
|
||||
GiftPackage gift)
|
||||
{
|
||||
if (playerId <= 0 || gift == null)
|
||||
return null;
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.PendingGiftPackages ??=
|
||||
new List<GiftPackage>();
|
||||
|
||||
if (player.Player.PlayerExtra.PendingGiftPackages.Count >=
|
||||
MaximumPendingGiftPackages)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[GIFT QUEUE] recipient={playerId} rejected=queue_full " +
|
||||
$"limit={MaximumPendingGiftPackages}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (gift.GiftPackageId <= 0 ||
|
||||
gift.GiftPackageId > int.MaxValue ||
|
||||
player.Player.PlayerExtra.PendingGiftPackages.Any(value =>
|
||||
value.GiftPackageId == gift.GiftPackageId))
|
||||
{
|
||||
gift.GiftPackageId = NextGiftPackageIdNoLock(
|
||||
player.Player.PlayerExtra.PendingGiftPackages);
|
||||
}
|
||||
|
||||
gift.PlayerId = checked((int)playerId);
|
||||
player.Player.PlayerExtra.PendingGiftPackages.Add(gift);
|
||||
|
||||
return Players.Update(player) ? gift : null;
|
||||
}
|
||||
}
|
||||
|
||||
public enum FriendotronGiftStatus
|
||||
{
|
||||
Success,
|
||||
InvalidRequest,
|
||||
SenderNotFound,
|
||||
RecipientNotFound,
|
||||
DailyLimitReached,
|
||||
RecipientQueueFull,
|
||||
PersistenceFailed
|
||||
}
|
||||
|
||||
public static FriendotronGiftStatus QueueFriendotronGift(
|
||||
long senderPlayerId,
|
||||
long recipientPlayerId,
|
||||
GiftPackage gift,
|
||||
out GiftPackage? queuedGift,
|
||||
out DateTime nextAvailableAtUtc)
|
||||
{
|
||||
queuedGift = null;
|
||||
nextAvailableAtUtc = DateTime.UtcNow;
|
||||
|
||||
if (senderPlayerId is <= 0 or > int.MaxValue ||
|
||||
recipientPlayerId is <= 0 or > int.MaxValue ||
|
||||
gift == null)
|
||||
{
|
||||
return FriendotronGiftStatus.InvalidRequest;
|
||||
}
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
FullPlayer? sender = Players.FindById(senderPlayerId);
|
||||
if (sender?.Player == null)
|
||||
return FriendotronGiftStatus.SenderNotFound;
|
||||
|
||||
FullPlayer? recipient = senderPlayerId == recipientPlayerId
|
||||
? sender
|
||||
: Players.FindById(recipientPlayerId);
|
||||
if (recipient?.Player == null)
|
||||
return FriendotronGiftStatus.RecipientNotFound;
|
||||
|
||||
sender.Player.PlayerExtra ??= new PlayerExtra();
|
||||
recipient.Player.PlayerExtra ??= new PlayerExtra();
|
||||
recipient.Player.PlayerExtra.PendingGiftPackages ??=
|
||||
new List<GiftPackage>();
|
||||
|
||||
long utcDay = DateTimeOffset.UtcNow.ToUnixTimeSeconds() /
|
||||
(24L * 60L * 60L);
|
||||
nextAvailableAtUtc = DateTimeOffset.FromUnixTimeSeconds(
|
||||
checked((utcDay + 1) * 24L * 60L * 60L))
|
||||
.UtcDateTime;
|
||||
|
||||
if (sender.Player.PlayerExtra.FriendotronLastSentUtcDay >=
|
||||
utcDay)
|
||||
{
|
||||
return FriendotronGiftStatus.DailyLimitReached;
|
||||
}
|
||||
|
||||
if (recipient.Player.PlayerExtra.PendingGiftPackages.Count >=
|
||||
MaximumPendingGiftPackages)
|
||||
{
|
||||
return FriendotronGiftStatus.RecipientQueueFull;
|
||||
}
|
||||
|
||||
if (gift.GiftPackageId <= 0 ||
|
||||
gift.GiftPackageId > int.MaxValue ||
|
||||
recipient.Player.PlayerExtra.PendingGiftPackages.Any(value =>
|
||||
value.GiftPackageId == gift.GiftPackageId))
|
||||
{
|
||||
gift.GiftPackageId = NextGiftPackageIdNoLock(
|
||||
recipient.Player.PlayerExtra.PendingGiftPackages);
|
||||
}
|
||||
|
||||
gift.FromPlayerId = checked((int)senderPlayerId);
|
||||
gift.PlayerId = checked((int)recipientPlayerId);
|
||||
recipient.Player.PlayerExtra.PendingGiftPackages.Add(gift);
|
||||
long previousSentUtcDay =
|
||||
sender.Player.PlayerExtra.FriendotronLastSentUtcDay;
|
||||
sender.Player.PlayerExtra.FriendotronLastSentUtcDay = utcDay;
|
||||
|
||||
int expectedUpdates = senderPlayerId == recipientPlayerId
|
||||
? 1
|
||||
: 2;
|
||||
int updated = expectedUpdates == 1
|
||||
? (Players.Update(sender) ? 1 : 0)
|
||||
: Players.Update(new[] { sender, recipient });
|
||||
if (updated != expectedUpdates)
|
||||
{
|
||||
recipient.Player.PlayerExtra.PendingGiftPackages.Remove(gift);
|
||||
sender.Player.PlayerExtra.FriendotronLastSentUtcDay =
|
||||
previousSentUtcDay;
|
||||
return FriendotronGiftStatus.PersistenceFailed;
|
||||
}
|
||||
|
||||
queuedGift = gift;
|
||||
return FriendotronGiftStatus.Success;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RepairPendingGiftIdsNoLock(
|
||||
List<GiftPackage> gifts)
|
||||
{
|
||||
bool changed = false;
|
||||
var used = new HashSet<long>();
|
||||
foreach (GiftPackage gift in gifts)
|
||||
{
|
||||
if (gift.GiftPackageId > 0 &&
|
||||
gift.GiftPackageId <= int.MaxValue &&
|
||||
used.Add(gift.GiftPackageId))
|
||||
{
|
||||
GiftSequence = Math.Max(GiftSequence, gift.GiftPackageId);
|
||||
continue;
|
||||
}
|
||||
|
||||
gift.GiftPackageId = NextGiftPackageIdNoLock(gifts, used);
|
||||
used.Add(gift.GiftPackageId);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static long NextGiftPackageIdNoLock(
|
||||
IEnumerable<GiftPackage> gifts,
|
||||
HashSet<long>? reserved = null)
|
||||
{
|
||||
var used = reserved ?? gifts
|
||||
.Where(value => value.GiftPackageId > 0 &&
|
||||
value.GiftPackageId <= int.MaxValue)
|
||||
.Select(value => value.GiftPackageId)
|
||||
.ToHashSet();
|
||||
|
||||
for (int attempt = 0; attempt < 100000; attempt++)
|
||||
{
|
||||
GiftSequence++;
|
||||
if (GiftSequence <= 0 || GiftSequence > int.MaxValue)
|
||||
GiftSequence = 100000;
|
||||
if (!used.Contains(GiftSequence))
|
||||
return GiftSequence;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Unable to allocate a client-safe gift package ID.");
|
||||
}
|
||||
|
||||
public static GiftPackage? ConsumeGiftPackage(
|
||||
long playerId,
|
||||
long giftPackageId)
|
||||
{
|
||||
if (playerId <= 0 || giftPackageId <= 0)
|
||||
return null;
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.PendingGiftPackages ??=
|
||||
new List<GiftPackage>();
|
||||
player.Player.PlayerExtra.AvatarItems ??= new List<string>();
|
||||
|
||||
GiftPackage? gift =
|
||||
player.Player.PlayerExtra.PendingGiftPackages.FirstOrDefault(
|
||||
value => value.GiftPackageId == giftPackageId);
|
||||
|
||||
if (gift == null)
|
||||
return null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gift.AvatarItemDesc) &&
|
||||
!player.Player.PlayerExtra.AvatarItems.Contains(
|
||||
gift.AvatarItemDesc,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
player.Player.PlayerExtra.AvatarItems.Add(
|
||||
gift.AvatarItemDesc);
|
||||
PlayerInventoryStore.SetAvatarItemOwned(
|
||||
playerId,
|
||||
gift.AvatarItemDesc,
|
||||
avatarItemId: 0,
|
||||
friendlyName: null,
|
||||
owned: true,
|
||||
legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gift.ConsumableItemDesc))
|
||||
{
|
||||
PlayerInventoryStore.AddConsumable(
|
||||
playerId,
|
||||
gift.ConsumableItemDesc,
|
||||
consumableItemId: 0,
|
||||
friendlyName: null,
|
||||
amount: Math.Clamp(gift.ConsumableQuantity, 1, 100000));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gift.EquipmentPrefabName) &&
|
||||
!string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid))
|
||||
{
|
||||
EquipmentInventoryStore.AddOwnedItem(
|
||||
playerId,
|
||||
gift.EquipmentPrefabName,
|
||||
gift.EquipmentModificationGuid,
|
||||
friendlyName: gift.FriendlyName,
|
||||
tooltip: gift.Tooltip,
|
||||
rarity: gift.Rarity > 0 ? gift.Rarity : null,
|
||||
thumbnailImage: gift.ThumbnailImage);
|
||||
}
|
||||
|
||||
if (gift.Currency != 0)
|
||||
{
|
||||
player.Player.PlayerExtra.Currencies ??=
|
||||
new List<PlayerCurrency>();
|
||||
|
||||
CurrencyType currencyType =
|
||||
Enum.IsDefined(typeof(CurrencyType), gift.CurrencyType)
|
||||
? (CurrencyType)gift.CurrencyType
|
||||
: CurrencyType.RecCenterTokens;
|
||||
|
||||
PlayerCurrency? currency =
|
||||
player.Player.PlayerExtra.Currencies.FirstOrDefault(
|
||||
value => value.CurrencyType == currencyType);
|
||||
|
||||
if (currency == null)
|
||||
{
|
||||
currency = new PlayerCurrency
|
||||
{
|
||||
CurrencyType = currencyType,
|
||||
BalanceType = BalanceType.NonPurchasedDefault
|
||||
};
|
||||
player.Player.PlayerExtra.Currencies.Add(currency);
|
||||
}
|
||||
|
||||
long adjustedBalance =
|
||||
(long)currency.Balance + gift.Currency;
|
||||
currency.Balance = (int)Math.Clamp(
|
||||
adjustedBalance,
|
||||
(long)int.MinValue,
|
||||
(long)int.MaxValue);
|
||||
}
|
||||
|
||||
if (gift.XP > 0)
|
||||
player.Player.XP = Math.Max(0, player.Player.XP + gift.XP);
|
||||
|
||||
player.Player.PlayerExtra.PendingGiftPackages.Remove(gift);
|
||||
return Players.Update(player) ? gift : null;
|
||||
}
|
||||
}
|
||||
|
||||
public static PartySnapshot? GetPartySnapshot(long playerId)
|
||||
{
|
||||
lock (PartyLock)
|
||||
return GetPartySnapshotNoLock(playerId);
|
||||
}
|
||||
|
||||
public static PartySnapshot? InviteToParty(
|
||||
long inviterPlayerId,
|
||||
long targetPlayerId)
|
||||
{
|
||||
if (inviterPlayerId <= 0 ||
|
||||
targetPlayerId <= 0 ||
|
||||
inviterPlayerId == targetPlayerId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PartySnapshot? party;
|
||||
|
||||
lock (PartyLock)
|
||||
{
|
||||
var inviter = Players.FindById(inviterPlayerId);
|
||||
var target = Players.FindById(targetPlayerId);
|
||||
if (inviter?.Player == null || target?.Player == null)
|
||||
return null;
|
||||
|
||||
party = EnsurePartyNoLock(inviterPlayerId);
|
||||
if (party == null || party.MemberPlayerIds.Contains(targetPlayerId))
|
||||
return party;
|
||||
|
||||
target.Player.PlayerExtra ??= new PlayerExtra();
|
||||
target.Player.PlayerExtra.PendingPartyInvites ??= new List<PartyInviteRecord>();
|
||||
|
||||
DateTime expiresBefore = DateTime.UtcNow.AddMinutes(-10);
|
||||
target.Player.PlayerExtra.PendingPartyInvites.RemoveAll(invite =>
|
||||
invite.CreatedAt < expiresBefore ||
|
||||
invite.PartyId == party.PartyId ||
|
||||
invite.InviterPlayerId == inviterPlayerId);
|
||||
|
||||
target.Player.PlayerExtra.PendingPartyInvites.Add(new PartyInviteRecord
|
||||
{
|
||||
PartyId = party.PartyId,
|
||||
InviterPlayerId = inviterPlayerId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
if (!Players.Update(target))
|
||||
return null;
|
||||
}
|
||||
|
||||
return party;
|
||||
}
|
||||
|
||||
public static PartySnapshot? AcceptPartyInvite(
|
||||
long inviteePlayerId,
|
||||
long inviterPlayerId)
|
||||
{
|
||||
if (inviteePlayerId <= 0 || inviterPlayerId <= 0)
|
||||
return null;
|
||||
|
||||
PartySnapshot? party;
|
||||
List<long> notifyPlayerIds;
|
||||
|
||||
lock (PartyLock)
|
||||
{
|
||||
var invitee = Players.FindById(inviteePlayerId);
|
||||
var inviter = Players.FindById(inviterPlayerId);
|
||||
if (invitee?.Player == null || inviter?.Player == null)
|
||||
return null;
|
||||
|
||||
invitee.Player.PlayerExtra ??= new PlayerExtra();
|
||||
invitee.Player.PlayerExtra.PendingPartyInvites ??= new List<PartyInviteRecord>();
|
||||
|
||||
PartyInviteRecord? invite = invitee.Player.PlayerExtra.PendingPartyInvites
|
||||
.Where(value => value.CreatedAt >= DateTime.UtcNow.AddMinutes(-10))
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefault(value => value.InviterPlayerId == inviterPlayerId);
|
||||
|
||||
party = EnsurePartyNoLock(inviterPlayerId);
|
||||
if (party == null ||
|
||||
invite == null ||
|
||||
invite.PartyId != party.PartyId ||
|
||||
party.MemberPlayerIds.Count >= 8)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
RemovePlayerFromPartyNoLock(inviteePlayerId);
|
||||
|
||||
party.MemberPlayerIds = party.MemberPlayerIds
|
||||
.Append(inviteePlayerId)
|
||||
.Where(value => value > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
invitee.Player.PlayerExtra.PendingPartyInvites.RemoveAll(value =>
|
||||
value.PartyId == party.PartyId ||
|
||||
value.InviterPlayerId == inviterPlayerId ||
|
||||
value.CreatedAt < DateTime.UtcNow.AddMinutes(-10));
|
||||
|
||||
Players.Update(invitee);
|
||||
PersistPartyNoLock(party);
|
||||
notifyPlayerIds = party.MemberPlayerIds.ToList();
|
||||
}
|
||||
|
||||
_ = NotiController.NotifyPartyUpdatedAsync(
|
||||
notifyPlayerIds,
|
||||
CreatePartyNotificationPayload(party));
|
||||
return party;
|
||||
}
|
||||
|
||||
public static PartySnapshot? LeaveParty(long playerId)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return null;
|
||||
|
||||
PartySnapshot? remainingParty;
|
||||
List<long> notifyPlayerIds;
|
||||
|
||||
lock (PartyLock)
|
||||
{
|
||||
PartySnapshot? existing = GetPartySnapshotNoLock(playerId);
|
||||
if (existing == null)
|
||||
{
|
||||
ClearPartyStateNoLock(playerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
notifyPlayerIds = existing.MemberPlayerIds.ToList();
|
||||
remainingParty = RemovePlayerFromPartyNoLock(playerId);
|
||||
}
|
||||
|
||||
var payload = remainingParty ?? new PartySnapshot
|
||||
{
|
||||
PartyId = 0,
|
||||
LeaderPlayerId = 0,
|
||||
MemberPlayerIds = new List<long>()
|
||||
};
|
||||
|
||||
_ = NotiController.NotifyPartyUpdatedAsync(
|
||||
notifyPlayerIds.Append(playerId),
|
||||
CreatePartyNotificationPayload(payload));
|
||||
|
||||
return remainingParty;
|
||||
}
|
||||
|
||||
public static bool DeclinePartyInvite(
|
||||
long inviteePlayerId,
|
||||
long inviterPlayerId)
|
||||
{
|
||||
lock (PartyLock)
|
||||
{
|
||||
var invitee = Players.FindById(inviteePlayerId);
|
||||
if (invitee?.Player == null)
|
||||
return false;
|
||||
|
||||
invitee.Player.PlayerExtra ??= new PlayerExtra();
|
||||
invitee.Player.PlayerExtra.PendingPartyInvites ??= new List<PartyInviteRecord>();
|
||||
|
||||
int removed = invitee.Player.PlayerExtra.PendingPartyInvites.RemoveAll(value =>
|
||||
value.InviterPlayerId == inviterPlayerId ||
|
||||
value.CreatedAt < DateTime.UtcNow.AddMinutes(-10));
|
||||
|
||||
return removed > 0 && Players.Update(invitee);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<PartyInviteRecord> GetPendingPartyInvites(long playerId)
|
||||
{
|
||||
lock (PartyLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return new List<PartyInviteRecord>();
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.PendingPartyInvites ??= new List<PartyInviteRecord>();
|
||||
player.Player.PlayerExtra.PendingPartyInvites.RemoveAll(value =>
|
||||
value.CreatedAt < DateTime.UtcNow.AddMinutes(-10));
|
||||
Players.Update(player);
|
||||
|
||||
return player.Player.PlayerExtra.PendingPartyInvites
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private static PartySnapshot? EnsurePartyNoLock(long playerId)
|
||||
{
|
||||
PartySnapshot? existing = GetPartySnapshotNoLock(playerId);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
long partyId = Interlocked.Increment(ref PartySequence);
|
||||
if (partyId <= 0)
|
||||
{
|
||||
PartySequence = DateTime.UtcNow.Ticks & long.MaxValue;
|
||||
partyId = Interlocked.Increment(ref PartySequence);
|
||||
}
|
||||
|
||||
var party = new PartySnapshot
|
||||
{
|
||||
PartyId = partyId,
|
||||
LeaderPlayerId = playerId,
|
||||
MemberPlayerIds = new List<long> { playerId }
|
||||
};
|
||||
|
||||
PersistPartyNoLock(party);
|
||||
return party;
|
||||
}
|
||||
|
||||
private static PartySnapshot? GetPartySnapshotNoLock(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
PlayerExtra extra = player.Player.PlayerExtra;
|
||||
extra.PartyMemberPlayerIds ??= new List<long>();
|
||||
|
||||
if (extra.PartyId <= 0)
|
||||
return null;
|
||||
|
||||
List<long> members = extra.PartyMemberPlayerIds
|
||||
.Append(playerId)
|
||||
.Where(value => value > 0 && Players.FindById(value)?.Player != null)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (members.Count == 0)
|
||||
return null;
|
||||
|
||||
long leader = members.Contains(extra.PartyLeaderPlayerId)
|
||||
? extra.PartyLeaderPlayerId
|
||||
: members[0];
|
||||
|
||||
return new PartySnapshot
|
||||
{
|
||||
PartyId = extra.PartyId,
|
||||
LeaderPlayerId = leader,
|
||||
MemberPlayerIds = members
|
||||
};
|
||||
}
|
||||
|
||||
private static PartySnapshot? RemovePlayerFromPartyNoLock(long playerId)
|
||||
{
|
||||
PartySnapshot? party = GetPartySnapshotNoLock(playerId);
|
||||
if (party == null)
|
||||
{
|
||||
ClearPartyStateNoLock(playerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
party.MemberPlayerIds.RemoveAll(value => value == playerId);
|
||||
ClearPartyStateNoLock(playerId);
|
||||
|
||||
if (party.MemberPlayerIds.Count <= 1)
|
||||
{
|
||||
foreach (long remainingPlayerId in party.MemberPlayerIds)
|
||||
ClearPartyStateNoLock(remainingPlayerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (party.LeaderPlayerId == playerId ||
|
||||
!party.MemberPlayerIds.Contains(party.LeaderPlayerId))
|
||||
{
|
||||
party.LeaderPlayerId = party.MemberPlayerIds[0];
|
||||
}
|
||||
|
||||
PersistPartyNoLock(party);
|
||||
return party;
|
||||
}
|
||||
|
||||
private static void PersistPartyNoLock(PartySnapshot party)
|
||||
{
|
||||
party.MemberPlayerIds = party.MemberPlayerIds
|
||||
.Where(value => value > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (!party.MemberPlayerIds.Contains(party.LeaderPlayerId))
|
||||
party.LeaderPlayerId = party.MemberPlayerIds.FirstOrDefault();
|
||||
|
||||
foreach (long memberPlayerId in party.MemberPlayerIds)
|
||||
{
|
||||
var member = Players.FindById(memberPlayerId);
|
||||
if (member?.Player == null)
|
||||
continue;
|
||||
|
||||
member.Player.PlayerExtra ??= new PlayerExtra();
|
||||
member.Player.PlayerExtra.PartyId = party.PartyId;
|
||||
member.Player.PlayerExtra.PartyLeaderPlayerId = party.LeaderPlayerId;
|
||||
member.Player.PlayerExtra.PartyMemberPlayerIds = party.MemberPlayerIds.ToList();
|
||||
Players.Update(member);
|
||||
}
|
||||
}
|
||||
|
||||
private static object CreatePartyNotificationPayload(PartySnapshot party) => new
|
||||
{
|
||||
partyId = party.PartyId,
|
||||
leaderPlayerId = party.LeaderPlayerId,
|
||||
memberPlayerIds = party.MemberPlayerIds.ToArray()
|
||||
};
|
||||
|
||||
private static void ClearPartyStateNoLock(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.PartyId = 0;
|
||||
player.Player.PlayerExtra.PartyLeaderPlayerId = 0;
|
||||
player.Player.PlayerExtra.PartyMemberPlayerIds = new List<long>();
|
||||
Players.Update(player);
|
||||
}
|
||||
|
||||
public static List<Reputation> GetReputationBulk(
|
||||
List<long> playerIds)
|
||||
{
|
||||
lock (SocialLock)
|
||||
{
|
||||
var allPlayers = Players.FindAll().ToList();
|
||||
var players = allPlayers
|
||||
.Where(x => playerIds.Contains(x.PlayerId))
|
||||
.ToList();
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var result = new List<Reputation>(players.Count);
|
||||
|
||||
foreach (FullPlayer p in players)
|
||||
{
|
||||
var rep = p.Player?.Reputation ?? new Reputation();
|
||||
if (p.Player != null)
|
||||
{
|
||||
p.Player.Reputation = rep;
|
||||
if (RefreshCheerCreditsNoLock(rep, p.PlayerId, now))
|
||||
Players.Update(p);
|
||||
}
|
||||
|
||||
bool isDeveloper = HasRole(
|
||||
p,
|
||||
PlayerRoles.Developer);
|
||||
bool isCommunityTeam = HasRole(
|
||||
p,
|
||||
PlayerRoles.CommunityTeam);
|
||||
bool hasStaffBadgeAccess =
|
||||
isDeveloper || isCommunityTeam;
|
||||
|
||||
result.Add(new Reputation
|
||||
{
|
||||
AccountId = p.PlayerId,
|
||||
IsCheerful =
|
||||
hasStaffBadgeAccess || rep.IsCheerful,
|
||||
Noteriety = rep.Noteriety,
|
||||
SelectedCheer = NormalizeSelectedStaffBadge(
|
||||
rep.SelectedCheer,
|
||||
isDeveloper,
|
||||
isCommunityTeam),
|
||||
CheerCredit = Math.Clamp(
|
||||
rep.CheerCredit,
|
||||
0,
|
||||
CheerCreditLimit),
|
||||
CheerGeneral = hasStaffBadgeAccess
|
||||
? Math.Max(10, rep.CheerGeneral)
|
||||
: rep.CheerGeneral,
|
||||
CheerHelpful = hasStaffBadgeAccess
|
||||
? Math.Max(10, rep.CheerHelpful)
|
||||
: rep.CheerHelpful,
|
||||
CheerCreative = hasStaffBadgeAccess
|
||||
? Math.Max(10, rep.CheerCreative)
|
||||
: rep.CheerCreative,
|
||||
CheerGreatHost = hasStaffBadgeAccess
|
||||
? Math.Max(10, rep.CheerGreatHost)
|
||||
: rep.CheerGreatHost,
|
||||
CheerSportsman = hasStaffBadgeAccess
|
||||
? Math.Max(10, rep.CheerSportsman)
|
||||
: rep.CheerSportsman,
|
||||
SubscriberCount =
|
||||
RelationshipDB.GetSubscriberCount(p.PlayerId),
|
||||
SubscribedCount =
|
||||
RelationshipDB.GetSubscribedCount(p.PlayerId)
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GrantDeveloperCheerAccess(
|
||||
FullPlayer account,
|
||||
bool selectDeveloperBadge)
|
||||
{
|
||||
return GrantStaffCheerAccess(
|
||||
account,
|
||||
CheerCategory.RecRoomDeveloper,
|
||||
selectDeveloperBadge);
|
||||
}
|
||||
|
||||
public static bool GrantCommunityTeamCheerAccess(
|
||||
FullPlayer account,
|
||||
bool selectCommunityTeamBadge)
|
||||
{
|
||||
return GrantStaffCheerAccess(
|
||||
account,
|
||||
CheerCategory.RecRoomCommunityTeam,
|
||||
selectCommunityTeamBadge);
|
||||
}
|
||||
|
||||
private static bool GrantStaffCheerAccess(
|
||||
FullPlayer account,
|
||||
CheerCategory staffBadge,
|
||||
bool selectStaffBadge)
|
||||
{
|
||||
if (account.Player == null ||
|
||||
staffBadge is not (
|
||||
CheerCategory.RecRoomDeveloper or
|
||||
CheerCategory.RecRoomCommunityTeam))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
account.Player.Reputation ??= new Reputation();
|
||||
Reputation reputation = account.Player.Reputation;
|
||||
bool changed = false;
|
||||
|
||||
if (!reputation.IsCheerful)
|
||||
{
|
||||
reputation.IsCheerful = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (reputation.CheerGeneral < 10)
|
||||
{
|
||||
reputation.CheerGeneral = 10;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (reputation.CheerHelpful < 10)
|
||||
{
|
||||
reputation.CheerHelpful = 10;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (reputation.CheerCreative < 10)
|
||||
{
|
||||
reputation.CheerCreative = 10;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (reputation.CheerGreatHost < 10)
|
||||
{
|
||||
reputation.CheerGreatHost = 10;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (reputation.CheerSportsman < 10)
|
||||
{
|
||||
reputation.CheerSportsman = 10;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (selectStaffBadge &&
|
||||
reputation.SelectedCheer != staffBadge)
|
||||
{
|
||||
reputation.SelectedCheer = staffBadge;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static bool HasRole(
|
||||
FullPlayer account,
|
||||
PlayerRoles role)
|
||||
{
|
||||
return account.PlayerRoles?.Contains(role) == true;
|
||||
}
|
||||
|
||||
private static CheerCategory NormalizeSelectedStaffBadge(
|
||||
CheerCategory selectedCheer,
|
||||
bool isDeveloper,
|
||||
bool isCommunityTeam)
|
||||
{
|
||||
if (selectedCheer == CheerCategory.RecRoomDeveloper &&
|
||||
!isDeveloper)
|
||||
{
|
||||
return CheerCategory.General;
|
||||
}
|
||||
|
||||
if (selectedCheer ==
|
||||
CheerCategory.RecRoomCommunityTeam &&
|
||||
!isCommunityTeam)
|
||||
{
|
||||
return CheerCategory.General;
|
||||
}
|
||||
|
||||
return selectedCheer;
|
||||
}
|
||||
|
||||
public static int EnsureSocialDefaultsForAllPlayers()
|
||||
{
|
||||
lock (SocialLock)
|
||||
{
|
||||
var allPlayers = Players.FindAll().ToList();
|
||||
int updated = 0;
|
||||
|
||||
updated += RelationshipDB.MigrateLegacyData(allPlayers);
|
||||
|
||||
foreach (var account in allPlayers)
|
||||
{
|
||||
if (account.Player == null)
|
||||
continue;
|
||||
|
||||
bool changed = false;
|
||||
account.Player.Relationships ??= new List<PlayerRelationship>();
|
||||
account.Player.ReceivedCheers ??= new List<PlayerCheerRecord>();
|
||||
account.Player.SubscribedAccountIds ??= new List<long>();
|
||||
account.Player.Reputation ??= new Reputation();
|
||||
|
||||
if (account.Player.Reputation.AccountId != account.PlayerId)
|
||||
{
|
||||
account.Player.Reputation.AccountId = account.PlayerId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed |= RefreshCheerCreditsNoLock(
|
||||
account.Player.Reputation,
|
||||
account.PlayerId,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
|
||||
bool isDeveloper = HasRole(
|
||||
account,
|
||||
PlayerRoles.Developer);
|
||||
bool isCommunityTeam = HasRole(
|
||||
account,
|
||||
PlayerRoles.CommunityTeam);
|
||||
|
||||
Reputation reputation =
|
||||
account.Player.Reputation;
|
||||
bool hasNoEarnedCheer =
|
||||
reputation.CheerGeneral == 0 &&
|
||||
reputation.CheerHelpful == 0 &&
|
||||
reputation.CheerCreative == 0 &&
|
||||
reputation.CheerGreatHost == 0 &&
|
||||
reputation.CheerSportsman == 0;
|
||||
|
||||
if (isDeveloper)
|
||||
{
|
||||
changed |= GrantDeveloperCheerAccess(
|
||||
account,
|
||||
selectDeveloperBadge:
|
||||
hasNoEarnedCheer &&
|
||||
reputation.SelectedCheer ==
|
||||
CheerCategory.General);
|
||||
}
|
||||
else if (isCommunityTeam)
|
||||
{
|
||||
changed |= GrantCommunityTeamCheerAccess(
|
||||
account,
|
||||
selectCommunityTeamBadge:
|
||||
hasNoEarnedCheer &&
|
||||
reputation.SelectedCheer ==
|
||||
CheerCategory.General);
|
||||
}
|
||||
|
||||
CheerCategory normalizedSelectedCheer =
|
||||
NormalizeSelectedStaffBadge(
|
||||
reputation.SelectedCheer,
|
||||
isDeveloper,
|
||||
isCommunityTeam);
|
||||
|
||||
if (normalizedSelectedCheer !=
|
||||
reputation.SelectedCheer)
|
||||
{
|
||||
reputation.SelectedCheer =
|
||||
normalizedSelectedCheer;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
List<PlayerRelationship> authoritativeRelationships =
|
||||
RelationshipDB.GetRelationships(account.PlayerId);
|
||||
if (!RelationshipListsMatch(
|
||||
account.Player.Relationships,
|
||||
authoritativeRelationships))
|
||||
{
|
||||
account.Player.Relationships = authoritativeRelationships;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed && Players.Update(account))
|
||||
updated++;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RelationshipListsMatch(
|
||||
IReadOnlyCollection<PlayerRelationship> first,
|
||||
IReadOnlyCollection<PlayerRelationship> second)
|
||||
{
|
||||
if (first.Count != second.Count)
|
||||
return false;
|
||||
|
||||
var firstByPlayer = first
|
||||
.GroupBy(value => value.PlayerID)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
if (firstByPlayer.Count != first.Count)
|
||||
return false;
|
||||
|
||||
foreach (PlayerRelationship expected in second)
|
||||
{
|
||||
if (!firstByPlayer.TryGetValue(expected.PlayerID, out PlayerRelationship? actual) ||
|
||||
actual.Id != expected.Id ||
|
||||
actual.RelationshipType != expected.RelationshipType ||
|
||||
actual.Favorited != expected.Favorited ||
|
||||
actual.Muted != expected.Muted ||
|
||||
actual.Ignored != expected.Ignored)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static PlayerDBClasses.RoomInstance CloneRoomInstance(
|
||||
PlayerDBClasses.RoomInstance source)
|
||||
{
|
||||
return new PlayerDBClasses.RoomInstance
|
||||
{
|
||||
encryptVoiceChat = source.encryptVoiceChat,
|
||||
clubId = source.clubId,
|
||||
dataBlob = source.dataBlob,
|
||||
eventId = source.eventId,
|
||||
|
||||
isFull = source.isFull,
|
||||
isInProgress = source.isInProgress,
|
||||
isPrivate = source.isPrivate,
|
||||
|
||||
location = source.location,
|
||||
maxCapacity = source.maxCapacity,
|
||||
Name = source.Name,
|
||||
|
||||
photonRegion = source.photonRegion,
|
||||
photonRegionId = source.photonRegionId,
|
||||
photonRoomId = source.photonRoomId,
|
||||
|
||||
roomCode = source.roomCode,
|
||||
roomId = source.roomId,
|
||||
roomInstanceId = source.roomInstanceId,
|
||||
roomInstanceType = source.roomInstanceType,
|
||||
subRoomId = source.subRoomId,
|
||||
subRoomDataSaveId = source.subRoomDataSaveId,
|
||||
createdAt = source.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
public static int EnsureInitialTokensForAllPlayers()
|
||||
{
|
||||
lock (SocialLock)
|
||||
{
|
||||
int updated = 0;
|
||||
|
||||
foreach (var account in Players.FindAll().ToList())
|
||||
{
|
||||
if (account.Player == null)
|
||||
continue;
|
||||
|
||||
account.Player.PlayerExtra ??= new PlayerExtra();
|
||||
account.Player.PlayerExtra.Settings ??= new List<Setting>();
|
||||
account.Player.PlayerExtra.Currencies ??=
|
||||
new List<PlayerCurrency>();
|
||||
|
||||
bool alreadyGranted = account.Player.PlayerExtra.Settings
|
||||
.Any(setting =>
|
||||
string.Equals(
|
||||
setting.Key,
|
||||
InitialTokensSettingKey,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(
|
||||
setting.Value,
|
||||
"True",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (alreadyGranted)
|
||||
continue;
|
||||
|
||||
var tokenCurrency = account.Player.PlayerExtra.Currencies
|
||||
.FirstOrDefault(currency =>
|
||||
currency.CurrencyType ==
|
||||
CurrencyType.RecCenterTokens);
|
||||
|
||||
if (tokenCurrency == null)
|
||||
{
|
||||
tokenCurrency = new PlayerCurrency
|
||||
{
|
||||
CurrencyType = CurrencyType.RecCenterTokens,
|
||||
BalanceType = BalanceType.NonPurchasedDefault,
|
||||
Balance = InitialTokenBalance
|
||||
};
|
||||
account.Player.PlayerExtra.Currencies.Add(tokenCurrency);
|
||||
}
|
||||
else if (tokenCurrency.Balance <= 0)
|
||||
{
|
||||
tokenCurrency.Balance = InitialTokenBalance;
|
||||
tokenCurrency.BalanceType =
|
||||
BalanceType.NonPurchasedDefault;
|
||||
}
|
||||
|
||||
account.Player.PlayerExtra.Settings.Add(new Setting
|
||||
{
|
||||
Key = InitialTokensSettingKey,
|
||||
Value = "True"
|
||||
});
|
||||
|
||||
if (Players.Update(account))
|
||||
updated++;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
public static int ResetAlpacaShirtOwnershipForExistingPlayers()
|
||||
{
|
||||
lock (SocialLock)
|
||||
{
|
||||
int ownershipsRemoved = 0;
|
||||
|
||||
foreach (var account in Players.FindAll().ToList())
|
||||
{
|
||||
if (account.Player == null)
|
||||
continue;
|
||||
|
||||
account.Player.PlayerExtra ??= new PlayerExtra();
|
||||
account.Player.PlayerExtra.Settings ??= new List<Setting>();
|
||||
account.Player.PlayerExtra.AvatarItems ??= new List<string>();
|
||||
|
||||
bool resetAlreadyApplied = account.Player.PlayerExtra.Settings
|
||||
.Any(setting =>
|
||||
string.Equals(
|
||||
setting.Key,
|
||||
AlpacaShirtOwnershipResetSettingKey,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(
|
||||
setting.Value,
|
||||
"True",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (resetAlreadyApplied)
|
||||
continue;
|
||||
|
||||
int removedForAccount = account.Player.PlayerExtra.AvatarItems
|
||||
.RemoveAll(descriptor => string.Equals(
|
||||
descriptor,
|
||||
AlpacaShirtAvatarItemDescriptor,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
account.Player.PlayerExtra.Settings.Add(new Setting
|
||||
{
|
||||
Key = AlpacaShirtOwnershipResetSettingKey,
|
||||
Value = "True"
|
||||
});
|
||||
|
||||
if (Players.Update(account))
|
||||
ownershipsRemoved += removedForAccount;
|
||||
}
|
||||
|
||||
return ownershipsRemoved;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool OwnsAvatarItem(
|
||||
long playerId,
|
||||
string avatarItemDescriptor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(avatarItemDescriptor))
|
||||
return false;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var legacyItems = Players.FindById(playerId)?
|
||||
.Player?.PlayerExtra?.AvatarItems;
|
||||
|
||||
return PlayerInventoryStore.OwnsAvatarItem(
|
||||
playerId,
|
||||
avatarItemDescriptor,
|
||||
legacyAvatarDescriptors: legacyItems);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool RemoveAvatarItem(
|
||||
long playerId,
|
||||
string avatarItemDescriptor,
|
||||
long avatarItemId = 0)
|
||||
{
|
||||
if (playerId <= 0 ||
|
||||
(string.IsNullOrWhiteSpace(avatarItemDescriptor) && avatarItemId <= 0))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.AvatarItems ??= new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(avatarItemDescriptor))
|
||||
{
|
||||
player.Player.PlayerExtra.AvatarItems.RemoveAll(value =>
|
||||
string.Equals(
|
||||
value,
|
||||
avatarItemDescriptor,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
bool saved = PlayerInventoryStore.SetAvatarItemOwned(
|
||||
playerId,
|
||||
avatarItemDescriptor,
|
||||
avatarItemId,
|
||||
friendlyName: null,
|
||||
owned: false,
|
||||
legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems);
|
||||
|
||||
return saved && Players.Update(player);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<PlayerRelationship> GetRelationships(long playerId)
|
||||
{
|
||||
return Players.FindById(playerId)?.Player == null
|
||||
? new List<PlayerRelationship>()
|
||||
: RelationshipDB.GetRelationships(playerId);
|
||||
}
|
||||
|
||||
public static PlayerRelationship? SetRelationshipFlags(
|
||||
long sourcePlayerId,
|
||||
long targetPlayerId,
|
||||
bool? ignored = null,
|
||||
bool? muted = null,
|
||||
bool? favorited = null)
|
||||
{
|
||||
if (!ValidateRelationshipPlayers(sourcePlayerId, targetPlayerId))
|
||||
return null;
|
||||
|
||||
PlayerRelationship relationship;
|
||||
lock (SocialLock)
|
||||
{
|
||||
relationship = RelationshipDB.SetFlags(
|
||||
sourcePlayerId,
|
||||
targetPlayerId,
|
||||
ignored: ignored,
|
||||
muted: muted,
|
||||
favorited: favorited);
|
||||
|
||||
if (!SynchronizeLegacyRelationshipNoLock(sourcePlayerId, targetPlayerId))
|
||||
return null;
|
||||
}
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
private static bool ApplyBanStateToHeartbeat(
|
||||
FullPlayer player,
|
||||
long playerId,
|
||||
out Heartbeat heartbeat)
|
||||
{
|
||||
player.Player ??= new Player();
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Heartbeat ??= new Heartbeat();
|
||||
|
||||
heartbeat = player.Player.PlayerExtra.Heartbeat;
|
||||
heartbeat.playerId = playerId;
|
||||
|
||||
var moderation =
|
||||
player.Player.PlayerExtra.ModerationBlockDetails;
|
||||
|
||||
if (moderation?.IsBan != true)
|
||||
{
|
||||
if (heartbeat.errorCode ==
|
||||
MatchmakingErrorCode.Banned)
|
||||
{
|
||||
heartbeat.errorCode =
|
||||
MatchmakingErrorCode.Success;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
long now = DateTimeOffset.UtcNow
|
||||
.ToUnixTimeSeconds();
|
||||
|
||||
bool permanent = moderation.Duration <= 0;
|
||||
|
||||
bool active =
|
||||
permanent ||
|
||||
moderation.ModerationSetUnixTime +
|
||||
moderation.Duration > now;
|
||||
|
||||
if (!active)
|
||||
{
|
||||
|
||||
moderation.IsBan = false;
|
||||
moderation.Duration = 0;
|
||||
moderation.Message = "";
|
||||
moderation.ModerationSetUnixTime = 0;
|
||||
moderation.BannedByPlayerId = 0;
|
||||
|
||||
if (heartbeat.errorCode ==
|
||||
MatchmakingErrorCode.Banned)
|
||||
{
|
||||
heartbeat.errorCode =
|
||||
MatchmakingErrorCode.Success;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
heartbeat.isOnline = false;
|
||||
heartbeat.roomInstance = null;
|
||||
heartbeat.errorCode =
|
||||
MatchmakingErrorCode.Banned;
|
||||
|
||||
heartbeat.lastHeartbeatUnixTime =
|
||||
now;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static PlayerRelationship? SendFriendRequest(
|
||||
long sourcePlayerId,
|
||||
long targetPlayerId)
|
||||
{
|
||||
if (!ValidateRelationshipPlayers(sourcePlayerId, targetPlayerId))
|
||||
return null;
|
||||
|
||||
PlayerRelationship? result;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
PlayerRelationship? existing = RelationshipDB.GetRelationship(
|
||||
sourcePlayerId,
|
||||
targetPlayerId);
|
||||
|
||||
if (existing?.RelationshipType is RelationshipType.OutgoingFriendRequest
|
||||
or RelationshipType.Friend)
|
||||
{
|
||||
result = existing;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = RelationshipDB.SendFriendRequest(
|
||||
sourcePlayerId,
|
||||
targetPlayerId,
|
||||
out _);
|
||||
}
|
||||
|
||||
if (!SynchronizeLegacyRelationshipNoLock(sourcePlayerId, targetPlayerId))
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PlayerRelationship? AcceptFriendRequest(
|
||||
long receiverPlayerId,
|
||||
long requesterPlayerId)
|
||||
{
|
||||
if (!ValidateRelationshipPlayers(receiverPlayerId, requesterPlayerId))
|
||||
return null;
|
||||
|
||||
PlayerRelationship? result;
|
||||
lock (SocialLock)
|
||||
{
|
||||
result = RelationshipDB.AcceptFriendRequest(
|
||||
receiverPlayerId,
|
||||
requesterPlayerId);
|
||||
if (result == null)
|
||||
return null;
|
||||
|
||||
if (!SynchronizeLegacyRelationshipNoLock(receiverPlayerId, requesterPlayerId))
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PlayerRelationship? AddFriendRequest(
|
||||
long sourcePlayerId,
|
||||
long targetPlayerId) =>
|
||||
SendFriendRequest(sourcePlayerId, targetPlayerId);
|
||||
|
||||
private static bool ValidateRelationshipPlayers(
|
||||
long firstPlayerId,
|
||||
long secondPlayerId)
|
||||
{
|
||||
if (firstPlayerId <= 0 ||
|
||||
secondPlayerId <= 0 ||
|
||||
firstPlayerId == secondPlayerId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Players.FindById(firstPlayerId)?.Player != null &&
|
||||
Players.FindById(secondPlayerId)?.Player != null;
|
||||
}
|
||||
|
||||
private static bool SynchronizeLegacyRelationshipNoLock(
|
||||
long firstPlayerId,
|
||||
long secondPlayerId)
|
||||
{
|
||||
FullPlayer? first = Players.FindById(firstPlayerId);
|
||||
FullPlayer? second = Players.FindById(secondPlayerId);
|
||||
if (first?.Player == null || second?.Player == null)
|
||||
return false;
|
||||
|
||||
first.Player.Relationships ??= new List<PlayerRelationship>();
|
||||
second.Player.Relationships ??= new List<PlayerRelationship>();
|
||||
|
||||
SynchronizeOneLegacyRelationship(
|
||||
first.Player.Relationships,
|
||||
RelationshipDB.GetRelationship(firstPlayerId, secondPlayerId),
|
||||
secondPlayerId);
|
||||
SynchronizeOneLegacyRelationship(
|
||||
second.Player.Relationships,
|
||||
RelationshipDB.GetRelationship(secondPlayerId, firstPlayerId),
|
||||
firstPlayerId);
|
||||
|
||||
return Players.Update(first) && Players.Update(second);
|
||||
}
|
||||
|
||||
private static void SynchronizeOneLegacyRelationship(
|
||||
List<PlayerRelationship> relationships,
|
||||
PlayerRelationship? authoritative,
|
||||
long otherPlayerId)
|
||||
{
|
||||
relationships.RemoveAll(value => value.PlayerID == otherPlayerId);
|
||||
if (authoritative != null && authoritative.RelationshipType != RelationshipType.None)
|
||||
relationships.Add(authoritative);
|
||||
}
|
||||
|
||||
private static long CreateRelationshipId(
|
||||
long firstPlayerId,
|
||||
long secondPlayerId) =>
|
||||
RelationshipDB.GetStableNumericId(firstPlayerId, secondPlayerId);
|
||||
|
||||
public static bool SetSubscription(
|
||||
long subscriberPlayerId,
|
||||
long targetPlayerId,
|
||||
bool subscribe)
|
||||
{
|
||||
if (subscriberPlayerId == targetPlayerId)
|
||||
return false;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var subscriber = Players.FindById(subscriberPlayerId);
|
||||
var target = Players.FindById(targetPlayerId);
|
||||
|
||||
if (subscriber?.Player == null || target?.Player == null)
|
||||
return false;
|
||||
|
||||
if (!RelationshipDB.SetSubscription(
|
||||
subscriberPlayerId,
|
||||
targetPlayerId,
|
||||
subscribe))
|
||||
return false;
|
||||
|
||||
subscriber.Player.SubscribedAccountIds ??= new List<long>();
|
||||
subscriber.Player.Reputation ??= new Reputation();
|
||||
target.Player.Reputation ??= new Reputation();
|
||||
|
||||
if (subscribe)
|
||||
{
|
||||
if (!subscriber.Player.SubscribedAccountIds.Contains(targetPlayerId))
|
||||
subscriber.Player.SubscribedAccountIds.Add(targetPlayerId);
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriber.Player.SubscribedAccountIds.RemoveAll(value =>
|
||||
value == targetPlayerId);
|
||||
}
|
||||
|
||||
subscriber.Player.Reputation.SubscribedCount =
|
||||
RelationshipDB.GetSubscribedCount(subscriberPlayerId);
|
||||
Players.Update(subscriber);
|
||||
|
||||
target.Player.Reputation.SubscriberCount =
|
||||
RelationshipDB.GetSubscriberCount(targetPlayerId);
|
||||
|
||||
Players.Update(target);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static int GetSubscriberCount(long targetPlayerId)
|
||||
{
|
||||
return RelationshipDB.GetSubscriberCount(targetPlayerId);
|
||||
}
|
||||
|
||||
public static int GetCurrencyBalance(
|
||||
long playerId,
|
||||
CurrencyType currencyType)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
return player?.Player?.PlayerExtra?.Currencies?
|
||||
.FirstOrDefault(currency =>
|
||||
currency.CurrencyType == currencyType)?.Balance ?? 0;
|
||||
}
|
||||
|
||||
public static List<(CurrencyType CurrencyType, int Balance)> GetAllCurrencyBalances(
|
||||
long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
var currencies = player?.Player?.PlayerExtra?.Currencies ?? new List<PlayerCurrency>();
|
||||
|
||||
var storedBalances = currencies
|
||||
.GroupBy(currency => currency.CurrencyType)
|
||||
.ToDictionary(group => group.Key, group => group.First().Balance);
|
||||
|
||||
return Enum.GetValues<CurrencyType>()
|
||||
.Where(currency => currency != CurrencyType.Invalid)
|
||||
.Select(currency => (
|
||||
currency,
|
||||
storedBalances.TryGetValue(currency, out int balance) ? balance : 0))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static int? SetCurrencyBalance(
|
||||
long playerId,
|
||||
CurrencyType currencyType,
|
||||
int amount,
|
||||
bool add)
|
||||
{
|
||||
lock (SocialLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Currencies ??= new List<PlayerCurrency>();
|
||||
|
||||
var currency = player.Player.PlayerExtra.Currencies
|
||||
.FirstOrDefault(value => value.CurrencyType == currencyType);
|
||||
|
||||
if (currency == null)
|
||||
{
|
||||
currency = new PlayerCurrency
|
||||
{
|
||||
CurrencyType = currencyType,
|
||||
BalanceType = BalanceType.NonPurchasedDefault
|
||||
};
|
||||
player.Player.PlayerExtra.Currencies.Add(currency);
|
||||
}
|
||||
|
||||
long requestedBalance = add
|
||||
? (long)currency.Balance + amount
|
||||
: amount;
|
||||
currency.Balance = (int)Math.Clamp(
|
||||
requestedBalance,
|
||||
0L,
|
||||
int.MaxValue);
|
||||
|
||||
return Players.Update(player) ? currency.Balance : null;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TrySpendCurrency(
|
||||
long playerId,
|
||||
CurrencyType currencyType,
|
||||
int amount,
|
||||
out int newBalance)
|
||||
{
|
||||
newBalance = 0;
|
||||
if (amount < 0)
|
||||
return false;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Currencies ??= new List<PlayerCurrency>();
|
||||
|
||||
var currency = player.Player.PlayerExtra.Currencies
|
||||
.FirstOrDefault(value => value.CurrencyType == currencyType);
|
||||
|
||||
newBalance = currency?.Balance ?? 0;
|
||||
if (amount == 0)
|
||||
return true;
|
||||
if (currency == null || currency.Balance < amount)
|
||||
return false;
|
||||
|
||||
int originalBalance = currency.Balance;
|
||||
currency.Balance -= amount;
|
||||
newBalance = currency.Balance;
|
||||
|
||||
if (Players.Update(player))
|
||||
return true;
|
||||
|
||||
currency.Balance = originalBalance;
|
||||
newBalance = originalBalance;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryPurchaseAvatarItem(
|
||||
long playerId,
|
||||
string avatarItemDescriptor,
|
||||
int price,
|
||||
out int newBalance,
|
||||
out bool alreadyOwned)
|
||||
{
|
||||
newBalance = 0;
|
||||
alreadyOwned = false;
|
||||
|
||||
if (price < 0 || string.IsNullOrWhiteSpace(avatarItemDescriptor))
|
||||
return false;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Currencies ??= new List<PlayerCurrency>();
|
||||
player.Player.PlayerExtra.AvatarItems ??= new List<string>();
|
||||
|
||||
var currency = player.Player.PlayerExtra.Currencies
|
||||
.FirstOrDefault(value =>
|
||||
value.CurrencyType == CurrencyType.RecCenterTokens);
|
||||
|
||||
newBalance = currency?.Balance ?? 0;
|
||||
alreadyOwned = PlayerInventoryStore.OwnsAvatarItem(
|
||||
playerId,
|
||||
avatarItemDescriptor,
|
||||
legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems);
|
||||
|
||||
if (alreadyOwned)
|
||||
return true;
|
||||
if (currency == null || currency.Balance < price)
|
||||
return false;
|
||||
|
||||
currency.Balance -= price;
|
||||
player.Player.PlayerExtra.AvatarItems.Add(avatarItemDescriptor);
|
||||
PlayerInventoryStore.SetAvatarItemOwned(
|
||||
playerId,
|
||||
avatarItemDescriptor,
|
||||
avatarItemId: 0,
|
||||
friendlyName: null,
|
||||
owned: true,
|
||||
legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems);
|
||||
newBalance = currency.Balance;
|
||||
return Players.Update(player);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GrantAvatarItem(
|
||||
long playerId,
|
||||
string avatarItemDescriptor)
|
||||
{
|
||||
if (playerId <= 0 || string.IsNullOrWhiteSpace(avatarItemDescriptor))
|
||||
return false;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.AvatarItems ??= new List<string>();
|
||||
|
||||
if (player.Player.PlayerExtra.AvatarItems.Contains(
|
||||
avatarItemDescriptor,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
PlayerInventoryStore.SetAvatarItemOwned(
|
||||
playerId,
|
||||
avatarItemDescriptor,
|
||||
avatarItemId: 0,
|
||||
friendlyName: null,
|
||||
owned: true,
|
||||
legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems);
|
||||
return true;
|
||||
}
|
||||
|
||||
player.Player.PlayerExtra.AvatarItems.Add(avatarItemDescriptor);
|
||||
PlayerInventoryStore.SetAvatarItemOwned(
|
||||
playerId,
|
||||
avatarItemDescriptor,
|
||||
avatarItemId: 0,
|
||||
friendlyName: null,
|
||||
owned: true,
|
||||
legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems);
|
||||
return Players.Update(player);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsSubscribed(long subscriberPlayerId, long targetPlayerId)
|
||||
{
|
||||
return RelationshipDB.IsSubscribed(subscriberPlayerId, targetPlayerId);
|
||||
}
|
||||
|
||||
public static bool RemoveFriend(long sourcePlayerId, long targetPlayerId)
|
||||
{
|
||||
bool removed;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var source = Players.FindById(sourcePlayerId);
|
||||
var target = Players.FindById(targetPlayerId);
|
||||
|
||||
if (source?.Player == null || target?.Player == null)
|
||||
return false;
|
||||
|
||||
source.Player.Relationships ??= new List<PlayerRelationship>();
|
||||
target.Player.Relationships ??= new List<PlayerRelationship>();
|
||||
|
||||
source.Player.Relationships.RemoveAll(value =>
|
||||
value.PlayerID == targetPlayerId);
|
||||
target.Player.Relationships.RemoveAll(value =>
|
||||
value.PlayerID == sourcePlayerId);
|
||||
|
||||
Players.Update(source);
|
||||
Players.Update(target);
|
||||
removed = RelationshipDB.RemoveFriend(sourcePlayerId, targetPlayerId);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
public static PlayerCheerRecord? GivePlayerCheer(
|
||||
long giverPlayerId,
|
||||
long receiverPlayerId,
|
||||
CheerCategory category,
|
||||
out NotificationDB.ClientNotification? notification,
|
||||
bool anonymous = false)
|
||||
{
|
||||
notification = null;
|
||||
|
||||
if (giverPlayerId == receiverPlayerId ||
|
||||
!IsPublicCheerCategory(category))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PlayerCheerRecord? cheer;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var giver = Players.FindById(giverPlayerId);
|
||||
var receiver = Players.FindById(receiverPlayerId);
|
||||
|
||||
if (giver?.Player == null || receiver?.Player == null)
|
||||
return null;
|
||||
|
||||
giver.Player.Reputation ??= new Reputation();
|
||||
receiver.Player.ReceivedCheers ??= new List<PlayerCheerRecord>();
|
||||
receiver.Player.Reputation ??= new Reputation();
|
||||
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
RefreshCheerCreditsNoLock(
|
||||
giver.Player.Reputation,
|
||||
giverPlayerId,
|
||||
now);
|
||||
if (giver.Player.Reputation.CheerCredit <= 0)
|
||||
return null;
|
||||
|
||||
cheer = new PlayerCheerRecord
|
||||
{
|
||||
CheerId = Interlocked.Increment(ref CheerSequence),
|
||||
GiverPlayerId = giverPlayerId,
|
||||
ReceiverPlayerId = receiverPlayerId,
|
||||
CheerCategory = category,
|
||||
Anonymous = anonymous,
|
||||
Status = CheerStatus.Pending,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
notification = NotificationDB.CreatePlayerCheer(
|
||||
giverPlayerId,
|
||||
receiverPlayerId,
|
||||
(int)category,
|
||||
anonymous);
|
||||
|
||||
cheer.NotificationId = notification.Id;
|
||||
receiver.Player.ReceivedCheers.Add(cheer);
|
||||
|
||||
var reputation = receiver.Player.Reputation;
|
||||
reputation.AccountId = receiverPlayerId;
|
||||
reputation.IsCheerful = true;
|
||||
giver.Player.Reputation.AccountId = giverPlayerId;
|
||||
giver.Player.Reputation.CheerCredit--;
|
||||
|
||||
switch (category)
|
||||
{
|
||||
case CheerCategory.General:
|
||||
reputation.CheerGeneral++;
|
||||
break;
|
||||
case CheerCategory.Helpful:
|
||||
reputation.CheerHelpful++;
|
||||
break;
|
||||
case CheerCategory.Sportmanship:
|
||||
reputation.CheerSportsman++;
|
||||
break;
|
||||
case CheerCategory.GreatHost:
|
||||
reputation.CheerGreatHost++;
|
||||
break;
|
||||
case CheerCategory.Creative:
|
||||
reputation.CheerCreative++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (Players.Update(new[] { giver, receiver }) != 2)
|
||||
{
|
||||
NotificationDB.DeleteMessages(
|
||||
receiverPlayerId,
|
||||
new[] { notification.Id });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return cheer;
|
||||
}
|
||||
|
||||
private static bool RefreshCheerCreditsNoLock(
|
||||
Reputation reputation,
|
||||
long accountId,
|
||||
long nowUnixTime)
|
||||
{
|
||||
bool changed = false;
|
||||
|
||||
if (reputation.AccountId != accountId)
|
||||
{
|
||||
reputation.AccountId = accountId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (reputation.CheerCreditRefreshAtUnixTime <= 0)
|
||||
{
|
||||
|
||||
reputation.CheerCredit = CheerCreditLimit;
|
||||
reputation.CheerCreditRefreshAtUnixTime =
|
||||
nowUnixTime + CheerCreditRefreshSeconds;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (nowUnixTime >= reputation.CheerCreditRefreshAtUnixTime)
|
||||
{
|
||||
long elapsed =
|
||||
nowUnixTime - reputation.CheerCreditRefreshAtUnixTime;
|
||||
long completedIntervals =
|
||||
(elapsed / CheerCreditRefreshSeconds) + 1;
|
||||
|
||||
reputation.CheerCredit = CheerCreditLimit;
|
||||
reputation.CheerCreditRefreshAtUnixTime +=
|
||||
completedIntervals * CheerCreditRefreshSeconds;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
int clamped = Math.Clamp(
|
||||
reputation.CheerCredit,
|
||||
0,
|
||||
CheerCreditLimit);
|
||||
if (clamped != reputation.CheerCredit)
|
||||
{
|
||||
reputation.CheerCredit = clamped;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
public static int ResolvePlayerCheers(
|
||||
long receiverPlayerId,
|
||||
IEnumerable<long> cheerOrNotificationIds,
|
||||
CheerStatus status)
|
||||
{
|
||||
if (receiverPlayerId <= 0 ||
|
||||
status == CheerStatus.Pending)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
HashSet<long> ids = cheerOrNotificationIds
|
||||
.Where(value => value > 0)
|
||||
.ToHashSet();
|
||||
if (ids.Count == 0)
|
||||
return 0;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var receiver = Players.FindById(receiverPlayerId);
|
||||
if (receiver?.Player == null)
|
||||
return 0;
|
||||
|
||||
receiver.Player.ReceivedCheers ??=
|
||||
new List<PlayerCheerRecord>();
|
||||
|
||||
List<PlayerCheerRecord> matching =
|
||||
receiver.Player.ReceivedCheers
|
||||
.Where(value =>
|
||||
value.Status == CheerStatus.Pending &&
|
||||
(ids.Contains(value.CheerId) ||
|
||||
ids.Contains(value.NotificationId)))
|
||||
.ToList();
|
||||
|
||||
if (matching.Count == 0)
|
||||
return 0;
|
||||
|
||||
DateTime handledAt = DateTime.UtcNow;
|
||||
foreach (PlayerCheerRecord cheer in matching)
|
||||
{
|
||||
cheer.Status = status;
|
||||
cheer.HandledAt = handledAt;
|
||||
}
|
||||
|
||||
return Players.Update(receiver)
|
||||
? matching.Count
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<PlayerCheerRecord> GetPendingPlayerCheers(
|
||||
long receiverPlayerId)
|
||||
{
|
||||
if (receiverPlayerId <= 0)
|
||||
return new List<PlayerCheerRecord>();
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var receiver = Players.FindById(receiverPlayerId);
|
||||
return receiver?.Player?.ReceivedCheers?
|
||||
.Where(value => value.Status == CheerStatus.Pending)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.ToList() ?? new List<PlayerCheerRecord>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPublicCheerCategory(
|
||||
CheerCategory category) =>
|
||||
category is CheerCategory.General or
|
||||
CheerCategory.Helpful or
|
||||
CheerCategory.Sportmanship or
|
||||
CheerCategory.GreatHost or
|
||||
CheerCategory.Creative;
|
||||
|
||||
public static Heartbeat GetPlayerHeartbeat(
|
||||
long playerId,
|
||||
long? viewingPlayerId = null)
|
||||
{
|
||||
var player = Players.FindOne(
|
||||
x => x.PlayerId == playerId);
|
||||
|
||||
return CreateClientHeartbeat(
|
||||
player,
|
||||
playerId,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
viewingPlayerId);
|
||||
}
|
||||
|
||||
public static List<Heartbeat> GetPlayerHeartbeatsBulk(
|
||||
List<long> playerIds,
|
||||
long? viewingPlayerId = null)
|
||||
{
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var players = Players
|
||||
.Find(x => playerIds.Contains(x.PlayerId))
|
||||
.ToList();
|
||||
|
||||
return players
|
||||
.Select(p => CreateClientHeartbeat(
|
||||
p,
|
||||
p.PlayerId,
|
||||
now,
|
||||
viewingPlayerId))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static long[] GetActiveSameInstancePlayerIds(long subjectPlayerId)
|
||||
{
|
||||
if (subjectPlayerId <= 0)
|
||||
return Array.Empty<long>();
|
||||
|
||||
Heartbeat? subjectHeartbeat = Players.FindById(subjectPlayerId)?
|
||||
.Player?.PlayerExtra?.Heartbeat;
|
||||
long roomInstanceId =
|
||||
subjectHeartbeat?.roomInstance?.roomInstanceId ?? 0;
|
||||
|
||||
if (roomInstanceId <= 0)
|
||||
return Array.Empty<long>();
|
||||
|
||||
long activeAfter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() -
|
||||
PresenceFreshnessSeconds;
|
||||
|
||||
return Players.FindAll()
|
||||
.Where(account =>
|
||||
{
|
||||
if (account.PlayerId == subjectPlayerId)
|
||||
return false;
|
||||
|
||||
Heartbeat? heartbeat =
|
||||
account.Player?.PlayerExtra?.Heartbeat;
|
||||
|
||||
return heartbeat?.isOnline == true &&
|
||||
heartbeat.lastHeartbeatUnixTime >= activeAfter &&
|
||||
heartbeat.roomInstance?.roomInstanceId == roomInstanceId;
|
||||
})
|
||||
.Select(account => account.PlayerId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static Heartbeat CreateClientHeartbeat(
|
||||
FullPlayer? player,
|
||||
long playerId,
|
||||
long now,
|
||||
long? viewingPlayerId)
|
||||
{
|
||||
Heartbeat stored =
|
||||
player?.Player?.PlayerExtra?.Heartbeat ??
|
||||
new Heartbeat();
|
||||
|
||||
bool isFresh =
|
||||
stored.lastHeartbeatUnixTime > 0 &&
|
||||
stored.lastHeartbeatUnixTime >=
|
||||
now - PresenceFreshnessSeconds;
|
||||
bool isOnline = stored.isOnline && isFresh;
|
||||
|
||||
RoomInstance? roomInstance = isOnline &&
|
||||
stored.roomInstance != null
|
||||
? CloneRoomInstance(stored.roomInstance)
|
||||
: null;
|
||||
|
||||
bool viewerSharesRestrictedInstance = false;
|
||||
if (roomInstance != null &&
|
||||
viewingPlayerId.HasValue &&
|
||||
viewingPlayerId.Value != playerId)
|
||||
{
|
||||
Heartbeat? viewerHeartbeat = Players
|
||||
.FindById(viewingPlayerId.Value)?
|
||||
.Player?.PlayerExtra?.Heartbeat;
|
||||
viewerSharesRestrictedInstance =
|
||||
viewerHeartbeat?.isOnline == true &&
|
||||
viewerHeartbeat.roomInstance?.roomInstanceId ==
|
||||
roomInstance.roomInstanceId;
|
||||
}
|
||||
|
||||
if (roomInstance != null &&
|
||||
viewingPlayerId.HasValue &&
|
||||
viewingPlayerId.Value != playerId &&
|
||||
Sessions.IsRestrictedInstance(roomInstance) &&
|
||||
!viewerSharesRestrictedInstance &&
|
||||
!NotificationDB.HasActiveRoomInvite(
|
||||
viewingPlayerId.Value,
|
||||
playerId,
|
||||
roomInstance.roomInstanceId))
|
||||
{
|
||||
roomInstance = null;
|
||||
}
|
||||
|
||||
if (roomInstance != null &&
|
||||
roomInstance.roomInstanceType != RoomInstanceType.Dormroom &&
|
||||
roomInstance.roomId > 0)
|
||||
{
|
||||
var room = RoomDB.GetRoom(roomInstance.roomId);
|
||||
if (room != null && !string.IsNullOrWhiteSpace(room.Name))
|
||||
roomInstance.Name = $"^{room.Name.Trim().TrimStart('^')}";
|
||||
}
|
||||
|
||||
return new Heartbeat
|
||||
{
|
||||
appVersion = stored.appVersion,
|
||||
deviceClass = stored.deviceClass,
|
||||
errorCode = stored.errorCode,
|
||||
isOnline = isOnline,
|
||||
playerId = playerId,
|
||||
roomInstance = roomInstance,
|
||||
statusVisibility = isOnline
|
||||
? stored.statusVisibility
|
||||
: StatusVisibility.Offline,
|
||||
vrMovementMode = stored.vrMovementMode,
|
||||
lastHeartbeatUnixTime = stored.lastHeartbeatUnixTime
|
||||
};
|
||||
}
|
||||
|
||||
public static void RecordRoomVisit(
|
||||
long playerId,
|
||||
long roomId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return;
|
||||
|
||||
player.Player.VisitedRooms ??= new List<long>();
|
||||
|
||||
if (!player.Player.VisitedRooms.Contains(roomId))
|
||||
player.Player.VisitedRooms.Add(roomId);
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
|
||||
player.Player.PlayerExtra.RoomVisits ??=
|
||||
new List<RoomVisit>();
|
||||
|
||||
var existingVisit =
|
||||
player.Player.PlayerExtra.RoomVisits
|
||||
.FirstOrDefault(v => v.RoomId == roomId);
|
||||
|
||||
bool isNewVisitor = existingVisit == null;
|
||||
|
||||
if (existingVisit != null)
|
||||
{
|
||||
existingVisit.VisitedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
player.Player.PlayerExtra.RoomVisits.Add(
|
||||
new RoomVisit
|
||||
{
|
||||
RoomId = roomId,
|
||||
VisitedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
var room = RoomDB.GetRoom(roomId);
|
||||
|
||||
if (room != null)
|
||||
{
|
||||
room.Stats ??= new RoomDBClasses.Stats();
|
||||
|
||||
room.Stats.VisitCount++;
|
||||
|
||||
if (isNewVisitor)
|
||||
room.Stats.VisitorCount++;
|
||||
|
||||
RoomDB.Rooms.Update(room);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ToggleCheer(
|
||||
long playerId,
|
||||
long roomId,
|
||||
out bool isNowCheered)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
isNowCheered = false;
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.CheeredRooms ??= new List<long>();
|
||||
|
||||
bool wasCheered =
|
||||
player.Player.CheeredRooms.Contains(roomId);
|
||||
|
||||
if (wasCheered)
|
||||
{
|
||||
player.Player.CheeredRooms.Remove(roomId);
|
||||
}
|
||||
else
|
||||
{
|
||||
player.Player.CheeredRooms.Add(roomId);
|
||||
}
|
||||
|
||||
isNowCheered = !wasCheered;
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
var room = RoomDB.GetRoom(roomId);
|
||||
|
||||
if (room != null)
|
||||
{
|
||||
room.Stats ??= new RoomDBClasses.Stats();
|
||||
|
||||
room.Stats.CheerCount = Math.Max(
|
||||
0,
|
||||
room.Stats.CheerCount +
|
||||
(wasCheered ? -1 : 1));
|
||||
|
||||
RoomDB.Rooms.Update(room);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ToggleFavorite(
|
||||
long playerId,
|
||||
long roomId,
|
||||
out bool isNowFavorited)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
isNowFavorited = false;
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.FavoritedRooms ??= new List<long>();
|
||||
|
||||
bool wasFavorited =
|
||||
player.Player.FavoritedRooms.Contains(roomId);
|
||||
|
||||
if (wasFavorited)
|
||||
{
|
||||
player.Player.FavoritedRooms.Remove(roomId);
|
||||
}
|
||||
else
|
||||
{
|
||||
player.Player.FavoritedRooms.Add(roomId);
|
||||
}
|
||||
|
||||
isNowFavorited = !wasFavorited;
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
var room = RoomDB.GetRoom(roomId);
|
||||
|
||||
if (room != null)
|
||||
{
|
||||
room.Stats ??= new RoomDBClasses.Stats();
|
||||
|
||||
room.Stats.FavoriteCount = Math.Max(
|
||||
0,
|
||||
room.Stats.FavoriteCount +
|
||||
(wasFavorited ? -1 : 1));
|
||||
|
||||
RoomDB.Rooms.Update(room);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static (
|
||||
List<PlayerDTOBase> Results,
|
||||
int Total)
|
||||
SearchAccounts(
|
||||
string query,
|
||||
int skip,
|
||||
int take)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return (
|
||||
new List<PlayerDTOBase>(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
var q = query
|
||||
.Trim()
|
||||
.ToLowerInvariant();
|
||||
|
||||
var matches = Players
|
||||
.FindAll()
|
||||
.Where(p =>
|
||||
(p.Player?.Username?
|
||||
.ToLowerInvariant()
|
||||
.Contains(q) ?? false)
|
||||
||
|
||||
(p.Player?.DisplayName?
|
||||
.ToLowerInvariant()
|
||||
.Contains(q) ?? false))
|
||||
.OrderBy(p => p.Player?.Username)
|
||||
.ToList();
|
||||
|
||||
int total = matches.Count;
|
||||
|
||||
var page = matches
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.Select(p => MapToDTO(p, false))
|
||||
.ToList();
|
||||
|
||||
return (page, total);
|
||||
}
|
||||
|
||||
public static Heartbeat? UpdatePlayerHeartbeat(
|
||||
long playerId,
|
||||
RoomInstance? roomInstance,
|
||||
bool online = true,
|
||||
Platforms platform = Platforms.All,
|
||||
DeviceClasses deviceClasses = DeviceClasses.Unknown)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
player.Player ??= new Player();
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
|
||||
player.Player.PlayerExtra.Heartbeat ??=
|
||||
new Heartbeat();
|
||||
|
||||
var hb = player.Player.PlayerExtra.Heartbeat;
|
||||
long? oldRoomInstanceId = hb.roomInstance?.roomInstanceId;
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
hb.playerId = playerId;
|
||||
hb.roomInstance = online ? roomInstance : null;
|
||||
hb.errorCode = MatchmakingErrorCode.Success;
|
||||
hb.isOnline = online;
|
||||
hb.lastHeartbeatUnixTime = now;
|
||||
|
||||
long newRoomInstanceId =
|
||||
online ? roomInstance?.roomInstanceId ?? 0 : 0;
|
||||
if (player.Player.PlayerExtra.LevelingRoomInstanceId !=
|
||||
newRoomInstanceId)
|
||||
{
|
||||
ResetRoomLevelTimer(
|
||||
player.Player.PlayerExtra,
|
||||
newRoomInstanceId,
|
||||
newRoomInstanceId > 0 ? now : 0);
|
||||
}
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
if (oldRoomInstanceId != hb.roomInstance?.roomInstanceId)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM MEMBERSHIP] player={playerId} " +
|
||||
$"oldInstance={oldRoomInstanceId?.ToString() ?? "none"} " +
|
||||
$"newInstance={hb.roomInstance?.roomInstanceId.ToString() ?? "none"} " +
|
||||
"broadcast=false");
|
||||
}
|
||||
|
||||
return hb;
|
||||
}
|
||||
public static Heartbeat? LeaveCurrentRoom(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
player.Player ??= new Player();
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Heartbeat ??= new Heartbeat();
|
||||
|
||||
var heartbeat = player.Player.PlayerExtra.Heartbeat;
|
||||
long? oldRoomInstanceId = heartbeat.roomInstance?.roomInstanceId;
|
||||
|
||||
heartbeat.playerId = playerId;
|
||||
|
||||
heartbeat.isOnline = true;
|
||||
heartbeat.roomInstance = null;
|
||||
heartbeat.lastHeartbeatUnixTime =
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
ResetRoomLevelTimer(player.Player.PlayerExtra, 0, 0);
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ROOM LEAVE] player={playerId} " +
|
||||
$"instance={oldRoomInstanceId?.ToString() ?? "none"} " +
|
||||
"isolated=true presencePush=controller-awaited");
|
||||
|
||||
return heartbeat;
|
||||
}
|
||||
|
||||
public static Heartbeat? TouchPlayerHeartbeat(long playerId)
|
||||
{
|
||||
int? oldProgressionLevel = null;
|
||||
int? newProgressionLevel = null;
|
||||
int progressionXp = 0;
|
||||
Heartbeat? result;
|
||||
|
||||
lock (ProgressionLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
bool oldOnline =
|
||||
player.Player?.PlayerExtra?.Heartbeat?.isOnline ?? false;
|
||||
MatchmakingErrorCode? oldErrorCode =
|
||||
player.Player?.PlayerExtra?.Heartbeat?.errorCode;
|
||||
|
||||
if (ApplyBanStateToHeartbeat(
|
||||
player,
|
||||
playerId,
|
||||
out var heartbeat))
|
||||
{
|
||||
if (player.Player?.PlayerExtra != null)
|
||||
ResetRoomLevelTimer(
|
||||
player.Player.PlayerExtra,
|
||||
0,
|
||||
0);
|
||||
|
||||
Players.Update(player);
|
||||
if (oldOnline != heartbeat.isOnline ||
|
||||
oldErrorCode != heartbeat.errorCode)
|
||||
{
|
||||
_ = NotiController.NotifyPlayerPresenceUpdatedAsync(
|
||||
playerId);
|
||||
}
|
||||
|
||||
return heartbeat;
|
||||
}
|
||||
|
||||
heartbeat.playerId = playerId;
|
||||
heartbeat.isOnline = true;
|
||||
heartbeat.errorCode =
|
||||
MatchmakingErrorCode.Success;
|
||||
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
heartbeat.lastHeartbeatUnixTime = now;
|
||||
|
||||
if (player.Player != null &&
|
||||
ApplyRoomTimeLeveling(
|
||||
player.Player,
|
||||
now,
|
||||
out int awardedFromLevel,
|
||||
out int awardedToLevel))
|
||||
{
|
||||
oldProgressionLevel = awardedFromLevel;
|
||||
newProgressionLevel = awardedToLevel;
|
||||
progressionXp = player.Player.XP;
|
||||
}
|
||||
|
||||
Players.Update(player);
|
||||
if (oldOnline != heartbeat.isOnline ||
|
||||
oldErrorCode != heartbeat.errorCode)
|
||||
{
|
||||
_ = NotiController.NotifyPlayerPresenceUpdatedAsync(
|
||||
playerId);
|
||||
}
|
||||
|
||||
result = heartbeat;
|
||||
}
|
||||
|
||||
if (oldProgressionLevel.HasValue &&
|
||||
newProgressionLevel.HasValue)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM LEVEL] player={playerId} " +
|
||||
$"level={oldProgressionLevel.Value}->{newProgressionLevel.Value} " +
|
||||
$"interval={RoomLevelSeconds}s");
|
||||
|
||||
GrantLevelUpBox(
|
||||
playerId,
|
||||
oldProgressionLevel.Value,
|
||||
newProgressionLevel.Value);
|
||||
|
||||
_ = NotiController.NotifyProgressionAsync(
|
||||
playerId,
|
||||
oldProgressionLevel.Value,
|
||||
newProgressionLevel.Value,
|
||||
progressionXp);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Heartbeat? ResumePlayerHeartbeat(long playerId)
|
||||
{
|
||||
lock (ProgressionLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
player.Player ??= new Player();
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
Heartbeat heartbeat = player.Player.PlayerExtra.Heartbeat ??=
|
||||
new Heartbeat();
|
||||
|
||||
if (ApplyBanStateToHeartbeat(
|
||||
player,
|
||||
playerId,
|
||||
out heartbeat))
|
||||
{
|
||||
ResetRoomLevelTimer(
|
||||
player.Player.PlayerExtra,
|
||||
0,
|
||||
0);
|
||||
Players.Update(player);
|
||||
return heartbeat;
|
||||
}
|
||||
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
bool existingPresenceIsFresh =
|
||||
heartbeat.isOnline &&
|
||||
heartbeat.lastHeartbeatUnixTime > 0 &&
|
||||
heartbeat.lastHeartbeatUnixTime >=
|
||||
now - PresenceFreshnessSeconds;
|
||||
|
||||
if (!existingPresenceIsFresh)
|
||||
{
|
||||
heartbeat.roomInstance = null;
|
||||
ResetRoomLevelTimer(
|
||||
player.Player.PlayerExtra,
|
||||
0,
|
||||
0);
|
||||
}
|
||||
|
||||
heartbeat.playerId = playerId;
|
||||
heartbeat.isOnline = true;
|
||||
heartbeat.errorCode = MatchmakingErrorCode.Success;
|
||||
heartbeat.lastHeartbeatUnixTime = now;
|
||||
Players.Update(player);
|
||||
|
||||
return heartbeat;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetPlayerOffline(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
var heartbeat = player?.Player?.PlayerExtra?.Heartbeat;
|
||||
|
||||
if (player == null || heartbeat == null)
|
||||
return;
|
||||
|
||||
long? preservedRoomInstanceId = heartbeat.roomInstance?.roomInstanceId;
|
||||
|
||||
heartbeat.isOnline = false;
|
||||
heartbeat.lastHeartbeatUnixTime =
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
if (player.Player?.PlayerExtra != null)
|
||||
ResetRoomLevelTimer(player.Player.PlayerExtra, 0, 0);
|
||||
Players.Update(player);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[PLAYER OFFLINE] player={playerId} " +
|
||||
$"preservedInstance={preservedRoomInstanceId?.ToString() ?? "none"} " +
|
||||
"broadcast=controller-awaited");
|
||||
}
|
||||
|
||||
private static bool ApplyRoomTimeLeveling(
|
||||
Player player,
|
||||
long now,
|
||||
out int oldLevel,
|
||||
out int newLevel)
|
||||
{
|
||||
oldLevel = player.Level;
|
||||
newLevel = player.Level;
|
||||
|
||||
player.PlayerExtra ??= new PlayerExtra();
|
||||
Heartbeat heartbeat = player.PlayerExtra.Heartbeat ??=
|
||||
new Heartbeat();
|
||||
long roomInstanceId =
|
||||
heartbeat.isOnline
|
||||
? heartbeat.roomInstance?.roomInstanceId ?? 0
|
||||
: 0;
|
||||
|
||||
if (roomInstanceId <= 0 || player.Level >= MaximumLevel)
|
||||
{
|
||||
ResetRoomLevelTimer(
|
||||
player.PlayerExtra,
|
||||
roomInstanceId,
|
||||
roomInstanceId > 0 ? now : 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (player.PlayerExtra.LevelingRoomInstanceId != roomInstanceId)
|
||||
{
|
||||
ResetRoomLevelTimer(
|
||||
player.PlayerExtra,
|
||||
roomInstanceId,
|
||||
now);
|
||||
return false;
|
||||
}
|
||||
|
||||
long previous =
|
||||
player.PlayerExtra.LevelingLastHeartbeatUnixTime;
|
||||
player.PlayerExtra.LevelingLastHeartbeatUnixTime = now;
|
||||
|
||||
if (previous <= 0 || now <= previous)
|
||||
return false;
|
||||
|
||||
long elapsed = now - previous;
|
||||
if (elapsed > MaximumCreditableHeartbeatGapSeconds)
|
||||
{
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
player.PlayerExtra.LevelingRoomActiveSeconds = Math.Min(
|
||||
RoomLevelSeconds,
|
||||
player.PlayerExtra.LevelingRoomActiveSeconds +
|
||||
checked((int)elapsed));
|
||||
|
||||
if (player.PlayerExtra.LevelingRoomActiveSeconds <
|
||||
RoomLevelSeconds)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
oldLevel = player.Level;
|
||||
player.Level = Math.Min(MaximumLevel, player.Level + 1);
|
||||
newLevel = player.Level;
|
||||
player.PlayerExtra.LevelingRoomActiveSeconds = 0;
|
||||
|
||||
return newLevel > oldLevel;
|
||||
}
|
||||
|
||||
private static void GrantLevelUpBox(
|
||||
long playerId,
|
||||
int oldLevel,
|
||||
int newLevel)
|
||||
{
|
||||
if (playerId <= 0 || newLevel <= oldLevel)
|
||||
return;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Currencies ??= new List<PlayerCurrency>();
|
||||
|
||||
for (int level = oldLevel + 1; level <= newLevel; level++)
|
||||
{
|
||||
int rarity = GetLevelBoxRarity(level);
|
||||
|
||||
APIController.StorefrontAdminItem? boxItem =
|
||||
APIController.GetWebsiteStorefrontItems()
|
||||
.Where(item =>
|
||||
item.Rarity == rarity &&
|
||||
!string.IsNullOrWhiteSpace(item.ConsumableItemDesc))
|
||||
.OrderBy(_ => LevelBoxRng.Next())
|
||||
.FirstOrDefault();
|
||||
|
||||
if (boxItem != null)
|
||||
{
|
||||
PlayerInventoryStore.AddConsumable(
|
||||
playerId,
|
||||
boxItem.ConsumableItemDesc,
|
||||
consumableItemId: 0,
|
||||
friendlyName: boxItem.FriendlyName,
|
||||
amount: 1);
|
||||
}
|
||||
|
||||
APIController.StorefrontAdminItem? avatarBoxItem =
|
||||
APIController.GetWebsiteStorefrontItems()
|
||||
.Where(item =>
|
||||
item.Rarity == rarity &&
|
||||
!string.IsNullOrWhiteSpace(item.AvatarItemDesc))
|
||||
.OrderBy(_ => LevelBoxRng.Next())
|
||||
.FirstOrDefault() ??
|
||||
APIController.GetWebsiteStorefrontItems()
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.AvatarItemDesc))
|
||||
.OrderBy(_ => LevelBoxRng.Next())
|
||||
.FirstOrDefault();
|
||||
|
||||
if (avatarBoxItem != null)
|
||||
{
|
||||
PlayerInventoryStore.SetAvatarItemOwned(
|
||||
playerId,
|
||||
avatarBoxItem.AvatarItemDesc,
|
||||
avatarBoxItem.AvatarItemId,
|
||||
avatarBoxItem.FriendlyName,
|
||||
owned: true);
|
||||
}
|
||||
|
||||
PlayerCurrency? currency =
|
||||
player.Player.PlayerExtra.Currencies.FirstOrDefault(
|
||||
value => value.CurrencyType == CurrencyType.RecCenterTokens);
|
||||
if (currency == null)
|
||||
{
|
||||
currency = new PlayerCurrency
|
||||
{
|
||||
CurrencyType = CurrencyType.RecCenterTokens,
|
||||
BalanceType = BalanceType.NonPurchasedDefault
|
||||
};
|
||||
player.Player.PlayerExtra.Currencies.Add(currency);
|
||||
}
|
||||
|
||||
long adjustedBalance = (long)currency.Balance + 1000;
|
||||
currency.Balance = (int)Math.Clamp(
|
||||
adjustedBalance,
|
||||
(long)int.MinValue,
|
||||
(long)int.MaxValue);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[LEVEL BOX] player={playerId} level={level} " +
|
||||
$"rarity={rarity} box={boxItem?.FriendlyName ?? "none"} " +
|
||||
$"avatarItem={avatarBoxItem?.FriendlyName ?? "none"} " +
|
||||
"tokens=1000");
|
||||
}
|
||||
|
||||
Players.Update(player);
|
||||
}
|
||||
|
||||
private static void ResetRoomLevelTimer(
|
||||
PlayerExtra playerExtra,
|
||||
long roomInstanceId,
|
||||
long lastHeartbeatUnixTime)
|
||||
{
|
||||
playerExtra.LevelingRoomInstanceId = roomInstanceId;
|
||||
playerExtra.LevelingRoomActiveSeconds = 0;
|
||||
playerExtra.LevelingLastHeartbeatUnixTime =
|
||||
lastHeartbeatUnixTime;
|
||||
}
|
||||
|
||||
public static void BanPlayer(
|
||||
long playerId,
|
||||
int durationSeconds,
|
||||
string reason,
|
||||
ulong bannedByPlayerId = 0)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
|
||||
string appealCode = GenerateAppealCode();
|
||||
string reasonWithAppeal =
|
||||
$"{reason}\n\nAppeal this ban: /recnet/banappeal?code={appealCode}";
|
||||
|
||||
player.Player.PlayerExtra.ModerationBlockDetails =
|
||||
new ModerationBlockDetails
|
||||
{
|
||||
ReportCategory =
|
||||
ReportCategory.CoC_Discrimination,
|
||||
|
||||
Duration = durationSeconds,
|
||||
IsBan = true,
|
||||
Message = reasonWithAppeal,
|
||||
|
||||
ModerationSetUnixTime =
|
||||
DateTimeOffset.UtcNow
|
||||
.ToUnixTimeSeconds(),
|
||||
|
||||
BannedByPlayerId = bannedByPlayerId,
|
||||
AppealCode = appealCode
|
||||
};
|
||||
|
||||
ApplyBanStateToHeartbeat(
|
||||
player,
|
||||
playerId,
|
||||
out _);
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
NotiController.ForceDisconnectPlayer(playerId);
|
||||
}
|
||||
|
||||
private const string AppealCodeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
private static string GenerateAppealCode()
|
||||
{
|
||||
Span<char> buffer = stackalloc char[10];
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
buffer[i] = AppealCodeChars[System.Random.Shared.Next(AppealCodeChars.Length)];
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
public static bool UnbanPlayer(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?
|
||||
.Player?
|
||||
.PlayerExtra?
|
||||
.ModerationBlockDetails == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
player.Player.PlayerExtra.ModerationBlockDetails =
|
||||
new ModerationBlockDetails();
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool UpdateEquipment(
|
||||
long playerId,
|
||||
List<EquipmentItem> equipment)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Equipment = equipment;
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static string? GetEmail(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
return player?.Player?.Email;
|
||||
}
|
||||
|
||||
public static bool UpdateDisplayEmoji(
|
||||
long playerId,
|
||||
string displayEmoji)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.DisplayEmoji = displayEmoji ?? "";
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool UpdateEmail(
|
||||
long playerId,
|
||||
string email)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.Email = email;
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool IsPlayerBanned(
|
||||
long playerId,
|
||||
out ModerationBlockDetails? details)
|
||||
{
|
||||
details = null;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
var mod = player?
|
||||
.Player?
|
||||
.PlayerExtra?
|
||||
.ModerationBlockDetails;
|
||||
|
||||
if (mod == null || mod.IsBan != true)
|
||||
return false;
|
||||
|
||||
long now = DateTimeOffset.UtcNow
|
||||
.ToUnixTimeSeconds();
|
||||
|
||||
bool permanentBan = mod.Duration <= 0;
|
||||
|
||||
bool stillBanned =
|
||||
permanentBan ||
|
||||
(mod.ModerationSetUnixTime + mod.Duration) > now;
|
||||
|
||||
details = stillBanned ? mod : null;
|
||||
|
||||
return stillBanned;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class PlayerEventDB
|
||||
{
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "PlayerEvents.db"));
|
||||
|
||||
private static readonly ILiteCollection<PlayerEvent> Events =
|
||||
Database.GetCollection<PlayerEvent>("Events");
|
||||
private static readonly ILiteCollection<PlayerEventResponse> Responses =
|
||||
Database.GetCollection<PlayerEventResponse>("Responses");
|
||||
|
||||
private static readonly object Sync = new();
|
||||
|
||||
static PlayerEventDB()
|
||||
{
|
||||
LiteDbMaintenance.StartPeriodicCheckpoint("PlayerEvents.db", Database);
|
||||
Events.EnsureIndex(value => value.CreatorAccountId);
|
||||
Events.EnsureIndex(value => value.ClubId);
|
||||
Events.EnsureIndex(value => value.StartsAt);
|
||||
Responses.EnsureIndex(value => value.EventId);
|
||||
Responses.EnsureIndex(value => value.AccountId);
|
||||
}
|
||||
|
||||
public sealed class PlayerEvent
|
||||
{
|
||||
[BsonId]
|
||||
public long EventId { get; set; }
|
||||
public long CreatorAccountId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
public DateTime StartsAt { get; set; }
|
||||
public DateTime? EndsAt { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
public long? ClubId { get; set; }
|
||||
|
||||
public string Accessibility { get; set; } = "Public";
|
||||
public bool MultiInstance { get; set; }
|
||||
public List<string> Tags { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerEventResponse
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long EventId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
|
||||
public string ResponseType { get; set; } = "Going";
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static string ResponseId(long eventId, long accountId) => $"{eventId}:{accountId}";
|
||||
|
||||
public static long GetNextEventId()
|
||||
{
|
||||
lock (Sync)
|
||||
return Events.Count() == 0 ? 1 : Events.Max(value => value.EventId) + 1;
|
||||
}
|
||||
|
||||
public static PlayerEvent Create(PlayerEvent evt)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
evt.EventId = GetNextEventId();
|
||||
evt.CreatedAt = DateTime.UtcNow;
|
||||
evt.UpdatedAt = evt.CreatedAt;
|
||||
Events.Insert(evt);
|
||||
return evt;
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerEvent? Get(long eventId) => Events.FindById(eventId);
|
||||
|
||||
public static List<PlayerEvent> GetByIds(IEnumerable<long> ids)
|
||||
{
|
||||
var idSet = ids.ToHashSet();
|
||||
return Events.Find(value => idSet.Contains(value.EventId)).ToList();
|
||||
}
|
||||
|
||||
public static List<PlayerEvent> GetByCreator(long accountId) =>
|
||||
Events.Find(value => value.CreatorAccountId == accountId)
|
||||
.OrderByDescending(value => value.StartsAt)
|
||||
.ToList();
|
||||
|
||||
public static List<PlayerEvent> GetByClub(long clubId) =>
|
||||
Events.Find(value => value.ClubId == clubId)
|
||||
.OrderByDescending(value => value.StartsAt)
|
||||
.ToList();
|
||||
|
||||
public static bool Update(PlayerEvent evt)
|
||||
{
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
return Events.Update(evt);
|
||||
}
|
||||
|
||||
public static bool Delete(long eventId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
Responses.DeleteMany(value => value.EventId == eventId);
|
||||
return Events.Delete(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerEventResponse SetResponse(long eventId, long accountId, string responseType)
|
||||
{
|
||||
var response = new PlayerEventResponse
|
||||
{
|
||||
Id = ResponseId(eventId, accountId),
|
||||
EventId = eventId,
|
||||
AccountId = accountId,
|
||||
ResponseType = responseType,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
Responses.Upsert(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
public static bool DeleteResponse(long eventId, long accountId) =>
|
||||
Responses.Delete(ResponseId(eventId, accountId));
|
||||
|
||||
public static List<PlayerEventResponse> GetResponses(long eventId) =>
|
||||
Responses.Find(value => value.EventId == eventId).ToList();
|
||||
|
||||
public static PlayerEventResponse? GetResponse(long eventId, long accountId) =>
|
||||
Responses.FindById(ResponseId(eventId, accountId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
using LiteDB;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Mocha2023.Classes;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
public static class RecNetDB
|
||||
{
|
||||
private const int ChallengeDefinitionVersion = 2;
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "RecNet.db"));
|
||||
|
||||
public static readonly ILiteCollection<PhotoCheer> PhotoCheers =
|
||||
Database.GetCollection<PhotoCheer>("PhotoCheers");
|
||||
|
||||
public static readonly ILiteCollection<PhotoComment> PhotoComments =
|
||||
Database.GetCollection<PhotoComment>("PhotoComments");
|
||||
|
||||
public static readonly ILiteCollection<SavedImage> SavedImages =
|
||||
Database.GetCollection<SavedImage>("SavedImages");
|
||||
|
||||
public static readonly ILiteCollection<ModerationLock> ModerationLocks =
|
||||
Database.GetCollection<ModerationLock>("ModerationLocks");
|
||||
|
||||
public static readonly ILiteCollection<Event> Events =
|
||||
Database.GetCollection<Event>("Events");
|
||||
|
||||
public static readonly ILiteCollection<Announcement> Announcements =
|
||||
Database.GetCollection<Announcement>("Announcements");
|
||||
|
||||
public static readonly ILiteCollection<ChallengeProgress> ChallengeProgresses =
|
||||
Database.GetCollection<ChallengeProgress>("ChallengeProgresses");
|
||||
|
||||
public static readonly ILiteCollection<ChallengeCompletionEvent> ChallengeCompletionEvents =
|
||||
Database.GetCollection<ChallengeCompletionEvent>("ChallengeCompletionEvents");
|
||||
|
||||
public static readonly ILiteCollection<FeatureSetting> FeatureSettings =
|
||||
Database.GetCollection<FeatureSetting>("FeatureSettings");
|
||||
|
||||
public static readonly ILiteCollection<IpBan> IpBans =
|
||||
Database.GetCollection<IpBan>("IpBans");
|
||||
|
||||
private static readonly object ChallengeProgressLock = new();
|
||||
private static readonly object FeatureSettingsLock = new();
|
||||
private const string RecNetSignupSettingKey = "RecNet.SignupEnabled";
|
||||
private const string AccountCreationSettingKey = "Accounts.CreationEnabled";
|
||||
private const string VpnBlockingSettingKey = "Security.VpnBlockingEnabled";
|
||||
|
||||
static RecNetDB()
|
||||
{
|
||||
SavedImages.EnsureIndex(image => image.AccountId);
|
||||
SavedImages.EnsureIndex(image => image.ImageId);
|
||||
SavedImages.EnsureIndex(image => image.ContentHash);
|
||||
SavedImages.EnsureIndex(image => image.LookupName);
|
||||
SavedImages.EnsureIndex(image => image.CreatedAt);
|
||||
PhotoCheers.EnsureIndex(cheer => cheer.PhotoPath);
|
||||
PhotoCheers.EnsureIndex(cheer => cheer.AccountId);
|
||||
IpBans.EnsureIndex(ban => ban.Network, unique: true);
|
||||
IpBans.EnsureIndex(ban => ban.CreatedAt);
|
||||
|
||||
foreach (PhotoCheer cheer in PhotoCheers.FindAll().ToList())
|
||||
{
|
||||
string normalizedPath = NormalizePhotoPath(cheer.PhotoPath);
|
||||
if (string.IsNullOrWhiteSpace(normalizedPath))
|
||||
{
|
||||
PhotoCheers.Delete(cheer.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
string normalizedId = PhotoCheerId(normalizedPath, cheer.AccountId);
|
||||
if (string.Equals(cheer.Id, normalizedId, StringComparison.Ordinal) &&
|
||||
string.Equals(cheer.PhotoPath, normalizedPath, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PhotoCheers.Delete(cheer.Id);
|
||||
cheer.Id = normalizedId;
|
||||
cheer.PhotoPath = normalizedPath;
|
||||
PhotoCheers.Upsert(cheer);
|
||||
}
|
||||
|
||||
foreach (var image in SavedImages.FindAll()
|
||||
.Where(image => string.IsNullOrWhiteSpace(image.LookupName)))
|
||||
{
|
||||
string lookupName = Path.GetFileName(image.PhotoPath ?? string.Empty)
|
||||
?.ToLowerInvariant() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(lookupName))
|
||||
continue;
|
||||
|
||||
image.LookupName = lookupName;
|
||||
SavedImages.Update(image);
|
||||
}
|
||||
}
|
||||
|
||||
public class PhotoCheer
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string PhotoPath { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public static string NormalizePhotoPath(string? photoPath) =>
|
||||
(photoPath ?? string.Empty)
|
||||
.Replace('\\', '/')
|
||||
.Trim()
|
||||
.TrimStart('/')
|
||||
.ToLowerInvariant();
|
||||
|
||||
public static string PhotoCheerId(string? photoPath, long accountId) =>
|
||||
$"{accountId}:{NormalizePhotoPath(photoPath)}";
|
||||
|
||||
public static bool SetPhotoCheer(
|
||||
string? photoPath,
|
||||
long accountId,
|
||||
bool cheered)
|
||||
{
|
||||
string normalizedPath = NormalizePhotoPath(photoPath);
|
||||
if (accountId <= 0 || string.IsNullOrWhiteSpace(normalizedPath))
|
||||
return false;
|
||||
|
||||
string id = PhotoCheerId(normalizedPath, accountId);
|
||||
if (!cheered)
|
||||
{
|
||||
PhotoCheers.Delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
PhotoCheers.Upsert(new PhotoCheer
|
||||
{
|
||||
Id = id,
|
||||
PhotoPath = normalizedPath,
|
||||
AccountId = accountId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool HasPhotoCheer(string? photoPath, long accountId)
|
||||
{
|
||||
if (accountId <= 0)
|
||||
return false;
|
||||
return PhotoCheers.Exists(value =>
|
||||
value.Id == PhotoCheerId(photoPath, accountId));
|
||||
}
|
||||
|
||||
public static int CountPhotoCheers(string? photoPath)
|
||||
{
|
||||
string normalizedPath = NormalizePhotoPath(photoPath);
|
||||
return string.IsNullOrWhiteSpace(normalizedPath)
|
||||
? 0
|
||||
: PhotoCheers.Count(value => value.PhotoPath == normalizedPath);
|
||||
}
|
||||
|
||||
public class PhotoComment
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string PhotoPath { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class SavedImage
|
||||
{
|
||||
[BsonId]
|
||||
public string PhotoPath { get; set; } = string.Empty;
|
||||
public long ImageId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
public long? PlayerEventId { get; set; }
|
||||
public int SavedImageType { get; set; }
|
||||
public int Accessibility { get; set; }
|
||||
public List<int> TaggedPlayerIds { get; set; } = new();
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
public string LookupName { get; set; } = string.Empty;
|
||||
public long ByteLength { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class ModerationLock
|
||||
{
|
||||
[BsonId]
|
||||
public long AccountId { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public long IssuedByAccountId { get; set; }
|
||||
public DateTime IssuedAt { get; set; }
|
||||
public string BanGroupId { get; set; } = string.Empty;
|
||||
public long? RelatedAccountId { get; set; }
|
||||
public string? RelatedUsername { get; set; }
|
||||
}
|
||||
|
||||
public class Event
|
||||
{
|
||||
[BsonId]
|
||||
public long Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string? ImageName { get; set; }
|
||||
public DateTime StartsAt { get; set; }
|
||||
public DateTime? EndsAt { get; set; }
|
||||
public bool Pinned { get; set; }
|
||||
public long CreatedByAccountId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class Announcement
|
||||
{
|
||||
[BsonId]
|
||||
public long Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string BodyMarkdown { get; set; } = string.Empty;
|
||||
public string Kind { get; set; } = "info";
|
||||
public bool Pinned { get; set; }
|
||||
public bool Published { get; set; } = true;
|
||||
public long CreatedByAccountId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class ChallengeProgress
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
public int ChallengeMapId { get; set; }
|
||||
public int ChallengeId { get; set; }
|
||||
public int Progress { get; set; }
|
||||
public int Goal { get; set; }
|
||||
public bool Complete { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class ChallengeCompletionEvent
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
public int ChallengeMapId { get; set; }
|
||||
public int ChallengeId { get; set; }
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class FeatureSetting
|
||||
{
|
||||
[BsonId]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public bool Enabled { get; set; }
|
||||
public long UpdatedByAccountId { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class IpBan
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Network { get; set; } = string.Empty;
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public long CreatedByAccountId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static bool IsFeatureEnabled(string key, bool defaultValue)
|
||||
{
|
||||
lock (FeatureSettingsLock)
|
||||
return FeatureSettings.FindById(key)?.Enabled ?? defaultValue;
|
||||
}
|
||||
|
||||
private static void SetFeatureEnabled(
|
||||
string key,
|
||||
bool enabled,
|
||||
long updatedByAccountId)
|
||||
{
|
||||
lock (FeatureSettingsLock)
|
||||
{
|
||||
FeatureSettings.Upsert(new FeatureSetting
|
||||
{
|
||||
Key = key,
|
||||
Enabled = enabled,
|
||||
UpdatedByAccountId = updatedByAccountId,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsAccountCreationEnabled() =>
|
||||
IsFeatureEnabled(AccountCreationSettingKey, true);
|
||||
|
||||
public static void SetAccountCreationEnabled(bool enabled, long updatedByAccountId) =>
|
||||
SetFeatureEnabled(AccountCreationSettingKey, enabled, updatedByAccountId);
|
||||
|
||||
public static bool IsVpnBlockingEnabled() =>
|
||||
IsFeatureEnabled(VpnBlockingSettingKey, true);
|
||||
|
||||
public static void SetVpnBlockingEnabled(bool enabled, long updatedByAccountId) =>
|
||||
SetFeatureEnabled(VpnBlockingSettingKey, enabled, updatedByAccountId);
|
||||
|
||||
public static IReadOnlyList<IpBan> GetIpBans() =>
|
||||
IpBans.FindAll().OrderByDescending(value => value.CreatedAt).ToList();
|
||||
|
||||
public static IpBan AddIpBan(
|
||||
string network,
|
||||
string? reason,
|
||||
long createdByAccountId)
|
||||
{
|
||||
if (!IpNetwork.TryNormalize(network, out string normalized))
|
||||
throw new ArgumentException("Enter a valid IPv4/IPv6 address or CIDR range.");
|
||||
|
||||
string cleanedReason = (reason ?? string.Empty).Trim();
|
||||
if (cleanedReason.Length > 500)
|
||||
cleanedReason = cleanedReason[..500];
|
||||
|
||||
IpBan? existing = IpBans.FindOne(value => value.Network == normalized);
|
||||
if (existing != null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cleanedReason))
|
||||
existing.Reason = cleanedReason;
|
||||
existing.CreatedByAccountId = createdByAccountId;
|
||||
existing.CreatedAt = DateTime.UtcNow;
|
||||
IpBans.Update(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var record = new IpBan
|
||||
{
|
||||
Network = normalized,
|
||||
Reason = cleanedReason,
|
||||
CreatedByAccountId = createdByAccountId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
IpBans.Insert(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
public static bool RemoveIpBan(string id) =>
|
||||
!string.IsNullOrWhiteSpace(id) && IpBans.Delete(id);
|
||||
|
||||
public static bool TryGetIpBan(IPAddress address, out IpBan? record)
|
||||
{
|
||||
record = IpBans.FindAll().FirstOrDefault(value =>
|
||||
IpNetwork.Contains(value.Network, address));
|
||||
return record != null;
|
||||
}
|
||||
|
||||
public static bool IsRecNetSignupEnabled() =>
|
||||
IsFeatureEnabled(RecNetSignupSettingKey, true);
|
||||
|
||||
public static void SetRecNetSignupEnabled(bool enabled, long updatedByAccountId) =>
|
||||
SetFeatureEnabled(RecNetSignupSettingKey, enabled, updatedByAccountId);
|
||||
|
||||
public static (int MapId, DateTime StartsAt, DateTime EndsAt) GetCurrentChallengeWindow()
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
int daysSinceMonday = ((int)now.DayOfWeek + 6) % 7;
|
||||
DateTime startsAt = now.Date.AddDays(-daysSinceMonday);
|
||||
DateTime endsAt = startsAt.AddDays(7);
|
||||
int mapId = ((startsAt.Year * 1000) + startsAt.DayOfYear) * 10 +
|
||||
ChallengeDefinitionVersion;
|
||||
return (mapId, startsAt, endsAt);
|
||||
}
|
||||
|
||||
public static ChallengeProgress GetChallengeProgress(
|
||||
long accountId,
|
||||
int challengeMapId,
|
||||
int challengeId,
|
||||
int goal)
|
||||
{
|
||||
string id = ChallengeProgressId(accountId, challengeMapId, challengeId);
|
||||
var saved = ChallengeProgresses.FindById(id);
|
||||
return saved ?? new ChallengeProgress
|
||||
{
|
||||
Id = id,
|
||||
AccountId = accountId,
|
||||
ChallengeMapId = challengeMapId,
|
||||
ChallengeId = challengeId,
|
||||
Goal = Math.Max(1, goal)
|
||||
};
|
||||
}
|
||||
|
||||
public static ChallengeProgress SetChallengeProgress(
|
||||
long accountId,
|
||||
int challengeMapId,
|
||||
int challengeId,
|
||||
int progress,
|
||||
int goal,
|
||||
bool? complete = null)
|
||||
{
|
||||
goal = Math.Max(1, goal);
|
||||
progress = Math.Clamp(progress, 0, goal);
|
||||
|
||||
lock (ChallengeProgressLock)
|
||||
{
|
||||
var saved = GetChallengeProgress(accountId, challengeMapId, challengeId, goal);
|
||||
saved.Goal = goal;
|
||||
saved.Progress = progress;
|
||||
saved.Complete = complete ?? progress >= goal;
|
||||
if (saved.Complete)
|
||||
saved.Progress = goal;
|
||||
saved.UpdatedAt = DateTime.UtcNow;
|
||||
ChallengeProgresses.Upsert(saved);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
public static ChallengeProgress AddChallengeProgress(
|
||||
long accountId,
|
||||
int challengeMapId,
|
||||
int challengeId,
|
||||
int amount,
|
||||
int goal)
|
||||
{
|
||||
lock (ChallengeProgressLock)
|
||||
{
|
||||
var saved = GetChallengeProgress(accountId, challengeMapId, challengeId, goal);
|
||||
int next = Math.Clamp(saved.Progress + Math.Max(0, amount), 0, Math.Max(1, goal));
|
||||
saved.Goal = Math.Max(1, goal);
|
||||
saved.Progress = next;
|
||||
saved.Complete = next >= saved.Goal;
|
||||
saved.UpdatedAt = DateTime.UtcNow;
|
||||
ChallengeProgresses.Upsert(saved);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
public static void ApplyChallengeTelemetry(long accountId, string eventType, JsonElement eventData)
|
||||
{
|
||||
if (accountId <= 0 || eventData.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
return;
|
||||
|
||||
var (mapId, _, _) = GetCurrentChallengeWindow();
|
||||
string searchable = eventData.GetRawText();
|
||||
|
||||
bool isFinishedEvent =
|
||||
eventType.Contains("complete", StringComparison.OrdinalIgnoreCase) ||
|
||||
eventType.Contains("end", StringComparison.OrdinalIgnoreCase) ||
|
||||
HasTruthyMatchingValue(eventData, "complete", "victory", "won", "win");
|
||||
|
||||
bool isCrimsonCauldron =
|
||||
searchable.Contains("CrimsonCauldron", StringComparison.OrdinalIgnoreCase) ||
|
||||
searchable.Contains("Crimson Cauldron", StringComparison.OrdinalIgnoreCase) ||
|
||||
searchable.Contains("Goblin2", StringComparison.OrdinalIgnoreCase);
|
||||
if (isCrimsonCauldron && isFinishedEvent)
|
||||
{
|
||||
SetChallengeProgress(accountId, mapId, challengeId: 1, progress: 1, goal: 1);
|
||||
}
|
||||
|
||||
bool isGoldenTrophy = searchable.Contains("GoldenTrophy", StringComparison.OrdinalIgnoreCase) ||
|
||||
searchable.Contains("Golden Trophy", StringComparison.OrdinalIgnoreCase);
|
||||
if (isGoldenTrophy && isFinishedEvent)
|
||||
{
|
||||
SetChallengeProgress(accountId, mapId, challengeId: 2, progress: 1, goal: 1);
|
||||
}
|
||||
|
||||
bool isJumbotron = searchable.Contains("Jumbotron", StringComparison.OrdinalIgnoreCase);
|
||||
if (isJumbotron && isFinishedEvent)
|
||||
{
|
||||
SetChallengeProgress(accountId, mapId, challengeId: 3, progress: 1, goal: 1);
|
||||
}
|
||||
|
||||
bool isPaintball = searchable.Contains("Paintball", StringComparison.OrdinalIgnoreCase);
|
||||
if (isPaintball && isFinishedEvent)
|
||||
{
|
||||
AddUniqueGameCompletion(accountId, mapId, challengeId: 4, eventData);
|
||||
}
|
||||
|
||||
bool isDodgeball = searchable.Contains("Dodgeball", StringComparison.OrdinalIgnoreCase);
|
||||
if (isDodgeball && isFinishedEvent)
|
||||
{
|
||||
AddUniqueGameCompletion(accountId, mapId, challengeId: 5, eventData);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddUniqueGameCompletion(
|
||||
long accountId,
|
||||
int challengeMapId,
|
||||
int challengeId,
|
||||
JsonElement eventData)
|
||||
{
|
||||
string? sessionId = FindMatchingScalar(
|
||||
eventData,
|
||||
"_GameSessionId",
|
||||
"GameSessionId",
|
||||
"SubroomInstanceId",
|
||||
"subroom_instance_id");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
AddChallengeProgress(accountId, challengeMapId, challengeId, amount: 1, goal: 5);
|
||||
return;
|
||||
}
|
||||
|
||||
string completionId = $"{accountId}:{challengeMapId}:{challengeId}:{sessionId}";
|
||||
lock (ChallengeProgressLock)
|
||||
{
|
||||
if (ChallengeCompletionEvents.FindById(completionId) != null)
|
||||
return;
|
||||
|
||||
ChallengeCompletionEvents.Insert(new ChallengeCompletionEvent
|
||||
{
|
||||
Id = completionId,
|
||||
AccountId = accountId,
|
||||
ChallengeMapId = challengeMapId,
|
||||
ChallengeId = challengeId,
|
||||
SessionId = sessionId
|
||||
});
|
||||
AddChallengeProgress(accountId, challengeMapId, challengeId, amount: 1, goal: 5);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindMatchingScalar(JsonElement element, params string[] names)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (names.Any(name => string.Equals(
|
||||
property.Name,
|
||||
name,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.String)
|
||||
return property.Value.GetString();
|
||||
if (property.Value.ValueKind == JsonValueKind.Number)
|
||||
return property.Value.GetRawText();
|
||||
}
|
||||
|
||||
string? nested = FindMatchingScalar(property.Value, names);
|
||||
if (!string.IsNullOrWhiteSpace(nested))
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
string? nested = FindMatchingScalar(item, names);
|
||||
if (!string.IsNullOrWhiteSpace(nested))
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ChallengeProgressId(long accountId, int challengeMapId, int challengeId) =>
|
||||
$"{accountId}:{challengeMapId}:{challengeId}";
|
||||
|
||||
private static int FindLargestMatchingNumber(JsonElement element, params string[] names)
|
||||
{
|
||||
int largest = 0;
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (names.Any(name => property.Name.Contains(name, StringComparison.OrdinalIgnoreCase)) &&
|
||||
property.Value.ValueKind == JsonValueKind.Number &&
|
||||
property.Value.TryGetInt32(out int value))
|
||||
{
|
||||
largest = Math.Max(largest, value);
|
||||
}
|
||||
|
||||
largest = Math.Max(largest, FindLargestMatchingNumber(property.Value, names));
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
largest = Math.Max(largest, FindLargestMatchingNumber(item, names));
|
||||
}
|
||||
|
||||
return largest;
|
||||
}
|
||||
|
||||
private static bool HasTruthyMatchingValue(JsonElement element, params string[] names)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
bool matchingName = names.Any(name =>
|
||||
property.Name.Contains(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (matchingName &&
|
||||
(property.Value.ValueKind == JsonValueKind.True ||
|
||||
(property.Value.ValueKind == JsonValueKind.Number &&
|
||||
property.Value.TryGetInt32(out int value) && value > 0) ||
|
||||
(property.Value.ValueKind == JsonValueKind.String &&
|
||||
bool.TryParse(property.Value.GetString(), out bool parsed) && parsed)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (HasTruthyMatchingValue(property.Value, names))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
if (HasTruthyMatchingValue(item, names))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
using LiteDB;
|
||||
using static Mocha2023.Classes.DBs.DBClasses.PlayerDBClasses;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class RelationshipDB
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "Relationships.db"));
|
||||
|
||||
private static readonly ILiteCollection<RelationshipRecord> Records =
|
||||
Database.GetCollection<RelationshipRecord>("Relationships");
|
||||
|
||||
static RelationshipDB()
|
||||
{
|
||||
|
||||
var rawRecords = Database.GetCollection("Relationships");
|
||||
foreach (BsonDocument document in rawRecords.FindAll().ToList())
|
||||
{
|
||||
BsonValue id = document["_id"];
|
||||
if (!id.IsString)
|
||||
rawRecords.Delete(id);
|
||||
}
|
||||
|
||||
Records.EnsureIndex(value => value.SourcePlayerId);
|
||||
Records.EnsureIndex(value => value.TargetPlayerId);
|
||||
Records.EnsureIndex(value => value.Kind);
|
||||
}
|
||||
|
||||
public enum RelationshipKind
|
||||
{
|
||||
Friendship,
|
||||
Subscription
|
||||
}
|
||||
|
||||
public sealed class RelationshipRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public RelationshipKind Kind { get; set; }
|
||||
public long SourcePlayerId { get; set; }
|
||||
public long TargetPlayerId { get; set; }
|
||||
public RelationshipType RelationshipType { get; set; }
|
||||
public List<long> FavoritedBy { get; set; } = new();
|
||||
public List<long> MutedBy { get; set; } = new();
|
||||
public List<long> IgnoredBy { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static int MigrateLegacyData(IEnumerable<FullPlayer> players)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
int imported = 0;
|
||||
var allPlayers = players.Where(value => value.Player != null).ToList();
|
||||
var validIds = allPlayers.Select(value => value.PlayerId).ToHashSet();
|
||||
|
||||
foreach (FullPlayer account in allPlayers)
|
||||
{
|
||||
foreach (PlayerRelationship legacy in account.Player!.Relationships ?? new())
|
||||
{
|
||||
if (legacy.PlayerID == account.PlayerId || !validIds.Contains(legacy.PlayerID))
|
||||
continue;
|
||||
|
||||
string id = FriendshipId(account.PlayerId, legacy.PlayerID);
|
||||
RelationshipRecord? record = Records.FindById(id);
|
||||
bool isNew = record == null;
|
||||
record ??= NewFriendship(account.PlayerId, legacy.PlayerID);
|
||||
record.Id = id;
|
||||
|
||||
if (legacy.RelationshipType == RelationshipType.OutgoingFriendRequest)
|
||||
{
|
||||
record.SourcePlayerId = account.PlayerId;
|
||||
record.TargetPlayerId = legacy.PlayerID;
|
||||
record.RelationshipType = RelationshipType.OutgoingFriendRequest;
|
||||
}
|
||||
else if (legacy.RelationshipType == RelationshipType.IncomingFriendRequest)
|
||||
{
|
||||
record.SourcePlayerId = legacy.PlayerID;
|
||||
record.TargetPlayerId = account.PlayerId;
|
||||
record.RelationshipType = RelationshipType.OutgoingFriendRequest;
|
||||
}
|
||||
else if (legacy.RelationshipType == RelationshipType.Friend)
|
||||
{
|
||||
record.RelationshipType = RelationshipType.Friend;
|
||||
}
|
||||
else if (isNew)
|
||||
{
|
||||
|
||||
record.RelationshipType = RelationshipType.None;
|
||||
}
|
||||
|
||||
SetFlag(record.FavoritedBy, account.PlayerId, legacy.Favorited);
|
||||
SetFlag(record.MutedBy, account.PlayerId, legacy.Muted);
|
||||
SetFlag(record.IgnoredBy, account.PlayerId, legacy.Ignored);
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
Records.Upsert(record);
|
||||
if (isNew)
|
||||
imported++;
|
||||
}
|
||||
|
||||
foreach (long targetId in account.Player.SubscribedAccountIds ?? new())
|
||||
{
|
||||
if (targetId == account.PlayerId || !validIds.Contains(targetId))
|
||||
continue;
|
||||
|
||||
string id = SubscriptionId(account.PlayerId, targetId);
|
||||
if (Records.Exists(value => value.Id == id))
|
||||
continue;
|
||||
|
||||
Records.Insert(new RelationshipRecord
|
||||
{
|
||||
Id = id,
|
||||
Kind = RelationshipKind.Subscription,
|
||||
SourcePlayerId = account.PlayerId,
|
||||
TargetPlayerId = targetId,
|
||||
RelationshipType = RelationshipType.None
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
return imported;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<PlayerRelationship> GetRelationships(long playerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
return Records.Find(value =>
|
||||
value.Kind == RelationshipKind.Friendship &&
|
||||
(value.SourcePlayerId == playerId || value.TargetPlayerId == playerId))
|
||||
.Select(value => ToClientRelationship(value, playerId))
|
||||
.OrderByDescending(value => value.RelationshipType == RelationshipType.Friend)
|
||||
.ThenByDescending(value => value.RelationshipType == RelationshipType.IncomingFriendRequest)
|
||||
.ThenBy(value => value.PlayerID)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerRelationship? GetRelationship(long playerId, long otherPlayerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
RelationshipRecord? record = Records.FindById(
|
||||
FriendshipId(playerId, otherPlayerId));
|
||||
return record == null ? null : ToClientRelationship(record, playerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<ClientRelationshipDTO> GetClientRelationships(long playerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
return Records.Find(value =>
|
||||
value.Kind == RelationshipKind.Friendship &&
|
||||
(value.SourcePlayerId == playerId || value.TargetPlayerId == playerId))
|
||||
.Select(value => ToClientRelationshipDto(value, playerId))
|
||||
.OrderByDescending(value => value.RelationshipType == (int)RelationshipType.Friend)
|
||||
.ThenByDescending(value => value.RelationshipType == (int)RelationshipType.IncomingFriendRequest)
|
||||
.ThenBy(value => value.PlayerID)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static ClientRelationshipDTO? GetClientRelationship(
|
||||
long playerId,
|
||||
long otherPlayerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
RelationshipRecord? record = Records.FindById(
|
||||
FriendshipId(playerId, otherPlayerId));
|
||||
return record == null ? null : ToClientRelationshipDto(record, playerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerRelationship SendFriendRequest(
|
||||
long requesterPlayerId,
|
||||
long receiverPlayerId,
|
||||
out bool acceptedExistingRequest)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
acceptedExistingRequest = false;
|
||||
string id = FriendshipId(requesterPlayerId, receiverPlayerId);
|
||||
RelationshipRecord? existing = Records.FindById(id);
|
||||
|
||||
if (existing?.RelationshipType == RelationshipType.Friend)
|
||||
return ToClientRelationship(existing, requesterPlayerId);
|
||||
|
||||
if (existing != null &&
|
||||
existing.RelationshipType != RelationshipType.Friend &&
|
||||
existing.SourcePlayerId == receiverPlayerId &&
|
||||
existing.TargetPlayerId == requesterPlayerId)
|
||||
{
|
||||
existing.RelationshipType = RelationshipType.Friend;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
Records.Upsert(existing);
|
||||
acceptedExistingRequest = true;
|
||||
return ToClientRelationship(existing, requesterPlayerId);
|
||||
}
|
||||
|
||||
RelationshipRecord record = existing ?? new RelationshipRecord
|
||||
{
|
||||
Id = id,
|
||||
Kind = RelationshipKind.Friendship,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
record.SourcePlayerId = requesterPlayerId;
|
||||
record.TargetPlayerId = receiverPlayerId;
|
||||
|
||||
record.RelationshipType = RelationshipType.OutgoingFriendRequest;
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
Records.Upsert(record);
|
||||
|
||||
return ToClientRelationship(record, requesterPlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerRelationship? AcceptFriendRequest(
|
||||
long receiverPlayerId,
|
||||
long requesterPlayerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
string id = FriendshipId(receiverPlayerId, requesterPlayerId);
|
||||
RelationshipRecord? record = Records.FindById(id);
|
||||
if (record == null)
|
||||
return null;
|
||||
|
||||
if (record.RelationshipType == RelationshipType.Friend)
|
||||
return ToClientRelationship(record, receiverPlayerId);
|
||||
|
||||
if (record.SourcePlayerId != requesterPlayerId ||
|
||||
record.TargetPlayerId != receiverPlayerId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
record.RelationshipType = RelationshipType.Friend;
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
Records.Upsert(record);
|
||||
return ToClientRelationship(record, receiverPlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerRelationship AddFriend(long firstPlayerId, long secondPlayerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
string id = FriendshipId(firstPlayerId, secondPlayerId);
|
||||
RelationshipRecord record = Records.FindById(id) ??
|
||||
NewFriendship(firstPlayerId, secondPlayerId);
|
||||
record.Id = id;
|
||||
record.RelationshipType = RelationshipType.Friend;
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
Records.Upsert(record);
|
||||
return ToClientRelationship(record, firstPlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerRelationship SetFlags(
|
||||
long viewerPlayerId,
|
||||
long otherPlayerId,
|
||||
bool? ignored = null,
|
||||
bool? muted = null,
|
||||
bool? favorited = null)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
string id = FriendshipId(viewerPlayerId, otherPlayerId);
|
||||
RelationshipRecord record = Records.FindById(id) ?? new RelationshipRecord
|
||||
{
|
||||
Id = id,
|
||||
Kind = RelationshipKind.Friendship,
|
||||
SourcePlayerId = viewerPlayerId,
|
||||
TargetPlayerId = otherPlayerId,
|
||||
RelationshipType = RelationshipType.None,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (ignored.HasValue)
|
||||
SetFlag(record.IgnoredBy, viewerPlayerId, ignored.Value);
|
||||
if (muted.HasValue)
|
||||
SetFlag(record.MutedBy, viewerPlayerId, muted.Value);
|
||||
if (favorited.HasValue)
|
||||
SetFlag(record.FavoritedBy, viewerPlayerId, favorited.Value);
|
||||
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
Records.Upsert(record);
|
||||
return ToClientRelationship(record, viewerPlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool RemoveFriend(long firstPlayerId, long secondPlayerId)
|
||||
{
|
||||
lock (Sync)
|
||||
return Records.Delete(FriendshipId(firstPlayerId, secondPlayerId));
|
||||
}
|
||||
|
||||
public static bool SetSubscription(long subscriberId, long targetId, bool subscribe)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
string id = SubscriptionId(subscriberId, targetId);
|
||||
if (!subscribe)
|
||||
{
|
||||
Records.Delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
Records.Upsert(new RelationshipRecord
|
||||
{
|
||||
Id = id,
|
||||
Kind = RelationshipKind.Subscription,
|
||||
SourcePlayerId = subscriberId,
|
||||
TargetPlayerId = targetId,
|
||||
RelationshipType = RelationshipType.None,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsSubscribed(long subscriberId, long targetId)
|
||||
{
|
||||
lock (Sync)
|
||||
return Records.Exists(value => value.Id == SubscriptionId(subscriberId, targetId));
|
||||
}
|
||||
|
||||
public static int GetSubscriberCount(long targetId)
|
||||
{
|
||||
lock (Sync)
|
||||
return Records.Count(value =>
|
||||
value.Kind == RelationshipKind.Subscription &&
|
||||
value.TargetPlayerId == targetId);
|
||||
}
|
||||
|
||||
public static int GetSubscribedCount(long subscriberId)
|
||||
{
|
||||
lock (Sync)
|
||||
return Records.Count(value =>
|
||||
value.Kind == RelationshipKind.Subscription &&
|
||||
value.SourcePlayerId == subscriberId);
|
||||
}
|
||||
|
||||
public static long[] GetPersistentLiveSubjectIds(long viewerPlayerId)
|
||||
{
|
||||
if (viewerPlayerId <= 0)
|
||||
return Array.Empty<long>();
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
return Records.FindAll()
|
||||
.Where(record =>
|
||||
(record.Kind == RelationshipKind.Friendship &&
|
||||
(record.SourcePlayerId == viewerPlayerId ||
|
||||
record.TargetPlayerId == viewerPlayerId)) ||
|
||||
(record.Kind == RelationshipKind.Subscription &&
|
||||
record.SourcePlayerId == viewerPlayerId))
|
||||
.Select(record => record.Kind == RelationshipKind.Subscription
|
||||
? record.TargetPlayerId
|
||||
: record.SourcePlayerId == viewerPlayerId
|
||||
? record.TargetPlayerId
|
||||
: record.SourcePlayerId)
|
||||
.Where(playerId =>
|
||||
playerId > 0 &&
|
||||
playerId != viewerPlayerId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static long[] GetPersistentLiveWatcherIds(long subjectPlayerId)
|
||||
{
|
||||
if (subjectPlayerId <= 0)
|
||||
return Array.Empty<long>();
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
return Records.FindAll()
|
||||
.Where(record =>
|
||||
(record.Kind == RelationshipKind.Friendship &&
|
||||
(record.SourcePlayerId == subjectPlayerId ||
|
||||
record.TargetPlayerId == subjectPlayerId)) ||
|
||||
(record.Kind == RelationshipKind.Subscription &&
|
||||
record.TargetPlayerId == subjectPlayerId))
|
||||
.Select(record => record.Kind == RelationshipKind.Subscription
|
||||
? record.SourcePlayerId
|
||||
: record.SourcePlayerId == subjectPlayerId
|
||||
? record.TargetPlayerId
|
||||
: record.SourcePlayerId)
|
||||
.Where(playerId =>
|
||||
playerId > 0 &&
|
||||
playerId != subjectPlayerId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static long GetStableNumericId(long firstPlayerId, long secondPlayerId)
|
||||
{
|
||||
|
||||
unchecked
|
||||
{
|
||||
uint hash = 2166136261;
|
||||
long lower = Math.Min(firstPlayerId, secondPlayerId);
|
||||
long upper = Math.Max(firstPlayerId, secondPlayerId);
|
||||
|
||||
foreach (long value in new[] { lower, upper })
|
||||
{
|
||||
ulong bytes = (ulong)value;
|
||||
for (int index = 0; index < sizeof(long); index++)
|
||||
{
|
||||
hash ^= (byte)(bytes >> (index * 8));
|
||||
hash *= 16777619;
|
||||
}
|
||||
}
|
||||
|
||||
int id = (int)(hash & 0x7FFFFFFF);
|
||||
return id == 0 ? 1 : id;
|
||||
}
|
||||
}
|
||||
|
||||
private static ClientRelationshipDTO ToClientRelationshipDto(
|
||||
RelationshipRecord record,
|
||||
long viewerPlayerId)
|
||||
{
|
||||
long otherPlayerId = record.SourcePlayerId == viewerPlayerId
|
||||
? record.TargetPlayerId
|
||||
: record.SourcePlayerId;
|
||||
|
||||
RelationshipType clientType = record.RelationshipType;
|
||||
if (record.RelationshipType == RelationshipType.OutgoingFriendRequest)
|
||||
{
|
||||
clientType = record.SourcePlayerId == viewerPlayerId
|
||||
? RelationshipType.OutgoingFriendRequest
|
||||
: RelationshipType.IncomingFriendRequest;
|
||||
}
|
||||
|
||||
return new ClientRelationshipDTO
|
||||
{
|
||||
PlayerID = CheckedPlayerId(otherPlayerId),
|
||||
RelationshipType = (int)clientType,
|
||||
Favorited = GetPerspectiveStatus(record.FavoritedBy, viewerPlayerId, otherPlayerId),
|
||||
Muted = GetPerspectiveStatus(record.MutedBy, viewerPlayerId, otherPlayerId),
|
||||
Ignored = GetPerspectiveStatus(record.IgnoredBy, viewerPlayerId, otherPlayerId)
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetPerspectiveStatus(
|
||||
IReadOnlyCollection<long> values,
|
||||
long viewerPlayerId,
|
||||
long otherPlayerId)
|
||||
{
|
||||
bool local = values.Contains(viewerPlayerId);
|
||||
bool remote = values.Contains(otherPlayerId);
|
||||
|
||||
if (local && remote)
|
||||
return 3;
|
||||
if (local)
|
||||
return 1;
|
||||
if (remote)
|
||||
return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int CheckedPlayerId(long playerId)
|
||||
{
|
||||
if (playerId <= 0 || playerId > int.MaxValue)
|
||||
throw new InvalidOperationException(
|
||||
$"Player id {playerId} cannot be represented by the April 2023 client.");
|
||||
|
||||
return (int)playerId;
|
||||
}
|
||||
|
||||
private static PlayerRelationship ToClientRelationship(
|
||||
RelationshipRecord record,
|
||||
long viewerPlayerId)
|
||||
{
|
||||
long otherPlayerId = record.SourcePlayerId == viewerPlayerId
|
||||
? record.TargetPlayerId
|
||||
: record.SourcePlayerId;
|
||||
|
||||
RelationshipType clientType = record.RelationshipType;
|
||||
if (record.RelationshipType == RelationshipType.OutgoingFriendRequest)
|
||||
{
|
||||
clientType = record.SourcePlayerId == viewerPlayerId
|
||||
? RelationshipType.OutgoingFriendRequest
|
||||
: RelationshipType.IncomingFriendRequest;
|
||||
}
|
||||
|
||||
return new PlayerRelationship
|
||||
{
|
||||
Id = GetStableNumericId(record.SourcePlayerId, record.TargetPlayerId),
|
||||
PlayerID = otherPlayerId,
|
||||
RelationshipType = clientType,
|
||||
Favorited = record.FavoritedBy.Contains(viewerPlayerId),
|
||||
Muted = record.MutedBy.Contains(viewerPlayerId),
|
||||
Ignored = record.IgnoredBy.Contains(viewerPlayerId)
|
||||
};
|
||||
}
|
||||
|
||||
private static RelationshipRecord NewFriendship(long firstPlayerId, long secondPlayerId)
|
||||
{
|
||||
long source = Math.Min(firstPlayerId, secondPlayerId);
|
||||
long target = Math.Max(firstPlayerId, secondPlayerId);
|
||||
return new RelationshipRecord
|
||||
{
|
||||
Id = FriendshipId(firstPlayerId, secondPlayerId),
|
||||
Kind = RelationshipKind.Friendship,
|
||||
SourcePlayerId = source,
|
||||
TargetPlayerId = target,
|
||||
RelationshipType = RelationshipType.Friend
|
||||
};
|
||||
}
|
||||
|
||||
private static string FriendshipId(long firstPlayerId, long secondPlayerId) =>
|
||||
$"friend:{Math.Min(firstPlayerId, secondPlayerId)}:{Math.Max(firstPlayerId, secondPlayerId)}";
|
||||
|
||||
private static string SubscriptionId(long subscriberId, long targetId) =>
|
||||
$"subscription:{subscriberId}:{targetId}";
|
||||
|
||||
private static void SetFlag(List<long> values, long playerId, bool enabled)
|
||||
{
|
||||
values.RemoveAll(value => value == playerId);
|
||||
if (enabled)
|
||||
values.Add(playerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
public static class ReportsDB
|
||||
{
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "Reports.db"));
|
||||
|
||||
public static readonly ILiteCollection<PlayerReport> PlayerReports =
|
||||
Database.GetCollection<PlayerReport>("PlayerReports");
|
||||
|
||||
public static readonly ILiteCollection<BugReport> BugReports =
|
||||
Database.GetCollection<BugReport>("BugReports");
|
||||
|
||||
static ReportsDB()
|
||||
{
|
||||
PlayerReports.EnsureIndex(report => report.Status);
|
||||
PlayerReports.EnsureIndex(report => report.CreatedAt);
|
||||
PlayerReports.EnsureIndex(report => report.ReportedPlayerId);
|
||||
BugReports.EnsureIndex(report => report.Status);
|
||||
BugReports.EnsureIndex(report => report.CreatedAt);
|
||||
}
|
||||
|
||||
public class PlayerReport
|
||||
{
|
||||
[BsonId]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public long ReporterId { get; set; }
|
||||
public string? ReporterUsername { get; set; }
|
||||
public long? ReportedPlayerId { get; set; }
|
||||
public string? ReportedUsername { get; set; }
|
||||
public int? ReportCategory { get; set; }
|
||||
public string? Details { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
public string? RoomInstanceType { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Pending, Banned, TimedOut, NoAction
|
||||
public string Status { get; set; } = "Pending";
|
||||
|
||||
public long? ResolvedByAccountId { get; set; }
|
||||
public string? ResolvedByUsername { get; set; }
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
public string? ResolutionNote { get; set; }
|
||||
public int? ActionDurationSeconds { get; set; }
|
||||
}
|
||||
|
||||
public class BugReport
|
||||
{
|
||||
[BsonId]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public long ReporterId { get; set; }
|
||||
public string? ReporterUsername { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Open, Closed
|
||||
public string Status { get; set; } = "Open";
|
||||
|
||||
public long? ResolvedByAccountId { get; set; }
|
||||
public string? ResolvedByUsername { get; set; }
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1649 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using LiteDB;
|
||||
using Mocha2023.Classes;
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
using static Mocha2023.Classes.DBs.DBClasses.RoomDBClasses;
|
||||
using SysJson = System.Text.Json;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
public class RoomDB
|
||||
{
|
||||
public const int CompatibleLegacyPersistenceVersion = 38;
|
||||
public const int CompatibleLegacyUgcSubVersion = 0;
|
||||
public const int CompatibleLegacyOMVersion = 0;
|
||||
|
||||
public static int NormalizeLegacyPersistenceVersion(int? sourceVersion)
|
||||
{
|
||||
int value = sourceVersion.GetValueOrDefault(CompatibleLegacyPersistenceVersion);
|
||||
return value <= 0
|
||||
? CompatibleLegacyPersistenceVersion
|
||||
: Math.Min(value, CompatibleLegacyPersistenceVersion);
|
||||
}
|
||||
|
||||
public static int NormalizeLegacyUgcSubVersion(int sourceVersion)
|
||||
{
|
||||
return CompatibleLegacyUgcSubVersion;
|
||||
}
|
||||
|
||||
public static int NormalizeLegacyOMVersion(int sourceVersion)
|
||||
{
|
||||
return CompatibleLegacyOMVersion;
|
||||
}
|
||||
|
||||
public static LiteDatabase RoomDBFile = new LiteDatabase(Path.Combine(Program.dataDir, "DBs", "Rooms.db"));
|
||||
public static readonly ILiteCollection<Room> Rooms = RoomDBFile.GetCollection<Room>("Rooms");
|
||||
public static readonly ILiteCollection<SubRoomDataSave> SubRoomDataSaves = RoomDBFile.GetCollection<SubRoomDataSave>("SubRoomDataSaves");
|
||||
public static readonly ILiteCollection<RoomBan> RoomBans = RoomDBFile.GetCollection<RoomBan>("RoomBans");
|
||||
private static readonly object SaveIdLock = new();
|
||||
private static readonly object DormCreationLock = new();
|
||||
private static readonly object SubRoomMutationLock = new();
|
||||
private static readonly string[] CanonicalBaseRoomNames =
|
||||
{
|
||||
"PerformanceHall",
|
||||
"MakerRoom",
|
||||
"Park",
|
||||
"Lounge",
|
||||
"RecCenter"
|
||||
};
|
||||
|
||||
static RoomDB()
|
||||
{
|
||||
LiteDbMaintenance.StartPeriodicCheckpoint("Rooms.db", RoomDBFile);
|
||||
|
||||
RepairCanonicalBaseRoomMetadata();
|
||||
}
|
||||
|
||||
private static string NormalizeCanonicalRoomName(string? name) =>
|
||||
new((name ?? string.Empty)
|
||||
.Trim()
|
||||
.TrimStart('^')
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.Select(char.ToLowerInvariant)
|
||||
.ToArray());
|
||||
|
||||
public static bool IsCanonicalBaseRoom(Room? room)
|
||||
{
|
||||
if (room == null)
|
||||
return false;
|
||||
if (room.IsBaseRoom)
|
||||
return true;
|
||||
|
||||
string normalized = NormalizeCanonicalRoomName(room.Name);
|
||||
return CanonicalBaseRoomNames.Any(name =>
|
||||
NormalizeCanonicalRoomName(name) == normalized);
|
||||
}
|
||||
|
||||
public static int GetCanonicalBaseRoomOrder(Room room)
|
||||
{
|
||||
string normalized = NormalizeCanonicalRoomName(room.Name);
|
||||
int index = Array.FindIndex(
|
||||
CanonicalBaseRoomNames,
|
||||
name => NormalizeCanonicalRoomName(name) == normalized);
|
||||
return index < 0 ? int.MaxValue : index;
|
||||
}
|
||||
|
||||
public static int RepairCanonicalBaseRoomMetadata()
|
||||
{
|
||||
int changed = 0;
|
||||
foreach (Room room in Rooms.FindAll().Where(IsCanonicalBaseRoom).ToList())
|
||||
{
|
||||
bool dirty = false;
|
||||
if (!room.IsBaseRoom)
|
||||
{
|
||||
room.IsBaseRoom = true;
|
||||
dirty = true;
|
||||
}
|
||||
if (room.IsDorm)
|
||||
{
|
||||
room.IsDorm = false;
|
||||
dirty = true;
|
||||
}
|
||||
if (!room.IsRRO)
|
||||
{
|
||||
room.IsRRO = true;
|
||||
dirty = true;
|
||||
}
|
||||
if (room.Accessibility != RoomAccessibility.Public)
|
||||
{
|
||||
room.Accessibility = RoomAccessibility.Public;
|
||||
dirty = true;
|
||||
}
|
||||
if (room.State != RoomState.Active)
|
||||
{
|
||||
room.State = RoomState.Active;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
room.Tags ??= new List<Tags>();
|
||||
if (!room.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "base", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
room.Tags.Add(new Tags { Tag = "base", Type = TagType.Auto });
|
||||
dirty = true;
|
||||
}
|
||||
if (!room.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "rro", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
room.Tags.Add(new Tags { Tag = "rro", Type = TagType.AGOnly });
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (dirty && Rooms.Update(room))
|
||||
changed++;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public static bool CanPlayerAccessRoom(Room? room, long accountId)
|
||||
{
|
||||
if (room == null || accountId <= 0 ||
|
||||
room.State == RoomState.MarkedForDelete ||
|
||||
room.State == RoomState.Moderation_Closed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsCanonicalBaseRoom(room))
|
||||
return true;
|
||||
|
||||
if (room.IsDorm)
|
||||
return room.CreatorAccountId == accountId || IsConfirmedLiveGuest(room, accountId);
|
||||
|
||||
if (room.Accessibility != RoomAccessibility.Private)
|
||||
return true;
|
||||
|
||||
return IsRoomCollaborator(room, accountId) || IsConfirmedLiveGuest(room, accountId);
|
||||
}
|
||||
|
||||
private static bool IsConfirmedLiveGuest(Room room, long accountId)
|
||||
{
|
||||
PlayerDBClasses.Heartbeat heartbeat = PlayerDB.GetPlayerHeartbeat(accountId);
|
||||
PlayerDBClasses.RoomInstance? instance = heartbeat.roomInstance;
|
||||
|
||||
if (!heartbeat.isOnline || instance == null || instance.roomId != room.RoomId)
|
||||
return false;
|
||||
|
||||
return Sessions.IsConfirmedParticipant(accountId, instance.roomInstanceId);
|
||||
}
|
||||
|
||||
public static bool CanPlayerAccessSubRoom(
|
||||
Room? room,
|
||||
SubRooms? subRoom,
|
||||
long accountId)
|
||||
{
|
||||
if (!CanPlayerAccessRoom(room, accountId) ||
|
||||
room == null ||
|
||||
subRoom == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsCanonicalBaseRoom(room))
|
||||
return true;
|
||||
|
||||
return subRoom.Accessibility != RoomAccessibility.Private ||
|
||||
IsRoomCollaborator(room, accountId) ||
|
||||
IsConfirmedLiveGuest(room, accountId);
|
||||
}
|
||||
|
||||
private static bool IsRoomCollaborator(Room room, long accountId)
|
||||
{
|
||||
return room.CreatorAccountId == accountId ||
|
||||
room.Roles?.Any(role =>
|
||||
role.AccountId == accountId &&
|
||||
role.Role is Role.Host or
|
||||
Role.Moderator or
|
||||
Role.CoOwner or
|
||||
Role.TemporaryCoOwner or
|
||||
Role.Creator) == true;
|
||||
}
|
||||
|
||||
public static RoomBan BanPlayerFromRoom(long roomId, long accountId, long bannedByAccountId, string? reason)
|
||||
{
|
||||
var existing = RoomBans.FindOne(b => b.RoomId == roomId && b.AccountId == accountId);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
var ban = new RoomBan
|
||||
{
|
||||
RoomId = roomId,
|
||||
AccountId = accountId,
|
||||
BannedByAccountId = bannedByAccountId,
|
||||
Reason = reason,
|
||||
BannedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
RoomBans.Insert(ban);
|
||||
Console.WriteLine($"[ROOM BAN] room={roomId} account={accountId} bannedBy={bannedByAccountId}");
|
||||
return ban;
|
||||
}
|
||||
|
||||
public static bool UnbanPlayerFromRoom(long roomId, long accountId)
|
||||
{
|
||||
int removed = RoomBans.DeleteMany(b => b.RoomId == roomId && b.AccountId == accountId);
|
||||
if (removed > 0)
|
||||
Console.WriteLine($"[ROOM UNBAN] room={roomId} account={accountId}");
|
||||
|
||||
return removed > 0;
|
||||
}
|
||||
|
||||
public static bool IsPlayerBannedFromRoom(long roomId, long accountId)
|
||||
{
|
||||
return RoomBans.Exists(b => b.RoomId == roomId && b.AccountId == accountId);
|
||||
}
|
||||
|
||||
public static List<RoomBan> GetActiveBans(long roomId)
|
||||
{
|
||||
return RoomBans.Find(b => b.RoomId == roomId).ToList();
|
||||
}
|
||||
|
||||
public static SubRoomDataSave CreateSubRoomDataSave(long roomId, long subRoomId, long accountId, string dataBlob, int persistenceVersion, bool isPublished, string? dataBlobHash = null, string? description = null, string? roomDataBlob = null)
|
||||
{
|
||||
lock (SaveIdLock)
|
||||
{
|
||||
return InsertSubRoomDataSave(
|
||||
roomId,
|
||||
subRoomId,
|
||||
accountId,
|
||||
dataBlob,
|
||||
persistenceVersion,
|
||||
isPublished,
|
||||
dataBlobHash,
|
||||
description,
|
||||
roomDataBlob);
|
||||
}
|
||||
}
|
||||
|
||||
public static SubRoomDataSave GetOrCreateSubRoomDataSave(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
long accountId,
|
||||
string dataBlob,
|
||||
int persistenceVersion,
|
||||
bool isPublished,
|
||||
string? dataBlobHash,
|
||||
string? description,
|
||||
string? roomDataBlob,
|
||||
out bool created)
|
||||
{
|
||||
lock (SaveIdLock)
|
||||
{
|
||||
|
||||
var existing = SubRoomDataSaves
|
||||
.Find(save =>
|
||||
save.RoomId == roomId &&
|
||||
save.SubRoomId == subRoomId)
|
||||
.OrderByDescending(save => save.CreatedAt)
|
||||
.ThenByDescending(save => save.SubRoomDataSaveId)
|
||||
.FirstOrDefault(save =>
|
||||
save.SavedByAccountId == accountId &&
|
||||
string.Equals(
|
||||
save.DataBlob,
|
||||
dataBlob,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(
|
||||
save.RoomDataBlob,
|
||||
roomDataBlob,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
save.PersistenceVersion == persistenceVersion &&
|
||||
string.Equals(
|
||||
save.Description ?? string.Empty,
|
||||
description ?? string.Empty,
|
||||
StringComparison.Ordinal));
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
bool changed = false;
|
||||
if (string.IsNullOrWhiteSpace(existing.DataBlobHash) &&
|
||||
!string.IsNullOrWhiteSpace(dataBlobHash))
|
||||
{
|
||||
existing.DataBlobHash = dataBlobHash;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (isPublished && !existing.IsPublished)
|
||||
{
|
||||
existing.IsPublished = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed && !SubRoomDataSaves.Update(existing))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to update existing subroom save {existing.SubRoomDataSaveId}.");
|
||||
}
|
||||
|
||||
created = false;
|
||||
return existing;
|
||||
}
|
||||
|
||||
created = true;
|
||||
return InsertSubRoomDataSave(
|
||||
roomId,
|
||||
subRoomId,
|
||||
accountId,
|
||||
dataBlob,
|
||||
persistenceVersion,
|
||||
isPublished,
|
||||
dataBlobHash,
|
||||
description,
|
||||
roomDataBlob);
|
||||
}
|
||||
}
|
||||
|
||||
private static SubRoomDataSave InsertSubRoomDataSave(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
long accountId,
|
||||
string dataBlob,
|
||||
int persistenceVersion,
|
||||
bool isPublished,
|
||||
string? dataBlobHash,
|
||||
string? description,
|
||||
string? roomDataBlob)
|
||||
{
|
||||
long nextId = SubRoomDataSaves.Count() == 0
|
||||
? 1
|
||||
: Convert.ToInt64(SubRoomDataSaves.Max(x => x.SubRoomDataSaveId)) + 1;
|
||||
|
||||
var save = new SubRoomDataSave
|
||||
{
|
||||
SubRoomDataSaveId = nextId,
|
||||
RoomId = roomId,
|
||||
SubRoomId = subRoomId,
|
||||
SavedByAccountId = accountId,
|
||||
DataBlob = dataBlob,
|
||||
RoomDataBlob = roomDataBlob,
|
||||
DataBlobHash = dataBlobHash,
|
||||
PersistenceVersion = persistenceVersion,
|
||||
Description = description,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsPublished = isPublished
|
||||
};
|
||||
SubRoomDataSaves.Insert(save);
|
||||
return save;
|
||||
}
|
||||
|
||||
public static SubRoomDataSave? EnsureSubRoomDataSave(Room room, SubRooms subRoom)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(room);
|
||||
ArgumentNullException.ThrowIfNull(subRoom);
|
||||
|
||||
if (subRoom.SubRoomDataSaveId > 0)
|
||||
{
|
||||
var current = SubRoomDataSaves.FindById(subRoom.SubRoomDataSaveId);
|
||||
if (IsUsablePersistencePair(room, subRoom, current))
|
||||
{
|
||||
NormalizeSubRoomDataSaveHash(current!);
|
||||
SynchronizePersistencePointers(room, subRoom, current!);
|
||||
return current;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"[CV2 SNAPSHOT GUARD] rejected current save room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId} save={subRoom.SubRoomDataSaveId}");
|
||||
}
|
||||
|
||||
var newestValidSave = SubRoomDataSaves
|
||||
.Find(save => save.RoomId == room.RoomId && save.SubRoomId == subRoom.SubRoomId)
|
||||
.OrderByDescending(save => save.CreatedAt)
|
||||
.ThenByDescending(save => save.SubRoomDataSaveId)
|
||||
.FirstOrDefault(save => IsUsablePersistencePair(room, subRoom, save));
|
||||
|
||||
if (newestValidSave != null)
|
||||
{
|
||||
NormalizeSubRoomDataSaveHash(newestValidSave);
|
||||
SynchronizePersistencePointers(room, subRoom, newestValidSave);
|
||||
Console.WriteLine(
|
||||
$"[CV2 SNAPSHOT REPAIR] room={room.RoomId} subroom={subRoom.SubRoomId} " +
|
||||
$"save={newestValidSave.SubRoomDataSaveId}");
|
||||
return newestValidSave;
|
||||
}
|
||||
|
||||
if (HasCompleteDirectBlobPair(room, subRoom))
|
||||
{
|
||||
var save = CreateSubRoomDataSave(
|
||||
room.RoomId,
|
||||
subRoom.SubRoomId,
|
||||
subRoom.SavedByAccountId,
|
||||
subRoom.DataBlob!,
|
||||
room.PersistenceVersion,
|
||||
false,
|
||||
roomDataBlob: room.DataBlob);
|
||||
|
||||
NormalizeSubRoomDataSaveHash(save);
|
||||
SynchronizePersistencePointers(room, subRoom, save);
|
||||
Console.WriteLine(
|
||||
$"[CV2 SNAPSHOT REPAIR] synthesized save room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId} save={save.SubRoomDataSaveId}");
|
||||
return save;
|
||||
}
|
||||
|
||||
bool clearedInvalidSavePointer = subRoom.SubRoomDataSaveId != 0;
|
||||
subRoom.SubRoomDataSave = null;
|
||||
subRoom.SubRoomDataSaveId = 0;
|
||||
if (clearedInvalidSavePointer)
|
||||
Rooms.Update(room);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[CV2 SNAPSHOT GUARD] fresh scene room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId} roomBlob={room.DataBlob ?? "null"} " +
|
||||
$"subRoomBlob={subRoom.DataBlob ?? "null"}");
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool TryGetPersistencePair(
|
||||
Room room,
|
||||
SubRooms subRoom,
|
||||
out SubRoomDataSave? save,
|
||||
out string roomDataBlob,
|
||||
out string subRoomDataBlob)
|
||||
{
|
||||
save = EnsureSubRoomDataSave(room, subRoom);
|
||||
if (!IsUsablePersistencePair(room, subRoom, save))
|
||||
{
|
||||
roomDataBlob = string.Empty;
|
||||
subRoomDataBlob = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
roomDataBlob = string.IsNullOrWhiteSpace(save!.RoomDataBlob)
|
||||
? string.Empty
|
||||
: save.RoomDataBlob;
|
||||
subRoomDataBlob = save.DataBlob;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsBakedUnitySave(SubRoomDataSave? save) =>
|
||||
save != null && !string.IsNullOrWhiteSpace(save.UnityAssetId);
|
||||
|
||||
private static bool IsUsablePersistencePair(
|
||||
Room room,
|
||||
SubRooms subRoom,
|
||||
SubRoomDataSave? save)
|
||||
{
|
||||
if (save == null ||
|
||||
save.RoomId != room.RoomId ||
|
||||
save.SubRoomId != subRoom.SubRoomId ||
|
||||
string.IsNullOrWhiteSpace(save.DataBlob))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsBakedUnitySave(save))
|
||||
{
|
||||
string bakedRoomBlob = string.IsNullOrWhiteSpace(save.RoomDataBlob)
|
||||
? save.DataBlob
|
||||
: save.RoomDataBlob;
|
||||
return RoomBlobExists(save.DataBlob) && RoomBlobExists(bakedRoomBlob);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(save.RoomDataBlob))
|
||||
{
|
||||
|
||||
return RoomBlobExists(save.DataBlob);
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
save.RoomDataBlob,
|
||||
save.DataBlob,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return RoomBlobExists(save.RoomDataBlob) && RoomBlobExists(save.DataBlob);
|
||||
}
|
||||
|
||||
private static bool HasCompleteDirectBlobPair(Room room, SubRooms subRoom)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(room.DataBlob) &&
|
||||
!string.IsNullOrWhiteSpace(subRoom.DataBlob) &&
|
||||
!string.Equals(
|
||||
room.DataBlob,
|
||||
subRoom.DataBlob,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
RoomBlobExists(room.DataBlob) &&
|
||||
RoomBlobExists(subRoom.DataBlob);
|
||||
}
|
||||
|
||||
private static void SynchronizePersistencePointers(
|
||||
Room room,
|
||||
SubRooms subRoom,
|
||||
SubRoomDataSave save)
|
||||
{
|
||||
bool changed = false;
|
||||
|
||||
string? effectiveRoomBlob;
|
||||
if (IsBakedUnitySave(save))
|
||||
{
|
||||
effectiveRoomBlob = string.IsNullOrWhiteSpace(save.RoomDataBlob)
|
||||
? save.DataBlob
|
||||
: save.RoomDataBlob;
|
||||
}
|
||||
else
|
||||
{
|
||||
effectiveRoomBlob = string.IsNullOrWhiteSpace(save.RoomDataBlob)
|
||||
? null
|
||||
: save.RoomDataBlob;
|
||||
}
|
||||
|
||||
if (!string.Equals(room.DataBlob, effectiveRoomBlob, StringComparison.Ordinal))
|
||||
{
|
||||
room.DataBlob = effectiveRoomBlob;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(subRoom.DataBlob, save.DataBlob, StringComparison.Ordinal))
|
||||
{
|
||||
subRoom.DataBlob = save.DataBlob;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (subRoom.SubRoomDataSaveId != save.SubRoomDataSaveId)
|
||||
{
|
||||
subRoom.SubRoomDataSaveId = save.SubRoomDataSaveId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
subRoom.SubRoomDataSave = save;
|
||||
if (changed)
|
||||
Rooms.Update(room);
|
||||
}
|
||||
|
||||
private static void NormalizeSubRoomDataSaveHash(SubRoomDataSave save)
|
||||
{
|
||||
string? actualHash = ComputeBlobHashBase64(save.DataBlob);
|
||||
if (actualHash == null ||
|
||||
string.Equals(save.DataBlobHash, actualHash, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? oldHash = save.DataBlobHash;
|
||||
save.DataBlobHash = actualHash;
|
||||
SubRoomDataSaves.Update(save);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ROOM HASH REPAIR] save={save.SubRoomDataSaveId} " +
|
||||
$"blob={save.DataBlob} old={oldHash ?? "null"} new={actualHash}");
|
||||
}
|
||||
|
||||
private static string? ComputeBlobHashBase64(string? filename)
|
||||
{
|
||||
if (!RoomBlobExists(filename))
|
||||
return null;
|
||||
|
||||
string path = Path.Combine(
|
||||
Program.dataDir,
|
||||
"CDN",
|
||||
"room",
|
||||
Path.GetFileName(filename));
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
byte[] digest = System.Security.Cryptography.SHA256.HashData(stream);
|
||||
return Convert.ToBase64String(digest);
|
||||
}
|
||||
|
||||
public static Room? PrepareRoomForClient(Room? room)
|
||||
{
|
||||
if (room == null)
|
||||
return null;
|
||||
|
||||
room.PersistenceVersion = NormalizeLegacyPersistenceVersion(
|
||||
room.PersistenceVersion);
|
||||
room.UgcVersion = 1;
|
||||
|
||||
var normalizedFields = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(room.Name))
|
||||
{
|
||||
room.Name = $"Room{room.RoomId}";
|
||||
normalizedFields.Add(nameof(room.Name));
|
||||
}
|
||||
|
||||
if (room.Description == null)
|
||||
{
|
||||
room.Description = string.Equals(
|
||||
room.Name,
|
||||
"RecCenter",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? "A social hub to meet and mingle with friends new and old!"
|
||||
: "No description has been added yet.";
|
||||
normalizedFields.Add(nameof(room.Description));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(room.ImageName))
|
||||
{
|
||||
room.ImageName = "DefaultRoomImage.jpg";
|
||||
normalizedFields.Add(nameof(room.ImageName));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(room.RankedEntityId))
|
||||
{
|
||||
room.RankedEntityId = room.RoomId.ToString();
|
||||
normalizedFields.Add(nameof(room.RankedEntityId));
|
||||
}
|
||||
|
||||
room.Stats ??= new Stats();
|
||||
room.SubRooms ??= new List<SubRooms>();
|
||||
room.Roles ??= new List<Roles>();
|
||||
room.Tags ??= new List<Tags>();
|
||||
room.PromoImages ??= new List<string>();
|
||||
room.PromoExternalContent ??= new List<PromoExternalContent>();
|
||||
room.LoadScreens ??= new List<LoadScreens>();
|
||||
|
||||
if (IsCanonicalBaseRoom(room))
|
||||
{
|
||||
room.IsBaseRoom = true;
|
||||
room.IsDorm = false;
|
||||
room.IsRRO = true;
|
||||
room.Accessibility = RoomAccessibility.Public;
|
||||
room.State = RoomState.Active;
|
||||
if (!room.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "base", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
room.Tags.Add(new Tags { Tag = "base", Type = TagType.Auto });
|
||||
}
|
||||
if (!room.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "rro", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
room.Tags.Add(new Tags { Tag = "rro", Type = TagType.AGOnly });
|
||||
}
|
||||
}
|
||||
|
||||
bool hasBetaTag = room.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "beta", StringComparison.OrdinalIgnoreCase));
|
||||
if (room.CreativeToolsBetaEnabled && !hasBetaTag)
|
||||
{
|
||||
room.Tags.Add(new Tags { Tag = "beta", Type = TagType.Auto });
|
||||
}
|
||||
else if (!room.CreativeToolsBetaEnabled && hasBetaTag)
|
||||
{
|
||||
|
||||
room.CreativeToolsBetaEnabled = true;
|
||||
}
|
||||
|
||||
ResetInvalidPersonalDormToFreshState(room);
|
||||
|
||||
foreach (var subRoom in room.SubRooms)
|
||||
{
|
||||
subRoom.Name ??= string.Empty;
|
||||
subRoom.UnitySceneId ??= string.Empty;
|
||||
subRoom.Permissions ??= new List<SubRoomPermission>();
|
||||
subRoom.SubRoomDataSave = EnsureSubRoomDataSave(room, subRoom);
|
||||
|
||||
if (subRoom.SubRoomDataSave != null)
|
||||
{
|
||||
subRoom.SubRoomDataSave.DataBlob ??= string.Empty;
|
||||
subRoom.SubRoomDataSave.Description ??= room.Description;
|
||||
subRoom.SubRoomDataSave.PersistenceVersion =
|
||||
NormalizeLegacyPersistenceVersion(
|
||||
subRoom.SubRoomDataSave.PersistenceVersion);
|
||||
subRoom.SubRoomDataSave.UgcSubVersion =
|
||||
NormalizeLegacyUgcSubVersion(
|
||||
subRoom.SubRoomDataSave.UgcSubVersion);
|
||||
subRoom.SubRoomDataSave.OMVersion =
|
||||
NormalizeLegacyOMVersion(
|
||||
subRoom.SubRoomDataSave.OMVersion);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var tag in room.Tags)
|
||||
tag.Tag ??= string.Empty;
|
||||
|
||||
foreach (var externalContent in room.PromoExternalContent)
|
||||
externalContent.Reference ??= string.Empty;
|
||||
|
||||
foreach (var loadScreen in room.LoadScreens)
|
||||
{
|
||||
loadScreen.ImageName ??= string.Empty;
|
||||
loadScreen.Title ??= string.Empty;
|
||||
loadScreen.Subtitle ??= string.Empty;
|
||||
}
|
||||
|
||||
if (normalizedFields.Count > 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM DTO NORMALIZE] room={room.RoomId} name={room.Name} " +
|
||||
$"fields={string.Join(',', normalizedFields)}");
|
||||
}
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
private static void ResetInvalidPersonalDormToFreshState(Room room)
|
||||
{
|
||||
if (!room.IsDorm || room.RoomId == 1 || room.CreatorAccountId <= 0 ||
|
||||
room.SubRooms == null || room.SubRooms.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool roomBlobSet = !string.IsNullOrWhiteSpace(room.DataBlob);
|
||||
bool anySubRoomBlobSet = room.SubRooms.Any(subRoom =>
|
||||
!string.IsNullOrWhiteSpace(subRoom.DataBlob));
|
||||
bool hasMissingFile =
|
||||
(roomBlobSet && !RoomBlobExists(room.DataBlob)) ||
|
||||
room.SubRooms.Any(subRoom =>
|
||||
!string.IsNullOrWhiteSpace(subRoom.DataBlob) &&
|
||||
!RoomBlobExists(subRoom.DataBlob));
|
||||
bool hasAliasedPayload = roomBlobSet && room.SubRooms.Any(subRoom =>
|
||||
string.Equals(room.DataBlob, subRoom.DataBlob, StringComparison.OrdinalIgnoreCase));
|
||||
bool hasIncompletePair = roomBlobSet != anySubRoomBlobSet;
|
||||
|
||||
if (!hasMissingFile && !hasAliasedPayload && !hasIncompletePair)
|
||||
return;
|
||||
|
||||
room.DataBlob = null;
|
||||
room.PersistenceVersion = 0;
|
||||
foreach (var subRoom in room.SubRooms)
|
||||
{
|
||||
subRoom.DataBlob = null;
|
||||
subRoom.SubRoomDataSaveId = 0;
|
||||
subRoom.SubRoomDataSave = null;
|
||||
}
|
||||
|
||||
SubRoomDataSaves.DeleteMany(save => save.RoomId == room.RoomId);
|
||||
Rooms.Update(room);
|
||||
Console.WriteLine(
|
||||
$"[DORM FRESH] account={room.CreatorAccountId} room={room.RoomId} " +
|
||||
"discarded invalid persistence data");
|
||||
}
|
||||
|
||||
private static bool RoomBlobExists(string? filename)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filename))
|
||||
return false;
|
||||
|
||||
string safeName = Path.GetFileName(filename);
|
||||
return string.Equals(safeName, filename, StringComparison.Ordinal) &&
|
||||
File.Exists(Path.Combine(Program.dataDir, "CDN", "room", safeName));
|
||||
}
|
||||
|
||||
public static List<Room> PrepareRoomsForClient(IEnumerable<Room> rooms)
|
||||
{
|
||||
var prepared = rooms.ToList();
|
||||
foreach (var room in prepared)
|
||||
PrepareRoomForClient(room);
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
public static async Task ImportRooms(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
string jsonData = await File.ReadAllTextAsync(path);
|
||||
|
||||
var options = new SysJson.JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = SysJson.JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
var roomsList = SysJson.JsonSerializer.Deserialize<List<Room>>(jsonData, options);
|
||||
|
||||
if (roomsList == null) return;
|
||||
|
||||
foreach (var room in roomsList)
|
||||
{
|
||||
if (room == null) continue;
|
||||
if (Rooms.FindById(room.RoomId) != null) continue;
|
||||
|
||||
await AddRoom(room, log: true, shouldAssignNewIds: false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Import Error] Failed to import JSON: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<bool> AddRoom(Room newRoom, bool log = false, bool shouldAssignNewIds = true)
|
||||
{
|
||||
if (newRoom == null)
|
||||
return false;
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (shouldAssignNewIds || newRoom.RoomId <= 0)
|
||||
newRoom.RoomId = GetNextRoomId();
|
||||
|
||||
newRoom.RankedEntityId = newRoom.RoomId.ToString();
|
||||
newRoom.CreatedAt = newRoom.CreatedAt == default
|
||||
? DateTime.UtcNow
|
||||
: newRoom.CreatedAt;
|
||||
|
||||
newRoom.Stats ??= new Stats();
|
||||
newRoom.SubRooms ??= new List<SubRooms>();
|
||||
newRoom.Roles ??= new List<Roles>();
|
||||
newRoom.Tags ??= new List<Tags>();
|
||||
newRoom.PromoImages ??= new List<string>();
|
||||
newRoom.PromoExternalContent ??= new List<PromoExternalContent>();
|
||||
newRoom.LoadScreens ??= new List<LoadScreens>();
|
||||
|
||||
if (IsCanonicalBaseRoom(newRoom))
|
||||
{
|
||||
newRoom.IsBaseRoom = true;
|
||||
newRoom.IsDorm = false;
|
||||
newRoom.IsRRO = true;
|
||||
newRoom.Accessibility = RoomAccessibility.Public;
|
||||
newRoom.State = RoomState.Active;
|
||||
if (!newRoom.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "base", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
newRoom.Tags.Add(new Tags { Tag = "base", Type = TagType.Auto });
|
||||
}
|
||||
if (!newRoom.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "rro", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
newRoom.Tags.Add(new Tags { Tag = "rro", Type = TagType.AGOnly });
|
||||
}
|
||||
}
|
||||
|
||||
long nextSubRoomId = GetNextSubRoomId();
|
||||
|
||||
foreach (var sub in newRoom.SubRooms)
|
||||
{
|
||||
if (shouldAssignNewIds || sub.SubRoomId <= 0)
|
||||
sub.SubRoomId = nextSubRoomId++;
|
||||
|
||||
sub.RoomId = newRoom.RoomId;
|
||||
sub.Name ??= "Home";
|
||||
sub.UnitySceneId ??= string.Empty;
|
||||
sub.Permissions ??= new List<SubRoomPermission>();
|
||||
}
|
||||
|
||||
Rooms.Insert(newRoom);
|
||||
|
||||
if (log)
|
||||
Console.WriteLine($"[DB] Added room: {newRoom.Name} (ID: {newRoom.RoomId})");
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[DB Error] Failed to add room {newRoom.Name}: {ex}");
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static long GetNextRoomId()
|
||||
{
|
||||
if (Rooms.Count() == 0)
|
||||
return 1;
|
||||
|
||||
var maxId = Rooms.Max(x => x.RoomId);
|
||||
|
||||
return Convert.ToInt64(maxId) + 1;
|
||||
}
|
||||
|
||||
public static bool ChangeRoomId(long oldRoomId, long newRoomId, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (newRoomId <= 0)
|
||||
{
|
||||
error = "The new room ID must be a positive number.";
|
||||
return false;
|
||||
}
|
||||
if (newRoomId == oldRoomId)
|
||||
{
|
||||
error = "That's already this room's ID.";
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (SubRoomMutationLock)
|
||||
{
|
||||
Room? room = Rooms.FindById(oldRoomId);
|
||||
if (room == null)
|
||||
{
|
||||
error = "Room not found.";
|
||||
return false;
|
||||
}
|
||||
if (Rooms.Exists(candidate => candidate.RoomId == newRoomId))
|
||||
{
|
||||
error = $"Room {newRoomId} already exists.";
|
||||
return false;
|
||||
}
|
||||
|
||||
room.RoomId = newRoomId;
|
||||
room.RankedEntityId = newRoomId.ToString();
|
||||
if (room.SubRooms != null)
|
||||
{
|
||||
foreach (SubRooms subRoom in room.SubRooms)
|
||||
subRoom.RoomId = newRoomId;
|
||||
}
|
||||
|
||||
Rooms.Insert(room);
|
||||
Rooms.Delete(oldRoomId);
|
||||
|
||||
foreach (SubRoomDataSave save in SubRoomDataSaves.Find(value => value.RoomId == oldRoomId).ToList())
|
||||
{
|
||||
save.RoomId = newRoomId;
|
||||
SubRoomDataSaves.Update(save);
|
||||
}
|
||||
|
||||
foreach (RoomBan ban in RoomBans.Find(value => value.RoomId == oldRoomId).ToList())
|
||||
{
|
||||
ban.RoomId = newRoomId;
|
||||
RoomBans.Update(ban);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static long GetNextSubRoomId()
|
||||
{
|
||||
|
||||
lock (SubRoomMutationLock)
|
||||
{
|
||||
long maxSafeSubRoomId = 0;
|
||||
int ignoredImportedIds = 0;
|
||||
|
||||
foreach (var room in Rooms.FindAll())
|
||||
{
|
||||
if (room.SubRooms == null)
|
||||
continue;
|
||||
|
||||
foreach (var subRoom in room.SubRooms)
|
||||
{
|
||||
long id = subRoom.SubRoomId;
|
||||
if (id <= 0 || id > int.MaxValue)
|
||||
{
|
||||
ignoredImportedIds++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (id > maxSafeSubRoomId)
|
||||
maxSafeSubRoomId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxSafeSubRoomId >= int.MaxValue)
|
||||
throw new InvalidOperationException(
|
||||
"No client-safe SubRoomId values remain in the positive Int32 range.");
|
||||
|
||||
long nextId = maxSafeSubRoomId + 1;
|
||||
Console.WriteLine(
|
||||
$"[SUBROOM ID ALLOCATOR] next={nextId} " +
|
||||
$"maxSafeExisting={maxSafeSubRoomId} ignoredImported={ignoredImportedIds}");
|
||||
return nextId;
|
||||
}
|
||||
}
|
||||
|
||||
public static Room? AddSubRoom(long roomId, long accountId, string requestedName)
|
||||
{
|
||||
lock (SubRoomMutationLock)
|
||||
{
|
||||
var room = Rooms.FindById(roomId);
|
||||
if (room == null)
|
||||
return null;
|
||||
|
||||
room.SubRooms ??= new List<SubRooms>();
|
||||
string name = GetUniqueSubRoomName(
|
||||
room,
|
||||
string.IsNullOrWhiteSpace(requestedName) ? "New Room" : requestedName.Trim());
|
||||
var template = room.SubRooms.FirstOrDefault(subRoom =>
|
||||
string.Equals(subRoom.Name, "Home", StringComparison.OrdinalIgnoreCase))
|
||||
?? room.SubRooms.FirstOrDefault();
|
||||
|
||||
var subRoom = new SubRooms
|
||||
{
|
||||
SubRoomId = GetNextSubRoomId(),
|
||||
RoomId = room.RoomId,
|
||||
Name = name,
|
||||
DataBlob = null,
|
||||
SubRoomDataSaveId = 0,
|
||||
IsSandbox = template?.IsSandbox ?? true,
|
||||
MaxPlayers = Math.Max(1, template?.MaxPlayers ?? room.MaxPlayers),
|
||||
Accessibility = template?.Accessibility ?? room.Accessibility,
|
||||
UnitySceneId = template?.UnitySceneId ?? string.Empty,
|
||||
Permissions = ClonePermissions(template?.Permissions),
|
||||
SavedByAccountId = accountId
|
||||
};
|
||||
|
||||
room.SubRooms.Add(subRoom);
|
||||
room.UgcVersion = Math.Max(1, room.UgcVersion + 1);
|
||||
Rooms.Update(room);
|
||||
Console.WriteLine(
|
||||
$"[SUBROOM ADD] room={roomId} subroom={subRoom.SubRoomId} name={subRoom.Name} by={accountId}");
|
||||
return PrepareRoomForClient(room);
|
||||
}
|
||||
}
|
||||
|
||||
public static Room? CloneSubRoom(long roomId, long subRoomId, long accountId)
|
||||
{
|
||||
lock (SubRoomMutationLock)
|
||||
{
|
||||
var room = Rooms.FindById(roomId);
|
||||
var source = room?.SubRooms?.FirstOrDefault(subRoom =>
|
||||
subRoom.SubRoomId == subRoomId);
|
||||
if (room == null || source == null)
|
||||
return null;
|
||||
|
||||
string name = GetUniqueSubRoomName(room, $"{source.Name} Copy");
|
||||
var clone = new SubRooms
|
||||
{
|
||||
SubRoomId = GetNextSubRoomId(),
|
||||
RoomId = room.RoomId,
|
||||
Name = name,
|
||||
DataBlob = source.DataBlob,
|
||||
SubRoomDataSaveId = 0,
|
||||
IsSandbox = source.IsSandbox,
|
||||
MaxPlayers = Math.Max(1, source.MaxPlayers),
|
||||
Accessibility = source.Accessibility,
|
||||
UnitySceneId = source.UnitySceneId ?? string.Empty,
|
||||
Permissions = ClonePermissions(source.Permissions),
|
||||
SavedByAccountId = accountId
|
||||
};
|
||||
|
||||
room.SubRooms!.Add(clone);
|
||||
room.UgcVersion = Math.Max(1, room.UgcVersion + 1);
|
||||
Rooms.Update(room);
|
||||
Console.WriteLine(
|
||||
$"[SUBROOM CLONE] room={roomId} source={subRoomId} clone={clone.SubRoomId} name={clone.Name} by={accountId}");
|
||||
return PrepareRoomForClient(room);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetUniqueSubRoomName(Room room, string requestedName)
|
||||
{
|
||||
string baseName = requestedName.Trim();
|
||||
if (baseName.Length > 50)
|
||||
baseName = baseName[..50].TrimEnd();
|
||||
if (string.IsNullOrWhiteSpace(baseName))
|
||||
baseName = "New Room";
|
||||
|
||||
var usedNames = (room.SubRooms ?? new List<SubRooms>())
|
||||
.Select(subRoom => subRoom.Name)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
if (!usedNames.Contains(baseName))
|
||||
return baseName;
|
||||
|
||||
for (int suffix = 2; suffix < 10_000; suffix++)
|
||||
{
|
||||
string suffixText = $" {suffix}";
|
||||
int prefixLength = Math.Min(baseName.Length, 50 - suffixText.Length);
|
||||
string candidate = baseName[..prefixLength].TrimEnd() + suffixText;
|
||||
if (!usedNames.Contains(candidate))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return $"Room {Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
private static List<SubRoomPermission> ClonePermissions(
|
||||
IEnumerable<SubRoomPermission>? permissions)
|
||||
{
|
||||
return permissions?.Select(permission => new SubRoomPermission
|
||||
{
|
||||
Override = permission.Override,
|
||||
Permission = permission.Permission,
|
||||
Role = permission.Role,
|
||||
Type = permission.Type,
|
||||
Value = permission.Value
|
||||
}).ToList() ?? new List<SubRoomPermission>();
|
||||
}
|
||||
|
||||
public static Room GetRoom(long roomId)
|
||||
{
|
||||
return PrepareRoomForClient(Rooms.FindById(roomId));
|
||||
}
|
||||
|
||||
public static Room GetRoomByName(string name)
|
||||
{
|
||||
Room? room = Rooms.FindOne(x =>
|
||||
x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (room == null)
|
||||
{
|
||||
string normalized = NormalizeCanonicalRoomName(name);
|
||||
if (CanonicalBaseRoomNames.Any(canonical =>
|
||||
NormalizeCanonicalRoomName(canonical) == normalized))
|
||||
{
|
||||
room = Rooms.FindAll().FirstOrDefault(candidate =>
|
||||
NormalizeCanonicalRoomName(candidate.Name) == normalized);
|
||||
}
|
||||
}
|
||||
return PrepareRoomForClient(room);
|
||||
}
|
||||
|
||||
public static List<Room> GetRoomsByNames(List<string> names)
|
||||
{
|
||||
if (names == null || names.Count == 0)
|
||||
return new List<Room>();
|
||||
|
||||
return PrepareRoomsForClient(Rooms.Find(room =>
|
||||
names.Contains(room.Name, StringComparer.OrdinalIgnoreCase)
|
||||
));
|
||||
}
|
||||
|
||||
public static Room? GetOrCreatePlayerDorm(long accountId)
|
||||
{
|
||||
if (accountId <= 0)
|
||||
return null;
|
||||
|
||||
lock (DormCreationLock)
|
||||
{
|
||||
|
||||
var existing = Rooms.Find(room =>
|
||||
room.IsDorm &&
|
||||
room.RoomId != 1 &&
|
||||
room.CreatorAccountId == accountId &&
|
||||
room.State != RoomState.MarkedForDelete)
|
||||
.OrderBy(room => room.RoomId)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Roles ??= new List<Roles>();
|
||||
if (!existing.Roles.Any(role =>
|
||||
role.AccountId == accountId &&
|
||||
role.Role == Role.Creator))
|
||||
{
|
||||
existing.Roles.RemoveAll(role => role.AccountId == accountId);
|
||||
existing.Roles.Add(new Roles
|
||||
{
|
||||
AccountId = accountId,
|
||||
Role = Role.Creator,
|
||||
InvitedRole = Role.None
|
||||
});
|
||||
Rooms.Update(existing);
|
||||
}
|
||||
|
||||
return PrepareRoomForClient(existing);
|
||||
}
|
||||
|
||||
var template = Rooms.FindById(1) ??
|
||||
Rooms.FindOne(room => room.IsDorm);
|
||||
if (template == null)
|
||||
return null;
|
||||
|
||||
long roomId = GetNextRoomId();
|
||||
long nextSubRoomId = GetNextSubRoomId();
|
||||
var dorm = new Room
|
||||
{
|
||||
RoomId = roomId,
|
||||
IsDorm = true,
|
||||
MaxPlayerCalculationMode = template.MaxPlayerCalculationMode,
|
||||
MaxPlayers = template.MaxPlayers,
|
||||
CloningAllowed = false,
|
||||
DisableMicAutoMute = template.DisableMicAutoMute,
|
||||
DisableRoomComments = template.DisableRoomComments,
|
||||
EncryptVoiceChat = template.EncryptVoiceChat,
|
||||
ToxmodEnabled = template.ToxmodEnabled,
|
||||
LoadScreenLocked = template.LoadScreenLocked,
|
||||
PersistenceVersion = template.PersistenceVersion,
|
||||
AutoLocalizeRoom = template.AutoLocalizeRoom,
|
||||
IsDeveloperOwned = false,
|
||||
Name = "DormRoom",
|
||||
Description = template.Description,
|
||||
ImageName = template.ImageName,
|
||||
WarningMask = template.WarningMask,
|
||||
CustomWarning = template.CustomWarning,
|
||||
CreatorAccountId = accountId,
|
||||
State = RoomState.Active,
|
||||
Accessibility = RoomAccessibility.Private,
|
||||
SupportsLevelVoting = template.SupportsLevelVoting,
|
||||
IsRRO = template.IsRRO,
|
||||
SupportsScreens = template.SupportsScreens,
|
||||
SupportsWalkVR = template.SupportsWalkVR,
|
||||
SupportsTeleportVR = template.SupportsTeleportVR,
|
||||
SupportsVRLow = template.SupportsVRLow,
|
||||
SupportsQuest2 = template.SupportsQuest2,
|
||||
SupportsMobile = template.SupportsMobile,
|
||||
SupportsJuniors = template.SupportsJuniors,
|
||||
MinLevel = template.MinLevel,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Stats = new Stats(),
|
||||
RankedEntityId = roomId.ToString(),
|
||||
RankingContext = null,
|
||||
DataBlob = template.DataBlob,
|
||||
UgcVersion = Math.Max(1, template.UgcVersion),
|
||||
Tags = template.Tags?
|
||||
.Select(tag => new Tags { Tag = tag.Tag, Type = tag.Type })
|
||||
.ToList() ?? new List<Tags>(),
|
||||
PromoImages = template.PromoImages?.ToList() ?? new List<string>(),
|
||||
PromoExternalContent = template.PromoExternalContent?
|
||||
.Select(item => new PromoExternalContent
|
||||
{
|
||||
Type = item.Type,
|
||||
Reference = item.Reference
|
||||
})
|
||||
.ToList() ?? new List<PromoExternalContent>(),
|
||||
LoadScreens = template.LoadScreens?
|
||||
.Select(screen => new LoadScreens
|
||||
{
|
||||
ImageName = screen.ImageName,
|
||||
Title = screen.Title,
|
||||
Subtitle = screen.Subtitle
|
||||
})
|
||||
.ToList() ?? new List<LoadScreens>(),
|
||||
Roles = new List<Roles>
|
||||
{
|
||||
new Roles
|
||||
{
|
||||
AccountId = accountId,
|
||||
Role = Role.Creator,
|
||||
InvitedRole = Role.None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
dorm.SubRooms = (template.SubRooms ?? new List<SubRooms>())
|
||||
.Select(subRoom => new SubRooms
|
||||
{
|
||||
SubRoomId = nextSubRoomId++,
|
||||
RoomId = roomId,
|
||||
Name = subRoom.Name,
|
||||
DataBlob = subRoom.DataBlob,
|
||||
SubRoomDataSaveId = 0,
|
||||
IsSandbox = subRoom.IsSandbox,
|
||||
MaxPlayers = subRoom.MaxPlayers,
|
||||
Accessibility = RoomAccessibility.Private,
|
||||
UnitySceneId = subRoom.UnitySceneId,
|
||||
Permissions = ClonePermissions(subRoom.Permissions),
|
||||
SavedByAccountId = accountId
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (dorm.SubRooms.Count == 0)
|
||||
return null;
|
||||
|
||||
Rooms.Insert(dorm);
|
||||
Console.WriteLine(
|
||||
$"[DORM CREATE] account={accountId} room={roomId} " +
|
||||
$"subrooms={dorm.SubRooms.Count}");
|
||||
return PrepareRoomForClient(dorm);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> EnsureCanonicalBaseRooms(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return 0;
|
||||
|
||||
string jsonData = await File.ReadAllTextAsync(path);
|
||||
var options = new SysJson.JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = SysJson.JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
var importedRooms = SysJson.JsonSerializer.Deserialize<List<Room>>(jsonData, options) ?? new();
|
||||
var templates = importedRooms
|
||||
.Where(IsCanonicalBaseRoom)
|
||||
.OrderBy(GetCanonicalBaseRoomOrder)
|
||||
.ToList();
|
||||
|
||||
int changed = 0;
|
||||
foreach (var template in templates)
|
||||
{
|
||||
var existing = GetRoomByName(template.Name);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.IsDorm = false;
|
||||
existing.IsRRO = true;
|
||||
existing.IsBaseRoom = true;
|
||||
existing.Accessibility = RoomAccessibility.Public;
|
||||
existing.State = RoomState.Active;
|
||||
existing.Tags ??= new List<Tags>();
|
||||
if (!existing.Tags.Any(tag =>
|
||||
string.Equals(tag.Tag, "base", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
existing.Tags.Add(new Tags { Tag = "base", Type = TagType.Auto });
|
||||
changed++;
|
||||
}
|
||||
if (!existing.Tags.Any(tag =>
|
||||
string.Equals(tag.Tag, "rro", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
existing.Tags.Add(new Tags { Tag = "rro", Type = TagType.AGOnly });
|
||||
changed++;
|
||||
}
|
||||
|
||||
Rooms.Update(existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await AddRoom(
|
||||
template,
|
||||
log: true,
|
||||
shouldAssignNewIds: true))
|
||||
{
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
public static int EnsurePlayerDormsForAllPlayers()
|
||||
{
|
||||
int created = 0;
|
||||
foreach (long accountId in PlayerDB.Players.FindAll()
|
||||
.Select(player => player.PlayerId)
|
||||
.Where(accountId => accountId > 0))
|
||||
{
|
||||
bool alreadyExisted = Rooms.Exists(room =>
|
||||
room.IsDorm &&
|
||||
room.RoomId != 1 &&
|
||||
room.CreatorAccountId == accountId &&
|
||||
room.State != RoomState.MarkedForDelete);
|
||||
|
||||
if (GetOrCreatePlayerDorm(accountId) != null && !alreadyExisted)
|
||||
created++;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public static int DeletePlayerDorms(long accountId)
|
||||
{
|
||||
if (accountId <= 0)
|
||||
return 0;
|
||||
|
||||
lock (DormCreationLock)
|
||||
{
|
||||
var roomIds = Rooms.Find(room =>
|
||||
room.IsDorm &&
|
||||
room.RoomId != 1 &&
|
||||
room.CreatorAccountId == accountId)
|
||||
.Select(room => room.RoomId)
|
||||
.ToList();
|
||||
|
||||
return roomIds.Count(roomId => Rooms.Delete(roomId));
|
||||
}
|
||||
}
|
||||
|
||||
public static int RemoveOrphanedPlayerDorms()
|
||||
{
|
||||
var accountIds = PlayerDB.Players.FindAll()
|
||||
.Select(player => player.PlayerId)
|
||||
.Where(accountId => accountId > 0)
|
||||
.ToHashSet();
|
||||
|
||||
lock (DormCreationLock)
|
||||
{
|
||||
var orphanedRoomIds = Rooms.Find(room =>
|
||||
room.IsDorm &&
|
||||
room.RoomId != 1 &&
|
||||
room.CreatorAccountId > 0 &&
|
||||
!accountIds.Contains(room.CreatorAccountId))
|
||||
.Select(room => room.RoomId)
|
||||
.ToList();
|
||||
|
||||
return orphanedRoomIds.Count(roomId => Rooms.Delete(roomId));
|
||||
}
|
||||
}
|
||||
|
||||
public static (List<Room> Results, int Total) GetHotRooms(string tag, int skip, int take)
|
||||
{
|
||||
|
||||
RepairCanonicalBaseRoomMetadata();
|
||||
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
string t = string.IsNullOrWhiteSpace(tag)
|
||||
? "hot"
|
||||
: tag.Trim().ToLowerInvariant();
|
||||
|
||||
IEnumerable<Room> query = Rooms.FindAll()
|
||||
.Where(r => !r.IsDorm && r.State != RoomState.MarkedForDelete);
|
||||
|
||||
switch (t)
|
||||
{
|
||||
case "hot":
|
||||
query = query.Where(r =>
|
||||
r.Accessibility == RoomAccessibility.Public);
|
||||
break;
|
||||
|
||||
case "rro":
|
||||
case "recroomoriginal":
|
||||
query = query.Where(r =>
|
||||
r.Accessibility == RoomAccessibility.Public &&
|
||||
(r.IsRRO ||
|
||||
(r.Tags?.Any(x =>
|
||||
string.Equals(x.Tag, "rro", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(x.Tag, "recroomoriginal", StringComparison.OrdinalIgnoreCase)) ?? false)));
|
||||
break;
|
||||
|
||||
case "base":
|
||||
query = query.Where(r =>
|
||||
r.Accessibility == RoomAccessibility.Public &&
|
||||
IsCanonicalBaseRoom(r));
|
||||
break;
|
||||
|
||||
case "new":
|
||||
query = query.Where(r => r.Accessibility == RoomAccessibility.Public);
|
||||
break;
|
||||
|
||||
default:
|
||||
query = query.Where(r =>
|
||||
r.Accessibility == RoomAccessibility.Public &&
|
||||
(r.Tags?.Any(x =>
|
||||
string.Equals(x.Tag, t, StringComparison.OrdinalIgnoreCase)) ?? false));
|
||||
break;
|
||||
}
|
||||
|
||||
query = t switch
|
||||
{
|
||||
"new" => query.OrderByDescending(r => r.CreatedAt)
|
||||
.ThenByDescending(r => r.RoomId),
|
||||
"base" => query.OrderBy(GetCanonicalBaseRoomOrder),
|
||||
_ => query.OrderByDescending(r => r.Stats?.VisitCount ?? 0)
|
||||
.ThenByDescending(r => r.CreatedAt)
|
||||
};
|
||||
|
||||
var all = query.ToList();
|
||||
var page = PrepareRoomsForClient(all.Skip(skip).Take(take));
|
||||
|
||||
return (
|
||||
page,
|
||||
all.Count
|
||||
);
|
||||
}
|
||||
|
||||
public static Room? CloneRoom(Room source, long ownerId, string? newName)
|
||||
{
|
||||
if (source == null || ownerId <= 0 || !source.CloningAllowed)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
long nextRoomId = GetNextRoomId();
|
||||
long nextSubRoomId = GetNextSubRoomId();
|
||||
|
||||
string requestedName = string.IsNullOrWhiteSpace(newName)
|
||||
? $"{source.Name} Copy"
|
||||
: newName.Trim();
|
||||
|
||||
string uniqueName = requestedName;
|
||||
int suffix = 2;
|
||||
|
||||
while (GetRoomByName(uniqueName) != null)
|
||||
uniqueName = $"{requestedName} {suffix++}";
|
||||
|
||||
var clonedRoom = new Room
|
||||
{
|
||||
RoomId = nextRoomId,
|
||||
IsDorm = false,
|
||||
MaxPlayerCalculationMode = source.MaxPlayerCalculationMode,
|
||||
MaxPlayers = source.MaxPlayers,
|
||||
CloningAllowed = true,
|
||||
DisableMicAutoMute = source.DisableMicAutoMute,
|
||||
DisableRoomComments = source.DisableRoomComments,
|
||||
EncryptVoiceChat = source.EncryptVoiceChat,
|
||||
ToxmodEnabled = source.ToxmodEnabled,
|
||||
LoadScreenLocked = false,
|
||||
PersistenceVersion = source.PersistenceVersion,
|
||||
AutoLocalizeRoom = source.AutoLocalizeRoom,
|
||||
IsDeveloperOwned = false,
|
||||
Name = uniqueName,
|
||||
Description = source.Description,
|
||||
ImageName = source.ImageName,
|
||||
WarningMask = source.WarningMask,
|
||||
CustomWarning = source.CustomWarning,
|
||||
CreatorAccountId = ownerId,
|
||||
State = RoomState.Active,
|
||||
Accessibility = RoomAccessibility.Private,
|
||||
SupportsLevelVoting = source.SupportsLevelVoting,
|
||||
IsRRO = false,
|
||||
SupportsScreens = source.SupportsScreens,
|
||||
SupportsWalkVR = source.SupportsWalkVR,
|
||||
SupportsTeleportVR = source.SupportsTeleportVR,
|
||||
SupportsVRLow = source.SupportsVRLow,
|
||||
SupportsQuest2 = source.SupportsQuest2,
|
||||
SupportsMobile = source.SupportsMobile,
|
||||
SupportsJuniors = source.SupportsJuniors,
|
||||
MinLevel = source.MinLevel,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Stats = new Stats(),
|
||||
RankedEntityId = nextRoomId.ToString(),
|
||||
RankingContext = null,
|
||||
UgcVersion = Math.Max(1, source.UgcVersion),
|
||||
Tags = source.Tags?
|
||||
.Where(t => !string.Equals(t.Tag, "rro", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(t.Tag, "base", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(t => new Tags { Tag = t.Tag, Type = t.Type })
|
||||
.ToList() ?? new List<Tags>(),
|
||||
PromoImages = source.PromoImages?.ToList() ?? new List<string>(),
|
||||
PromoExternalContent = source.PromoExternalContent?
|
||||
.Select(x => new PromoExternalContent { Type = x.Type, Reference = x.Reference })
|
||||
.ToList() ?? new List<PromoExternalContent>(),
|
||||
LoadScreens = source.LoadScreens?
|
||||
.Select(x => new LoadScreens
|
||||
{
|
||||
ImageName = x.ImageName,
|
||||
Title = x.Title,
|
||||
Subtitle = x.Subtitle
|
||||
})
|
||||
.ToList() ?? new List<LoadScreens>(),
|
||||
Roles = new List<Roles>
|
||||
{
|
||||
new Roles
|
||||
{
|
||||
AccountId = ownerId,
|
||||
Role = Role.Creator,
|
||||
InvitedRole = Role.None
|
||||
}
|
||||
},
|
||||
DataBlob = source.DataBlob
|
||||
};
|
||||
|
||||
clonedRoom.SubRooms = (source.SubRooms ?? new List<SubRooms>())
|
||||
.Select(sr => new SubRooms
|
||||
{
|
||||
SubRoomId = nextSubRoomId++,
|
||||
RoomId = nextRoomId,
|
||||
Name = sr.Name,
|
||||
DataBlob = sr.DataBlob,
|
||||
SubRoomDataSaveId = 0,
|
||||
IsSandbox = sr.IsSandbox,
|
||||
MaxPlayers = sr.MaxPlayers,
|
||||
Accessibility = sr.Accessibility,
|
||||
UnitySceneId = sr.UnitySceneId,
|
||||
Permissions = ClonePermissions(sr.Permissions),
|
||||
SavedByAccountId = ownerId
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (clonedRoom.SubRooms.Count == 0)
|
||||
return null;
|
||||
|
||||
Rooms.Insert(clonedRoom);
|
||||
Console.WriteLine($"[DB] Cloned room {source.RoomId} -> {clonedRoom.RoomId} ({clonedRoom.Name})");
|
||||
return clonedRoom;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[DB Error] Failed to clone room {source.Name}: {ex}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool DeleteRoom(long roomId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Rooms.Delete(roomId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[DB Error] Failed to delete room {roomId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static Room? RenameRoom(long roomId, string newName)
|
||||
{
|
||||
var room = Rooms.FindById(roomId);
|
||||
if (room == null) return null;
|
||||
|
||||
room.Name = newName;
|
||||
|
||||
try
|
||||
{
|
||||
Rooms.Update(room);
|
||||
return room;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[DB Error] Failed to rename room {roomId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static (int RoomsOwned, int SubRoomSaves, int RoomBans) RenameAccountReferences(
|
||||
long oldAccountId,
|
||||
long newAccountId)
|
||||
{
|
||||
if (oldAccountId <= 0 || newAccountId <= 0 || oldAccountId == newAccountId)
|
||||
return (0, 0, 0);
|
||||
|
||||
int roomsOwned = 0;
|
||||
foreach (Room room in Rooms.Find(value => value.CreatorAccountId == oldAccountId).ToList())
|
||||
{
|
||||
room.CreatorAccountId = newAccountId;
|
||||
if (Rooms.Update(room))
|
||||
roomsOwned++;
|
||||
}
|
||||
|
||||
int subRoomSaves = 0;
|
||||
foreach (SubRoomDataSave save in SubRoomDataSaves
|
||||
.Find(value => value.SavedByAccountId == oldAccountId)
|
||||
.ToList())
|
||||
{
|
||||
save.SavedByAccountId = newAccountId;
|
||||
if (SubRoomDataSaves.Update(save))
|
||||
subRoomSaves++;
|
||||
}
|
||||
|
||||
int roomBans = 0;
|
||||
foreach (RoomBan ban in RoomBans
|
||||
.Find(value =>
|
||||
value.AccountId == oldAccountId ||
|
||||
value.BannedByAccountId == oldAccountId)
|
||||
.ToList())
|
||||
{
|
||||
if (ban.AccountId == oldAccountId)
|
||||
ban.AccountId = newAccountId;
|
||||
if (ban.BannedByAccountId == oldAccountId)
|
||||
ban.BannedByAccountId = newAccountId;
|
||||
|
||||
if (RoomBans.Update(ban))
|
||||
roomBans++;
|
||||
}
|
||||
|
||||
return (roomsOwned, subRoomSaves, roomBans);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs;
|
||||
|
||||
public static class ScannerLogDB
|
||||
{
|
||||
public class ScannerAttempt
|
||||
{
|
||||
[BsonId]
|
||||
public long Id { get; set; }
|
||||
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
public string IpAddress { get; set; } = "unknown";
|
||||
public string Method { get; set; } = "";
|
||||
public string Path { get; set; } = "";
|
||||
public string? QueryString { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
public string? MatchedPattern { get; set; }
|
||||
}
|
||||
|
||||
private static readonly LiteDatabase DB = new(
|
||||
Path.Combine(Program.dataDir, "DBs", "ScannerLogs.db"));
|
||||
|
||||
public static readonly ILiteCollection<ScannerAttempt> Attempts =
|
||||
DB.GetCollection<ScannerAttempt>("Attempts");
|
||||
|
||||
private const int MaxStored = 20_000;
|
||||
private const int TrimBatchSize = 1_000;
|
||||
|
||||
public static void Log(
|
||||
string ipAddress,
|
||||
string method,
|
||||
string path,
|
||||
string? queryString,
|
||||
string? userAgent,
|
||||
string? matchedPattern)
|
||||
{
|
||||
Attempts.Insert(new ScannerAttempt
|
||||
{
|
||||
IpAddress = ipAddress,
|
||||
Method = method,
|
||||
Path = path,
|
||||
QueryString = queryString,
|
||||
UserAgent = userAgent,
|
||||
MatchedPattern = matchedPattern
|
||||
});
|
||||
|
||||
long count = Attempts.LongCount();
|
||||
if (count > MaxStored)
|
||||
{
|
||||
List<long> oldestIds = Attempts.Query()
|
||||
.OrderBy(a => a.Id)
|
||||
.Limit(TrimBatchSize)
|
||||
.ToList()
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (long id in oldestIds)
|
||||
Attempts.Delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1363 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
using static Mocha2023.Classes.DBs.DBClasses.RoomDBClasses;
|
||||
|
||||
namespace Mocha2023.Classes.DBs;
|
||||
|
||||
public static class ShowdownImporter
|
||||
{
|
||||
private const int CompatiblePersistenceVersion = 38;
|
||||
private const int CompatibleUgcSubVersion = 0;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString
|
||||
};
|
||||
|
||||
public sealed record ImportResult(
|
||||
long RoomId,
|
||||
int SubRoomsImported,
|
||||
int SavesImported,
|
||||
int BlobsCopied,
|
||||
int SavesSkipped,
|
||||
string PlayableSubRoomName,
|
||||
long PlayableSubRoomId)
|
||||
{
|
||||
public int BakedAssetsImported { get; init; }
|
||||
public int AssetBundlesCopied { get; init; }
|
||||
public int AssetBundlesMissing { get; init; }
|
||||
public bool ImageCopied { get; init; }
|
||||
public IReadOnlyList<string> UnityEngineVersions { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public static ImportResult ImportUnityMetadata(
|
||||
string json,
|
||||
long creatorAccountId = 1,
|
||||
bool replaceExisting = true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
throw new InvalidDataException("The room metadata JSON is empty.");
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(json, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
JsonElement root = document.RootElement;
|
||||
foreach (string wrapper in new[] { "room", "Room", "value", "Value", "data", "Data" })
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
TryGetProperty(root, wrapper, out JsonElement nested) &&
|
||||
nested.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
root = nested;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ExportRoom exportRoom = JsonSerializer.Deserialize<ExportRoom>(
|
||||
root.GetRawText(),
|
||||
JsonOptions) ?? throw new InvalidDataException("Could not deserialize the room metadata JSON.");
|
||||
|
||||
return ImportUnityExport(exportRoom, creatorAccountId, replaceExisting);
|
||||
}
|
||||
|
||||
public static ImportResult Import(
|
||||
string exportRoot,
|
||||
long creatorAccountId = 1,
|
||||
bool replaceExisting = true)
|
||||
{
|
||||
exportRoot = Path.GetFullPath(exportRoot.Trim().Trim('"'));
|
||||
string roomJsonPath = Path.Combine(exportRoot, "room.json");
|
||||
|
||||
if (!File.Exists(roomJsonPath))
|
||||
throw new FileNotFoundException("room.json was not found.", roomJsonPath);
|
||||
|
||||
ExportRoom exportRoom = ReadJson<ExportRoom>(roomJsonPath);
|
||||
if (string.IsNullOrWhiteSpace(exportRoom.Name))
|
||||
throw new InvalidDataException("room.json does not contain a valid Name.");
|
||||
|
||||
string roomBlobDestination = Path.Combine(Program.dataDir, "CDN", "room");
|
||||
string assetBundleDestination = Path.Combine(Program.dataDir, "CDN", "assetbundles");
|
||||
string imageDestination = Path.Combine(Program.dataDir, "Images");
|
||||
Directory.CreateDirectory(roomBlobDestination);
|
||||
Directory.CreateDirectory(assetBundleDestination);
|
||||
Directory.CreateDirectory(imageDestination);
|
||||
|
||||
string? assetBundleSource = FindDirectory(exportRoot, "AssetBundles");
|
||||
|
||||
long assignedRoomId = RoomDB.GetNextRoomId();
|
||||
string normalizedRoomName = exportRoom.Name.Trim().TrimStart('^');
|
||||
Room? existingByName = RoomDB.Rooms.FindAll().FirstOrDefault(room =>
|
||||
string.Equals(room.Name, normalizedRoomName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (replaceExisting)
|
||||
{
|
||||
if (existingByName != null)
|
||||
{
|
||||
RoomDB.SubRoomDataSaves.DeleteMany(save => save.RoomId == existingByName.RoomId);
|
||||
RoomDB.Rooms.Delete(existingByName.RoomId);
|
||||
}
|
||||
}
|
||||
else if (existingByName != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A room named \"{normalizedRoomName}\" already exists. Enable replaceExisting to overwrite it.");
|
||||
}
|
||||
|
||||
long nextLocalSaveId = RoomDB.SubRoomDataSaves.Count() == 0
|
||||
? 1
|
||||
: Convert.ToInt64(RoomDB.SubRoomDataSaves.Max(save => save.SubRoomDataSaveId)) + 1;
|
||||
|
||||
var importedSubRooms = new List<SubRooms>();
|
||||
int savesImported = 0;
|
||||
int blobsCopied = 0;
|
||||
int savesSkipped = 0;
|
||||
int bakedAssetsImported = 0;
|
||||
int assetBundlesCopied = 0;
|
||||
int assetBundlesMissing = 0;
|
||||
var unityEngineVersions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
SubRooms? playableSubRoom = null;
|
||||
SubRoomDataSave? playableCurrentSave = null;
|
||||
|
||||
foreach (ExportSubRoom listedSubRoom in exportRoom.SubRooms ?? new List<ExportSubRoom>())
|
||||
{
|
||||
string subRoomDirectory = Path.Combine(exportRoot, listedSubRoom.SubRoomId.ToString());
|
||||
string subRoomJsonPath = Path.Combine(subRoomDirectory, "subRoom.json");
|
||||
if (!File.Exists(subRoomJsonPath))
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Missing subRoom.json for {listedSubRoom.SubRoomId}; skipped.");
|
||||
continue;
|
||||
}
|
||||
|
||||
ExportSubRoom sourceSubRoom = ReadJson<ExportSubRoom>(subRoomJsonPath);
|
||||
MergeSubRoomMetadata(listedSubRoom, sourceSubRoom);
|
||||
|
||||
string savesDirectory = Path.Combine(subRoomDirectory, "Saves");
|
||||
if (!Directory.Exists(savesDirectory))
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Missing Saves directory for {sourceSubRoom.SubRoomId}; skipped.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var importedForThisSubRoom = new List<(long SourceSaveId, SubRoomDataSave LocalSave)>();
|
||||
|
||||
foreach (string saveDirectory in Directory.EnumerateDirectories(savesDirectory))
|
||||
{
|
||||
string directoryName = Path.GetFileName(saveDirectory);
|
||||
string saveJsonPath = Path.Combine(saveDirectory, directoryName + ".json");
|
||||
|
||||
if (!File.Exists(saveJsonPath))
|
||||
{
|
||||
saveJsonPath = Directory.EnumerateFiles(saveDirectory, "*.json")
|
||||
.FirstOrDefault(path =>
|
||||
!Path.GetFileName(path).StartsWith(
|
||||
"bakedUnityAsset-",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
?? string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(saveJsonPath) || !File.Exists(saveJsonPath))
|
||||
{
|
||||
savesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
ExportSave sourceSave;
|
||||
try
|
||||
{
|
||||
sourceSave = ReadJson<ExportSave>(saveJsonPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Invalid save JSON {saveJsonPath}: {exception.Message}");
|
||||
savesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
string safeBlobName = Path.GetFileName(sourceSave.DataBlob ?? string.Empty);
|
||||
string sourceBlobPath = Path.Combine(saveDirectory, safeBlobName);
|
||||
if (string.IsNullOrWhiteSpace(safeBlobName) || !File.Exists(sourceBlobPath))
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Missing blob for source save {sourceSave.SubRoomDataSaveId}; skipped.");
|
||||
savesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
string destinationBlobPath = Path.Combine(roomBlobDestination, safeBlobName);
|
||||
if (CopyIfDifferent(sourceBlobPath, destinationBlobPath))
|
||||
blobsCopied++;
|
||||
|
||||
var bakedAssets = new List<BakedUnityAsset>();
|
||||
foreach (string bakedJsonPath in Directory.EnumerateFiles(
|
||||
saveDirectory,
|
||||
"bakedUnityAsset-*.json",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
BakedUnityAsset? bakedAsset;
|
||||
try
|
||||
{
|
||||
bakedAsset = ReadJson<BakedUnityAsset>(bakedJsonPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Invalid baked asset JSON {bakedJsonPath}: {exception.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
bakedAsset.UnityAssetId = string.IsNullOrWhiteSpace(bakedAsset.UnityAssetId)
|
||||
? sourceSave.UnityAssetId ?? string.Empty
|
||||
: bakedAsset.UnityAssetId.Trim();
|
||||
bakedAsset.Filename = Path.GetFileName(bakedAsset.Filename ?? string.Empty);
|
||||
bakedAsset.UnityVersion = ParseUnityVersionFromMetadataName(Path.GetFileName(bakedJsonPath));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bakedAsset.UnityVersion))
|
||||
unityEngineVersions.Add(bakedAsset.UnityVersion);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bakedAsset.UnityAssetId) ||
|
||||
string.IsNullOrWhiteSpace(bakedAsset.Filename))
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Baked asset metadata {bakedJsonPath} is missing an ID or filename.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assetBundleSource == null)
|
||||
{
|
||||
bakedAsset.IsAvailable = false;
|
||||
assetBundlesMissing++;
|
||||
}
|
||||
else
|
||||
{
|
||||
string sourceBundlePath = Path.Combine(assetBundleSource, bakedAsset.Filename);
|
||||
string destinationBundlePath = Path.Combine(assetBundleDestination, bakedAsset.Filename);
|
||||
if (!File.Exists(sourceBundlePath))
|
||||
{
|
||||
bakedAsset.IsAvailable = false;
|
||||
assetBundlesMissing++;
|
||||
Console.WriteLine(
|
||||
$"[ROOM IMPORT] Missing asset bundle {bakedAsset.Filename} " +
|
||||
$"for UnityAssetId={bakedAsset.UnityAssetId} target={bakedAsset.Target}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (CopyIfDifferent(sourceBundlePath, destinationBundlePath))
|
||||
assetBundlesCopied++;
|
||||
|
||||
string actualBundleHash = ComputeSha256Base64(destinationBundlePath);
|
||||
if (!string.Equals(bakedAsset.Hash, actualBundleHash, StringComparison.Ordinal))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM IMPORT] Asset bundle hash normalized " +
|
||||
$"file={bakedAsset.Filename} old={bakedAsset.Hash ?? "null"} " +
|
||||
$"new={actualBundleHash}");
|
||||
bakedAsset.Hash = actualBundleHash;
|
||||
}
|
||||
|
||||
bakedAsset.IsAvailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
bakedAssets.Add(bakedAsset);
|
||||
bakedAssetsImported++;
|
||||
}
|
||||
|
||||
string actualHash = ComputeSha256Base64(destinationBlobPath);
|
||||
bool isSingleBlobBakedSave = !string.IsNullOrWhiteSpace(sourceSave.UnityAssetId);
|
||||
var localSave = new SubRoomDataSave
|
||||
{
|
||||
SubRoomDataSaveId = nextLocalSaveId++,
|
||||
RoomId = assignedRoomId,
|
||||
SubRoomId = sourceSubRoom.SubRoomId,
|
||||
SavedByAccountId = creatorAccountId,
|
||||
DataBlob = safeBlobName,
|
||||
RoomDataBlob = isSingleBlobBakedSave ? safeBlobName : null,
|
||||
DataBlobHash = actualHash,
|
||||
PersistenceVersion = NormalizePersistenceVersion(sourceSave.PersistenceVersion),
|
||||
SavedOnPlatform = sourceSave.SavedOnPlatform,
|
||||
SavedOnDeviceClass = sourceSave.SavedOnDeviceClass,
|
||||
Description = sourceSave.Description,
|
||||
CreatedAt = NormalizeCreatedAt(sourceSave.CreatedAt),
|
||||
IsPublished = sourceSave.ModerationState != 0,
|
||||
UnityAssetId = sourceSave.UnityAssetId,
|
||||
ReferencedUnityAssetIds = sourceSave.ReferencedUnityAssetIds ?? new List<string>(),
|
||||
OMVersion = RoomDB.NormalizeLegacyOMVersion(sourceSave.OMVersion),
|
||||
UgcSubVersion = NormalizeUgcSubVersion(sourceSave.UgcSubVersion),
|
||||
ModerationState = sourceSave.ModerationState,
|
||||
Tags = sourceSave.Tags ?? new List<string>(),
|
||||
BakedUnityAssets = bakedAssets
|
||||
};
|
||||
|
||||
RoomDB.SubRoomDataSaves.Insert(localSave);
|
||||
importedForThisSubRoom.Add((sourceSave.SubRoomDataSaveId, localSave));
|
||||
savesImported++;
|
||||
}
|
||||
|
||||
if (importedForThisSubRoom.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Subroom {sourceSubRoom.Name} has no usable saves; skipped.");
|
||||
continue;
|
||||
}
|
||||
|
||||
long requestedCurrentSaveId = sourceSubRoom.CurrentSave?.SubRoomDataSaveId
|
||||
?? listedSubRoom.CurrentSave?.SubRoomDataSaveId
|
||||
?? 0;
|
||||
|
||||
(long SourceSaveId, SubRoomDataSave LocalSave) selectedItem =
|
||||
importedForThisSubRoom.FirstOrDefault(item => item.SourceSaveId == requestedCurrentSaveId);
|
||||
|
||||
if (selectedItem.LocalSave == null)
|
||||
{
|
||||
selectedItem = importedForThisSubRoom
|
||||
.OrderByDescending(item => item.LocalSave.CreatedAt)
|
||||
.ThenByDescending(item => item.SourceSaveId)
|
||||
.First();
|
||||
}
|
||||
|
||||
SubRoomDataSave currentSave = selectedItem.LocalSave;
|
||||
Console.WriteLine(
|
||||
$"[ROOM IMPORT SELECT] subroom={sourceSubRoom.SubRoomId}:{sourceSubRoom.Name} " +
|
||||
$"sourceSave={selectedItem.SourceSaveId} requested={requestedCurrentSaveId} " +
|
||||
$"created={currentSave.CreatedAt:O} blob={currentSave.DataBlob} " +
|
||||
$"persistence={currentSave.PersistenceVersion} " +
|
||||
$"unityAsset={currentSave.UnityAssetId ?? "none"}");
|
||||
|
||||
int accessibilityValue = Enum.IsDefined(typeof(RoomAccessibility), sourceSubRoom.Accessibility)
|
||||
? sourceSubRoom.Accessibility
|
||||
: (int)RoomAccessibility.Public;
|
||||
|
||||
var localSubRoom = new SubRooms
|
||||
{
|
||||
SubRoomId = sourceSubRoom.SubRoomId,
|
||||
RoomId = assignedRoomId,
|
||||
Name = string.IsNullOrWhiteSpace(sourceSubRoom.Name) ? "Home" : sourceSubRoom.Name,
|
||||
DataBlob = currentSave.DataBlob,
|
||||
SubRoomDataSaveId = currentSave.SubRoomDataSaveId,
|
||||
SubRoomDataSave = currentSave,
|
||||
IsSandbox = sourceSubRoom.IsSandbox,
|
||||
MaxPlayers = Math.Clamp(sourceSubRoom.MaxPlayers > 0 ? sourceSubRoom.MaxPlayers : 6, 1, 100),
|
||||
Accessibility = (RoomAccessibility)accessibilityValue,
|
||||
UnitySceneId = sourceSubRoom.UnitySceneId ?? string.Empty,
|
||||
SavedByAccountId = creatorAccountId,
|
||||
Permissions = new List<SubRoomPermission>()
|
||||
};
|
||||
|
||||
importedSubRooms.Add(localSubRoom);
|
||||
|
||||
bool isNamedHome = string.Equals(localSubRoom.Name, "Home", StringComparison.OrdinalIgnoreCase);
|
||||
bool isPublic = localSubRoom.Accessibility != RoomAccessibility.Private;
|
||||
bool currentIsPublic = playableSubRoom == null ||
|
||||
playableSubRoom.Accessibility != RoomAccessibility.Private;
|
||||
if (playableSubRoom == null ||
|
||||
(isPublic && !currentIsPublic) ||
|
||||
(isPublic == currentIsPublic && isNamedHome))
|
||||
{
|
||||
playableSubRoom = localSubRoom;
|
||||
playableCurrentSave = currentSave;
|
||||
}
|
||||
}
|
||||
|
||||
if (importedSubRooms.Count == 0 &&
|
||||
exportRoom.SubRooms?.Any(subRoom => !string.IsNullOrWhiteSpace(subRoom.UnitySceneId)) == true)
|
||||
{
|
||||
return ImportUnityExport(exportRoom, creatorAccountId, replaceExisting: false);
|
||||
}
|
||||
|
||||
if (importedSubRooms.Count == 0)
|
||||
throw new InvalidDataException("No compatible save blobs or UnitySceneId subrooms could be imported.");
|
||||
|
||||
playableSubRoom ??= importedSubRooms[0];
|
||||
playableCurrentSave ??= RoomDB.SubRoomDataSaves.FindById(playableSubRoom.SubRoomDataSaveId);
|
||||
playableSubRoom.Name = "Home";
|
||||
importedSubRooms.Remove(playableSubRoom);
|
||||
importedSubRooms.Insert(0, playableSubRoom);
|
||||
|
||||
bool imageCopied = CopyRoomImage(exportRoot, exportRoom.ImageName, imageDestination, out string imageName);
|
||||
int roomAccessibility = Enum.IsDefined(typeof(RoomAccessibility), exportRoom.Accessibility)
|
||||
? exportRoom.Accessibility
|
||||
: (int)RoomAccessibility.Public;
|
||||
|
||||
bool hasBakedAssets = importedSubRooms.Any(subRoom =>
|
||||
RoomDB.SubRoomDataSaves.FindById(subRoom.SubRoomDataSaveId)?.BakedUnityAssets?.Count > 0);
|
||||
|
||||
var room = new Room
|
||||
{
|
||||
RoomId = assignedRoomId,
|
||||
IsDorm = false,
|
||||
MaxPlayerCalculationMode = 0,
|
||||
MaxPlayers = Math.Clamp(
|
||||
exportRoom.MaxPlayers > 0 ? exportRoom.MaxPlayers : playableSubRoom.MaxPlayers,
|
||||
1,
|
||||
100),
|
||||
CloningAllowed = false,
|
||||
DisableMicAutoMute = false,
|
||||
DisableRoomComments = false,
|
||||
EncryptVoiceChat = false,
|
||||
ToxmodEnabled = true,
|
||||
LoadScreenLocked = false,
|
||||
PersistenceVersion = NormalizePersistenceVersion(
|
||||
playableCurrentSave?.PersistenceVersion ?? exportRoom.PersistenceVersion),
|
||||
AutoLocalizeRoom = false,
|
||||
IsDeveloperOwned = true,
|
||||
Name = exportRoom.Name.Trim().TrimStart('^'),
|
||||
Description = string.IsNullOrWhiteSpace(exportRoom.Description)
|
||||
? "Imported room export."
|
||||
: exportRoom.Description,
|
||||
ImageName = imageName,
|
||||
WarningMask = 0,
|
||||
CustomWarning = null,
|
||||
CreatorAccountId = creatorAccountId,
|
||||
State = RoomState.Active,
|
||||
Accessibility = (RoomAccessibility)roomAccessibility,
|
||||
SupportsLevelVoting = false,
|
||||
IsRRO = false,
|
||||
IsBaseRoom = false,
|
||||
CreativeToolsBetaEnabled = hasBakedAssets,
|
||||
SupportsScreens = true,
|
||||
SupportsWalkVR = true,
|
||||
SupportsTeleportVR = true,
|
||||
SupportsVRLow = true,
|
||||
SupportsQuest2 = true,
|
||||
SupportsMobile = true,
|
||||
SupportsJuniors = true,
|
||||
MinLevel = 0,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Stats = new Stats(),
|
||||
RankedEntityId = assignedRoomId.ToString(),
|
||||
RankingContext = null,
|
||||
SubRooms = importedSubRooms,
|
||||
Roles = new List<Roles>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AccountId = creatorAccountId,
|
||||
Role = Role.Creator,
|
||||
InvitedRole = Role.None
|
||||
}
|
||||
},
|
||||
|
||||
DataBlob = playableCurrentSave?.RoomDataBlob,
|
||||
UgcVersion = 1,
|
||||
Tags = new List<Tags>(),
|
||||
PromoImages = new List<string>(),
|
||||
PromoExternalContent = new List<PromoExternalContent>(),
|
||||
LoadScreens = new List<LoadScreens>()
|
||||
};
|
||||
|
||||
if (!RoomDB.Rooms.Upsert(room))
|
||||
throw new InvalidOperationException("LiteDB failed to upsert the imported room.");
|
||||
|
||||
Room? verifiedRoom = RoomDB.Rooms.FindById(room.RoomId);
|
||||
if (verifiedRoom?.SubRooms == null || verifiedRoom.SubRooms.Count == 0)
|
||||
throw new InvalidOperationException("The room was written, but LiteDB returned no subrooms after import.");
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ROOM IMPORT] Imported {room.Name} room={room.RoomId} " +
|
||||
$"subrooms={importedSubRooms.Count} saves={savesImported} " +
|
||||
$"bakedAssets={bakedAssetsImported} bundlesCopied={assetBundlesCopied} " +
|
||||
$"bundlesMissing={assetBundlesMissing} playable={playableSubRoom.SubRoomId}:{playableSubRoom.Name}");
|
||||
|
||||
return new ImportResult(
|
||||
room.RoomId,
|
||||
importedSubRooms.Count,
|
||||
savesImported,
|
||||
blobsCopied,
|
||||
savesSkipped,
|
||||
playableSubRoom.Name,
|
||||
playableSubRoom.SubRoomId)
|
||||
{
|
||||
BakedAssetsImported = bakedAssetsImported,
|
||||
AssetBundlesCopied = assetBundlesCopied,
|
||||
AssetBundlesMissing = assetBundlesMissing,
|
||||
ImageCopied = imageCopied,
|
||||
UnityEngineVersions = unityEngineVersions.OrderBy(value => value).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly HttpClient DownloadClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(10)
|
||||
};
|
||||
|
||||
public static async Task<ImportResult> ImportFromUrlAsync(
|
||||
string rootUrl,
|
||||
long creatorAccountId = 1,
|
||||
bool replaceExisting = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootUrl) ||
|
||||
!Uri.TryCreate(rootUrl, UriKind.Absolute, out Uri? rootUri) ||
|
||||
(rootUri.Scheme != Uri.UriSchemeHttp && rootUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidDataException("Enter a valid http:// or https:// URL to the room's own folder.");
|
||||
}
|
||||
|
||||
string normalizedRoot = rootUrl.TrimEnd('/') + "/";
|
||||
string importId = Guid.NewGuid().ToString("N");
|
||||
string stagingRoot = Path.Combine(Program.dataDir, "Temp", "RoomImports", importId);
|
||||
Directory.CreateDirectory(stagingRoot);
|
||||
|
||||
const long maxTotalBytes = 1024L * 1024L * 1024L;
|
||||
const int maxSubRooms = 500;
|
||||
const int maxSavesPerSubRoom = 500;
|
||||
long totalDownloadedBytes = 0;
|
||||
|
||||
async Task DownloadFileAsync(string fileUrl, string destinationPath)
|
||||
{
|
||||
using HttpResponseMessage response = await DownloadClient.GetAsync(
|
||||
fileUrl,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new InvalidDataException($"Could not download {fileUrl} ({(int)response.StatusCode}).");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
await using Stream input = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var output = new FileStream(
|
||||
destinationPath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
81920,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
byte[] buffer = new byte[81920];
|
||||
while (true)
|
||||
{
|
||||
int read = await input.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
|
||||
if (read == 0)
|
||||
break;
|
||||
|
||||
totalDownloadedBytes += read;
|
||||
if (totalDownloadedBytes > maxTotalBytes)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"This room's files add up to more than the 1 GiB limit for URL imports. " +
|
||||
"Download it and use the ZIP import instead.");
|
||||
}
|
||||
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
List<(string Name, bool IsDirectory)> rootEntries =
|
||||
await ListRemoteDirectoryAsync(normalizedRoot, cancellationToken);
|
||||
|
||||
if (!rootEntries.Any(entry => !entry.IsDirectory &&
|
||||
string.Equals(entry.Name, "room.json", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"No room.json was found at that URL - point this at a room's own folder " +
|
||||
"(the page that directly lists room.json and the subroom folders).");
|
||||
}
|
||||
|
||||
await DownloadFileAsync(normalizedRoot + "room.json", Path.Combine(stagingRoot, "room.json"));
|
||||
ExportRoom exportRoom = ReadJson<ExportRoom>(Path.Combine(stagingRoot, "room.json"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(exportRoom.ImageName))
|
||||
{
|
||||
string imageFileName = Path.GetFileName(exportRoom.ImageName);
|
||||
(string Name, bool IsDirectory) imageEntry = rootEntries.FirstOrDefault(entry =>
|
||||
!entry.IsDirectory &&
|
||||
string.Equals(entry.Name, imageFileName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (imageEntry.Name != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DownloadFileAsync(
|
||||
normalizedRoot + Uri.EscapeDataString(imageEntry.Name),
|
||||
Path.Combine(stagingRoot, imageEntry.Name));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Console.WriteLine($"[ROOM IMPORT URL] Could not fetch thumbnail: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var referencedBundleFilenames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
int subRoomsFound = 0;
|
||||
|
||||
foreach ((string Name, bool IsDirectory) entry in rootEntries.Where(entry =>
|
||||
entry.IsDirectory &&
|
||||
!string.Equals(entry.Name, "AssetBundles", StringComparison.OrdinalIgnoreCase) &&
|
||||
long.TryParse(entry.Name, out _)))
|
||||
{
|
||||
if (subRoomsFound >= maxSubRooms)
|
||||
break;
|
||||
|
||||
string subRoomUrl = normalizedRoot + Uri.EscapeDataString(entry.Name) + "/";
|
||||
List<(string Name, bool IsDirectory)> subRoomEntries =
|
||||
await ListRemoteDirectoryAsync(subRoomUrl, cancellationToken);
|
||||
|
||||
if (!subRoomEntries.Any(e => !e.IsDirectory &&
|
||||
string.Equals(e.Name, "subRoom.json", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
subRoomsFound++;
|
||||
string localSubRoomDir = Path.Combine(stagingRoot, entry.Name);
|
||||
await DownloadFileAsync(subRoomUrl + "subRoom.json", Path.Combine(localSubRoomDir, "subRoom.json"));
|
||||
|
||||
(string Name, bool IsDirectory) savesFolder = subRoomEntries.FirstOrDefault(e =>
|
||||
e.IsDirectory && string.Equals(e.Name, "Saves", StringComparison.OrdinalIgnoreCase));
|
||||
if (savesFolder.Name == null)
|
||||
continue;
|
||||
|
||||
string savesUrl = subRoomUrl + "Saves/";
|
||||
List<(string Name, bool IsDirectory)> saveEntries =
|
||||
await ListRemoteDirectoryAsync(savesUrl, cancellationToken);
|
||||
|
||||
int savesForThisSubRoom = 0;
|
||||
foreach ((string Name, bool IsDirectory) saveEntry in saveEntries.Where(e => e.IsDirectory))
|
||||
{
|
||||
if (savesForThisSubRoom >= maxSavesPerSubRoom)
|
||||
break;
|
||||
savesForThisSubRoom++;
|
||||
|
||||
string saveUrl = savesUrl + Uri.EscapeDataString(saveEntry.Name) + "/";
|
||||
List<(string Name, bool IsDirectory)> saveFileEntries =
|
||||
await ListRemoteDirectoryAsync(saveUrl, cancellationToken);
|
||||
string localSaveDir = Path.Combine(localSubRoomDir, "Saves", saveEntry.Name);
|
||||
|
||||
foreach ((string Name, bool IsDirectory) file in saveFileEntries.Where(e => !e.IsDirectory))
|
||||
{
|
||||
await DownloadFileAsync(
|
||||
saveUrl + Uri.EscapeDataString(file.Name),
|
||||
Path.Combine(localSaveDir, file.Name));
|
||||
|
||||
if (file.Name.StartsWith("bakedUnityAsset-", StringComparison.OrdinalIgnoreCase) &&
|
||||
file.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
BakedUnityAsset baked = ReadJson<BakedUnityAsset>(
|
||||
Path.Combine(localSaveDir, file.Name));
|
||||
if (!string.IsNullOrWhiteSpace(baked.Filename))
|
||||
referencedBundleFilenames.Add(Path.GetFileName(baked.Filename));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM IMPORT URL] Unreadable baked asset metadata {file.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (subRoomsFound == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"No subroom folders with a subRoom.json were found under that URL.");
|
||||
}
|
||||
|
||||
if (referencedBundleFilenames.Count > 0 &&
|
||||
rootEntries.Any(entry => entry.IsDirectory &&
|
||||
string.Equals(entry.Name, "AssetBundles", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
string bundlesUrl = normalizedRoot + "AssetBundles/";
|
||||
string localBundlesDir = Path.Combine(stagingRoot, "AssetBundles");
|
||||
foreach (string filename in referencedBundleFilenames)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DownloadFileAsync(
|
||||
bundlesUrl + Uri.EscapeDataString(filename),
|
||||
Path.Combine(localBundlesDir, filename));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM IMPORT URL] Could not fetch asset bundle {filename}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Import(stagingRoot, creatorAccountId, replaceExisting);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(stagingRoot))
|
||||
Directory.Delete(stagingRoot, recursive: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT URL] Could not clean up staging folder: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<List<(string Name, string Url)>> DiscoverRoomFoldersAsync(
|
||||
string rootUrl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootUrl) ||
|
||||
!Uri.TryCreate(rootUrl, UriKind.Absolute, out Uri? rootUri) ||
|
||||
(rootUri.Scheme != Uri.UriSchemeHttp && rootUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidDataException("Enter a valid http:// or https:// URL to the rooms index folder.");
|
||||
}
|
||||
|
||||
string normalizedRoot = rootUrl.TrimEnd('/') + "/";
|
||||
List<(string Name, bool IsDirectory)> entries =
|
||||
await ListRemoteDirectoryAsync(normalizedRoot, cancellationToken);
|
||||
|
||||
if (entries.Any(entry => !entry.IsDirectory &&
|
||||
string.Equals(entry.Name, "room.json", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
string singleName = rootUri.Segments.Length > 0
|
||||
? Uri.UnescapeDataString(rootUri.Segments[^1].Trim('/'))
|
||||
: normalizedRoot;
|
||||
return new List<(string Name, string Url)>
|
||||
{
|
||||
(string.IsNullOrWhiteSpace(singleName) ? normalizedRoot : singleName, normalizedRoot)
|
||||
};
|
||||
}
|
||||
|
||||
const int maxCandidates = 300;
|
||||
var found = new List<(string Name, string Url)>();
|
||||
foreach ((string Name, bool IsDirectory) entry in entries.Where(entry =>
|
||||
entry.IsDirectory &&
|
||||
!string.Equals(entry.Name, "AssetBundles", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (found.Count >= maxCandidates)
|
||||
break;
|
||||
|
||||
string candidateUrl = normalizedRoot + Uri.EscapeDataString(entry.Name) + "/";
|
||||
List<(string Name, bool IsDirectory)> candidateEntries;
|
||||
try
|
||||
{
|
||||
candidateEntries = await ListRemoteDirectoryAsync(candidateUrl, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT URL DISCOVER] Skipping {candidateUrl}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidateEntries.Any(e => !e.IsDirectory &&
|
||||
string.Equals(e.Name, "room.json", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
found.Add((entry.Name, candidateUrl));
|
||||
}
|
||||
}
|
||||
|
||||
if (found.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"No room folders (containing room.json) were found directly under that URL.");
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private static async Task<List<(string Name, bool IsDirectory)>> ListRemoteDirectoryAsync(
|
||||
string url,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string html = await DownloadClient.GetStringAsync(url, cancellationToken);
|
||||
var entries = new List<(string Name, bool IsDirectory)>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (Match match in Regex.Matches(html, "href\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase))
|
||||
{
|
||||
string href = System.Net.WebUtility.HtmlDecode(match.Groups[1].Value).Trim();
|
||||
if (string.IsNullOrWhiteSpace(href) ||
|
||||
href.StartsWith('#') ||
|
||||
href.StartsWith('?') ||
|
||||
href is "../" or ".." or "/" or "./")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (href.Contains("://"))
|
||||
{
|
||||
|
||||
if (!Uri.TryCreate(href, UriKind.Absolute, out Uri? absolute) ||
|
||||
!Uri.TryCreate(url, UriKind.Absolute, out Uri? baseUri) ||
|
||||
!string.Equals(absolute.Host, baseUri.Host, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
bool isDirectory = href.EndsWith('/');
|
||||
string trimmed = href.TrimEnd('/');
|
||||
string rawName = trimmed.Contains('/') ? trimmed[(trimmed.LastIndexOf('/') + 1)..] : trimmed;
|
||||
string name = Uri.UnescapeDataString(rawName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || name is ".." or "." || name.Contains('\0'))
|
||||
continue;
|
||||
|
||||
if (seen.Add(name))
|
||||
entries.Add((name, isDirectory));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static ImportResult ImportUnityExport(
|
||||
ExportRoom exportRoom,
|
||||
long creatorAccountId,
|
||||
bool replaceExisting)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(exportRoom.Name))
|
||||
throw new InvalidDataException("Room metadata must contain a Name.");
|
||||
|
||||
var sourceSubRooms = (exportRoom.SubRooms ?? new List<ExportSubRoom>())
|
||||
.Where(subRoom => !string.IsNullOrWhiteSpace(subRoom.UnitySceneId))
|
||||
.ToList();
|
||||
|
||||
long assignedRoomId = RoomDB.GetNextRoomId();
|
||||
string normalizedRoomName = exportRoom.Name.Trim().TrimStart('^');
|
||||
Room? existingByName = RoomDB.Rooms.FindAll().FirstOrDefault(room =>
|
||||
string.Equals(room.Name, normalizedRoomName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (sourceSubRooms.Count == 0 && !string.IsNullOrWhiteSpace(exportRoom.UnitySceneId))
|
||||
{
|
||||
sourceSubRooms.Add(new ExportSubRoom
|
||||
{
|
||||
RoomId = assignedRoomId,
|
||||
SubRoomId = exportRoom.SubRoomId,
|
||||
Name = "Home",
|
||||
UnitySceneId = exportRoom.UnitySceneId,
|
||||
MaxPlayers = exportRoom.MaxPlayers,
|
||||
Accessibility = exportRoom.Accessibility
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceSubRooms.Count == 0)
|
||||
throw new InvalidDataException("No UnitySceneId was found in the room metadata.");
|
||||
|
||||
if (replaceExisting)
|
||||
{
|
||||
if (existingByName != null)
|
||||
{
|
||||
RoomDB.SubRoomDataSaves.DeleteMany(save => save.RoomId == existingByName.RoomId);
|
||||
RoomDB.Rooms.Delete(existingByName.RoomId);
|
||||
}
|
||||
}
|
||||
else if (existingByName != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A room named \"{normalizedRoomName}\" already exists. Enable replaceExisting to overwrite it.");
|
||||
}
|
||||
|
||||
long generatedSubRoomId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var importedSubRooms = new List<SubRooms>();
|
||||
|
||||
foreach ((ExportSubRoom source, int index) in sourceSubRooms.Select((value, index) => (value, index)))
|
||||
{
|
||||
long subRoomId = source.SubRoomId > 0 ? source.SubRoomId : generatedSubRoomId + index;
|
||||
int accessibilityValue = Enum.IsDefined(typeof(RoomAccessibility), source.Accessibility)
|
||||
? source.Accessibility
|
||||
: (int)RoomAccessibility.Public;
|
||||
|
||||
importedSubRooms.Add(new SubRooms
|
||||
{
|
||||
SubRoomId = subRoomId,
|
||||
RoomId = assignedRoomId,
|
||||
Name = string.IsNullOrWhiteSpace(source.Name) ? $"Scene {index + 1}" : source.Name,
|
||||
DataBlob = null,
|
||||
SubRoomDataSaveId = 0,
|
||||
SubRoomDataSave = null,
|
||||
IsSandbox = source.IsSandbox,
|
||||
MaxPlayers = source.MaxPlayers > 0
|
||||
? Math.Clamp(source.MaxPlayers, 1, 100)
|
||||
: Math.Clamp(exportRoom.MaxPlayers > 0 ? exportRoom.MaxPlayers : 6, 1, 100),
|
||||
Accessibility = (RoomAccessibility)accessibilityValue,
|
||||
UnitySceneId = source.UnitySceneId!.Trim(),
|
||||
SavedByAccountId = creatorAccountId,
|
||||
Permissions = new List<SubRoomPermission>()
|
||||
});
|
||||
}
|
||||
|
||||
SubRooms playableSubRoom = importedSubRooms.FirstOrDefault(subRoom =>
|
||||
string.Equals(subRoom.Name, "Home", StringComparison.OrdinalIgnoreCase)) ?? importedSubRooms[0];
|
||||
playableSubRoom.Name = "Home";
|
||||
importedSubRooms.Remove(playableSubRoom);
|
||||
importedSubRooms.Insert(0, playableSubRoom);
|
||||
|
||||
int roomAccessibility = Enum.IsDefined(typeof(RoomAccessibility), exportRoom.Accessibility)
|
||||
? exportRoom.Accessibility
|
||||
: (int)RoomAccessibility.Public;
|
||||
|
||||
var room = new Room
|
||||
{
|
||||
RoomId = assignedRoomId,
|
||||
IsDorm = false,
|
||||
MaxPlayerCalculationMode = 0,
|
||||
MaxPlayers = Math.Clamp(
|
||||
exportRoom.MaxPlayers > 0 ? exportRoom.MaxPlayers : playableSubRoom.MaxPlayers,
|
||||
1,
|
||||
100),
|
||||
CloningAllowed = false,
|
||||
DisableMicAutoMute = false,
|
||||
DisableRoomComments = false,
|
||||
EncryptVoiceChat = false,
|
||||
ToxmodEnabled = true,
|
||||
LoadScreenLocked = false,
|
||||
PersistenceVersion = NormalizePersistenceVersion(
|
||||
exportRoom.PersistenceVersion > 0 ? exportRoom.PersistenceVersion : 27),
|
||||
AutoLocalizeRoom = false,
|
||||
IsDeveloperOwned = true,
|
||||
Name = exportRoom.Name.Trim().TrimStart('^'),
|
||||
Description = string.IsNullOrWhiteSpace(exportRoom.Description)
|
||||
? "Imported built-in Unity scene room."
|
||||
: exportRoom.Description,
|
||||
ImageName = exportRoom.ImageName ?? string.Empty,
|
||||
WarningMask = 0,
|
||||
CustomWarning = null,
|
||||
CreatorAccountId = creatorAccountId,
|
||||
State = RoomState.Active,
|
||||
Accessibility = (RoomAccessibility)roomAccessibility,
|
||||
SupportsLevelVoting = false,
|
||||
IsRRO = true,
|
||||
IsBaseRoom = true,
|
||||
SupportsScreens = true,
|
||||
SupportsWalkVR = true,
|
||||
SupportsTeleportVR = true,
|
||||
SupportsVRLow = true,
|
||||
SupportsQuest2 = true,
|
||||
SupportsMobile = true,
|
||||
SupportsJuniors = true,
|
||||
MinLevel = 0,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Stats = new Stats(),
|
||||
RankedEntityId = assignedRoomId.ToString(),
|
||||
RankingContext = null,
|
||||
SubRooms = importedSubRooms,
|
||||
Roles = new List<Roles>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AccountId = creatorAccountId,
|
||||
Role = Role.Creator,
|
||||
InvitedRole = Role.None
|
||||
}
|
||||
},
|
||||
DataBlob = null,
|
||||
UgcVersion = 1,
|
||||
Tags = new List<Tags>
|
||||
{
|
||||
new() { Tag = "base", Type = TagType.Auto },
|
||||
new() { Tag = "rro", Type = TagType.AGOnly }
|
||||
},
|
||||
PromoImages = new List<string>(),
|
||||
PromoExternalContent = new List<PromoExternalContent>(),
|
||||
LoadScreens = new List<LoadScreens>()
|
||||
};
|
||||
|
||||
if (!RoomDB.Rooms.Upsert(room))
|
||||
throw new InvalidOperationException("LiteDB failed to save the imported Unity-scene room.");
|
||||
|
||||
return new ImportResult(
|
||||
room.RoomId,
|
||||
importedSubRooms.Count,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
playableSubRoom.Name,
|
||||
playableSubRoom.SubRoomId);
|
||||
}
|
||||
|
||||
public sealed record RoomExportResult(
|
||||
long RoomId,
|
||||
string ExportRoot,
|
||||
int SubRoomsExported,
|
||||
int SavesExported,
|
||||
bool ImageExported,
|
||||
int BakedAssetsExported,
|
||||
int AssetBundlesExported);
|
||||
|
||||
public static RoomExportResult ExportRoomToDirectory(
|
||||
long roomId,
|
||||
string outputRoot)
|
||||
{
|
||||
RoomDBClasses.Room room = RoomDB.GetRoom(roomId);
|
||||
if (room == null || room.RoomId <= 0)
|
||||
throw new FileNotFoundException($"Room {roomId} was not found.");
|
||||
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
|
||||
string roomBlobSource = Path.Combine(Program.dataDir, "CDN", "room");
|
||||
string assetBundleSourceRoot = Path.Combine(Program.dataDir, "CDN", "assetbundles");
|
||||
string imageSourceRoot = Path.Combine(Program.dataDir, "Images");
|
||||
string assetBundleDestination = Path.Combine(outputRoot, "AssetBundles");
|
||||
|
||||
var exportSubRooms = new List<ExportSubRoom>();
|
||||
int savesExported = 0;
|
||||
int bakedAssetsExported = 0;
|
||||
var exportedBundleNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (SubRooms subRoom in room.SubRooms ?? new List<SubRooms>())
|
||||
{
|
||||
string subRoomDirectory = Path.Combine(outputRoot, subRoom.SubRoomId.ToString());
|
||||
string savesDirectory = Path.Combine(subRoomDirectory, "Saves");
|
||||
Directory.CreateDirectory(savesDirectory);
|
||||
|
||||
List<SubRoomDataSave> saves = RoomDB.SubRoomDataSaves
|
||||
.Find(save => save.RoomId == roomId && save.SubRoomId == subRoom.SubRoomId)
|
||||
.OrderBy(save => save.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
foreach (SubRoomDataSave save in saves)
|
||||
{
|
||||
string safeBlobName = Path.GetFileName(save.DataBlob ?? string.Empty);
|
||||
string sourceBlobPath = Path.Combine(roomBlobSource, safeBlobName);
|
||||
if (string.IsNullOrWhiteSpace(safeBlobName) || !File.Exists(sourceBlobPath))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM EXPORT] Missing blob for save {save.SubRoomDataSaveId}; skipped.");
|
||||
continue;
|
||||
}
|
||||
|
||||
string saveDirectoryName = save.SubRoomDataSaveId.ToString();
|
||||
string saveDirectory = Path.Combine(savesDirectory, saveDirectoryName);
|
||||
Directory.CreateDirectory(saveDirectory);
|
||||
|
||||
File.Copy(
|
||||
sourceBlobPath,
|
||||
Path.Combine(saveDirectory, safeBlobName),
|
||||
overwrite: true);
|
||||
|
||||
var exportSave = new ExportSave
|
||||
{
|
||||
SubRoomDataSaveId = save.SubRoomDataSaveId,
|
||||
SubRoomId = save.SubRoomId,
|
||||
UnityAssetId = save.UnityAssetId,
|
||||
DataBlob = safeBlobName,
|
||||
DataBlobHash = save.DataBlobHash,
|
||||
ReferencedUnityAssetIds = save.ReferencedUnityAssetIds,
|
||||
PersistenceVersion = save.PersistenceVersion.GetValueOrDefault(),
|
||||
OMVersion = save.OMVersion,
|
||||
UgcSubVersion = save.UgcSubVersion,
|
||||
SavedOnPlatform = save.SavedOnPlatform,
|
||||
SavedOnDeviceClass = save.SavedOnDeviceClass,
|
||||
Description = save.Description,
|
||||
Tags = save.Tags,
|
||||
ModerationState = save.ModerationState,
|
||||
CreatedAt = save.CreatedAt
|
||||
};
|
||||
WriteJson(Path.Combine(saveDirectory, saveDirectoryName + ".json"), exportSave);
|
||||
|
||||
int bakedIndex = 0;
|
||||
foreach (BakedUnityAsset bakedAsset in save.BakedUnityAssets ?? new List<BakedUnityAsset>())
|
||||
{
|
||||
string versionSuffix = string.IsNullOrWhiteSpace(bakedAsset.UnityVersion)
|
||||
? string.Empty
|
||||
: $"-Unity_{bakedAsset.UnityVersion.Replace('.', '_')}";
|
||||
string bakedJsonName = $"bakedUnityAsset-{bakedIndex++}{versionSuffix}.json";
|
||||
WriteJson(Path.Combine(saveDirectory, bakedJsonName), bakedAsset);
|
||||
bakedAssetsExported++;
|
||||
|
||||
string bundleFileName = Path.GetFileName(bakedAsset.Filename ?? string.Empty);
|
||||
if (string.IsNullOrWhiteSpace(bundleFileName))
|
||||
continue;
|
||||
|
||||
string bundleSourcePath = Path.Combine(assetBundleSourceRoot, bundleFileName);
|
||||
if (!File.Exists(bundleSourcePath))
|
||||
continue;
|
||||
|
||||
if (exportedBundleNames.Add(bundleFileName))
|
||||
{
|
||||
Directory.CreateDirectory(assetBundleDestination);
|
||||
File.Copy(
|
||||
bundleSourcePath,
|
||||
Path.Combine(assetBundleDestination, bundleFileName),
|
||||
overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
savesExported++;
|
||||
}
|
||||
|
||||
var exportSubRoom = new ExportSubRoom
|
||||
{
|
||||
RoomId = roomId,
|
||||
SubRoomId = subRoom.SubRoomId,
|
||||
Name = subRoom.Name,
|
||||
UnitySceneId = subRoom.UnitySceneId,
|
||||
IsSandbox = subRoom.IsSandbox,
|
||||
MaxPlayers = subRoom.MaxPlayers,
|
||||
Accessibility = (int)subRoom.Accessibility,
|
||||
CurrentSave = new ExportSave { SubRoomDataSaveId = subRoom.SubRoomDataSaveId }
|
||||
};
|
||||
WriteJson(Path.Combine(subRoomDirectory, "subRoom.json"), exportSubRoom);
|
||||
exportSubRooms.Add(exportSubRoom);
|
||||
}
|
||||
|
||||
if (savesExported == 0)
|
||||
throw new InvalidDataException($"Room {roomId} has no save blobs on disk to export.");
|
||||
|
||||
bool imageExported = false;
|
||||
if (!string.IsNullOrWhiteSpace(room.ImageName))
|
||||
{
|
||||
string imageSourcePath = Path.Combine(imageSourceRoot, room.ImageName);
|
||||
if (File.Exists(imageSourcePath))
|
||||
{
|
||||
File.Copy(
|
||||
imageSourcePath,
|
||||
Path.Combine(outputRoot, Path.GetFileName(room.ImageName)),
|
||||
overwrite: true);
|
||||
imageExported = true;
|
||||
}
|
||||
}
|
||||
|
||||
var exportRoom = new ExportRoom
|
||||
{
|
||||
RoomId = room.RoomId,
|
||||
Name = room.Name,
|
||||
Description = room.Description,
|
||||
ImageName = room.ImageName,
|
||||
MaxPlayers = room.MaxPlayers,
|
||||
Accessibility = (int)room.Accessibility,
|
||||
PersistenceVersion = room.PersistenceVersion,
|
||||
SubRoomId = room.SubRooms?.FirstOrDefault()?.SubRoomId ?? 0,
|
||||
UnitySceneId = room.SubRooms?.FirstOrDefault()?.UnitySceneId,
|
||||
SubRooms = exportSubRooms
|
||||
};
|
||||
WriteJson(Path.Combine(outputRoot, "room.json"), exportRoom);
|
||||
|
||||
return new RoomExportResult(
|
||||
room.RoomId,
|
||||
outputRoot,
|
||||
exportSubRooms.Count,
|
||||
savesExported,
|
||||
imageExported,
|
||||
bakedAssetsExported,
|
||||
exportedBundleNames.Count);
|
||||
}
|
||||
|
||||
private static void WriteJson<T>(string path, T value)
|
||||
{
|
||||
File.WriteAllText(
|
||||
path,
|
||||
JsonSerializer.Serialize(value, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}));
|
||||
}
|
||||
|
||||
private static void MergeSubRoomMetadata(ExportSubRoom listed, ExportSubRoom source)
|
||||
{
|
||||
listed.Name = string.IsNullOrWhiteSpace(source.Name) ? listed.Name : source.Name;
|
||||
listed.UnitySceneId = string.IsNullOrWhiteSpace(source.UnitySceneId)
|
||||
? listed.UnitySceneId
|
||||
: source.UnitySceneId;
|
||||
listed.MaxPlayers = source.MaxPlayers > 0 ? source.MaxPlayers : listed.MaxPlayers;
|
||||
listed.Accessibility = source.Accessibility;
|
||||
listed.IsSandbox = source.IsSandbox;
|
||||
listed.CurrentSave = source.CurrentSave ?? listed.CurrentSave;
|
||||
}
|
||||
|
||||
private static bool CopyRoomImage(
|
||||
string exportRoot,
|
||||
string? requestedImageName,
|
||||
string destinationRoot,
|
||||
out string imageName)
|
||||
{
|
||||
string requestedFileName = Path.GetFileName(requestedImageName ?? string.Empty);
|
||||
imageName = requestedFileName;
|
||||
string? sourcePath = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requestedFileName))
|
||||
{
|
||||
sourcePath = Directory.EnumerateFiles(exportRoot, "*", SearchOption.TopDirectoryOnly)
|
||||
.FirstOrDefault(path => string.Equals(
|
||||
Path.GetFileName(path),
|
||||
requestedFileName,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
sourcePath ??= Directory.EnumerateFiles(exportRoot, "*", SearchOption.TopDirectoryOnly)
|
||||
.FirstOrDefault(path => IsImageExtension(Path.GetExtension(path)));
|
||||
|
||||
if (sourcePath == null)
|
||||
{
|
||||
imageName = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
imageName = Path.GetFileName(sourcePath);
|
||||
string destinationPath = Path.Combine(destinationRoot, imageName);
|
||||
return CopyIfDifferent(sourcePath, destinationPath) || File.Exists(destinationPath);
|
||||
}
|
||||
|
||||
private static bool IsImageExtension(string extension) =>
|
||||
extension.Equals(".png", StringComparison.OrdinalIgnoreCase) ||
|
||||
extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) ||
|
||||
extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) ||
|
||||
extension.Equals(".webp", StringComparison.OrdinalIgnoreCase) ||
|
||||
extension.Equals(".gif", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string? FindDirectory(string root, string name)
|
||||
{
|
||||
string direct = Path.Combine(root, name);
|
||||
if (Directory.Exists(direct))
|
||||
return direct;
|
||||
|
||||
return Directory.EnumerateDirectories(root, "*", SearchOption.TopDirectoryOnly)
|
||||
.FirstOrDefault(path => string.Equals(
|
||||
Path.GetFileName(path),
|
||||
name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool CopyIfDifferent(string sourcePath, string destinationPath)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
if (File.Exists(destinationPath))
|
||||
{
|
||||
var sourceInfo = new FileInfo(sourcePath);
|
||||
var destinationInfo = new FileInfo(destinationPath);
|
||||
if (sourceInfo.Length == destinationInfo.Length &&
|
||||
string.Equals(
|
||||
ComputeSha256Base64(sourcePath),
|
||||
ComputeSha256Base64(destinationPath),
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
File.Copy(sourcePath, destinationPath, overwrite: true);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? ParseUnityVersionFromMetadataName(string fileName)
|
||||
{
|
||||
|
||||
const string marker = "Unity_";
|
||||
int markerIndex = fileName.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (markerIndex < 0)
|
||||
return null;
|
||||
|
||||
int start = markerIndex + marker.Length;
|
||||
int end = fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
|
||||
? fileName.Length - 5
|
||||
: fileName.Length;
|
||||
if (start >= end)
|
||||
return null;
|
||||
|
||||
return fileName[start..end].Replace('_', '.');
|
||||
}
|
||||
|
||||
private static int NormalizePersistenceVersion(int sourceVersion)
|
||||
{
|
||||
if (sourceVersion <= 0)
|
||||
return CompatiblePersistenceVersion;
|
||||
|
||||
return Math.Min(sourceVersion, CompatiblePersistenceVersion);
|
||||
}
|
||||
|
||||
private static int NormalizeUgcSubVersion(int sourceVersion)
|
||||
{
|
||||
return CompatibleUgcSubVersion;
|
||||
}
|
||||
|
||||
private static DateTime NormalizeCreatedAt(DateTime createdAt)
|
||||
{
|
||||
if (createdAt == default)
|
||||
return DateTime.UtcNow;
|
||||
return createdAt.Kind == DateTimeKind.Utc
|
||||
? createdAt
|
||||
: createdAt.ToUniversalTime();
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(
|
||||
JsonElement element,
|
||||
string propertyName,
|
||||
out JsonElement value)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static T ReadJson<T>(string path)
|
||||
{
|
||||
string json = File.ReadAllText(path, Encoding.UTF8);
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOptions)
|
||||
?? throw new InvalidDataException($"Could not deserialize {path}.");
|
||||
}
|
||||
|
||||
private static string ComputeSha256Base64(string path)
|
||||
{
|
||||
using FileStream stream = File.OpenRead(path);
|
||||
return Convert.ToBase64String(SHA256.HashData(stream));
|
||||
}
|
||||
|
||||
private sealed class ExportRoom
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public int MaxPlayers { get; set; }
|
||||
public int Accessibility { get; set; }
|
||||
public int PersistenceVersion { get; set; }
|
||||
public long SubRoomId { get; set; }
|
||||
public string? UnitySceneId { get; set; }
|
||||
public List<ExportSubRoom>? SubRooms { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ExportSubRoom
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public long SubRoomId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? UnitySceneId { get; set; }
|
||||
public ExportSave? CurrentSave { get; set; }
|
||||
public bool IsSandbox { get; set; }
|
||||
public int MaxPlayers { get; set; }
|
||||
public int Accessibility { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ExportSave
|
||||
{
|
||||
public long SubRoomDataSaveId { get; set; }
|
||||
public long SubRoomId { get; set; }
|
||||
public string? UnityAssetId { get; set; }
|
||||
public string DataBlob { get; set; } = string.Empty;
|
||||
public string? DataBlobHash { get; set; }
|
||||
public List<string>? ReferencedUnityAssetIds { get; set; }
|
||||
public int PersistenceVersion { get; set; }
|
||||
public int OMVersion { get; set; }
|
||||
public int UgcSubVersion { get; set; }
|
||||
public int? SavedOnPlatform { get; set; }
|
||||
public int? SavedOnDeviceClass { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
public int ModerationState { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class SteamAccessDB
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static Dictionary<ulong, SteamBlacklistEntry>? Entries;
|
||||
|
||||
private static string BlacklistPath =>
|
||||
Path.Combine(Program.dataDir, "SteamBlacklist.json");
|
||||
|
||||
public static bool IsBlacklisted(ulong steamId)
|
||||
{
|
||||
if (steamId == 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
EnsureLoadedLocked();
|
||||
return Entries!.ContainsKey(steamId);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetBlockedSteamId(
|
||||
PlayerDBClasses.FullPlayer? player,
|
||||
out ulong steamId)
|
||||
{
|
||||
steamId = 0;
|
||||
|
||||
if (player?.PlatformIds == null)
|
||||
return false;
|
||||
|
||||
ulong[] linkedSteamIds = player.PlatformIds
|
||||
.Where(identity =>
|
||||
identity.Platform == PlayerDBClasses.Platforms.Steam &&
|
||||
identity.PlatformId > 0)
|
||||
.Select(identity => identity.PlatformId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
EnsureLoadedLocked();
|
||||
|
||||
foreach (ulong linkedSteamId in linkedSteamIds)
|
||||
{
|
||||
if (Entries!.ContainsKey(linkedSteamId))
|
||||
{
|
||||
steamId = linkedSteamId;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<SteamBlacklistEntry> GetAll()
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
EnsureLoadedLocked();
|
||||
|
||||
return Entries!.Values
|
||||
.OrderByDescending(entry => entry.AddedAt)
|
||||
.ThenBy(entry => entry.SteamId)
|
||||
.Select(Clone)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static SteamBlacklistEntry AddOrUpdate(
|
||||
ulong steamId,
|
||||
string? reason,
|
||||
long addedByAccountId)
|
||||
{
|
||||
if (steamId == 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(steamId),
|
||||
"Steam ID must be greater than zero.");
|
||||
|
||||
string cleanReason = (reason ?? string.Empty).Trim();
|
||||
if (cleanReason.Length == 0)
|
||||
cleanReason = "Blacklisted by an administrator.";
|
||||
if (cleanReason.Length > 500)
|
||||
cleanReason = cleanReason[..500];
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
EnsureLoadedLocked();
|
||||
|
||||
var entry = new SteamBlacklistEntry
|
||||
{
|
||||
SteamId = steamId,
|
||||
Reason = cleanReason,
|
||||
AddedByAccountId = addedByAccountId,
|
||||
AddedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Entries![steamId] = entry;
|
||||
SaveLocked();
|
||||
return Clone(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Remove(ulong steamId)
|
||||
{
|
||||
if (steamId == 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
EnsureLoadedLocked();
|
||||
|
||||
if (!Entries!.Remove(steamId))
|
||||
return false;
|
||||
|
||||
SaveLocked();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureLoadedLocked()
|
||||
{
|
||||
if (Entries != null)
|
||||
return;
|
||||
|
||||
Entries = new Dictionary<ulong, SteamBlacklistEntry>();
|
||||
string path = BlacklistPath;
|
||||
|
||||
if (!File.Exists(path))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(path);
|
||||
var loaded = JsonSerializer.Deserialize<
|
||||
List<SteamBlacklistEntry>>(json, JsonOptions)
|
||||
?? new List<SteamBlacklistEntry>();
|
||||
|
||||
Entries = loaded
|
||||
.Where(entry => entry.SteamId > 0)
|
||||
.GroupBy(entry => entry.SteamId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => Clone(group
|
||||
.OrderByDescending(entry => entry.AddedAt)
|
||||
.First()));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[STEAM BLACKLIST LOAD FAILED] " +
|
||||
$"{exception.GetType().Name}: {exception.Message}");
|
||||
|
||||
Entries = new Dictionary<ulong, SteamBlacklistEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveLocked()
|
||||
{
|
||||
string path = BlacklistPath;
|
||||
string? directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
string temporaryPath = path + ".tmp";
|
||||
string json = JsonSerializer.Serialize(
|
||||
Entries!.Values
|
||||
.OrderBy(entry => entry.SteamId)
|
||||
.ToList(),
|
||||
JsonOptions);
|
||||
|
||||
File.WriteAllText(temporaryPath, json);
|
||||
File.Move(temporaryPath, path, overwrite: true);
|
||||
}
|
||||
|
||||
private static SteamBlacklistEntry Clone(
|
||||
SteamBlacklistEntry entry) =>
|
||||
new()
|
||||
{
|
||||
SteamId = entry.SteamId,
|
||||
Reason = entry.Reason,
|
||||
AddedByAccountId = entry.AddedByAccountId,
|
||||
AddedAt = entry.AddedAt
|
||||
};
|
||||
|
||||
public sealed class SteamBlacklistEntry
|
||||
{
|
||||
public ulong SteamId { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public long AddedByAccountId { get; set; }
|
||||
public DateTime AddedAt { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
|
||||
internal static class DeveloperTelemetry
|
||||
{
|
||||
private const int MaximumRequests = 500;
|
||||
private const int MaximumPoints = 300;
|
||||
private static readonly object Sync = new();
|
||||
private static readonly Queue<RequestEntry> Requests = new();
|
||||
private static readonly Queue<TelemetryPoint> Points = new();
|
||||
|
||||
private static long _currentSecond;
|
||||
private static long _currentRequests;
|
||||
private static long _currentInboundBytes;
|
||||
private static long _currentOutboundBytes;
|
||||
private static long _currentErrors;
|
||||
private static long _currentLatencyMilliseconds;
|
||||
private static long _totalRequests;
|
||||
private static long _totalInboundBytes;
|
||||
private static long _totalOutboundBytes;
|
||||
private static long _totalErrors;
|
||||
|
||||
public static long EstimateInboundBytes(HttpRequest request)
|
||||
{
|
||||
long bytes = Math.Max(0, request.ContentLength ?? 0);
|
||||
bytes += request.Method.Length +
|
||||
(request.Path.Value?.Length ?? 0);
|
||||
|
||||
foreach (var header in request.Headers)
|
||||
{
|
||||
bytes += header.Key.Length + 4;
|
||||
foreach (string? value in header.Value)
|
||||
bytes += value?.Length ?? 0;
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static void RecordInboundBytes(long bytes)
|
||||
{
|
||||
if (bytes <= 0)
|
||||
return;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
RotateNoLock(DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
_currentInboundBytes += bytes;
|
||||
_totalInboundBytes += bytes;
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecordOutboundBytes(long bytes)
|
||||
{
|
||||
if (bytes <= 0)
|
||||
return;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
RotateNoLock(DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
_currentOutboundBytes += bytes;
|
||||
_totalOutboundBytes += bytes;
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecordRequest(
|
||||
int statusCode,
|
||||
string method,
|
||||
string path,
|
||||
long elapsedMilliseconds,
|
||||
long inboundBytes,
|
||||
long outboundBytes)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
string safePath = string.IsNullOrWhiteSpace(path)
|
||||
? "/"
|
||||
: path.Length > 300
|
||||
? path[..300]
|
||||
: path;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
RotateNoLock(now.ToUnixTimeSeconds());
|
||||
|
||||
_currentRequests++;
|
||||
_currentLatencyMilliseconds += Math.Max(0, elapsedMilliseconds);
|
||||
_totalRequests++;
|
||||
|
||||
if (statusCode >= StatusCodes.Status400BadRequest)
|
||||
{
|
||||
_currentErrors++;
|
||||
_totalErrors++;
|
||||
}
|
||||
|
||||
Requests.Enqueue(new RequestEntry(
|
||||
now,
|
||||
method,
|
||||
safePath,
|
||||
statusCode,
|
||||
elapsedMilliseconds,
|
||||
inboundBytes,
|
||||
outboundBytes));
|
||||
|
||||
while (Requests.Count > MaximumRequests)
|
||||
Requests.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
public static TelemetrySnapshot Snapshot(int requestCount = 100)
|
||||
{
|
||||
requestCount = Math.Clamp(requestCount, 1, 250);
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
RotateNoLock(now);
|
||||
|
||||
var points = Points.ToList();
|
||||
points.Add(CurrentPointNoLock());
|
||||
|
||||
return new TelemetrySnapshot(
|
||||
_totalRequests,
|
||||
_totalInboundBytes,
|
||||
_totalOutboundBytes,
|
||||
_totalErrors,
|
||||
Requests.Reverse().Take(requestCount).ToList(),
|
||||
points.TakeLast(120).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
private static void RotateNoLock(long second)
|
||||
{
|
||||
if (_currentSecond == 0)
|
||||
{
|
||||
_currentSecond = second;
|
||||
return;
|
||||
}
|
||||
|
||||
if (second <= _currentSecond)
|
||||
return;
|
||||
|
||||
Points.Enqueue(CurrentPointNoLock());
|
||||
while (Points.Count > MaximumPoints)
|
||||
Points.Dequeue();
|
||||
|
||||
long gap = Math.Min(second - _currentSecond - 1, MaximumPoints);
|
||||
for (long offset = gap; offset > 0; offset--)
|
||||
{
|
||||
Points.Enqueue(new TelemetryPoint(
|
||||
second - offset,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0));
|
||||
|
||||
while (Points.Count > MaximumPoints)
|
||||
Points.Dequeue();
|
||||
}
|
||||
|
||||
_currentSecond = second;
|
||||
_currentRequests = 0;
|
||||
_currentInboundBytes = 0;
|
||||
_currentOutboundBytes = 0;
|
||||
_currentErrors = 0;
|
||||
_currentLatencyMilliseconds = 0;
|
||||
}
|
||||
|
||||
private static TelemetryPoint CurrentPointNoLock() =>
|
||||
new(
|
||||
_currentSecond,
|
||||
_currentRequests,
|
||||
_currentInboundBytes,
|
||||
_currentOutboundBytes,
|
||||
_currentErrors,
|
||||
_currentRequests == 0
|
||||
? 0
|
||||
: (double)_currentLatencyMilliseconds / _currentRequests);
|
||||
|
||||
internal sealed record RequestEntry(
|
||||
DateTimeOffset At,
|
||||
string Method,
|
||||
string Path,
|
||||
int StatusCode,
|
||||
long ElapsedMilliseconds,
|
||||
long InboundBytes,
|
||||
long OutboundBytes);
|
||||
|
||||
internal sealed record TelemetryPoint(
|
||||
long UnixSecond,
|
||||
long Requests,
|
||||
long InboundBytes,
|
||||
long OutboundBytes,
|
||||
long Errors,
|
||||
double AverageLatencyMilliseconds);
|
||||
|
||||
internal sealed record TelemetrySnapshot(
|
||||
long TotalRequests,
|
||||
long TotalInboundBytes,
|
||||
long TotalOutboundBytes,
|
||||
long TotalErrors,
|
||||
IReadOnlyList<RequestEntry> Requests,
|
||||
IReadOnlyList<TelemetryPoint> Points);
|
||||
}
|
||||
|
||||
internal sealed class CountingResponseStream : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
private readonly Action<int> _onWrite;
|
||||
|
||||
public CountingResponseStream(Stream inner, Action<int> onWrite)
|
||||
{
|
||||
_inner = inner;
|
||||
_onWrite = onWrite;
|
||||
}
|
||||
|
||||
public long BytesWritten { get; private set; }
|
||||
public override bool CanRead => _inner.CanRead;
|
||||
public override bool CanSeek => _inner.CanSeek;
|
||||
public override bool CanWrite => _inner.CanWrite;
|
||||
public override long Length => _inner.Length;
|
||||
public override long Position
|
||||
{
|
||||
get => _inner.Position;
|
||||
set => _inner.Position = value;
|
||||
}
|
||||
|
||||
public override void Flush() => _inner.Flush();
|
||||
public override Task FlushAsync(CancellationToken cancellationToken) =>
|
||||
_inner.FlushAsync(cancellationToken);
|
||||
public override int Read(byte[] buffer, int offset, int count) =>
|
||||
_inner.Read(buffer, offset, count);
|
||||
public override long Seek(long offset, SeekOrigin origin) =>
|
||||
_inner.Seek(offset, origin);
|
||||
public override void SetLength(long value) => _inner.SetLength(value);
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
_inner.Write(buffer, offset, count);
|
||||
Count(count);
|
||||
}
|
||||
|
||||
public override async Task WriteAsync(
|
||||
byte[] buffer,
|
||||
int offset,
|
||||
int count,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _inner.WriteAsync(
|
||||
buffer.AsMemory(offset, count),
|
||||
cancellationToken);
|
||||
Count(count);
|
||||
}
|
||||
|
||||
public override void Write(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
_inner.Write(buffer);
|
||||
Count(buffer.Length);
|
||||
}
|
||||
|
||||
public override async ValueTask WriteAsync(
|
||||
ReadOnlyMemory<byte> buffer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _inner.WriteAsync(buffer, cancellationToken);
|
||||
Count(buffer.Length);
|
||||
}
|
||||
|
||||
private void Count(int bytes)
|
||||
{
|
||||
if (bytes <= 0)
|
||||
return;
|
||||
|
||||
BytesWritten += bytes;
|
||||
_onWrite(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
public static class DiscordLogger
|
||||
{
|
||||
private const string DISCORD_WEBHOOK_URL = "";
|
||||
private const string SHARECAM_DISCORD_WEBHOOK_URL = "";
|
||||
private const string SANITIZE_DISCORD_WEBHOOK_URL = "";
|
||||
private const string REPORT_DISCORD_WEBHOOK_URL = "";
|
||||
|
||||
private static readonly Uri? WebhookUrl = LoadWebhookUrl(
|
||||
"DISCORD_WEBHOOK_URL",
|
||||
DISCORD_WEBHOOK_URL);
|
||||
private static readonly Uri? ShareCameraWebhookUrl = LoadWebhookUrl(
|
||||
"SHARECAM_WEBHOOK_URL",
|
||||
SHARECAM_DISCORD_WEBHOOK_URL);
|
||||
private static readonly Uri? SanitizeWebhookUrl = LoadWebhookUrl(
|
||||
"SANITIZE_DISCORD_WEBHOOK_URL",
|
||||
SANITIZE_DISCORD_WEBHOOK_URL);
|
||||
private static readonly Uri? ReportWebhookUrl = LoadWebhookUrl(
|
||||
"REPORT_DISCORD_WEBHOOK_URL",
|
||||
REPORT_DISCORD_WEBHOOK_URL);
|
||||
private static readonly HttpClient _client = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
private static Uri? LoadWebhookUrl(
|
||||
string environmentVariable,
|
||||
string fallbackUrl)
|
||||
{
|
||||
string? configured =
|
||||
Mocha2023.Program.LoadLocalSetting(environmentVariable);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
configured = fallbackUrl;
|
||||
|
||||
if (!Uri.TryCreate(configured, UriKind.Absolute, out Uri? uri) ||
|
||||
uri.Scheme != Uri.UriSchemeHttps ||
|
||||
!(uri.Host.Equals("discord.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Host.EndsWith(".discord.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Host.Equals("discordapp.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Host.EndsWith(".discordapp.com", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
public static void Log(string content)
|
||||
{
|
||||
_ = SendEmbedAsync(WebhookUrl, "Mocha Server Logs", content, 0x6F4E37);
|
||||
}
|
||||
|
||||
public static void LogSanitize(string content)
|
||||
{
|
||||
_ = SendEmbedAsync(SanitizeWebhookUrl, "Mocha Sanitize", content, 0xE67E22);
|
||||
}
|
||||
public static void LogReport(string content)
|
||||
{
|
||||
_ = SendEmbedAsync(ReportWebhookUrl, "Mocha Reports", content, 0x6F4E37);
|
||||
}
|
||||
|
||||
public static void LogBan(
|
||||
long targetPlayerId,
|
||||
string? targetUsername,
|
||||
long bannedByPlayerId,
|
||||
string? bannedByUsername,
|
||||
string reason,
|
||||
int durationSeconds,
|
||||
IEnumerable<(long PlayerId, string? Username)>? linkedAccounts = null,
|
||||
IEnumerable<(string Platform, string PlatformId)>? platformIdentities = null)
|
||||
{
|
||||
_ = SendBanEmbedAsync(
|
||||
targetPlayerId,
|
||||
targetUsername,
|
||||
bannedByPlayerId,
|
||||
bannedByUsername,
|
||||
reason,
|
||||
durationSeconds,
|
||||
linkedAccounts,
|
||||
platformIdentities);
|
||||
}
|
||||
|
||||
private static async Task SendBanEmbedAsync(
|
||||
long targetPlayerId,
|
||||
string? targetUsername,
|
||||
long bannedByPlayerId,
|
||||
string? bannedByUsername,
|
||||
string reason,
|
||||
int durationSeconds,
|
||||
IEnumerable<(long PlayerId, string? Username)>? linkedAccounts,
|
||||
IEnumerable<(string Platform, string PlatformId)>? platformIdentities)
|
||||
{
|
||||
if (WebhookUrl == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
string targetName = CleanDiscordValue(targetUsername, "Unknown", 100);
|
||||
string bannedByName = CleanDiscordValue(bannedByUsername, "Unknown", 100);
|
||||
string reasonValue = CleanDiscordValue(reason, "No reason provided.", 1_000);
|
||||
|
||||
string durationValue = durationSeconds <= 0 || durationSeconds == int.MaxValue
|
||||
? "Permanent"
|
||||
: FormatDuration(durationSeconds);
|
||||
|
||||
var linked = (linkedAccounts ?? Enumerable.Empty<(long, string?)>())
|
||||
.Where(account => account.Item1 != targetPlayerId)
|
||||
.ToList();
|
||||
|
||||
var fields = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "🎯 Banned Player",
|
||||
value = $"**{targetName}**\n`Player ID: {targetPlayerId}`",
|
||||
inline = true
|
||||
},
|
||||
new
|
||||
{
|
||||
name = "🛡️ Banned By",
|
||||
value = $"**{bannedByName}**\n`Player ID: {bannedByPlayerId}`",
|
||||
inline = true
|
||||
},
|
||||
new
|
||||
{
|
||||
name = "⏱️ Duration",
|
||||
value = $"**{durationValue}**",
|
||||
inline = true
|
||||
},
|
||||
new
|
||||
{
|
||||
name = "📋 Reason",
|
||||
value = reasonValue,
|
||||
inline = false
|
||||
}
|
||||
};
|
||||
|
||||
if (platformIdentities != null)
|
||||
{
|
||||
string identityList = string.Join(
|
||||
"\n",
|
||||
platformIdentities.Select(identity =>
|
||||
$"`{CleanDiscordValue(identity.Platform, "Unknown", 30)}: " +
|
||||
$"{CleanDiscordValue(identity.PlatformId, "unknown", 80)}`"));
|
||||
if (!string.IsNullOrWhiteSpace(identityList))
|
||||
{
|
||||
fields.Add(new
|
||||
{
|
||||
name = "🔗 Linked Platform IDs",
|
||||
value = identityList,
|
||||
inline = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (linked.Count > 0)
|
||||
{
|
||||
string linkedList = string.Join(
|
||||
"\n",
|
||||
linked.Select(account =>
|
||||
$"**{CleanDiscordValue(account.Item2, "Unknown", 60)}** " +
|
||||
$"`(ID: {account.Item1})`"));
|
||||
fields.Add(new
|
||||
{
|
||||
name = $"👥 Also Banned (linked accounts, {linked.Count})",
|
||||
value = linkedList.Length > 1000 ? linkedList[..1000] + "..." : linkedList,
|
||||
inline = false
|
||||
});
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
username = "Mocha Moderation",
|
||||
allowed_mentions = new { parse = Array.Empty<string>() },
|
||||
embeds = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
title = "🔨 Player Banned",
|
||||
description = $"### {targetName} was banned",
|
||||
color = 0xC0392B,
|
||||
fields = fields.ToArray(),
|
||||
footer = new { text = "Mocha Moderation" },
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(payload);
|
||||
using var body = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using HttpResponseMessage response = await _client.PostAsync(WebhookUrl, body);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[DISCORD BAN] Webhook returned {(int)response.StatusCode} {response.StatusCode}");
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine($"[DISCORD BAN] Failed sending ban log: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatDuration(int totalSeconds)
|
||||
{
|
||||
if (totalSeconds <= 0)
|
||||
return "Permanent";
|
||||
|
||||
TimeSpan span = TimeSpan.FromSeconds(totalSeconds);
|
||||
if (span.TotalDays >= 1)
|
||||
return $"{span.TotalDays:0.##} day(s) ({totalSeconds}s)";
|
||||
if (span.TotalHours >= 1)
|
||||
return $"{span.TotalHours:0.##} hour(s) ({totalSeconds}s)";
|
||||
if (span.TotalMinutes >= 1)
|
||||
return $"{span.TotalMinutes:0.##} minute(s) ({totalSeconds}s)";
|
||||
return $"{totalSeconds} second(s)";
|
||||
}
|
||||
|
||||
public static void LogPlayerReport(
|
||||
Guid reportId,
|
||||
long reporterId,
|
||||
string? reporterUsername,
|
||||
long? reportedPlayerId,
|
||||
string? reportedUsername,
|
||||
int? reportCategory,
|
||||
string? details,
|
||||
long? roomId,
|
||||
string? roomInstanceType)
|
||||
{
|
||||
_ = SendPlayerReportAsync(
|
||||
reportId,
|
||||
reporterId,
|
||||
reporterUsername,
|
||||
reportedPlayerId,
|
||||
reportedUsername,
|
||||
reportCategory,
|
||||
details,
|
||||
roomId,
|
||||
roomInstanceType);
|
||||
}
|
||||
|
||||
private static async Task SendPlayerReportAsync(
|
||||
Guid reportId,
|
||||
long reporterId,
|
||||
string? reporterUsername,
|
||||
long? reportedPlayerId,
|
||||
string? reportedUsername,
|
||||
int? reportCategory,
|
||||
string? details,
|
||||
long? roomId,
|
||||
string? roomInstanceType)
|
||||
{
|
||||
if (ReportWebhookUrl == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
string reporterName = CleanDiscordValue(
|
||||
reporterUsername,
|
||||
"Unknown reporter",
|
||||
100);
|
||||
|
||||
string reportedName = CleanDiscordValue(
|
||||
reportedUsername,
|
||||
"Unknown player",
|
||||
100);
|
||||
|
||||
string reportDetails = CleanDiscordValue(
|
||||
details,
|
||||
"No additional details were provided.",
|
||||
1_000);
|
||||
|
||||
string categoryName = GetReportCategoryName(reportCategory);
|
||||
|
||||
string reportedPlayerValue = reportedPlayerId.HasValue
|
||||
? $"**{reportedName}**\n`Player ID: {reportedPlayerId.Value}`"
|
||||
: $"**{reportedName}**\n`Player ID unavailable`";
|
||||
|
||||
string reporterValue =
|
||||
$"**{reporterName}**\n`Player ID: {reporterId}`";
|
||||
|
||||
string roomValue = roomId.HasValue
|
||||
? $"`Room ID: {roomId.Value}`\n" +
|
||||
$"Instance: **{CleanDiscordValue(roomInstanceType, "Unknown", 50)}**"
|
||||
: "**Unknown room**";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
username = "Mocha Reports",
|
||||
|
||||
allowed_mentions = new
|
||||
{
|
||||
parse = Array.Empty<string>()
|
||||
},
|
||||
|
||||
embeds = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
title = "🚨 New Player Report",
|
||||
|
||||
description =
|
||||
$"### {reportedName} was reported\n" +
|
||||
reportDetails,
|
||||
|
||||
color = 0x6F4E37,
|
||||
|
||||
fields = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "🎯 Reported Player",
|
||||
value = reportedPlayerValue,
|
||||
inline = true
|
||||
},
|
||||
new
|
||||
{
|
||||
name = "👤 Reported By",
|
||||
value = reporterValue,
|
||||
inline = true
|
||||
},
|
||||
new
|
||||
{
|
||||
name = "📋 Category",
|
||||
value = $"**{categoryName}**",
|
||||
inline = false
|
||||
},
|
||||
new
|
||||
{
|
||||
name = "🏠 Room",
|
||||
value = roomValue,
|
||||
inline = false
|
||||
}
|
||||
},
|
||||
|
||||
footer = new
|
||||
{
|
||||
text = $"Mocha Moderation • Report {reportId}"
|
||||
},
|
||||
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(payload);
|
||||
|
||||
using var body = new StringContent(
|
||||
json,
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
using HttpResponseMessage response =
|
||||
await _client.PostAsync(ReportWebhookUrl, body);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[DISCORD REPORT] Webhook returned " +
|
||||
$"{(int)response.StatusCode} {response.StatusCode}");
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[DISCORD REPORT] Failed sending report: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string CleanDiscordValue(
|
||||
string? value,
|
||||
string fallback,
|
||||
int maximumLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return fallback;
|
||||
|
||||
string cleaned = value
|
||||
.Trim()
|
||||
.Replace("\r", " ")
|
||||
.Replace("\n", " ")
|
||||
.Replace("`", "'");
|
||||
|
||||
if (cleaned.Length > maximumLength)
|
||||
cleaned = cleaned[..maximumLength] + "...";
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private static string GetReportCategoryName(int? category)
|
||||
{
|
||||
if (!category.HasValue)
|
||||
return "Unspecified";
|
||||
|
||||
return category.Value switch
|
||||
{
|
||||
104 => "Player Conduct",
|
||||
_ => $"Report Category {category.Value}"
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task SendEmbedAsync(
|
||||
Uri? webhookUrl,
|
||||
string senderName,
|
||||
string content,
|
||||
int color)
|
||||
{
|
||||
if (webhookUrl == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (content.Length > 4_000)
|
||||
content = content[..4_000] + "...";
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
username = senderName,
|
||||
allowed_mentions = new { parse = Array.Empty<string>() },
|
||||
embeds = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
description = content,
|
||||
color,
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O")
|
||||
}
|
||||
}
|
||||
});
|
||||
var body = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
await _client.PostAsync(webhookUrl, body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public static void LogLogin(long playerId, string? username = null)
|
||||
{
|
||||
Log($"🟢 **Login** — `{username ?? "unknown"}` (ID: `{playerId}`)");
|
||||
}
|
||||
|
||||
public static void LogChat(long playerId, string message, string? username = null)
|
||||
{
|
||||
|
||||
var safeMsg = message.Length > 1500 ? message[..1500] + "..." : message;
|
||||
Log($"💬 **Chat** — `{username ?? "unknown"}` (ID: `{playerId}`): {safeMsg}");
|
||||
}
|
||||
|
||||
public static void LogSanitizeChat(long playerId, string message, string? username = null)
|
||||
{
|
||||
var safeMsg = message.Length > 1500 ? message[..1500] + "..." : message;
|
||||
LogSanitize($"**Sanitize** - `{username ?? "unknown"}` (ID: `{playerId}`): {safeMsg}");
|
||||
}
|
||||
|
||||
public static void LogImage(string content, byte[] imageBytes, string fileName)
|
||||
{
|
||||
_ = SendImageAsync(
|
||||
WebhookUrl,
|
||||
content,
|
||||
imageBytes,
|
||||
fileName,
|
||||
"Mocha Server Logs");
|
||||
}
|
||||
|
||||
public static void LogPhotoUpload(long playerId, string? username, byte[] imageBytes, string fileName)
|
||||
{
|
||||
LogImage($"📸 **Photo taken** by `{username ?? "unknown"}` (ID: `{playerId}`)", imageBytes, fileName);
|
||||
}
|
||||
|
||||
public static void LogShareCameraPhoto(
|
||||
long playerId,
|
||||
string? username,
|
||||
byte[] imageBytes,
|
||||
string fileName,
|
||||
long? roomId,
|
||||
string? roomName)
|
||||
{
|
||||
string safeRoomName = string.IsNullOrWhiteSpace(roomName)
|
||||
? roomId.HasValue ? $"Room {roomId.Value}" : "Rooms"
|
||||
: roomName.Replace("[", "\\[").Replace("]", "\\]");
|
||||
string roomUrl = roomId.HasValue
|
||||
? $"{ServerConfig.BaseURL.TrimEnd('/')}/recnet/#room/{roomId.Value}"
|
||||
: $"{ServerConfig.BaseURL.TrimEnd('/')}/recnet/#rooms";
|
||||
string content =
|
||||
$"📸 **Share Camera photo** by `{username ?? "unknown"}` " +
|
||||
$"(ID: `{playerId}`)\n" +
|
||||
$"🏠 Room: [^{safeRoomName}]({roomUrl})";
|
||||
|
||||
_ = SendImageAsync(
|
||||
ShareCameraWebhookUrl,
|
||||
content,
|
||||
imageBytes,
|
||||
fileName,
|
||||
"Sharecam Social");
|
||||
}
|
||||
|
||||
private static async Task SendImageAsync(
|
||||
Uri? webhookUrl,
|
||||
string content,
|
||||
byte[] imageBytes,
|
||||
string fileName,
|
||||
string senderName)
|
||||
{
|
||||
if (webhookUrl == null || imageBytes.Length == 0 ||
|
||||
imageBytes.Length > 10 * 1024 * 1024)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var form = new MultipartFormDataContent();
|
||||
|
||||
fileName = Path.GetFileName(fileName);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
fileName = "upload.png";
|
||||
if (fileName.Length > 120)
|
||||
fileName = fileName[^120..];
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
username = senderName,
|
||||
content = content.Length > 1_500 ? content[..1_500] : content,
|
||||
allowed_mentions = new { parse = Array.Empty<string>() },
|
||||
attachments = new[] { new { id = 0, filename = fileName } }
|
||||
});
|
||||
form.Add(new StringContent(payload, Encoding.UTF8, "application/json"), "payload_json");
|
||||
|
||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
var mimeType = extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/png"
|
||||
};
|
||||
|
||||
var imageContent = new ByteArrayContent(imageBytes);
|
||||
imageContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(mimeType);
|
||||
|
||||
form.Add(imageContent, "files[0]", fileName);
|
||||
|
||||
using var response = await _client.PostAsync(webhookUrl, form);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
|
||||
public static class EmojiCatalog
|
||||
{
|
||||
private static readonly Lazy<string[]> EmojiValues =
|
||||
new(Load, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
private static readonly Lazy<HashSet<string>> EmojiSet =
|
||||
new(
|
||||
() => EmojiValues.Value.ToHashSet(StringComparer.Ordinal),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
public static IReadOnlyList<string> All => EmojiValues.Value;
|
||||
|
||||
public static bool Contains(string? value) =>
|
||||
!string.IsNullOrWhiteSpace(value) &&
|
||||
EmojiSet.Value.Contains(value);
|
||||
|
||||
private static string[] Load()
|
||||
{
|
||||
string path = Path.Combine(Program.dataDir, "emoji-test.txt");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
path = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Data",
|
||||
"emoji-test.txt");
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[EMOJI] Data/emoji-test.txt was not found; using fallback set.");
|
||||
return ["😀", "😂", "❤️", "👍", "🎉"];
|
||||
}
|
||||
|
||||
var emojis = new List<string>();
|
||||
foreach (string line in File.ReadLines(path))
|
||||
{
|
||||
if (!line.Contains("; fully-qualified", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
int semicolon = line.IndexOf(';');
|
||||
if (semicolon <= 0)
|
||||
continue;
|
||||
|
||||
string[] codePoints = line[..semicolon].Split(
|
||||
' ',
|
||||
StringSplitOptions.RemoveEmptyEntries |
|
||||
StringSplitOptions.TrimEntries);
|
||||
try
|
||||
{
|
||||
string emoji = string.Concat(codePoints.Select(value =>
|
||||
char.ConvertFromUtf32(int.Parse(
|
||||
value,
|
||||
NumberStyles.HexNumber,
|
||||
CultureInfo.InvariantCulture))));
|
||||
if (emoji.Length > 0)
|
||||
emojis.Add(emoji);
|
||||
}
|
||||
catch (Exception ex) when (
|
||||
ex is FormatException or
|
||||
OverflowException or
|
||||
ArgumentOutOfRangeException)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[EMOJI] Skipped invalid Unicode data row: {line}");
|
||||
}
|
||||
}
|
||||
|
||||
string[] result = emojis
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
Console.WriteLine(
|
||||
$"[EMOJI] Loaded {result.Length} Unicode RGI emoji sequences.");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Text.Json;
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
|
||||
public static class EquipmentInventoryStore
|
||||
{
|
||||
private static readonly object EquipmentLock = new();
|
||||
private static readonly JsonSerializerOptions ReadOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
private static readonly JsonSerializerOptions WriteOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public static string GetPlayerEquipmentPath(long accountId)
|
||||
{
|
||||
string directory = Path.Combine(
|
||||
Program.dataDir,
|
||||
"SkinUnlocked",
|
||||
accountId.ToString());
|
||||
Directory.CreateDirectory(directory);
|
||||
return Path.Combine(directory, "Equipment.json");
|
||||
}
|
||||
|
||||
public static void EnsureInitialized(long accountId)
|
||||
{
|
||||
lock (EquipmentLock)
|
||||
{
|
||||
string playerPath = GetPlayerEquipmentPath(accountId);
|
||||
if (File.Exists(playerPath))
|
||||
return;
|
||||
|
||||
string catalogPath = Path.Combine(
|
||||
Program.dataDir,
|
||||
"APIS",
|
||||
"Items",
|
||||
"EquipmentDefault.json");
|
||||
|
||||
if (!File.Exists(catalogPath))
|
||||
{
|
||||
File.WriteAllText(playerPath, "[]");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string catalogJson = File.ReadAllText(catalogPath);
|
||||
List<PlayerDBClasses.EquipmentItem> catalog =
|
||||
JsonSerializer.Deserialize<List<PlayerDBClasses.EquipmentItem>>(
|
||||
catalogJson,
|
||||
ReadOptions) ?? new List<PlayerDBClasses.EquipmentItem>();
|
||||
|
||||
File.WriteAllText(
|
||||
playerPath,
|
||||
JsonSerializer.Serialize(catalog, WriteOptions));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[EQUIPMENT INVENTORY] Could not seed account {accountId} from Equipment.json: {ex.Message}");
|
||||
File.WriteAllText(playerPath, "[]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static List<PlayerDBClasses.EquipmentItem> GetOrCreate(long accountId)
|
||||
{
|
||||
lock (EquipmentLock)
|
||||
{
|
||||
EnsureInitialized(accountId);
|
||||
string json = File.ReadAllText(GetPlayerEquipmentPath(accountId));
|
||||
return JsonSerializer.Deserialize<List<PlayerDBClasses.EquipmentItem>>(
|
||||
json,
|
||||
ReadOptions) ?? new List<PlayerDBClasses.EquipmentItem>();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool AddOwnedItem(
|
||||
long accountId,
|
||||
string prefabName,
|
||||
string modificationGuid) =>
|
||||
AddOwnedItem(
|
||||
accountId,
|
||||
prefabName,
|
||||
modificationGuid,
|
||||
friendlyName: null,
|
||||
tooltip: null,
|
||||
rarity: null,
|
||||
thumbnailImage: null);
|
||||
|
||||
public static bool AddOwnedItem(
|
||||
long accountId,
|
||||
string prefabName,
|
||||
string modificationGuid,
|
||||
string? friendlyName,
|
||||
string? tooltip,
|
||||
int? rarity,
|
||||
string? thumbnailImage)
|
||||
{
|
||||
if (accountId <= 0 ||
|
||||
string.IsNullOrWhiteSpace(prefabName) ||
|
||||
string.IsNullOrWhiteSpace(modificationGuid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (EquipmentLock)
|
||||
{
|
||||
List<PlayerDBClasses.EquipmentItem> equipment =
|
||||
GetOrCreate(accountId);
|
||||
|
||||
if (equipment.Any(item =>
|
||||
string.Equals(
|
||||
item.PrefabName,
|
||||
prefabName,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(
|
||||
item.ModificationGuid,
|
||||
modificationGuid,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
PlayerDBClasses.EquipmentItem? catalogItem = null;
|
||||
string catalogPath = Path.Combine(
|
||||
Program.dataDir,
|
||||
"APIS",
|
||||
"Items",
|
||||
"EquipmentDefault.json");
|
||||
|
||||
if (File.Exists(catalogPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
List<PlayerDBClasses.EquipmentItem> catalog =
|
||||
JsonSerializer.Deserialize<List<PlayerDBClasses.EquipmentItem>>(
|
||||
File.ReadAllText(catalogPath),
|
||||
ReadOptions) ?? new List<PlayerDBClasses.EquipmentItem>();
|
||||
|
||||
catalogItem = catalog.FirstOrDefault(item =>
|
||||
string.Equals(
|
||||
item.PrefabName,
|
||||
prefabName,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(
|
||||
item.ModificationGuid,
|
||||
modificationGuid,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[EQUIPMENT INVENTORY] Could not read catalog while granting gift: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
PlayerDBClasses.EquipmentItem newItem = catalogItem ?? new PlayerDBClasses.EquipmentItem
|
||||
{
|
||||
PrefabName = prefabName,
|
||||
ModificationGuid = modificationGuid,
|
||||
UnlockedLevel = 0,
|
||||
Favorited = false,
|
||||
PlatformMask = -1
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(friendlyName))
|
||||
newItem.FriendlyName = friendlyName.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(tooltip))
|
||||
newItem.Tooltip = tooltip.Trim();
|
||||
if (rarity.HasValue)
|
||||
newItem.Rarity = rarity.Value;
|
||||
if (!string.IsNullOrWhiteSpace(thumbnailImage))
|
||||
newItem.ThumbnailImage = thumbnailImage.Trim();
|
||||
|
||||
equipment.Add(newItem);
|
||||
|
||||
Save(accountId, equipment);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Save(
|
||||
long accountId,
|
||||
List<PlayerDBClasses.EquipmentItem> equipment)
|
||||
{
|
||||
lock (EquipmentLock)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(equipment, WriteOptions);
|
||||
File.WriteAllText(GetPlayerEquipmentPath(accountId), json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
|
||||
public sealed class ImageSigner : IDisposable
|
||||
{
|
||||
private static readonly Lazy<ImageSigner> SharedInstance =
|
||||
new(() => LoadFromDataDirectory(), LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
private readonly RSA _rsa;
|
||||
private readonly object _signLock = new();
|
||||
|
||||
private ImageSigner(RSAParameters parameters)
|
||||
{
|
||||
_rsa = RSA.Create();
|
||||
_rsa.ImportParameters(parameters);
|
||||
}
|
||||
|
||||
public static ImageSigner Shared => SharedInstance.Value;
|
||||
|
||||
public byte[] SignImage(byte[] image)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(image);
|
||||
|
||||
lock (_signLock)
|
||||
{
|
||||
return _rsa.SignData(
|
||||
image,
|
||||
HashAlgorithmName.SHA1,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_rsa.Dispose();
|
||||
}
|
||||
|
||||
private static ImageSigner LoadFromDataDirectory()
|
||||
{
|
||||
string path = Path.Combine(Program.dataDir, "ImageRSAParams.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
"Image signing key was not found. Expected Data/ImageRSAParams.json.",
|
||||
path);
|
||||
}
|
||||
|
||||
ImageRsaParameters? stored;
|
||||
try
|
||||
{
|
||||
stored = JsonSerializer.Deserialize<ImageRsaParameters>(
|
||||
File.ReadAllText(path),
|
||||
new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
}
|
||||
catch (Exception ex) when (
|
||||
ex is JsonException or FormatException or IOException)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Data/ImageRSAParams.json is not a valid RSA private key.",
|
||||
ex);
|
||||
}
|
||||
|
||||
if (stored == null)
|
||||
throw new InvalidDataException("The image signing key is empty.");
|
||||
|
||||
try
|
||||
{
|
||||
return new ImageSigner(new RSAParameters
|
||||
{
|
||||
Modulus = Decode(stored.Modulus, nameof(stored.Modulus)),
|
||||
Exponent = Decode(stored.Exponent, nameof(stored.Exponent)),
|
||||
P = Decode(stored.P, nameof(stored.P)),
|
||||
Q = Decode(stored.Q, nameof(stored.Q)),
|
||||
DP = Decode(stored.DP, nameof(stored.DP)),
|
||||
DQ = Decode(stored.DQ, nameof(stored.DQ)),
|
||||
InverseQ = Decode(stored.InverseQ, nameof(stored.InverseQ)),
|
||||
D = Decode(stored.D, nameof(stored.D))
|
||||
});
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Data/ImageRSAParams.json contains inconsistent RSA parameters.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Decode(string? value, string field)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new InvalidDataException($"Image RSA field {field} is missing.");
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(value);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Image RSA field {field} is not valid Base64.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ImageRsaParameters
|
||||
{
|
||||
public string? Modulus { get; set; }
|
||||
public string? Exponent { get; set; }
|
||||
public string? P { get; set; }
|
||||
public string? Q { get; set; }
|
||||
public string? DP { get; set; }
|
||||
public string? DQ { get; set; }
|
||||
public string? InverseQ { get; set; }
|
||||
public string? D { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
|
||||
public static class LoadingScreenImageService
|
||||
{
|
||||
private const long MaxDownloadBytes = 8 * 1024 * 1024;
|
||||
private const int MaxSourceDimension = 4096;
|
||||
private const int MaxOutputDimension = 2048;
|
||||
private const int MaxRedirects = 3;
|
||||
private static readonly TimeSpan CacheLifetime = TimeSpan.FromHours(24);
|
||||
private static readonly ConcurrentDictionary<string, Uri> RegisteredUrls =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> AliasLocks =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly SemaphoreSlim DownloadSlots = new(3, 3);
|
||||
private static readonly HttpClient Client = CreateClient();
|
||||
|
||||
public static string RewriteConfiguration(string json)
|
||||
{
|
||||
JsonNode? root = JsonNode.Parse(
|
||||
json,
|
||||
documentOptions: new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
if (root == null)
|
||||
throw new JsonException("Loading-screen configuration was empty.");
|
||||
|
||||
RewriteNode(root);
|
||||
return root.ToJsonString(new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
WriteIndented = false
|
||||
});
|
||||
}
|
||||
|
||||
public static bool IsRemoteAlias(string? imageName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageName))
|
||||
return false;
|
||||
|
||||
string normalized = imageName.Replace('\\', '/').TrimStart('/');
|
||||
if (!normalized.StartsWith("RemoteLoadingScreens/", StringComparison.OrdinalIgnoreCase) ||
|
||||
!normalized.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
string hash = Path.GetFileNameWithoutExtension(normalized);
|
||||
return hash.Length == 64 && hash.All(Uri.IsHexDigit);
|
||||
}
|
||||
|
||||
public static async Task<string?> GetLocalImageAsync(
|
||||
string imageName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string alias = imageName.Replace('\\', '/').TrimStart('/');
|
||||
if (!IsRemoteAlias(alias) || !RegisteredUrls.TryGetValue(alias, out Uri? source))
|
||||
return null;
|
||||
|
||||
string cacheDirectory = Path.Combine(
|
||||
Program.dataDir,
|
||||
"Images",
|
||||
"RemoteLoadingScreens");
|
||||
Directory.CreateDirectory(cacheDirectory);
|
||||
string cachePath = Path.Combine(cacheDirectory, Path.GetFileName(alias));
|
||||
|
||||
if (IsFresh(cachePath))
|
||||
return cachePath;
|
||||
|
||||
SemaphoreSlim aliasLock = AliasLocks.GetOrAdd(alias, _ => new SemaphoreSlim(1, 1));
|
||||
await aliasLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (IsFresh(cachePath))
|
||||
return cachePath;
|
||||
|
||||
await DownloadSlots.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
byte[] bytes = await DownloadAsync(source, cancellationToken);
|
||||
await NormalizeAndSaveAsync(bytes, cachePath, cancellationToken);
|
||||
return cachePath;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Console.WriteLine($"[loading image] {source.Host}: download timed out");
|
||||
return System.IO.File.Exists(cachePath) ? cachePath : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine($"[loading image] {source.Host}: {ex.Message}");
|
||||
return System.IO.File.Exists(cachePath) ? cachePath : null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DownloadSlots.Release();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
aliasLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewriteNode(JsonNode node)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
string? explicitUrlKey = FindKey(obj, "ImageUrl");
|
||||
JsonNode? explicitUrlNode = explicitUrlKey == null ? null : obj[explicitUrlKey];
|
||||
string? explicitImageUrl = explicitUrlNode is JsonValue explicitUrlValue &&
|
||||
explicitUrlValue.TryGetValue(out string? parsedImageUrl)
|
||||
? parsedImageUrl
|
||||
: null;
|
||||
if (TryRegister(explicitImageUrl, out string? explicitAlias))
|
||||
{
|
||||
|
||||
string targetKey = FindKey(obj, "ThumbnailBlobName") ??
|
||||
FindKey(obj, "ThumbnailImageName") ??
|
||||
FindKey(obj, "ImageName") ??
|
||||
"ImageName";
|
||||
obj[targetKey] = explicitAlias;
|
||||
}
|
||||
|
||||
foreach (string key in obj.Select(pair => pair.Key).ToArray())
|
||||
{
|
||||
JsonNode? value = obj[key];
|
||||
if (IsImageNameField(key) &&
|
||||
value is JsonValue jsonValue &&
|
||||
jsonValue.TryGetValue(out string? imageName) &&
|
||||
TryRegister(imageName, out string? alias))
|
||||
{
|
||||
obj[key] = alias;
|
||||
}
|
||||
else if (value != null)
|
||||
{
|
||||
RewriteNode(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (node is JsonArray array)
|
||||
{
|
||||
foreach (JsonNode? value in array)
|
||||
{
|
||||
if (value != null)
|
||||
RewriteNode(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindKey(JsonObject obj, string expected) =>
|
||||
obj.Select(pair => pair.Key).FirstOrDefault(key =>
|
||||
key.Equals(expected, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsImageNameField(string key) =>
|
||||
key.Equals("ImageName", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Equals("ThumbnailBlobName", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Equals("ThumbnailImageName", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool TryRegister(string? value, out string? alias)
|
||||
{
|
||||
alias = null;
|
||||
if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out Uri? uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) ||
|
||||
!string.IsNullOrEmpty(uri.UserInfo))
|
||||
return false;
|
||||
|
||||
if (TryResolveLocalImageUrl(uri, out alias))
|
||||
return true;
|
||||
|
||||
string canonical = uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped);
|
||||
string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)))
|
||||
.ToLowerInvariant();
|
||||
alias = $"RemoteLoadingScreens/{hash}.png";
|
||||
RegisteredUrls[alias] = uri;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryResolveLocalImageUrl(Uri uri, out string? imageName)
|
||||
{
|
||||
imageName = null;
|
||||
if (!Uri.TryCreate(ServerConfig.BaseURL, UriKind.Absolute, out Uri? configuredBase))
|
||||
configuredBase = null;
|
||||
|
||||
bool isOwnHost = uri.IsLoopback || configuredBase != null &&
|
||||
uri.Scheme.Equals(configuredBase.Scheme, StringComparison.OrdinalIgnoreCase) &&
|
||||
uri.Host.Equals(configuredBase.Host, StringComparison.OrdinalIgnoreCase) &&
|
||||
uri.Port == configuredBase.Port;
|
||||
string? prefix = new[] { "/imageserver-v2/", "/imageserver/" }
|
||||
.FirstOrDefault(candidate => uri.AbsolutePath.StartsWith(
|
||||
candidate,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (!isOwnHost || prefix == null)
|
||||
return false;
|
||||
|
||||
string relative;
|
||||
try
|
||||
{
|
||||
relative = Uri.UnescapeDataString(uri.AbsolutePath[prefix.Length..])
|
||||
.Replace('\\', '/')
|
||||
.TrimStart('/');
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relative) || relative.Contains(':'))
|
||||
return false;
|
||||
|
||||
string root;
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
root = Path.GetFullPath(Path.Combine(Program.dataDir, "Images"));
|
||||
fullPath = Path.GetFullPath(Path.Combine(
|
||||
root,
|
||||
relative.Replace('/', Path.DirectorySeparatorChar)));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!fullPath.StartsWith(
|
||||
root + Path.DirectorySeparatorChar,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
imageName = relative;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFresh(string path) =>
|
||||
System.IO.File.Exists(path) &&
|
||||
DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(path) < CacheLifetime;
|
||||
|
||||
private static async Task<byte[]> DownloadAsync(
|
||||
Uri source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Uri current = source;
|
||||
for (int redirect = 0; redirect <= MaxRedirects; redirect++)
|
||||
{
|
||||
await EnsurePublicHostAsync(current, cancellationToken);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, current);
|
||||
request.Headers.UserAgent.ParseAdd("MochaLoadingScreen/1.0");
|
||||
using HttpResponseMessage response = await Client.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if ((int)response.StatusCode is >= 300 and < 400)
|
||||
{
|
||||
if (redirect == MaxRedirects || response.Headers.Location == null)
|
||||
throw new InvalidDataException("Too many image redirects.");
|
||||
Uri next = response.Headers.Location.IsAbsoluteUri
|
||||
? response.Headers.Location
|
||||
: new Uri(current, response.Headers.Location);
|
||||
if ((next.Scheme != Uri.UriSchemeHttps && next.Scheme != Uri.UriSchemeHttp) ||
|
||||
(current.Scheme == Uri.UriSchemeHttps && next.Scheme == Uri.UriSchemeHttp))
|
||||
throw new InvalidDataException("Unsafe image redirect.");
|
||||
current = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
if (response.Content.Headers.ContentLength > MaxDownloadBytes)
|
||||
throw new InvalidDataException("Remote image exceeds 8 MB.");
|
||||
|
||||
string? mediaType = response.Content.Headers.ContentType?.MediaType;
|
||||
if (mediaType != null && !mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("Remote URL did not return an image.");
|
||||
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var output = new MemoryStream();
|
||||
byte[] buffer = new byte[64 * 1024];
|
||||
long total = 0;
|
||||
while (true)
|
||||
{
|
||||
int read = await stream.ReadAsync(buffer, cancellationToken);
|
||||
if (read == 0)
|
||||
break;
|
||||
total += read;
|
||||
if (total > MaxDownloadBytes)
|
||||
throw new InvalidDataException("Remote image exceeds 8 MB.");
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
throw new InvalidDataException("Unable to download remote image.");
|
||||
}
|
||||
|
||||
private static async Task NormalizeAndSaveAsync(
|
||||
byte[] bytes,
|
||||
string cachePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ImageInfo? info = Image.Identify(bytes);
|
||||
if (info == null || info.Width <= 0 || info.Height <= 0 ||
|
||||
info.Width > MaxSourceDimension || info.Height > MaxSourceDimension ||
|
||||
(long)info.Width * info.Height > 16_777_216)
|
||||
throw new InvalidDataException("Remote image dimensions are too large.");
|
||||
|
||||
using Image image = Image.Load(bytes);
|
||||
while (image.Frames.Count > 1)
|
||||
image.Frames.RemoveFrame(image.Frames.Count - 1);
|
||||
if (image.Width > MaxOutputDimension || image.Height > MaxOutputDimension)
|
||||
{
|
||||
image.Mutate(context => context.Resize(new ResizeOptions
|
||||
{
|
||||
Mode = ResizeMode.Max,
|
||||
Size = new Size(MaxOutputDimension, MaxOutputDimension)
|
||||
}));
|
||||
}
|
||||
|
||||
string tempPath = cachePath + ".tmp-" + Guid.NewGuid().ToString("N");
|
||||
try
|
||||
{
|
||||
await image.SaveAsync(tempPath, new PngEncoder(), cancellationToken);
|
||||
System.IO.File.Move(tempPath, cachePath, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (System.IO.File.Exists(tempPath))
|
||||
System.IO.File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsurePublicHostAsync(
|
||||
Uri uri,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (uri.IsLoopback || string.IsNullOrWhiteSpace(uri.Host))
|
||||
throw new InvalidDataException("Local image URLs are not allowed.");
|
||||
|
||||
IPAddress[] addresses = await Dns.GetHostAddressesAsync(uri.DnsSafeHost, cancellationToken);
|
||||
if (addresses.Length == 0 || addresses.Any(address => !IsPublicAddress(address)))
|
||||
throw new InvalidDataException("Private-network image URLs are not allowed.");
|
||||
}
|
||||
|
||||
private static bool IsPublicAddress(IPAddress address)
|
||||
{
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
address = address.MapToIPv4();
|
||||
if (IPAddress.IsLoopback(address) || address.Equals(IPAddress.Any) ||
|
||||
address.Equals(IPAddress.IPv6Any) || address.Equals(IPAddress.None) ||
|
||||
address.Equals(IPAddress.IPv6None))
|
||||
return false;
|
||||
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
return !(bytes[0] is 0 or 10 or 127 ||
|
||||
(bytes[0] == 100 && bytes[1] is >= 64 and <= 127) ||
|
||||
(bytes[0] == 169 && bytes[1] == 254) ||
|
||||
(bytes[0] == 172 && bytes[1] is >= 16 and <= 31) ||
|
||||
(bytes[0] == 192 && bytes[1] == 168) ||
|
||||
(bytes[0] == 198 && bytes[1] is 18 or 19) ||
|
||||
bytes[0] >= 224);
|
||||
}
|
||||
|
||||
return !(address.IsIPv6LinkLocal || address.IsIPv6Multicast ||
|
||||
address.IsIPv6SiteLocal || bytes.All(value => value == 0) ||
|
||||
(bytes[0] & 0xFE) == 0xFC);
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient()
|
||||
{
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false,
|
||||
UseProxy = false,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(3),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||
ConnectCallback = async (context, cancellationToken) =>
|
||||
{
|
||||
IPAddress[] addresses = await Dns.GetHostAddressesAsync(
|
||||
context.DnsEndPoint.Host,
|
||||
cancellationToken);
|
||||
if (addresses.Length == 0 || addresses.Any(address => !IsPublicAddress(address)))
|
||||
throw new InvalidDataException("Private-network image URLs are not allowed.");
|
||||
|
||||
IPAddress address = addresses[Random.Shared.Next(addresses.Length)];
|
||||
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(
|
||||
new IPEndPoint(address, context.DnsEndPoint.Port),
|
||||
cancellationToken);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
};
|
||||
return new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(8)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace Mocha2023.Classes;
|
||||
|
||||
public static class LogBuffer
|
||||
{
|
||||
private const int MaxLines = 5000;
|
||||
private static readonly object Lock = new();
|
||||
private static readonly LinkedList<string> Lines = new();
|
||||
|
||||
public static void Add(string line)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
Lines.AddLast(line);
|
||||
while (Lines.Count > MaxLines)
|
||||
Lines.RemoveFirst();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<string> Snapshot(int take = 500)
|
||||
{
|
||||
lock (Lock)
|
||||
return Lines.Skip(Math.Max(0, Lines.Count - take)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TeeTextWriter : TextWriter
|
||||
{
|
||||
private readonly TextWriter inner;
|
||||
|
||||
public TeeTextWriter(TextWriter inner) => this.inner = inner;
|
||||
|
||||
public override Encoding Encoding => inner.Encoding;
|
||||
|
||||
public override void Write(char value) => inner.Write(value);
|
||||
|
||||
public override void Write(string? value) => inner.Write(value);
|
||||
|
||||
public override void WriteLine(string? value)
|
||||
{
|
||||
inner.WriteLine(value);
|
||||
LogBuffer.Add($"[{DateTime.UtcNow:HH:mm:ss}] {value}");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class LogBufferInitializer
|
||||
{
|
||||
[ModuleInitializer]
|
||||
public static void Init()
|
||||
{
|
||||
Console.SetOut(new TeeTextWriter(Console.Out));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
public class NameGen
|
||||
{
|
||||
public static Random random = new Random();
|
||||
|
||||
public static List<string> Nouns = new List<string>
|
||||
{
|
||||
"Aardvark", "Alpaca", "Ant", "Armadillo", "Badger", "Bat", "Bear", "Bee", "Buffalo", "Butterfly",
|
||||
"Capybara", "Cat", "Caterpillar", "Chameleon", "Cheetah", "Chicken", "Chimpanzee", "Cobra",
|
||||
"Coyote", "Crane", "Cricket", "Crow", "Deer", "Dog", "Dolphin", "Donkey", "Dove", "Duck", "Eagle",
|
||||
"Echidna", "Elephant", "Elk", "Emu", "Ferret", "Flamingo", "Fish", "Fox", "Frog", "Gazelle", "Giraffe",
|
||||
"Goat", "Goose", "Gorilla", "Hamster", "Hedgehog", "Hippo", "Horse", "Hyena", "Iguana", "Jaguar",
|
||||
"Jellyfish",
|
||||
"Kangaroo",
|
||||
"Kitten",
|
||||
"Koala",
|
||||
"Lemming",
|
||||
"Leopard",
|
||||
"Lion",
|
||||
"Lizard",
|
||||
"Llama",
|
||||
"Marmoset",
|
||||
"Monkey",
|
||||
"Moose",
|
||||
"Mouse",
|
||||
"Mule",
|
||||
"Newt",
|
||||
"Octopus",
|
||||
"Opposum",
|
||||
"Ostrich",
|
||||
"Otter",
|
||||
"Owl",
|
||||
"Oyster",
|
||||
"Panda",
|
||||
"Panther",
|
||||
"Parrot",
|
||||
"Penguin",
|
||||
"Pig",
|
||||
"Pigeon",
|
||||
"Piranha",
|
||||
"Platypus",
|
||||
"Pony",
|
||||
"Possum",
|
||||
"Puppy",
|
||||
"Quail",
|
||||
"Rabbit",
|
||||
"Raven",
|
||||
"Salmon",
|
||||
"Scorpion",
|
||||
"Seal",
|
||||
"Shark",
|
||||
"Sheep",
|
||||
"Sloth",
|
||||
"Snail",
|
||||
"Squid",
|
||||
"Squirrel",
|
||||
"Stork",
|
||||
"Tapir",
|
||||
"Tiger",
|
||||
"Tortoise",
|
||||
"Tuna",
|
||||
"Turtle",
|
||||
"Urchin",
|
||||
"Viper",
|
||||
"Vulture",
|
||||
"Walrus",
|
||||
"Whale",
|
||||
"Wombat",
|
||||
"Yak",
|
||||
"Zebra"
|
||||
};
|
||||
|
||||
public static List<string> Adjectives = new List<string>
|
||||
{
|
||||
"Adamant",
|
||||
"Adorable",
|
||||
"Adventurous",
|
||||
"Agreeable",
|
||||
"Aimless",
|
||||
"Alert",
|
||||
"Amused",
|
||||
"Aromatic",
|
||||
"Bashful",
|
||||
"Beautiful",
|
||||
"Bored",
|
||||
"Brave",
|
||||
"Bulbous",
|
||||
"Busy",
|
||||
"Calm",
|
||||
"Carefree",
|
||||
"Careless",
|
||||
"Caring",
|
||||
"Charming",
|
||||
"Cheerful",
|
||||
"Clever",
|
||||
"Clumsy",
|
||||
"Courageous",
|
||||
"Cowardly",
|
||||
"Cozy",
|
||||
"Crabby",
|
||||
"Cranky",
|
||||
"Crawling",
|
||||
"Creaky",
|
||||
"Creative",
|
||||
"Crispy",
|
||||
"Decisive",
|
||||
"Deep",
|
||||
"Delightful",
|
||||
"Determined",
|
||||
"Diligent",
|
||||
"Dull",
|
||||
"Eager",
|
||||
"Elated",
|
||||
"Emotional",
|
||||
"Enchanting",
|
||||
"Encouraging",
|
||||
"Endless",
|
||||
"Energetic",
|
||||
"Enthusiastic",
|
||||
"Excited",
|
||||
"Exuberant",
|
||||
"Fair",
|
||||
"Faithful",
|
||||
"Fantastic",
|
||||
"Fastidious",
|
||||
"Fine",
|
||||
"Fluttering",
|
||||
"Fragrant",
|
||||
"Friendly",
|
||||
"Fulsome",
|
||||
"Funny",
|
||||
"Fussy",
|
||||
"Fuzzy",
|
||||
"Generous",
|
||||
"Gentle",
|
||||
"Glassy",
|
||||
"Gloomy",
|
||||
"Glorious",
|
||||
"Glowing",
|
||||
"Good",
|
||||
"Grand",
|
||||
"Great",
|
||||
"Greedy",
|
||||
"Grimy",
|
||||
"Happy",
|
||||
"Hardworking",
|
||||
"Hasty",
|
||||
"Healthy",
|
||||
"Heavy",
|
||||
"Helpful",
|
||||
"Hilarious",
|
||||
"Hopeful",
|
||||
"Icy",
|
||||
"Important",
|
||||
"Inquisitive",
|
||||
"Jolly",
|
||||
"Joyful",
|
||||
"Joyous",
|
||||
"Kind",
|
||||
"Lazy",
|
||||
"Lively",
|
||||
"Loud",
|
||||
"Lovely",
|
||||
"Loyal",
|
||||
"Lucky",
|
||||
"Luminous",
|
||||
"Lumpy",
|
||||
"Majestic",
|
||||
"Meek",
|
||||
"Melodic",
|
||||
"Mighty",
|
||||
"Moody",
|
||||
"Nice",
|
||||
"Nimble",
|
||||
"Odd",
|
||||
"Optimistic",
|
||||
"Perfect",
|
||||
"Pervasive",
|
||||
"Pleasant",
|
||||
"Plucky",
|
||||
"Plush",
|
||||
"Polite",
|
||||
"Practical",
|
||||
"Proud",
|
||||
"Quick",
|
||||
"Quiet",
|
||||
"Rapid",
|
||||
"Redolent",
|
||||
"Reliable",
|
||||
"Relieved",
|
||||
"Royal",
|
||||
"Rusty",
|
||||
"Scared",
|
||||
"Selfish",
|
||||
"Sensible",
|
||||
"Sensitive",
|
||||
"Shining",
|
||||
"Shrill",
|
||||
"Silly",
|
||||
"Sincere",
|
||||
"Sizzling",
|
||||
"Sleepy",
|
||||
"Smiling",
|
||||
"Smooth",
|
||||
"Snug",
|
||||
"Soaring",
|
||||
"Sparkling",
|
||||
"Speedy",
|
||||
"Spiky",
|
||||
"Splendid",
|
||||
"Spoiled",
|
||||
"Steaming",
|
||||
"Still",
|
||||
"Strict",
|
||||
"Stuffed",
|
||||
"Sturdy",
|
||||
"Successful",
|
||||
"Surprised",
|
||||
"Swift",
|
||||
"Taciturn",
|
||||
"Tense",
|
||||
"Thankful",
|
||||
"Thoughtful",
|
||||
"Thrifty",
|
||||
"Tough",
|
||||
"Tricky",
|
||||
"Truthful",
|
||||
"Ubiquitous",
|
||||
"Unusual",
|
||||
"Versatile",
|
||||
"Victorious",
|
||||
"Wild",
|
||||
"Wise",
|
||||
"Witty",
|
||||
"Wonderful",
|
||||
"Worried",
|
||||
"Wrinkly",
|
||||
"Zany",
|
||||
"Zealous"
|
||||
};
|
||||
|
||||
public static string GetRandomName()
|
||||
{
|
||||
string adjective = Adjectives[random.Next(Adjectives.Count)];
|
||||
string noun = Nouns[random.Next(Nouns.Count)];
|
||||
int number = random.Next(1000, 99999);
|
||||
return adjective + noun + number;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
public static class ClientNetwork
|
||||
{
|
||||
private static readonly Lazy<string[]> TrustedProxyNetworks = new(
|
||||
LoadTrustedProxyNetworks);
|
||||
|
||||
public static IPAddress? GetClientIp(HttpRequest request)
|
||||
{
|
||||
IPAddress? remoteAddress = request.HttpContext.Connection.RemoteIpAddress;
|
||||
|
||||
if (remoteAddress != null && IsTrustedForwardingProxy(remoteAddress))
|
||||
{
|
||||
if (TryGetHeaderAddress(request, "CF-Connecting-IP", out IPAddress? cloudflareAddress))
|
||||
return Normalize(cloudflareAddress);
|
||||
|
||||
if (request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
|
||||
{
|
||||
string? first = forwardedFor.FirstOrDefault()?.Split(',')[0].Trim();
|
||||
if (IPAddress.TryParse(first, out IPAddress? forwardedAddress))
|
||||
return Normalize(forwardedAddress);
|
||||
}
|
||||
}
|
||||
|
||||
return Normalize(remoteAddress);
|
||||
}
|
||||
|
||||
private static bool IsTrustedForwardingProxy(IPAddress address)
|
||||
{
|
||||
IPAddress normalized = Normalize(address)!;
|
||||
if (IPAddress.IsLoopback(normalized))
|
||||
return true;
|
||||
|
||||
return TrustedProxyNetworks.Value.Any(network =>
|
||||
IpNetwork.Contains(network, normalized));
|
||||
}
|
||||
|
||||
private static string[] LoadTrustedProxyNetworks()
|
||||
{
|
||||
string configured =
|
||||
Program.LoadLocalSetting("TRUSTED_PROXY_NETWORKS") ??
|
||||
string.Empty;
|
||||
|
||||
var networks = new List<string>();
|
||||
foreach (string candidate in configured.Split(
|
||||
new[] { ',', ';', ' ', '\t', '\r', '\n' },
|
||||
StringSplitOptions.RemoveEmptyEntries |
|
||||
StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (IpNetwork.TryNormalize(candidate, out string normalized))
|
||||
{
|
||||
networks.Add(normalized);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[NETWORK SECURITY] Ignoring invalid trusted proxy network: {candidate}");
|
||||
}
|
||||
}
|
||||
|
||||
return networks.Distinct(StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
public static bool IsPrivateOrLoopback(IPAddress? address)
|
||||
{
|
||||
if (address == null)
|
||||
return false;
|
||||
if (IPAddress.IsLoopback(address))
|
||||
return true;
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
return IsPrivateOrLoopback(address.MapToIPv4());
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
return bytes[0] == 10 ||
|
||||
(bytes[0] == 172 && bytes[1] is >= 16 and <= 31) ||
|
||||
(bytes[0] == 192 && bytes[1] == 168) ||
|
||||
(bytes[0] == 169 && bytes[1] == 254);
|
||||
}
|
||||
|
||||
byte[] ipv6 = address.GetAddressBytes();
|
||||
return address.IsIPv6LinkLocal ||
|
||||
(ipv6.Length == 16 && (ipv6[0] & 0xFE) == 0xFC);
|
||||
}
|
||||
|
||||
private static IPAddress? Normalize(IPAddress? address) =>
|
||||
address?.IsIPv4MappedToIPv6 == true ? address.MapToIPv4() : address;
|
||||
|
||||
private static bool TryGetHeaderAddress(
|
||||
HttpRequest request,
|
||||
string header,
|
||||
out IPAddress? address)
|
||||
{
|
||||
address = null;
|
||||
string? value = request.Headers[header].FirstOrDefault()?.Trim();
|
||||
return !string.IsNullOrWhiteSpace(value) && IPAddress.TryParse(value, out address);
|
||||
}
|
||||
}
|
||||
|
||||
public static class IpNetwork
|
||||
{
|
||||
public static bool TryNormalize(string? value, out string normalized)
|
||||
{
|
||||
normalized = string.Empty;
|
||||
string input = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return false;
|
||||
|
||||
string[] parts = input.Split('/', 2, StringSplitOptions.TrimEntries);
|
||||
if (!IPAddress.TryParse(parts[0], out IPAddress? address))
|
||||
return false;
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
address = address.MapToIPv4();
|
||||
|
||||
int maxBits = address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128;
|
||||
int prefix = maxBits;
|
||||
if (parts.Length == 2 &&
|
||||
(!int.TryParse(parts[1], out prefix) || prefix < 0 || prefix > maxBits))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] network = ApplyMask(address.GetAddressBytes(), prefix);
|
||||
var canonical = new IPAddress(network);
|
||||
normalized = prefix == maxBits
|
||||
? canonical.ToString()
|
||||
: $"{canonical}/{prefix}";
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool Contains(string networkText, IPAddress address)
|
||||
{
|
||||
if (!TryNormalize(networkText, out string normalized))
|
||||
return false;
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
address = address.MapToIPv4();
|
||||
|
||||
string[] parts = normalized.Split('/', 2);
|
||||
if (!IPAddress.TryParse(parts[0], out IPAddress? networkAddress))
|
||||
return false;
|
||||
if (networkAddress.AddressFamily != address.AddressFamily)
|
||||
return false;
|
||||
|
||||
int maxBits = address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128;
|
||||
int prefix = parts.Length == 2 && int.TryParse(parts[1], out int parsed)
|
||||
? parsed
|
||||
: maxBits;
|
||||
|
||||
byte[] expected = ApplyMask(networkAddress.GetAddressBytes(), prefix);
|
||||
byte[] actual = ApplyMask(address.GetAddressBytes(), prefix);
|
||||
return expected.SequenceEqual(actual);
|
||||
}
|
||||
|
||||
private static byte[] ApplyMask(byte[] bytes, int prefixLength)
|
||||
{
|
||||
byte[] output = bytes.ToArray();
|
||||
int fullBytes = prefixLength / 8;
|
||||
int remainingBits = prefixLength % 8;
|
||||
|
||||
if (remainingBits != 0 && fullBytes < output.Length)
|
||||
{
|
||||
int mask = 0xFF << (8 - remainingBits);
|
||||
output[fullBytes] = (byte)(output[fullBytes] & mask);
|
||||
fullBytes++;
|
||||
}
|
||||
|
||||
for (int index = fullBytes; index < output.Length; index++)
|
||||
output[index] = 0;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
public static class VpnDetectionService
|
||||
{
|
||||
private sealed record CacheEntry(bool IsAnonymous, DateTime ExpiresAtUtc);
|
||||
|
||||
private static readonly ConcurrentDictionary<string, CacheEntry> Cache =
|
||||
new(StringComparer.Ordinal);
|
||||
private static readonly HttpClient Http = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(4)
|
||||
};
|
||||
|
||||
public static async Task<bool> IsVpnOrProxyAsync(
|
||||
IPAddress address,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ClientNetwork.IsPrivateOrLoopback(address))
|
||||
return false;
|
||||
|
||||
string ip = address.ToString();
|
||||
DateTime now = DateTime.UtcNow;
|
||||
if (Cache.TryGetValue(ip, out CacheEntry? cached) && cached.ExpiresAtUtc > now)
|
||||
return cached.IsAnonymous;
|
||||
|
||||
try
|
||||
{
|
||||
string apiKey = Program.LoadLocalSetting("PROXYCHECK_API_KEY")?.Trim() ?? string.Empty;
|
||||
string keyQuery = string.IsNullOrWhiteSpace(apiKey)
|
||||
? string.Empty
|
||||
: "&key=" + Uri.EscapeDataString(apiKey);
|
||||
string url = "https://proxycheck.io/v3/" + Uri.EscapeDataString(ip) +
|
||||
"?tag=0&p=0" + keyQuery;
|
||||
|
||||
using HttpResponseMessage response = await Http.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Cache[ip] = new CacheEntry(false, now.AddMinutes(10));
|
||||
return false;
|
||||
}
|
||||
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(
|
||||
stream,
|
||||
cancellationToken: cancellationToken);
|
||||
JsonElement root = document.RootElement;
|
||||
|
||||
bool detected = false;
|
||||
if (root.TryGetProperty(ip, out JsonElement details))
|
||||
detected = ReadDetection(details);
|
||||
else
|
||||
detected = ReadDetection(root);
|
||||
|
||||
Cache[ip] = new CacheEntry(
|
||||
detected,
|
||||
now.AddHours(detected ? 24 : 8));
|
||||
return detected;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
Cache[ip] = new CacheEntry(false, now.AddMinutes(10));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ReadDetection(JsonElement details)
|
||||
{
|
||||
if (details.ValueKind != JsonValueKind.Object)
|
||||
return false;
|
||||
|
||||
if (details.TryGetProperty("detections", out JsonElement detections) &&
|
||||
detections.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (ReadBool(detections, "anonymous") ||
|
||||
ReadBool(detections, "vpn") ||
|
||||
ReadBool(detections, "proxy") ||
|
||||
ReadBool(detections, "tor") ||
|
||||
ReadBool(detections, "hosting"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return ReadBool(details, "anonymous") ||
|
||||
ReadBool(details, "vpn") ||
|
||||
ReadBool(details, "proxy") ||
|
||||
ReadBool(details, "hosting") ||
|
||||
string.Equals(ReadString(details, "proxy"), "yes", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(ReadString(details, "type"), "vpn", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(ReadString(details, "type"), "tor", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(ReadNetworkType(details), "hosting", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ReadBool(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out JsonElement value))
|
||||
return false;
|
||||
return value.ValueKind == JsonValueKind.True ||
|
||||
(value.ValueKind == JsonValueKind.String &&
|
||||
bool.TryParse(value.GetString(), out bool parsed) && parsed);
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement element, string propertyName) =>
|
||||
element.TryGetProperty(propertyName, out JsonElement value) &&
|
||||
value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
|
||||
private static string? ReadNetworkType(JsonElement element)
|
||||
{
|
||||
if (!element.TryGetProperty("network", out JsonElement network) ||
|
||||
network.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return ReadString(network, "type");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
public static class PasswordSecurity
|
||||
{
|
||||
private const string Prefix = "$pbkdf2-sha256$";
|
||||
private const int Iterations = 120_000;
|
||||
private const int SaltSize = 16;
|
||||
private const int HashSize = 32;
|
||||
private const int MaximumLoginAttempts = 8;
|
||||
public const int MinPasswordLength = 8;
|
||||
public const int MaxPasswordLength = 256;
|
||||
private static readonly TimeSpan LoginAttemptWindow =
|
||||
TimeSpan.FromMinutes(15);
|
||||
private static readonly TimeSpan LoginLockout =
|
||||
TimeSpan.FromMinutes(15);
|
||||
private static readonly string DummyHash = Hash(
|
||||
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));
|
||||
private static readonly ConcurrentDictionary<string, LoginAttemptState>
|
||||
LoginAttempts = new(StringComparer.Ordinal);
|
||||
private static int LoginAttemptSweepCounter;
|
||||
|
||||
private sealed class LoginAttemptState
|
||||
{
|
||||
public object Sync { get; } = new();
|
||||
public Queue<DateTimeOffset> Attempts { get; } = new();
|
||||
public DateTimeOffset BlockedUntil { get; set; }
|
||||
public DateTimeOffset LastTouched { get; set; }
|
||||
}
|
||||
|
||||
public static string Hash(string password)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(password);
|
||||
|
||||
if (password.Length > MaxPasswordLength)
|
||||
throw new ArgumentException("Password is too long.", nameof(password));
|
||||
|
||||
byte[] salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
Iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
HashSize);
|
||||
|
||||
return $"{Prefix}{Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
public static bool Verify(
|
||||
string? password,
|
||||
string? storedPassword,
|
||||
out bool needsUpgrade)
|
||||
{
|
||||
needsUpgrade = false;
|
||||
|
||||
if (password == null || storedPassword == null ||
|
||||
password.Length > MaxPasswordLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!storedPassword.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
needsUpgrade = true;
|
||||
return FixedTimeTextEquals(password, storedPassword);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string[] parts = storedPassword.Split('$');
|
||||
if (parts.Length != 5 ||
|
||||
!int.TryParse(parts[2], out int iterations) ||
|
||||
iterations < 10_000 || iterations > 1_000_000)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] salt = Convert.FromBase64String(parts[3]);
|
||||
byte[] expected = Convert.FromBase64String(parts[4]);
|
||||
if (salt.Length < 8 || salt.Length > 64 ||
|
||||
expected.Length < 16 || expected.Length > 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] actual = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
expected.Length);
|
||||
|
||||
needsUpgrade = iterations != Iterations ||
|
||||
salt.Length != SaltSize ||
|
||||
expected.Length != HashSize;
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool VerifyLogin(
|
||||
string? password,
|
||||
string? storedPassword,
|
||||
out bool needsUpgrade)
|
||||
{
|
||||
bool hasStoredPassword = !string.IsNullOrEmpty(storedPassword);
|
||||
bool verified = Verify(
|
||||
password,
|
||||
hasStoredPassword ? storedPassword : DummyHash,
|
||||
out needsUpgrade);
|
||||
|
||||
return hasStoredPassword && verified;
|
||||
}
|
||||
|
||||
public static bool TryBeginLoginAttempt(
|
||||
string identity,
|
||||
string? clientAddress,
|
||||
out int retryAfterSeconds)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
string key = CreateLoginAttemptKey(identity, clientAddress);
|
||||
LoginAttemptState state = LoginAttempts.GetOrAdd(
|
||||
key,
|
||||
_ => new LoginAttemptState());
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
PruneLoginAttempts(state, now);
|
||||
state.LastTouched = now;
|
||||
|
||||
if (state.BlockedUntil > now)
|
||||
{
|
||||
retryAfterSeconds = Math.Max(
|
||||
1,
|
||||
(int)Math.Ceiling(
|
||||
(state.BlockedUntil - now).TotalSeconds));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.Attempts.Count >= MaximumLoginAttempts)
|
||||
{
|
||||
state.BlockedUntil = now + LoginLockout;
|
||||
retryAfterSeconds = (int)LoginLockout.TotalSeconds;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.Attempts.Enqueue(now);
|
||||
retryAfterSeconds = 0;
|
||||
}
|
||||
|
||||
SweepLoginAttempts(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void CompleteLoginAttempt(
|
||||
string identity,
|
||||
string? clientAddress,
|
||||
bool succeeded)
|
||||
{
|
||||
string key = CreateLoginAttemptKey(identity, clientAddress);
|
||||
if (succeeded)
|
||||
{
|
||||
LoginAttempts.TryRemove(key, out _);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!LoginAttempts.TryGetValue(key, out LoginAttemptState? state))
|
||||
return;
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
lock (state.Sync)
|
||||
{
|
||||
PruneLoginAttempts(state, now);
|
||||
state.LastTouched = now;
|
||||
if (state.Attempts.Count >= MaximumLoginAttempts)
|
||||
state.BlockedUntil = now + LoginLockout;
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateLoginAttemptKey(
|
||||
string identity,
|
||||
string? clientAddress)
|
||||
{
|
||||
string normalizedIdentity =
|
||||
(identity ?? string.Empty).Trim().ToLowerInvariant();
|
||||
string normalizedAddress =
|
||||
string.IsNullOrWhiteSpace(clientAddress)
|
||||
? "unknown"
|
||||
: clientAddress.Trim();
|
||||
byte[] material = Encoding.UTF8.GetBytes(
|
||||
normalizedIdentity + "\n" + normalizedAddress);
|
||||
return Convert.ToHexString(SHA256.HashData(material));
|
||||
}
|
||||
|
||||
private static void PruneLoginAttempts(
|
||||
LoginAttemptState state,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
DateTimeOffset cutoff = now - LoginAttemptWindow;
|
||||
while (state.Attempts.Count > 0 &&
|
||||
state.Attempts.Peek() < cutoff)
|
||||
{
|
||||
state.Attempts.Dequeue();
|
||||
}
|
||||
|
||||
if (state.BlockedUntil <= now && state.Attempts.Count == 0)
|
||||
state.BlockedUntil = default;
|
||||
}
|
||||
|
||||
private static void SweepLoginAttempts(DateTimeOffset now)
|
||||
{
|
||||
if ((Interlocked.Increment(ref LoginAttemptSweepCounter) & 255) != 0)
|
||||
return;
|
||||
|
||||
DateTimeOffset staleBefore =
|
||||
now - LoginAttemptWindow - LoginLockout;
|
||||
foreach (KeyValuePair<string, LoginAttemptState> entry in LoginAttempts)
|
||||
{
|
||||
if (entry.Value.LastTouched < staleBefore)
|
||||
LoginAttempts.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool FixedTimeTextEquals(string left, string right)
|
||||
{
|
||||
byte[] leftBytes = Encoding.UTF8.GetBytes(left);
|
||||
byte[] rightBytes = Encoding.UTF8.GetBytes(right);
|
||||
|
||||
return leftBytes.Length == rightBytes.Length &&
|
||||
CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
|
||||
public static class PhotonTicketService
|
||||
{
|
||||
private const string TicketVersion = "v1";
|
||||
private static readonly TimeSpan TicketLifetime = TimeSpan.FromHours(2);
|
||||
private static readonly TimeSpan AllowedClockSkew = TimeSpan.FromMinutes(5);
|
||||
|
||||
public static string Issue(
|
||||
long playerId,
|
||||
long? roomInstanceId,
|
||||
long? roomId,
|
||||
string? displayName)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
|
||||
var payload = new PhotonTicketPayload
|
||||
{
|
||||
PlayerId = playerId,
|
||||
RoomInstanceId = roomInstanceId,
|
||||
RoomId = roomId,
|
||||
DisplayName = displayName ?? string.Empty,
|
||||
IssuedAtUnix = now.ToUnixTimeSeconds(),
|
||||
ExpiresAtUnix = now.Add(TicketLifetime).ToUnixTimeSeconds(),
|
||||
Nonce = Base64UrlEncode(RandomNumberGenerator.GetBytes(16))
|
||||
};
|
||||
|
||||
byte[] payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload);
|
||||
string encodedPayload = Base64UrlEncode(payloadBytes);
|
||||
string signingInput = $"{TicketVersion}.{encodedPayload}";
|
||||
string signature = Base64UrlEncode(Sign(signingInput));
|
||||
string token = $"{signingInput}.{signature}";
|
||||
|
||||
Console.WriteLine(
|
||||
$"[PHOTON TICKET] Issued player={playerId} " +
|
||||
$"room={roomId?.ToString() ?? "none"} " +
|
||||
$"instance={roomInstanceId?.ToString() ?? "none"} " +
|
||||
$"fingerprint={GetFingerprint(token)}");
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
public static bool TryValidate(string? token, out PhotonTicket ticket)
|
||||
{
|
||||
return TryValidate(token, out ticket, out _);
|
||||
}
|
||||
|
||||
public static bool TryValidate(
|
||||
string? token,
|
||||
out PhotonTicket ticket,
|
||||
out string failureReason)
|
||||
{
|
||||
ticket = null!;
|
||||
failureReason = string.Empty;
|
||||
|
||||
token = NormalizeToken(token);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
failureReason = "missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] parts = token.Split('.', StringSplitOptions.None);
|
||||
if (parts.Length != 3 ||
|
||||
!string.Equals(parts[0], TicketVersion, StringComparison.Ordinal))
|
||||
{
|
||||
failureReason = "wrong-format";
|
||||
return false;
|
||||
}
|
||||
|
||||
string signingInput = $"{parts[0]}.{parts[1]}";
|
||||
byte[] expectedSignature = Sign(signingInput);
|
||||
|
||||
if (!TryBase64UrlDecode(parts[2], out byte[] suppliedSignature) ||
|
||||
suppliedSignature.Length != expectedSignature.Length ||
|
||||
!CryptographicOperations.FixedTimeEquals(
|
||||
suppliedSignature,
|
||||
expectedSignature))
|
||||
{
|
||||
failureReason = "bad-signature";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryBase64UrlDecode(parts[1], out byte[] payloadBytes))
|
||||
{
|
||||
failureReason = "bad-payload-encoding";
|
||||
return false;
|
||||
}
|
||||
|
||||
PhotonTicketPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<PhotonTicketPayload>(payloadBytes);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
failureReason = "bad-payload-json";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload == null || payload.PlayerId <= 0)
|
||||
{
|
||||
failureReason = "bad-player";
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
long nowUnix = now.ToUnixTimeSeconds();
|
||||
|
||||
if (payload.ExpiresAtUnix <= nowUnix)
|
||||
{
|
||||
failureReason = "expired";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.IssuedAtUnix > now.Add(AllowedClockSkew).ToUnixTimeSeconds())
|
||||
{
|
||||
failureReason = "issued-in-future";
|
||||
return false;
|
||||
}
|
||||
|
||||
ticket = new PhotonTicket
|
||||
{
|
||||
PlayerId = payload.PlayerId,
|
||||
RoomInstanceId = payload.RoomInstanceId,
|
||||
RoomId = payload.RoomId,
|
||||
DisplayName = payload.DisplayName ?? string.Empty,
|
||||
IssuedAt = DateTimeOffset.FromUnixTimeSeconds(payload.IssuedAtUnix),
|
||||
ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.ExpiresAtUnix)
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static string GetFingerprint(string? token)
|
||||
{
|
||||
token = NormalizeToken(token);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return "none";
|
||||
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
return Convert.ToHexString(hash.AsSpan(0, 6));
|
||||
}
|
||||
|
||||
private static byte[] Sign(string signingInput)
|
||||
{
|
||||
byte[] key = Encoding.UTF8.GetBytes(ServerConfig.PhotonCustomAuthSecret);
|
||||
using var hmac = new HMACSHA256(key);
|
||||
return hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput));
|
||||
}
|
||||
|
||||
private static string? NormalizeToken(string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
|
||||
string normalized = token.Trim().Trim('"');
|
||||
const string bearerPrefix = "Bearer ";
|
||||
if (normalized.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
normalized = normalized[bearerPrefix.Length..].Trim();
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(byte[] bytes)
|
||||
{
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static bool TryBase64UrlDecode(string value, out byte[] bytes)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
|
||||
try
|
||||
{
|
||||
string base64 = value
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
|
||||
int padding = base64.Length % 4;
|
||||
if (padding == 2)
|
||||
base64 += "==";
|
||||
else if (padding == 3)
|
||||
base64 += "=";
|
||||
else if (padding == 1)
|
||||
return false;
|
||||
|
||||
bytes = Convert.FromBase64String(base64);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PhotonTicketPayload
|
||||
{
|
||||
public long PlayerId { get; set; }
|
||||
public long? RoomInstanceId { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public long IssuedAtUnix { get; set; }
|
||||
public long ExpiresAtUnix { get; set; }
|
||||
public string? Nonce { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PhotonTicket
|
||||
{
|
||||
public long PlayerId { get; init; }
|
||||
public long? RoomInstanceId { get; init; }
|
||||
public long? RoomId { get; init; }
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
|
||||
public static class PlayerInventoryStore
|
||||
{
|
||||
private static readonly object InventoryLock = new();
|
||||
private static readonly JsonSerializerOptions ReadOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
private static readonly JsonSerializerOptions WriteOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public sealed class AvatarItemOwnership
|
||||
{
|
||||
public long AvatarItemId { get; set; }
|
||||
public string AvatarItemDesc { get; set; } = string.Empty;
|
||||
public string FriendlyName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ConsumableOwnership
|
||||
{
|
||||
public long ConsumableItemId { get; set; }
|
||||
public string ConsumableItemDesc { get; set; } = string.Empty;
|
||||
public string FriendlyName { get; set; } = string.Empty;
|
||||
public int Quantity { get; set; }
|
||||
}
|
||||
|
||||
public static string GetAvatarItemsPath(long accountId)
|
||||
{
|
||||
string directory = Path.Combine(
|
||||
Program.dataDir,
|
||||
"AvatarItemsUnlocked",
|
||||
accountId.ToString());
|
||||
Directory.CreateDirectory(directory);
|
||||
return Path.Combine(directory, "AvatarItems.json");
|
||||
}
|
||||
|
||||
public static string GetConsumablesPath(long accountId)
|
||||
{
|
||||
string directory = Path.Combine(
|
||||
Program.dataDir,
|
||||
"ConsumablesUnlocked",
|
||||
accountId.ToString());
|
||||
Directory.CreateDirectory(directory);
|
||||
return Path.Combine(directory, "Consumables.json");
|
||||
}
|
||||
|
||||
public static void EnsureInitialized(
|
||||
long accountId,
|
||||
IEnumerable<string>? legacyAvatarDescriptors = null)
|
||||
{
|
||||
EnsureAvatarItemsInitialized(accountId, legacyAvatarDescriptors);
|
||||
EnsureConsumablesInitialized(accountId);
|
||||
}
|
||||
|
||||
public static List<AvatarItemOwnership> GetAvatarCatalog() =>
|
||||
LoadAvatarCatalog();
|
||||
|
||||
public static List<ConsumableOwnership> GetConsumableCatalog() =>
|
||||
LoadConsumableCatalog();
|
||||
|
||||
public static void EnsureAvatarItemsInitialized(
|
||||
long accountId,
|
||||
IEnumerable<string>? legacyAvatarDescriptors = null)
|
||||
{
|
||||
lock (InventoryLock)
|
||||
{
|
||||
string playerPath = GetAvatarItemsPath(accountId);
|
||||
if (File.Exists(playerPath))
|
||||
return;
|
||||
|
||||
List<AvatarItemOwnership> owned = LoadAvatarCatalog()
|
||||
.Where(item => !IsSpecialAvatarItem(item))
|
||||
.ToList();
|
||||
|
||||
HashSet<string> legacy = (legacyAvatarDescriptors ?? Array.Empty<string>())
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (legacy.Count > 0)
|
||||
{
|
||||
foreach (AvatarItemOwnership item in LoadAvatarCatalog())
|
||||
{
|
||||
if (!legacy.Contains(item.AvatarItemDesc))
|
||||
continue;
|
||||
if (owned.Any(value => SameAvatar(value, item)))
|
||||
continue;
|
||||
owned.Add(item);
|
||||
}
|
||||
|
||||
foreach (string descriptor in legacy)
|
||||
{
|
||||
if (owned.Any(value => string.Equals(
|
||||
value.AvatarItemDesc,
|
||||
descriptor,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
owned.Add(new AvatarItemOwnership
|
||||
{
|
||||
AvatarItemDesc = descriptor,
|
||||
FriendlyName = descriptor
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SaveAvatarItemsUnsafe(playerPath, owned);
|
||||
}
|
||||
}
|
||||
|
||||
public static void EnsureConsumablesInitialized(long accountId)
|
||||
{
|
||||
lock (InventoryLock)
|
||||
{
|
||||
string playerPath = GetConsumablesPath(accountId);
|
||||
if (File.Exists(playerPath))
|
||||
return;
|
||||
|
||||
SaveConsumablesUnsafe(playerPath, LoadConsumableCatalog());
|
||||
}
|
||||
}
|
||||
|
||||
public static List<AvatarItemOwnership> GetAvatarItems(
|
||||
long accountId,
|
||||
IEnumerable<string>? legacyAvatarDescriptors = null)
|
||||
{
|
||||
lock (InventoryLock)
|
||||
{
|
||||
EnsureAvatarItemsInitialized(accountId, legacyAvatarDescriptors);
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<AvatarItemOwnership>>(
|
||||
File.ReadAllText(GetAvatarItemsPath(accountId)),
|
||||
ReadOptions)
|
||||
?? new List<AvatarItemOwnership>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new List<AvatarItemOwnership>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static List<ConsumableOwnership> GetConsumables(long accountId)
|
||||
{
|
||||
lock (InventoryLock)
|
||||
{
|
||||
EnsureConsumablesInitialized(accountId);
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<ConsumableOwnership>>(
|
||||
File.ReadAllText(GetConsumablesPath(accountId)),
|
||||
ReadOptions)
|
||||
?? new List<ConsumableOwnership>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new List<ConsumableOwnership>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool OwnsAvatarItem(
|
||||
long accountId,
|
||||
string descriptor,
|
||||
long avatarItemId = 0,
|
||||
IEnumerable<string>? legacyAvatarDescriptors = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(descriptor) && avatarItemId <= 0)
|
||||
return false;
|
||||
|
||||
return GetAvatarItems(accountId, legacyAvatarDescriptors).Any(item =>
|
||||
(!string.IsNullOrWhiteSpace(descriptor) && string.Equals(
|
||||
item.AvatarItemDesc,
|
||||
descriptor,
|
||||
StringComparison.OrdinalIgnoreCase)) ||
|
||||
(avatarItemId > 0 && item.AvatarItemId == avatarItemId));
|
||||
}
|
||||
|
||||
public static bool SetAvatarItemOwned(
|
||||
long accountId,
|
||||
string descriptor,
|
||||
long avatarItemId,
|
||||
string? friendlyName,
|
||||
bool owned,
|
||||
IEnumerable<string>? legacyAvatarDescriptors = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(descriptor) && avatarItemId <= 0)
|
||||
return false;
|
||||
|
||||
lock (InventoryLock)
|
||||
{
|
||||
List<AvatarItemOwnership> items = GetAvatarItems(
|
||||
accountId,
|
||||
legacyAvatarDescriptors);
|
||||
|
||||
int index = items.FindIndex(item =>
|
||||
(!string.IsNullOrWhiteSpace(descriptor) && string.Equals(
|
||||
item.AvatarItemDesc,
|
||||
descriptor,
|
||||
StringComparison.OrdinalIgnoreCase)) ||
|
||||
(avatarItemId > 0 && item.AvatarItemId == avatarItemId));
|
||||
|
||||
if (owned)
|
||||
{
|
||||
if (index < 0)
|
||||
{
|
||||
items.Add(new AvatarItemOwnership
|
||||
{
|
||||
AvatarItemId = avatarItemId,
|
||||
AvatarItemDesc = descriptor?.Trim() ?? string.Empty,
|
||||
FriendlyName = string.IsNullOrWhiteSpace(friendlyName)
|
||||
? descriptor?.Trim() ?? $"Avatar Item {avatarItemId}"
|
||||
: friendlyName.Trim()
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (avatarItemId > 0)
|
||||
items[index].AvatarItemId = avatarItemId;
|
||||
if (!string.IsNullOrWhiteSpace(descriptor))
|
||||
items[index].AvatarItemDesc = descriptor.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(friendlyName))
|
||||
items[index].FriendlyName = friendlyName.Trim();
|
||||
}
|
||||
}
|
||||
else if (index >= 0)
|
||||
{
|
||||
items.RemoveAt(index);
|
||||
}
|
||||
|
||||
SaveAvatarItemsUnsafe(GetAvatarItemsPath(accountId), items);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static int GetConsumableQuantity(
|
||||
long accountId,
|
||||
string descriptor,
|
||||
long consumableItemId = 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(descriptor) && consumableItemId <= 0)
|
||||
return 0;
|
||||
|
||||
return SumConsumableQuantity(
|
||||
GetConsumables(accountId).Where(value =>
|
||||
SameConsumable(value, descriptor, consumableItemId)));
|
||||
}
|
||||
|
||||
public static int SetConsumableQuantity(
|
||||
long accountId,
|
||||
string descriptor,
|
||||
long consumableItemId,
|
||||
string? friendlyName,
|
||||
int quantity)
|
||||
{
|
||||
quantity = Math.Clamp(quantity, 0, 100_000);
|
||||
if (string.IsNullOrWhiteSpace(descriptor) && consumableItemId <= 0)
|
||||
return 0;
|
||||
|
||||
lock (InventoryLock)
|
||||
{
|
||||
List<ConsumableOwnership> items = GetConsumables(accountId);
|
||||
List<int> matchingIndexes = items
|
||||
.Select((value, index) => new { value, index })
|
||||
.Where(entry => SameConsumable(
|
||||
entry.value,
|
||||
descriptor,
|
||||
consumableItemId))
|
||||
.Select(entry => entry.index)
|
||||
.ToList();
|
||||
|
||||
if (quantity <= 0)
|
||||
{
|
||||
for (int match = matchingIndexes.Count - 1; match >= 0; match--)
|
||||
items.RemoveAt(matchingIndexes[match]);
|
||||
}
|
||||
else if (matchingIndexes.Count == 0)
|
||||
{
|
||||
items.Add(new ConsumableOwnership
|
||||
{
|
||||
ConsumableItemId = consumableItemId,
|
||||
ConsumableItemDesc = descriptor?.Trim() ?? string.Empty,
|
||||
FriendlyName = string.IsNullOrWhiteSpace(friendlyName)
|
||||
? descriptor?.Trim() ?? $"Consumable {consumableItemId}"
|
||||
: friendlyName.Trim(),
|
||||
Quantity = quantity
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
int index = matchingIndexes[0];
|
||||
items[index].Quantity = quantity;
|
||||
if (consumableItemId > 0)
|
||||
items[index].ConsumableItemId = consumableItemId;
|
||||
if (!string.IsNullOrWhiteSpace(descriptor))
|
||||
items[index].ConsumableItemDesc = descriptor.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(friendlyName))
|
||||
items[index].FriendlyName = friendlyName.Trim();
|
||||
|
||||
for (int match = matchingIndexes.Count - 1; match >= 1; match--)
|
||||
items.RemoveAt(matchingIndexes[match]);
|
||||
}
|
||||
|
||||
SaveConsumablesUnsafe(GetConsumablesPath(accountId), items);
|
||||
return quantity;
|
||||
}
|
||||
}
|
||||
|
||||
public static int AddConsumable(
|
||||
long accountId,
|
||||
string descriptor,
|
||||
long consumableItemId,
|
||||
string? friendlyName,
|
||||
int amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
return GetConsumableQuantity(accountId, descriptor, consumableItemId);
|
||||
|
||||
lock (InventoryLock)
|
||||
{
|
||||
int current = GetConsumableQuantity(
|
||||
accountId,
|
||||
descriptor,
|
||||
consumableItemId);
|
||||
int next = (int)Math.Min(100_000L, (long)current + amount);
|
||||
return SetConsumableQuantity(
|
||||
accountId,
|
||||
descriptor,
|
||||
consumableItemId,
|
||||
friendlyName,
|
||||
next);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryConsumeConsumable(
|
||||
long accountId,
|
||||
string descriptor,
|
||||
long consumableItemId,
|
||||
int amount,
|
||||
out int previousQuantity,
|
||||
out int remainingQuantity)
|
||||
{
|
||||
previousQuantity = 0;
|
||||
remainingQuantity = 0;
|
||||
if (amount <= 0 ||
|
||||
(string.IsNullOrWhiteSpace(descriptor) && consumableItemId <= 0))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (InventoryLock)
|
||||
{
|
||||
List<ConsumableOwnership> items = GetConsumables(accountId);
|
||||
List<int> matchingIndexes = items
|
||||
.Select((value, index) => new { value, index })
|
||||
.Where(entry => SameConsumable(
|
||||
entry.value,
|
||||
descriptor,
|
||||
consumableItemId))
|
||||
.Select(entry => entry.index)
|
||||
.ToList();
|
||||
|
||||
previousQuantity = SumConsumableQuantity(
|
||||
matchingIndexes.Select(index => items[index]));
|
||||
if (matchingIndexes.Count == 0 || previousQuantity <= 0)
|
||||
return false;
|
||||
|
||||
remainingQuantity = Math.Max(0, previousQuantity - amount);
|
||||
|
||||
if (remainingQuantity == 0)
|
||||
{
|
||||
for (int match = matchingIndexes.Count - 1; match >= 0; match--)
|
||||
items.RemoveAt(matchingIndexes[match]);
|
||||
}
|
||||
else
|
||||
{
|
||||
int index = matchingIndexes[0];
|
||||
items[index].Quantity = remainingQuantity;
|
||||
for (int match = matchingIndexes.Count - 1; match >= 1; match--)
|
||||
items.RemoveAt(matchingIndexes[match]);
|
||||
}
|
||||
|
||||
SaveConsumablesUnsafe(GetConsumablesPath(accountId), items);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<AvatarItemOwnership> LoadAvatarCatalog()
|
||||
{
|
||||
string path = Path.Combine(
|
||||
Program.dataDir,
|
||||
"APIS",
|
||||
"Items",
|
||||
"AvatarItemsDefault.json");
|
||||
|
||||
if (!File.Exists(path))
|
||||
return new List<AvatarItemOwnership>();
|
||||
|
||||
try
|
||||
{
|
||||
JsonNode? root = JsonNode.Parse(File.ReadAllText(path));
|
||||
if (root is not JsonArray array)
|
||||
return new List<AvatarItemOwnership>();
|
||||
|
||||
var results = new List<AvatarItemOwnership>();
|
||||
foreach (JsonNode? node in array)
|
||||
{
|
||||
if (node is not JsonObject item)
|
||||
continue;
|
||||
|
||||
long id = ReadLong(item, "AvatarItemId", "ItemId", "Id");
|
||||
string descriptor = ReadString(
|
||||
item,
|
||||
"AvatarItemDesc",
|
||||
"AvatarItemDescriptor",
|
||||
"ItemDesc");
|
||||
string name = ReadString(item, "FriendlyName", "Name");
|
||||
|
||||
if (id <= 0 && string.IsNullOrWhiteSpace(descriptor))
|
||||
continue;
|
||||
|
||||
results.Add(new AvatarItemOwnership
|
||||
{
|
||||
AvatarItemId = id,
|
||||
AvatarItemDesc = descriptor,
|
||||
FriendlyName = string.IsNullOrWhiteSpace(name)
|
||||
? descriptor
|
||||
: name
|
||||
});
|
||||
}
|
||||
|
||||
return results
|
||||
.GroupBy(item => string.IsNullOrWhiteSpace(item.AvatarItemDesc)
|
||||
? $"id:{item.AvatarItemId}"
|
||||
: $"desc:{item.AvatarItemDesc}",
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<AvatarItemOwnership>();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ConsumableOwnership> LoadConsumableCatalog()
|
||||
{
|
||||
string path = Path.Combine(
|
||||
Program.dataDir,
|
||||
"APIS",
|
||||
"Items",
|
||||
"ConsumablesDefault.json");
|
||||
|
||||
if (!File.Exists(path))
|
||||
return new List<ConsumableOwnership>();
|
||||
|
||||
try
|
||||
{
|
||||
JsonNode? root = JsonNode.Parse(File.ReadAllText(path));
|
||||
if (root is not JsonArray array)
|
||||
return new List<ConsumableOwnership>();
|
||||
|
||||
var results = new List<ConsumableOwnership>();
|
||||
foreach (JsonNode? node in array)
|
||||
{
|
||||
if (node is not JsonObject item)
|
||||
continue;
|
||||
|
||||
long id = ReadLong(item, "ConsumableItemId", "ItemId", "Id");
|
||||
if (id <= 0 && item["Ids"] is JsonArray ids)
|
||||
{
|
||||
foreach (JsonNode? idNode in ids)
|
||||
{
|
||||
if (TryLong(idNode, out long candidate) && candidate > 0)
|
||||
{
|
||||
id = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string descriptor = ReadString(
|
||||
item,
|
||||
"ConsumableItemDesc",
|
||||
"ConsumableDesc",
|
||||
"ItemDesc");
|
||||
string name = ReadString(item, "FriendlyName", "Name");
|
||||
if (id <= 0 && string.IsNullOrWhiteSpace(descriptor))
|
||||
continue;
|
||||
|
||||
long configuredQuantity = ReadLong(
|
||||
item,
|
||||
"Count",
|
||||
"Quantity",
|
||||
"InitialCount");
|
||||
int quantity = configuredQuantity > 0
|
||||
? (int)Math.Min(100_000L, configuredQuantity)
|
||||
: item["Ids"] is JsonArray itemIds
|
||||
? Math.Max(1, itemIds.Count)
|
||||
: 1;
|
||||
|
||||
results.Add(new ConsumableOwnership
|
||||
{
|
||||
ConsumableItemId = id,
|
||||
ConsumableItemDesc = descriptor,
|
||||
FriendlyName = string.IsNullOrWhiteSpace(name)
|
||||
? descriptor
|
||||
: name,
|
||||
Quantity = quantity
|
||||
});
|
||||
}
|
||||
|
||||
return results
|
||||
.GroupBy(item => string.IsNullOrWhiteSpace(item.ConsumableItemDesc)
|
||||
? $"id:{item.ConsumableItemId}"
|
||||
: $"desc:{item.ConsumableItemDesc}",
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<ConsumableOwnership>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSpecialAvatarItem(AvatarItemOwnership item) =>
|
||||
string.Equals(item.FriendlyName, "Alpaca Shirt", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool SameAvatar(
|
||||
AvatarItemOwnership left,
|
||||
AvatarItemOwnership right) =>
|
||||
(!string.IsNullOrWhiteSpace(left.AvatarItemDesc) &&
|
||||
string.Equals(left.AvatarItemDesc, right.AvatarItemDesc, StringComparison.OrdinalIgnoreCase)) ||
|
||||
(left.AvatarItemId > 0 && left.AvatarItemId == right.AvatarItemId);
|
||||
|
||||
private static bool SameConsumable(
|
||||
ConsumableOwnership item,
|
||||
string descriptor,
|
||||
long consumableItemId) =>
|
||||
(!string.IsNullOrWhiteSpace(descriptor) && string.Equals(
|
||||
item.ConsumableItemDesc,
|
||||
descriptor,
|
||||
StringComparison.OrdinalIgnoreCase)) ||
|
||||
(consumableItemId > 0 &&
|
||||
item.ConsumableItemId == consumableItemId);
|
||||
|
||||
private static int SumConsumableQuantity(
|
||||
IEnumerable<ConsumableOwnership> items)
|
||||
{
|
||||
long total = items.Sum(item => (long)Math.Max(0, item.Quantity));
|
||||
return (int)Math.Min(100_000L, total);
|
||||
}
|
||||
|
||||
private static void SaveAvatarItemsUnsafe(
|
||||
string path,
|
||||
List<AvatarItemOwnership> items) =>
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(items, WriteOptions));
|
||||
|
||||
private static void SaveConsumablesUnsafe(
|
||||
string path,
|
||||
List<ConsumableOwnership> items) =>
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(items, WriteOptions));
|
||||
|
||||
private static string ReadString(JsonObject item, params string[] names)
|
||||
{
|
||||
foreach (string name in names)
|
||||
{
|
||||
JsonNode? node = item[name];
|
||||
if (node == null)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
string? value = node.GetValue<string>();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
string value = node.ToJsonString().Trim('"');
|
||||
if (!string.IsNullOrWhiteSpace(value) && value != "null")
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static long ReadLong(JsonObject item, params string[] names)
|
||||
{
|
||||
foreach (string name in names)
|
||||
if (TryLong(item[name], out long value))
|
||||
return value;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool TryLong(JsonNode? node, out long value)
|
||||
{
|
||||
value = 0;
|
||||
if (node == null)
|
||||
return false;
|
||||
try
|
||||
{
|
||||
if (node is JsonValue jsonValue && jsonValue.TryGetValue(out value))
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return long.TryParse(node.ToJsonString().Trim('"'), out value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
public enum ProfanitySeverity
|
||||
{
|
||||
None,
|
||||
|
||||
Mild,
|
||||
|
||||
Severe,
|
||||
|
||||
SevereBypass
|
||||
}
|
||||
|
||||
public sealed record ProfanityMatch(
|
||||
string CanonicalWord,
|
||||
string MatchedText,
|
||||
ProfanitySeverity Severity,
|
||||
int Index,
|
||||
int Length,
|
||||
bool WasBypassed
|
||||
);
|
||||
|
||||
public sealed record ProfanityAnalysis(
|
||||
ProfanitySeverity Severity,
|
||||
string SanitizedText,
|
||||
IReadOnlyList<ProfanityMatch> Matches
|
||||
);
|
||||
|
||||
public static class ProfanityFilter
|
||||
{
|
||||
private const int MaxRepeatedCharacterCount = 16;
|
||||
private const int MaxSeparatorsBetweenLetters = 12;
|
||||
private const int MaxAnalyzedCharacters = 4096;
|
||||
|
||||
private static readonly TimeSpan RegexTimeout =
|
||||
TimeSpan.FromMilliseconds(150);
|
||||
|
||||
private static readonly string[] MildWords =
|
||||
{
|
||||
"fuck",
|
||||
"fucks",
|
||||
"fucked",
|
||||
"fucker",
|
||||
"fuckers",
|
||||
"fucking",
|
||||
"motherfucker",
|
||||
"motherfuckers",
|
||||
"motherfucking",
|
||||
|
||||
"shit",
|
||||
"shits",
|
||||
"shitty",
|
||||
"shitting",
|
||||
"bullshit",
|
||||
|
||||
"bitch",
|
||||
"bitches",
|
||||
"bitchy",
|
||||
|
||||
"ass",
|
||||
"asses",
|
||||
"asshole",
|
||||
"assholes",
|
||||
"dumbass",
|
||||
"jackass",
|
||||
|
||||
"dick",
|
||||
"dicks",
|
||||
"dickhead",
|
||||
"dickheads",
|
||||
|
||||
"cock",
|
||||
"cocks",
|
||||
|
||||
"pussy",
|
||||
"pussies",
|
||||
|
||||
"whore",
|
||||
"whores",
|
||||
|
||||
"slut",
|
||||
"sluts",
|
||||
|
||||
"bastard",
|
||||
"bastards",
|
||||
|
||||
"twat",
|
||||
"twats",
|
||||
|
||||
"damn",
|
||||
"dammit",
|
||||
|
||||
"hell",
|
||||
"crap",
|
||||
"piss",
|
||||
"pissed",
|
||||
|
||||
"sex",
|
||||
"sexy",
|
||||
|
||||
"cum",
|
||||
"dildo",
|
||||
"dildos",
|
||||
|
||||
"dealdough"
|
||||
};
|
||||
|
||||
private static readonly string[] SevereWords =
|
||||
{
|
||||
"nigger",
|
||||
"niggers",
|
||||
"nigga",
|
||||
"niggas",
|
||||
|
||||
"faggot",
|
||||
"faggots",
|
||||
"fag",
|
||||
"fags",
|
||||
|
||||
"retard",
|
||||
"retards",
|
||||
"retarded",
|
||||
|
||||
"cunt",
|
||||
"cunts",
|
||||
|
||||
"rape",
|
||||
"rapes",
|
||||
"raped",
|
||||
"raping",
|
||||
"rapist",
|
||||
"rapists",
|
||||
|
||||
"cumslut",
|
||||
"cumsluts",
|
||||
|
||||
"kike",
|
||||
"kikes",
|
||||
|
||||
"chink",
|
||||
"chinks",
|
||||
|
||||
"spic",
|
||||
"spics",
|
||||
|
||||
"wetback",
|
||||
"wetbacks",
|
||||
|
||||
"tranny",
|
||||
"trannies"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<char, string[]> CharacterAliases =
|
||||
new()
|
||||
{
|
||||
['a'] =
|
||||
[
|
||||
"4", "@", "à", "á", "â", "ã", "ä", "å",
|
||||
"ɑ", "α", "а"
|
||||
],
|
||||
|
||||
['b'] =
|
||||
[
|
||||
"8", "ß", "þ", "Ь", "в"
|
||||
],
|
||||
|
||||
['c'] =
|
||||
[
|
||||
"k", "(", "<", "¢", "с"
|
||||
],
|
||||
|
||||
['d'] =
|
||||
[
|
||||
"đ", "ԁ"
|
||||
],
|
||||
|
||||
['e'] =
|
||||
[
|
||||
"3", "€", "è", "é", "ê", "ë",
|
||||
"ε", "е"
|
||||
],
|
||||
|
||||
['f'] =
|
||||
[
|
||||
"ph", "ƒ"
|
||||
],
|
||||
|
||||
['g'] =
|
||||
[
|
||||
"9", "q", "ɡ", "ģ"
|
||||
],
|
||||
|
||||
['h'] =
|
||||
[
|
||||
"#", "н"
|
||||
],
|
||||
|
||||
['i'] =
|
||||
[
|
||||
"1", "!", "|", "l", "ì", "í", "î", "ï",
|
||||
"ι", "і"
|
||||
],
|
||||
|
||||
['j'] =
|
||||
[
|
||||
"ј"
|
||||
],
|
||||
|
||||
['k'] =
|
||||
[
|
||||
"c", "|<", "κ", "к"
|
||||
],
|
||||
|
||||
['l'] =
|
||||
[
|
||||
"1", "!", "|", "i", "ł", "ⅼ"
|
||||
],
|
||||
|
||||
['m'] =
|
||||
[
|
||||
"rn", "м"
|
||||
],
|
||||
|
||||
['n'] =
|
||||
[
|
||||
"ñ", "η", "п"
|
||||
],
|
||||
|
||||
['o'] =
|
||||
[
|
||||
"0", "°", "ò", "ó", "ô", "õ", "ö",
|
||||
"ο", "о"
|
||||
],
|
||||
|
||||
['p'] =
|
||||
[
|
||||
"ρ", "р"
|
||||
],
|
||||
|
||||
['q'] =
|
||||
[
|
||||
"9"
|
||||
],
|
||||
|
||||
['r'] =
|
||||
[
|
||||
"®", "ŕ", "я"
|
||||
],
|
||||
|
||||
['s'] =
|
||||
[
|
||||
"5", "$", "z", "ś", "ѕ"
|
||||
],
|
||||
|
||||
['t'] =
|
||||
[
|
||||
"7", "+", "ţ", "т"
|
||||
],
|
||||
|
||||
['u'] =
|
||||
[
|
||||
"v", "ù", "ú", "û", "ü",
|
||||
"υ", "ц"
|
||||
],
|
||||
|
||||
['v'] =
|
||||
[
|
||||
"u", "\\/"
|
||||
],
|
||||
|
||||
['w'] =
|
||||
[
|
||||
"vv", "\\/\\/"
|
||||
],
|
||||
|
||||
['x'] =
|
||||
[
|
||||
"%", "×", "х"
|
||||
],
|
||||
|
||||
['y'] =
|
||||
[
|
||||
"¥", "ý", "ÿ", "у"
|
||||
],
|
||||
|
||||
['z'] =
|
||||
[
|
||||
"2", "ѕ"
|
||||
]
|
||||
};
|
||||
|
||||
private static readonly Dictionary<char, char>
|
||||
NormalizedConfusableCharacters =
|
||||
BuildNormalizedConfusableCharacters();
|
||||
|
||||
private static readonly CompiledRule[] MildRules =
|
||||
BuildRules(MildWords, ProfanitySeverity.Mild);
|
||||
|
||||
private static readonly CompiledRule[] SevereRules =
|
||||
BuildRules(SevereWords, ProfanitySeverity.Severe);
|
||||
|
||||
public static ProfanityAnalysis Analyze(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return new ProfanityAnalysis(
|
||||
ProfanitySeverity.None,
|
||||
input ?? string.Empty,
|
||||
Array.Empty<ProfanityMatch>()
|
||||
);
|
||||
}
|
||||
|
||||
string analyzedInput = input.Length > MaxAnalyzedCharacters
|
||||
? input[..MaxAnalyzedCharacters]
|
||||
: input;
|
||||
var candidates = new List<ProfanityMatch>();
|
||||
|
||||
AddMatches(analyzedInput, SevereRules, candidates);
|
||||
AddMatches(analyzedInput, MildRules, candidates);
|
||||
AddNormalizedMatches(analyzedInput, SevereRules, candidates);
|
||||
AddNormalizedMatches(analyzedInput, MildRules, candidates);
|
||||
|
||||
var selectedMatches = RemoveOverlappingMatches(candidates);
|
||||
|
||||
ProfanitySeverity finalSeverity;
|
||||
|
||||
if (selectedMatches.Any(x =>
|
||||
x.Severity == ProfanitySeverity.SevereBypass))
|
||||
{
|
||||
finalSeverity = ProfanitySeverity.SevereBypass;
|
||||
}
|
||||
else if (selectedMatches.Any(x =>
|
||||
x.Severity == ProfanitySeverity.Severe))
|
||||
{
|
||||
finalSeverity = ProfanitySeverity.Severe;
|
||||
}
|
||||
else if (selectedMatches.Any(x =>
|
||||
x.Severity == ProfanitySeverity.Mild))
|
||||
{
|
||||
finalSeverity = ProfanitySeverity.Mild;
|
||||
}
|
||||
else
|
||||
{
|
||||
finalSeverity = ProfanitySeverity.None;
|
||||
}
|
||||
|
||||
string sanitized = ApplyBlur(input, selectedMatches);
|
||||
|
||||
return new ProfanityAnalysis(
|
||||
finalSeverity,
|
||||
sanitized,
|
||||
selectedMatches
|
||||
);
|
||||
}
|
||||
|
||||
public static ProfanitySeverity GetSeverity(string input)
|
||||
{
|
||||
return Analyze(input).Severity;
|
||||
}
|
||||
|
||||
public static string Blur(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
return Analyze(input).SanitizedText;
|
||||
}
|
||||
|
||||
public static bool IsPure(string? input)
|
||||
{
|
||||
return Analyze(input).Severity == ProfanitySeverity.None;
|
||||
}
|
||||
|
||||
public static bool ContainsProfanity(string? input)
|
||||
{
|
||||
return Analyze(input).Severity != ProfanitySeverity.None;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ProfanityMatch> GetMatches(string? input)
|
||||
{
|
||||
return Analyze(input).Matches;
|
||||
}
|
||||
|
||||
private static void AddMatches(
|
||||
string input,
|
||||
IEnumerable<CompiledRule> rules,
|
||||
ICollection<ProfanityMatch> results)
|
||||
{
|
||||
foreach (CompiledRule rule in rules)
|
||||
{
|
||||
try
|
||||
{
|
||||
MatchCollection matches = rule.FuzzyRegex.Matches(input);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
if (!match.Success || match.Length == 0)
|
||||
continue;
|
||||
|
||||
bool wasBypassed =
|
||||
!rule.ExactRegex.IsMatch(match.Value);
|
||||
|
||||
ProfanitySeverity severity = rule.BaseSeverity;
|
||||
|
||||
if (severity == ProfanitySeverity.Severe &&
|
||||
wasBypassed)
|
||||
{
|
||||
severity = ProfanitySeverity.SevereBypass;
|
||||
}
|
||||
|
||||
results.Add(new ProfanityMatch(
|
||||
rule.CanonicalWord,
|
||||
match.Value,
|
||||
severity,
|
||||
match.Index,
|
||||
match.Length,
|
||||
wasBypassed
|
||||
));
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddNormalizedMatches(
|
||||
string input,
|
||||
IEnumerable<CompiledRule> rules,
|
||||
ICollection<ProfanityMatch> results)
|
||||
{
|
||||
NormalizedDetectionText normalized =
|
||||
NormalizeForDetection(input);
|
||||
if (!normalized.Changed || normalized.Text.Length == 0)
|
||||
return;
|
||||
|
||||
foreach (CompiledRule rule in rules)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (Match match in rule.FuzzyRegex.Matches(
|
||||
normalized.Text))
|
||||
{
|
||||
if (!match.Success || match.Length == 0)
|
||||
continue;
|
||||
|
||||
int normalizedEnd = match.Index + match.Length - 1;
|
||||
if (match.Index >= normalized.SourceStarts.Count ||
|
||||
normalizedEnd >= normalized.SourceEnds.Count)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int sourceStart =
|
||||
normalized.SourceStarts[match.Index];
|
||||
int sourceEnd =
|
||||
normalized.SourceEnds[normalizedEnd];
|
||||
int sourceLength = sourceEnd - sourceStart;
|
||||
if (sourceLength <= 0)
|
||||
continue;
|
||||
|
||||
bool duplicate = results.Any(existing =>
|
||||
existing.CanonicalWord.Equals(
|
||||
rule.CanonicalWord,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
existing.Index == sourceStart &&
|
||||
existing.Length == sourceLength);
|
||||
if (duplicate)
|
||||
continue;
|
||||
|
||||
ProfanitySeverity severity =
|
||||
rule.BaseSeverity ==
|
||||
ProfanitySeverity.Severe
|
||||
? ProfanitySeverity.SevereBypass
|
||||
: rule.BaseSeverity;
|
||||
results.Add(new ProfanityMatch(
|
||||
rule.CanonicalWord,
|
||||
input.Substring(sourceStart, sourceLength),
|
||||
severity,
|
||||
sourceStart,
|
||||
sourceLength,
|
||||
WasBypassed: true));
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static NormalizedDetectionText NormalizeForDetection(
|
||||
string input)
|
||||
{
|
||||
var text = new StringBuilder(input.Length);
|
||||
var starts = new List<int>(input.Length);
|
||||
var ends = new List<int>(input.Length);
|
||||
bool changed = false;
|
||||
int sourceIndex = 0;
|
||||
|
||||
foreach (Rune sourceRune in input.EnumerateRunes())
|
||||
{
|
||||
int sourceEnd =
|
||||
sourceIndex + sourceRune.Utf16SequenceLength;
|
||||
string compatibilityForm = sourceRune
|
||||
.ToString()
|
||||
.Normalize(NormalizationForm.FormKD);
|
||||
int outputStart = text.Length;
|
||||
|
||||
foreach (Rune normalizedRune in
|
||||
compatibilityForm.EnumerateRunes())
|
||||
{
|
||||
UnicodeCategory category =
|
||||
Rune.GetUnicodeCategory(normalizedRune);
|
||||
if (category is
|
||||
UnicodeCategory.NonSpacingMark or
|
||||
UnicodeCategory.SpacingCombiningMark or
|
||||
UnicodeCategory.EnclosingMark)
|
||||
{
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
char output;
|
||||
if (normalizedRune.IsAscii)
|
||||
{
|
||||
output = char.ToLowerInvariant(
|
||||
(char)normalizedRune.Value);
|
||||
}
|
||||
else if (normalizedRune.Value <= char.MaxValue &&
|
||||
NormalizedConfusableCharacters.TryGetValue(
|
||||
(char)normalizedRune.Value,
|
||||
out char mapped))
|
||||
{
|
||||
output = mapped;
|
||||
}
|
||||
else if (Rune.IsLetterOrDigit(normalizedRune) &&
|
||||
normalizedRune.Value <= char.MaxValue)
|
||||
{
|
||||
output = char.ToLowerInvariant(
|
||||
(char)normalizedRune.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
output = ' ';
|
||||
}
|
||||
|
||||
text.Append(output);
|
||||
starts.Add(sourceIndex);
|
||||
ends.Add(sourceEnd);
|
||||
}
|
||||
|
||||
if (text.Length == outputStart)
|
||||
{
|
||||
text.Append(' ');
|
||||
starts.Add(sourceIndex);
|
||||
ends.Add(sourceEnd);
|
||||
}
|
||||
|
||||
string produced = text
|
||||
.ToString(outputStart, text.Length - outputStart);
|
||||
if (!produced.Equals(
|
||||
sourceRune.ToString(),
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
changed = true;
|
||||
}
|
||||
|
||||
sourceIndex = sourceEnd;
|
||||
}
|
||||
|
||||
return new NormalizedDetectionText(
|
||||
text.ToString(),
|
||||
starts,
|
||||
ends,
|
||||
changed);
|
||||
}
|
||||
|
||||
private static Dictionary<char, char>
|
||||
BuildNormalizedConfusableCharacters()
|
||||
{
|
||||
var result = new Dictionary<char, char>();
|
||||
foreach ((char canonical, string[] aliases) in CharacterAliases)
|
||||
{
|
||||
foreach (string alias in aliases)
|
||||
{
|
||||
if (alias.Length != 1)
|
||||
continue;
|
||||
|
||||
char candidate = alias[0];
|
||||
if (candidate <= 127)
|
||||
continue;
|
||||
|
||||
UnicodeCategory category =
|
||||
CharUnicodeInfo.GetUnicodeCategory(candidate);
|
||||
if (category is
|
||||
UnicodeCategory.UppercaseLetter or
|
||||
UnicodeCategory.LowercaseLetter or
|
||||
UnicodeCategory.TitlecaseLetter or
|
||||
UnicodeCategory.ModifierLetter or
|
||||
UnicodeCategory.OtherLetter)
|
||||
{
|
||||
result.TryAdd(
|
||||
char.ToLowerInvariant(candidate),
|
||||
canonical);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ProfanityMatch>
|
||||
RemoveOverlappingMatches(IEnumerable<ProfanityMatch> matches)
|
||||
{
|
||||
|
||||
var prioritized = matches
|
||||
.OrderByDescending(x => GetSeverityPriority(x.Severity))
|
||||
.ThenByDescending(x => x.Length)
|
||||
.ThenBy(x => x.Index)
|
||||
.ToList();
|
||||
|
||||
var selected = new List<ProfanityMatch>();
|
||||
|
||||
foreach (ProfanityMatch candidate in prioritized)
|
||||
{
|
||||
bool overlapsExisting = selected.Any(existing =>
|
||||
RangesOverlap(
|
||||
candidate.Index,
|
||||
candidate.Length,
|
||||
existing.Index,
|
||||
existing.Length
|
||||
));
|
||||
|
||||
if (!overlapsExisting)
|
||||
selected.Add(candidate);
|
||||
}
|
||||
|
||||
return selected
|
||||
.OrderBy(x => x.Index)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static int GetSeverityPriority(
|
||||
ProfanitySeverity severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
ProfanitySeverity.SevereBypass => 3,
|
||||
ProfanitySeverity.Severe => 2,
|
||||
ProfanitySeverity.Mild => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static bool RangesOverlap(
|
||||
int firstIndex,
|
||||
int firstLength,
|
||||
int secondIndex,
|
||||
int secondLength)
|
||||
{
|
||||
int firstEnd = firstIndex + firstLength;
|
||||
int secondEnd = secondIndex + secondLength;
|
||||
|
||||
return firstIndex < secondEnd &&
|
||||
secondIndex < firstEnd;
|
||||
}
|
||||
|
||||
private static string ApplyBlur(
|
||||
string input,
|
||||
IEnumerable<ProfanityMatch> matches)
|
||||
{
|
||||
char[] characters = input.ToCharArray();
|
||||
|
||||
foreach (ProfanityMatch match in matches)
|
||||
{
|
||||
int start = Math.Max(0, match.Index);
|
||||
int end = Math.Min(
|
||||
characters.Length,
|
||||
match.Index + match.Length
|
||||
);
|
||||
|
||||
for (int i = start; i < end; i++)
|
||||
{
|
||||
|
||||
if (!char.IsWhiteSpace(characters[i]))
|
||||
characters[i] = '*';
|
||||
}
|
||||
}
|
||||
|
||||
return new string(characters);
|
||||
}
|
||||
|
||||
private static CompiledRule[] BuildRules(
|
||||
IEnumerable<string> words,
|
||||
ProfanitySeverity baseSeverity)
|
||||
{
|
||||
return words
|
||||
.Where(word => !string.IsNullOrWhiteSpace(word))
|
||||
.Select(word => word.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(word => word.Length)
|
||||
.Select(word => new CompiledRule(
|
||||
word,
|
||||
baseSeverity,
|
||||
BuildFuzzyRegex(word),
|
||||
BuildExactRegex(word)
|
||||
))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static Regex BuildFuzzyRegex(string word)
|
||||
{
|
||||
string pattern = BuildFuzzyWordPattern(word);
|
||||
|
||||
return new Regex(
|
||||
pattern,
|
||||
RegexOptions.IgnoreCase |
|
||||
RegexOptions.CultureInvariant |
|
||||
RegexOptions.Compiled,
|
||||
RegexTimeout
|
||||
);
|
||||
}
|
||||
|
||||
private static Regex BuildExactRegex(string word)
|
||||
{
|
||||
string escapedWord = Regex.Escape(word);
|
||||
|
||||
string pattern =
|
||||
$@"(?<![\p{{L}}\p{{N}}])" +
|
||||
escapedWord +
|
||||
$@"(?![\p{{L}}\p{{N}}])";
|
||||
|
||||
return new Regex(
|
||||
pattern,
|
||||
RegexOptions.IgnoreCase |
|
||||
RegexOptions.CultureInvariant |
|
||||
RegexOptions.Compiled,
|
||||
RegexTimeout
|
||||
);
|
||||
}
|
||||
|
||||
private static string BuildFuzzyWordPattern(string word)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
builder.Append(@"(?<![\p{L}\p{N}])");
|
||||
|
||||
for (int i = 0; i < word.Length; i++)
|
||||
{
|
||||
char character = char.ToLowerInvariant(word[i]);
|
||||
|
||||
builder.Append(
|
||||
BuildCharacterPattern(character)
|
||||
);
|
||||
|
||||
if (i < word.Length - 1)
|
||||
{
|
||||
|
||||
builder.Append(
|
||||
$@"[\s\p{{P}}\p{{S}}\p{{Cf}}]" +
|
||||
$"{{0,{MaxSeparatorsBetweenLetters}}}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append(@"(?![\p{L}\p{N}])");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildCharacterPattern(char character)
|
||||
{
|
||||
var alternatives = new HashSet<string>(
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
character.ToString()
|
||||
};
|
||||
|
||||
if (character is >= 'a' and <= 'z')
|
||||
{
|
||||
char fullWidthCharacter =
|
||||
(char)('\uFF41' + (character - 'a'));
|
||||
|
||||
alternatives.Add(fullWidthCharacter.ToString());
|
||||
}
|
||||
|
||||
if (CharacterAliases.TryGetValue(
|
||||
character,
|
||||
out string[]? aliases))
|
||||
{
|
||||
foreach (string alias in aliases)
|
||||
alternatives.Add(alias);
|
||||
}
|
||||
|
||||
string alternativesPattern = string.Join(
|
||||
"|",
|
||||
alternatives
|
||||
.OrderByDescending(value => value.Length)
|
||||
.Select(Regex.Escape)
|
||||
);
|
||||
|
||||
return
|
||||
$"(?:{alternativesPattern})" +
|
||||
$"{{1,{MaxRepeatedCharacterCount}}}";
|
||||
}
|
||||
|
||||
private sealed class CompiledRule
|
||||
{
|
||||
public string CanonicalWord { get; }
|
||||
|
||||
public ProfanitySeverity BaseSeverity { get; }
|
||||
|
||||
public Regex FuzzyRegex { get; }
|
||||
|
||||
public Regex ExactRegex { get; }
|
||||
|
||||
public CompiledRule(
|
||||
string canonicalWord,
|
||||
ProfanitySeverity baseSeverity,
|
||||
Regex fuzzyRegex,
|
||||
Regex exactRegex)
|
||||
{
|
||||
CanonicalWord = canonicalWord;
|
||||
BaseSeverity = baseSeverity;
|
||||
FuzzyRegex = fuzzyRegex;
|
||||
ExactRegex = exactRegex;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record NormalizedDetectionText(
|
||||
string Text,
|
||||
IReadOnlyList<int> SourceStarts,
|
||||
IReadOnlyList<int> SourceEnds,
|
||||
bool Changed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
public class ServerConfig
|
||||
{
|
||||
public static object Bracket => new List<object>();
|
||||
public static string BaseURL = "https://playmocha.xyz";
|
||||
public static int GameVersion = 20230406;
|
||||
|
||||
public const string PhotonRealtimeAppId =
|
||||
"c73b99b5-eba7-4152-90c2-b3f7bac5fa00";
|
||||
|
||||
public const string PhotonVoiceAppId =
|
||||
"88ed1211-b61c-451c-90f6-9e494ce7ef5f";
|
||||
|
||||
public const string PhotonRegion = "us";
|
||||
public const string PhotonAppVersion = "20230427_prod";
|
||||
public static readonly string PhotonCustomAuthSecret =
|
||||
Program.LoadLocalSetting("PHOTON_CUSTOM_AUTH_SECRET");
|
||||
|
||||
public static bool PhotonEnabled =>
|
||||
!string.IsNullOrWhiteSpace(PhotonRealtimeAppId);
|
||||
|
||||
public static readonly Version MinModVersion = new Version(1, 0, 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
using Mocha2023.Classes.DBs;
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
using static Mocha2023.Classes.DBs.DBClasses.PlayerDBClasses;
|
||||
using static Mocha2023.Classes.DBs.DBClasses.RoomDBClasses;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
public class Sessions
|
||||
{
|
||||
private static readonly Random _rng = new();
|
||||
private static readonly object _matchmakingLock = new();
|
||||
private const long ActiveHeartbeatWindowSeconds = 120;
|
||||
private static readonly Dictionary<long, HashSet<long>>
|
||||
_confirmedParticipantsByInstance = new();
|
||||
private static readonly Dictionary<long, long>
|
||||
_instanceOwners = new();
|
||||
private static readonly Dictionary<long, DateTime>
|
||||
_guestDormEnteredAt = new();
|
||||
private static readonly TimeSpan GuestDormHoldWindow =
|
||||
TimeSpan.FromSeconds(15);
|
||||
|
||||
public static Heartbeat? CreateRoom(
|
||||
long playerId,
|
||||
long roomId,
|
||||
string sceneName = "",
|
||||
bool isPrivate = false)
|
||||
{
|
||||
return CreateRoomInternal(
|
||||
playerId,
|
||||
roomId,
|
||||
sceneName,
|
||||
exactSubRoomId: null,
|
||||
isPrivate: isPrivate);
|
||||
}
|
||||
|
||||
public static Heartbeat? CreateRoom(
|
||||
long playerId,
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
bool isPrivate = false)
|
||||
{
|
||||
return CreateRoomInternal(
|
||||
playerId,
|
||||
roomId,
|
||||
sceneName: null,
|
||||
exactSubRoomId: subRoomId,
|
||||
isPrivate: isPrivate);
|
||||
}
|
||||
|
||||
private static Heartbeat? CreateRoomInternal(
|
||||
long playerId,
|
||||
long roomId,
|
||||
string? sceneName,
|
||||
long? exactSubRoomId,
|
||||
bool isPrivate)
|
||||
{
|
||||
var room = RoomDB.GetRoom(roomId);
|
||||
|
||||
if (room?.SubRooms == null || room.SubRooms.Count == 0)
|
||||
return null;
|
||||
|
||||
SubRooms? subRoom = null;
|
||||
|
||||
if (exactSubRoomId.HasValue)
|
||||
{
|
||||
subRoom = room.SubRooms.FirstOrDefault(sub =>
|
||||
sub.SubRoomId == exactSubRoomId.Value);
|
||||
|
||||
if (subRoom == null)
|
||||
return null;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(sceneName))
|
||||
{
|
||||
subRoom = room.SubRooms.FirstOrDefault(sub =>
|
||||
string.Equals(
|
||||
sub.Name,
|
||||
sceneName,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!exactSubRoomId.HasValue)
|
||||
{
|
||||
|
||||
subRoom ??= room.SubRooms
|
||||
.Where(sub => sub.Accessibility != RoomAccessibility.Private)
|
||||
.OrderByDescending(sub => string.Equals(
|
||||
sub.Name, "Home", StringComparison.OrdinalIgnoreCase))
|
||||
.FirstOrDefault();
|
||||
|
||||
subRoom ??= room.SubRooms.FirstOrDefault(sub =>
|
||||
string.Equals(
|
||||
sub.Name,
|
||||
"Home",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
subRoom ??= room.SubRooms.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (subRoom == null)
|
||||
return null;
|
||||
|
||||
lock (_matchmakingLock)
|
||||
{
|
||||
if (!RoomDB.CanPlayerAccessSubRoom(
|
||||
room,
|
||||
subRoom,
|
||||
playerId) ||
|
||||
RoomDB.IsPlayerBannedFromRoom(room.RoomId, playerId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM ACCESS DENIED] player={playerId} " +
|
||||
$"room={room.RoomId} subroom={subRoom.SubRoomId} " +
|
||||
$"isDorm={room.IsDorm} accessibility={room.Accessibility} " +
|
||||
$"subroomAccessibility={subRoom.Accessibility}");
|
||||
return null;
|
||||
}
|
||||
|
||||
bool effectivePrivate =
|
||||
isPrivate ||
|
||||
room.IsDorm ||
|
||||
room.Accessibility == RoomAccessibility.Private ||
|
||||
subRoom.Accessibility == RoomAccessibility.Private;
|
||||
|
||||
if (!effectivePrivate)
|
||||
{
|
||||
var existing = FindBestPublicInstance(
|
||||
playerId,
|
||||
room.RoomId,
|
||||
subRoom.SubRoomId,
|
||||
subRoom.MaxPlayers);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
var joinedSession = CloneInstance(existing.Value.Instance);
|
||||
joinedSession.isFull =
|
||||
existing.Value.PlayerCount + 1 >= joinedSession.maxCapacity;
|
||||
RemovePlayerFromConfirmedInstancesNoLock(playerId);
|
||||
TrackParticipantNoLock(
|
||||
playerId,
|
||||
joinedSession.roomInstanceId);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[MATCHMAKING] player={playerId} joined existing " +
|
||||
$"room={room.RoomId} instance={joinedSession.roomInstanceId} " +
|
||||
$"players={existing.Value.PlayerCount + 1}/{joinedSession.maxCapacity}");
|
||||
|
||||
return PlayerDB.UpdatePlayerHeartbeat(playerId, joinedSession);
|
||||
}
|
||||
}
|
||||
|
||||
bool hasPersistencePair = RoomDB.TryGetPersistencePair(
|
||||
room,
|
||||
subRoom,
|
||||
out var dataSave,
|
||||
out string roomDataBlob,
|
||||
out _);
|
||||
long instanceId = _rng.NextInt64(1_000_000L, 1_000_000_000_000L);
|
||||
string roomName = room.Name ?? "UnknownRoom";
|
||||
|
||||
var session = new RoomInstance
|
||||
{
|
||||
Name = $"^{roomName}",
|
||||
|
||||
dataBlob = hasPersistencePair ? roomDataBlob : string.Empty,
|
||||
isPrivate = effectivePrivate,
|
||||
location = subRoom.UnitySceneId,
|
||||
maxCapacity = Math.Max(1, subRoom.MaxPlayers),
|
||||
photonRegion = ServerConfig.PhotonRegion,
|
||||
photonRegionId = ServerConfig.PhotonRegion,
|
||||
photonRoomId = effectivePrivate
|
||||
? $"MochaRoom-{roomName}-private-{instanceId}-{Guid.NewGuid()}"
|
||||
: $"MochaRoom-{roomName}-{instanceId}",
|
||||
roomId = room.RoomId,
|
||||
roomInstanceId = instanceId,
|
||||
roomInstanceType = effectivePrivate
|
||||
? RoomInstanceType.Private
|
||||
: RoomInstanceType.Public,
|
||||
subRoomId = subRoom.SubRoomId,
|
||||
subRoomDataSaveId = dataSave?.SubRoomDataSaveId ?? 0,
|
||||
createdAt = DateTime.UtcNow
|
||||
};
|
||||
RemovePlayerFromConfirmedInstancesNoLock(playerId);
|
||||
TrackParticipantNoLock(playerId, session.roomInstanceId);
|
||||
if (IsRestrictedInstance(session))
|
||||
_instanceOwners[session.roomInstanceId] = playerId;
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ROOM LOAD] room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId} " +
|
||||
$"name={subRoom.Name} " +
|
||||
$"instance={session.roomInstanceId} new=true " +
|
||||
$"superRoomBlob={room.DataBlob ?? "null"} " +
|
||||
$"subRoomBlob={subRoom.DataBlob ?? "null"} " +
|
||||
$"subRoomDataSaveId={session.subRoomDataSaveId}");
|
||||
|
||||
return PlayerDB.UpdatePlayerHeartbeat(playerId, session);
|
||||
}
|
||||
}
|
||||
|
||||
private static (RoomInstance Instance, int PlayerCount)? FindBestPublicInstance(
|
||||
long playerId,
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
int configuredCapacity)
|
||||
{
|
||||
long activeAfter = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
||||
- ActiveHeartbeatWindowSeconds;
|
||||
|
||||
return PlayerDB.Players.FindAll()
|
||||
.Where(player => player.PlayerId != playerId)
|
||||
.Select(player => player.Player?.PlayerExtra?.Heartbeat)
|
||||
.Where(heartbeat =>
|
||||
heartbeat != null &&
|
||||
heartbeat.isOnline &&
|
||||
heartbeat.lastHeartbeatUnixTime >= activeAfter &&
|
||||
heartbeat.roomInstance != null &&
|
||||
|
||||
!heartbeat.roomInstance.isPrivate &&
|
||||
heartbeat.roomInstance.roomId == roomId &&
|
||||
heartbeat.roomInstance.subRoomId == subRoomId &&
|
||||
!string.IsNullOrWhiteSpace(heartbeat.roomInstance.photonRoomId))
|
||||
.GroupBy(heartbeat => heartbeat!.roomInstance!.roomInstanceId)
|
||||
.Select(group =>
|
||||
{
|
||||
var instance = group.First()!.roomInstance!;
|
||||
int playerCount = group
|
||||
.Select(heartbeat => heartbeat!.playerId)
|
||||
.Distinct()
|
||||
.Count();
|
||||
int capacity = Math.Max(
|
||||
1,
|
||||
instance.maxCapacity > 0
|
||||
? instance.maxCapacity
|
||||
: configuredCapacity);
|
||||
|
||||
return new
|
||||
{
|
||||
Instance = instance,
|
||||
PlayerCount = playerCount,
|
||||
Capacity = capacity
|
||||
};
|
||||
})
|
||||
.Where(candidate => candidate.PlayerCount < candidate.Capacity)
|
||||
.OrderByDescending(candidate => candidate.PlayerCount)
|
||||
.ThenBy(candidate => candidate.Instance.roomInstanceId)
|
||||
.Select(candidate => ((RoomInstance Instance, int PlayerCount)?)
|
||||
(candidate.Instance, candidate.PlayerCount))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static RoomInstance CloneInstance(RoomInstance source)
|
||||
{
|
||||
return new RoomInstance
|
||||
{
|
||||
encryptVoiceChat = source.encryptVoiceChat,
|
||||
clubId = source.clubId,
|
||||
dataBlob = source.dataBlob,
|
||||
eventId = source.eventId,
|
||||
isFull = source.isFull,
|
||||
isInProgress = source.isInProgress,
|
||||
isPrivate = source.isPrivate,
|
||||
location = source.location,
|
||||
maxCapacity = source.maxCapacity,
|
||||
Name = source.Name,
|
||||
photonRegion = source.photonRegion,
|
||||
photonRegionId = source.photonRegionId,
|
||||
photonRoomId = source.photonRoomId,
|
||||
roomCode = source.roomCode,
|
||||
roomId = source.roomId,
|
||||
roomInstanceId = source.roomInstanceId,
|
||||
roomInstanceType = source.roomInstanceType,
|
||||
subRoomId = source.subRoomId,
|
||||
subRoomDataSaveId = source.subRoomDataSaveId,
|
||||
createdAt = source.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsRestrictedInstance(RoomInstance? instance) =>
|
||||
instance != null &&
|
||||
(instance.isPrivate ||
|
||||
instance.roomInstanceType is
|
||||
RoomInstanceType.Private or RoomInstanceType.Dormroom);
|
||||
|
||||
public static Heartbeat? JoinRoomInstance(
|
||||
long playerId,
|
||||
long roomInstanceId)
|
||||
{
|
||||
return JoinRoomInstanceInternal(
|
||||
playerId,
|
||||
roomInstanceId,
|
||||
invitedByPlayerId: null,
|
||||
expectedRoomId: null);
|
||||
}
|
||||
|
||||
public static Heartbeat? JoinInvitedRoomInstance(
|
||||
long playerId,
|
||||
long roomInstanceId,
|
||||
long invitedByPlayerId,
|
||||
long expectedRoomId)
|
||||
{
|
||||
if (invitedByPlayerId <= 0 || expectedRoomId <= 0)
|
||||
return null;
|
||||
|
||||
return JoinRoomInstanceInternal(
|
||||
playerId,
|
||||
roomInstanceId,
|
||||
invitedByPlayerId,
|
||||
expectedRoomId);
|
||||
}
|
||||
|
||||
private static Heartbeat? JoinRoomInstanceInternal(
|
||||
long playerId,
|
||||
long roomInstanceId,
|
||||
long? invitedByPlayerId,
|
||||
long? expectedRoomId)
|
||||
{
|
||||
if (playerId <= 0 || roomInstanceId <= 0)
|
||||
return null;
|
||||
|
||||
lock (_matchmakingLock)
|
||||
{
|
||||
long activeAfter = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
||||
- ActiveHeartbeatWindowSeconds;
|
||||
|
||||
var occupants = PlayerDB.Players.FindAll()
|
||||
.Select(player => player.Player?.PlayerExtra?.Heartbeat)
|
||||
.Where(heartbeat =>
|
||||
heartbeat != null &&
|
||||
heartbeat.isOnline &&
|
||||
heartbeat.lastHeartbeatUnixTime >= activeAfter &&
|
||||
heartbeat.roomInstance != null &&
|
||||
heartbeat.roomInstance.roomInstanceId == roomInstanceId &&
|
||||
|
||||
!string.IsNullOrWhiteSpace(
|
||||
heartbeat.roomInstance.photonRoomId))
|
||||
.ToList();
|
||||
|
||||
RoomInstance? source = occupants
|
||||
.OrderByDescending(heartbeat =>
|
||||
heartbeat!.lastHeartbeatUnixTime)
|
||||
.Select(heartbeat => heartbeat!.roomInstance)
|
||||
.FirstOrDefault();
|
||||
if (source == null)
|
||||
return null;
|
||||
|
||||
if (expectedRoomId.HasValue &&
|
||||
source.roomId != expectedRoomId.Value)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[PRIVATE JOIN DENIED] player={playerId} " +
|
||||
$"instance={roomInstanceId} reason=invite-room-mismatch");
|
||||
return null;
|
||||
}
|
||||
|
||||
bool alreadyConfirmed = occupants.Any(heartbeat =>
|
||||
heartbeat!.playerId == playerId);
|
||||
bool isOwner = _instanceOwners.TryGetValue(
|
||||
roomInstanceId,
|
||||
out long ownerPlayerId) &&
|
||||
ownerPlayerId == playerId;
|
||||
bool inviterIsPresent = invitedByPlayerId.HasValue &&
|
||||
invitedByPlayerId.Value != playerId &&
|
||||
occupants.Any(heartbeat =>
|
||||
heartbeat!.playerId == invitedByPlayerId.Value);
|
||||
|
||||
bool hasVerifiedInviteGrant =
|
||||
invitedByPlayerId.HasValue &&
|
||||
invitedByPlayerId.Value != playerId &&
|
||||
NotificationDB.HasActiveRoomInvite(
|
||||
playerId,
|
||||
invitedByPlayerId.Value,
|
||||
roomInstanceId,
|
||||
source.roomId);
|
||||
|
||||
var sourceRoom = RoomDB.GetRoom(source.roomId);
|
||||
var sourceSubRoom = sourceRoom?.SubRooms?.FirstOrDefault(
|
||||
subRoom => subRoom.SubRoomId == source.subRoomId);
|
||||
bool persistentRoomIsRestricted =
|
||||
sourceRoom?.IsDorm == true ||
|
||||
sourceRoom?.Accessibility == RoomAccessibility.Private ||
|
||||
sourceSubRoom?.Accessibility == RoomAccessibility.Private;
|
||||
bool hasPersistentRoomAccess =
|
||||
persistentRoomIsRestricted &&
|
||||
RoomDB.CanPlayerAccessSubRoom(
|
||||
sourceRoom,
|
||||
sourceSubRoom,
|
||||
playerId);
|
||||
|
||||
if (sourceRoom != null &&
|
||||
RoomDB.IsPlayerBannedFromRoom(sourceRoom.RoomId, playerId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM ACCESS DENIED] player={playerId} " +
|
||||
$"room={sourceRoom.RoomId} instance={roomInstanceId} " +
|
||||
"reason=room-ban");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((IsRestrictedInstance(source) ||
|
||||
persistentRoomIsRestricted) &&
|
||||
!alreadyConfirmed &&
|
||||
!isOwner &&
|
||||
!hasVerifiedInviteGrant &&
|
||||
!hasPersistentRoomAccess)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[PRIVATE JOIN DENIED] player={playerId} " +
|
||||
$"instance={roomInstanceId} room={source.roomId} " +
|
||||
"reason=invite-required");
|
||||
return null;
|
||||
}
|
||||
|
||||
int playerCount = occupants
|
||||
.Select(heartbeat => heartbeat!.playerId)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Count();
|
||||
int capacity = Math.Max(1, source.maxCapacity);
|
||||
if (!alreadyConfirmed && playerCount >= capacity)
|
||||
return null;
|
||||
|
||||
RoomInstance joinedSession = CloneInstance(source);
|
||||
int joinedPlayerCount = alreadyConfirmed
|
||||
? playerCount
|
||||
: playerCount + 1;
|
||||
joinedSession.isFull = joinedPlayerCount >= capacity;
|
||||
RemovePlayerFromConfirmedInstancesNoLock(playerId);
|
||||
TrackParticipantNoLock(
|
||||
playerId,
|
||||
joinedSession.roomInstanceId);
|
||||
|
||||
if (hasVerifiedInviteGrant &&
|
||||
joinedSession.roomInstanceType == RoomInstanceType.Dormroom)
|
||||
{
|
||||
_guestDormEnteredAt[playerId] = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"[MATCHMAKING INSTANCE] player={playerId} " +
|
||||
$"joined room={joinedSession.roomId} " +
|
||||
$"instance={joinedSession.roomInstanceId} " +
|
||||
$"players={joinedPlayerCount}/{capacity} " +
|
||||
$"viaInvite={hasVerifiedInviteGrant.ToString().ToLowerInvariant()} " +
|
||||
$"inviterPresent={inviterIsPresent.ToString().ToLowerInvariant()}");
|
||||
|
||||
return PlayerDB.UpdatePlayerHeartbeat(
|
||||
playerId,
|
||||
joinedSession);
|
||||
}
|
||||
}
|
||||
|
||||
public static void MarkGuestDormEntry(long playerId)
|
||||
{
|
||||
lock (_matchmakingLock)
|
||||
_guestDormEnteredAt[playerId] = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static bool ShouldHoldInGuestDorm(long playerId, Heartbeat heartbeat)
|
||||
{
|
||||
if (!heartbeat.isOnline ||
|
||||
heartbeat.roomInstance?.roomInstanceType != RoomInstanceType.Dormroom)
|
||||
return false;
|
||||
|
||||
lock (_matchmakingLock)
|
||||
{
|
||||
return _guestDormEnteredAt.TryGetValue(
|
||||
playerId,
|
||||
out DateTime enteredAt) &&
|
||||
DateTime.UtcNow - enteredAt < GuestDormHoldWindow;
|
||||
}
|
||||
}
|
||||
|
||||
public static Heartbeat CreateDorm(long playerId, string name)
|
||||
{
|
||||
var dorm = RoomDB.GetOrCreatePlayerDorm(playerId)
|
||||
?? RoomDB.GetRoom(1);
|
||||
var subRoom =
|
||||
dorm?.SubRooms?.FirstOrDefault(sub =>
|
||||
string.Equals(
|
||||
sub.Name,
|
||||
"Home",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
?? dorm?.SubRooms?.FirstOrDefault();
|
||||
|
||||
long dormId = dorm?.RoomId ?? 1;
|
||||
|
||||
long instanceId = _rng.NextInt64(1_000_000L, 1_000_000_000_000L);
|
||||
SubRoomDataSave? dataSave = null;
|
||||
string roomDataBlob = string.Empty;
|
||||
bool hasPersistencePair = false;
|
||||
if (dorm != null && subRoom != null)
|
||||
{
|
||||
hasPersistencePair = RoomDB.TryGetPersistencePair(
|
||||
dorm,
|
||||
subRoom,
|
||||
out dataSave,
|
||||
out roomDataBlob,
|
||||
out _);
|
||||
}
|
||||
|
||||
var session = new RoomInstance
|
||||
{
|
||||
isPrivate = true,
|
||||
|
||||
dataBlob = hasPersistencePair ? roomDataBlob : string.Empty,
|
||||
|
||||
location = subRoom?.UnitySceneId
|
||||
?? "76d98498-60a1-430c-ab76-b54a29b7a163",
|
||||
|
||||
maxCapacity = subRoom?.MaxPlayers ?? 4,
|
||||
Name = $"@{name}'s Dorm",
|
||||
photonRegion = ServerConfig.PhotonRegion,
|
||||
photonRegionId = ServerConfig.PhotonRegion,
|
||||
photonRoomId = $"MochaDorm-{instanceId}-room",
|
||||
roomId = dormId,
|
||||
roomInstanceId = instanceId,
|
||||
subRoomId = subRoom?.SubRoomId ?? 0,
|
||||
subRoomDataSaveId = dataSave?.SubRoomDataSaveId ?? 0,
|
||||
roomInstanceType = RoomInstanceType.Dormroom,
|
||||
createdAt = DateTime.UtcNow
|
||||
};
|
||||
lock (_matchmakingLock)
|
||||
{
|
||||
RemovePlayerFromConfirmedInstancesNoLock(playerId);
|
||||
TrackParticipantNoLock(playerId, session.roomInstanceId);
|
||||
_instanceOwners[session.roomInstanceId] = playerId;
|
||||
_guestDormEnteredAt.Remove(playerId);
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"[DORM LOAD] account={playerId} room={session.roomId} " +
|
||||
$"subroom={session.subRoomId} " +
|
||||
$"superRoomBlob={(hasPersistencePair ? session.dataBlob : "null")} " +
|
||||
$"subRoomBlob={(hasPersistencePair ? dataSave?.DataBlob : "null")} " +
|
||||
$"subRoomDataSaveId={session.subRoomDataSaveId} " +
|
||||
$"cv2Pair={hasPersistencePair}");
|
||||
|
||||
return PlayerDB.UpdatePlayerHeartbeat(playerId, session)
|
||||
?? new Heartbeat
|
||||
{
|
||||
playerId = playerId,
|
||||
isOnline = true,
|
||||
roomInstance = session
|
||||
};
|
||||
}
|
||||
|
||||
public static bool ReportJoinResult(
|
||||
long playerId,
|
||||
long roomInstanceId,
|
||||
bool succeeded)
|
||||
{
|
||||
if (playerId <= 0 || roomInstanceId <= 0)
|
||||
return false;
|
||||
|
||||
Heartbeat heartbeat = PlayerDB.GetPlayerHeartbeat(playerId);
|
||||
if (heartbeat.roomInstance?.roomInstanceId != roomInstanceId)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[JOIN RESULT] player={playerId} instance={roomInstanceId} " +
|
||||
"ignored=true reason=heartbeat-mismatch");
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_matchmakingLock)
|
||||
{
|
||||
RemovePlayerFromConfirmedInstancesNoLock(playerId);
|
||||
if (succeeded)
|
||||
TrackParticipantNoLock(playerId, roomInstanceId);
|
||||
}
|
||||
|
||||
if (!succeeded)
|
||||
PlayerDB.LeaveCurrentRoom(playerId);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[JOIN RESULT] player={playerId} instance={roomInstanceId} " +
|
||||
$"success={succeeded.ToString().ToLowerInvariant()}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void MarkPlayerLeft(long playerId)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return;
|
||||
|
||||
lock (_matchmakingLock)
|
||||
{
|
||||
RemovePlayerFromConfirmedInstancesNoLock(playerId);
|
||||
_guestDormEnteredAt.Remove(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsConfirmedParticipant(
|
||||
long playerId,
|
||||
long roomInstanceId)
|
||||
{
|
||||
lock (_matchmakingLock)
|
||||
{
|
||||
if (IsConfirmedParticipantNoLock(playerId, roomInstanceId))
|
||||
return true;
|
||||
}
|
||||
|
||||
Heartbeat heartbeat = PlayerDB.GetPlayerHeartbeat(playerId);
|
||||
return heartbeat.isOnline &&
|
||||
heartbeat.roomInstance?.roomInstanceId == roomInstanceId;
|
||||
}
|
||||
|
||||
private static void TrackParticipantNoLock(
|
||||
long playerId,
|
||||
long roomInstanceId)
|
||||
{
|
||||
if (playerId <= 0 || roomInstanceId <= 0)
|
||||
return;
|
||||
|
||||
if (!_confirmedParticipantsByInstance.TryGetValue(
|
||||
roomInstanceId,
|
||||
out HashSet<long>? participants))
|
||||
{
|
||||
participants = new HashSet<long>();
|
||||
_confirmedParticipantsByInstance[roomInstanceId] = participants;
|
||||
}
|
||||
|
||||
participants.Add(playerId);
|
||||
}
|
||||
|
||||
private static bool IsConfirmedParticipantNoLock(
|
||||
long playerId,
|
||||
long roomInstanceId)
|
||||
{
|
||||
return _confirmedParticipantsByInstance.TryGetValue(
|
||||
roomInstanceId,
|
||||
out HashSet<long>? participants) &&
|
||||
participants.Contains(playerId);
|
||||
}
|
||||
|
||||
private static void RemovePlayerFromConfirmedInstancesNoLock(
|
||||
long playerId)
|
||||
{
|
||||
foreach (long instanceId in _confirmedParticipantsByInstance
|
||||
.Where(pair => pair.Value.Remove(playerId) &&
|
||||
pair.Value.Count == 0)
|
||||
.Select(pair => pair.Key)
|
||||
.ToList())
|
||||
{
|
||||
_confirmedParticipantsByInstance.Remove(instanceId);
|
||||
_instanceOwners.Remove(instanceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user