This commit is contained in:
mochaisvibecoded
2026-08-24 17:00:26 -04:00
parent cc1148050c
commit 42f7f57b72
401 changed files with 1108081 additions and 0 deletions
+616
View File
@@ -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 _);
}
}
}
}
+141
View File
@@ -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);
}
}
}
}
+62
View File
@@ -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);
}
}
}
+455
View File
@@ -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;
}
}
+768
View File
@@ -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('/');
}
}
}
+1981
View File
@@ -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();
}
}
}
+178
View File
@@ -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; }
}
}
}
+663
View File
@@ -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);
}
}
}
}
+623
View File
@@ -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
}
}
}
+33
View File
@@ -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);
}
}
}
+68
View File
@@ -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;
}
+820
View File
@@ -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);
}
}
}
}
+3732
View File
@@ -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;
}
}
}
+139
View File
@@ -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));
}
}
+613
View File
@@ -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;
}
}
}
+538
View File
@@ -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);
}
}
}
+69
View File
@@ -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; }
}
}
}
+1649
View File
@@ -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);
}
}
}
+63
View File
@@ -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);
}
}
}
+1363
View File
@@ -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; }
}
}
+210
View File
@@ -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; }
}
}
}
+280
View File
@@ -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);
}
}
}
+561
View File
@@ -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
{
}
}
}
}
+81
View File
@@ -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;
}
}
}
+198
View File
@@ -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);
}
}
}
}
+123
View File
@@ -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; }
}
}
}
+417
View File
@@ -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)
};
}
}
}
+55
View File
@@ -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));
}
}
+251
View File
@@ -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;
}
}
}
+304
View File
@@ -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");
}
}
}
+246
View File
@@ -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);
}
}
}
+228
View File
@@ -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; }
}
}
+619
View File
@@ -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);
}
}
}
+846
View File
@@ -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);
}
}
+32
View File
@@ -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);
}
}
+649
View File
@@ -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);
}
}
}
}