:3
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class AgeVerificationDB
|
||||
{
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "AgeVerification.db"));
|
||||
|
||||
private static readonly ILiteCollection<AgeVerificationRequest> Requests =
|
||||
Database.GetCollection<AgeVerificationRequest>("Requests");
|
||||
|
||||
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
private static readonly Random Rng = new();
|
||||
private static readonly object Sync = new();
|
||||
|
||||
static AgeVerificationDB()
|
||||
{
|
||||
LiteDbMaintenance.StartPeriodicCheckpoint("AgeVerification.db", Database);
|
||||
Requests.EnsureIndex(value => value.AccountId);
|
||||
Requests.EnsureIndex(value => value.Status);
|
||||
}
|
||||
|
||||
public sealed class AgeVerificationRequest
|
||||
{
|
||||
[BsonId]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
|
||||
public string Method { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "Pending";
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
public long? ReviewedByAccountId { get; set; }
|
||||
public string? RejectionReason { get; set; }
|
||||
}
|
||||
|
||||
public static string GenerateCode(long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
|
||||
foreach (AgeVerificationRequest stale in Requests
|
||||
.Find(value => value.AccountId == accountId &&
|
||||
(value.Status == "Pending" || value.Status == "UnderReview"))
|
||||
.ToList())
|
||||
{
|
||||
Requests.Delete(stale.Code);
|
||||
}
|
||||
|
||||
string code;
|
||||
do
|
||||
{
|
||||
code = NewCode();
|
||||
} while (Requests.Exists(value => value.Code == code));
|
||||
|
||||
Requests.Insert(new AgeVerificationRequest
|
||||
{
|
||||
Code = code,
|
||||
AccountId = accountId,
|
||||
Status = "Pending"
|
||||
});
|
||||
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NewCode()
|
||||
{
|
||||
char[] buffer = new char[6];
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
buffer[i] = CodeAlphabet[Rng.Next(CodeAlphabet.Length)];
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
public static AgeVerificationRequest? GetByCode(string? code)
|
||||
{
|
||||
string normalized = (code ?? string.Empty).Trim().ToUpperInvariant();
|
||||
return normalized.Length == 0 ? null : Requests.FindById(normalized);
|
||||
}
|
||||
|
||||
public static AgeVerificationRequest? GetActiveForAccount(long accountId)
|
||||
{
|
||||
return Requests.FindAll()
|
||||
.Where(value => value.AccountId == accountId &&
|
||||
(value.Status == "Pending" || value.Status == "UnderReview"))
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public static bool MarkUnderReview(string code, long accountId, string method)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
AgeVerificationRequest? request = GetByCode(code);
|
||||
if (request == null ||
|
||||
request.AccountId != accountId ||
|
||||
request.Status != "Pending")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
request.Method = method;
|
||||
request.Status = "UnderReview";
|
||||
request.SubmittedAt = DateTime.UtcNow;
|
||||
return Requests.Update(request);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<AgeVerificationRequest> GetForReview()
|
||||
{
|
||||
return Requests.Find(value => value.Status == "UnderReview")
|
||||
.OrderBy(value => value.SubmittedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static bool Review(string code, bool approve, long reviewerId, string? reason)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
AgeVerificationRequest? request = GetByCode(code);
|
||||
if (request == null || request.Status != "UnderReview")
|
||||
return false;
|
||||
|
||||
request.Status = approve ? "Approved" : "Rejected";
|
||||
request.ReviewedAt = DateTime.UtcNow;
|
||||
request.ReviewedByAccountId = reviewerId;
|
||||
request.RejectionReason = reason;
|
||||
return Requests.Update(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs;
|
||||
|
||||
public static class AnticheatLogDB
|
||||
{
|
||||
public class AnticheatEntry
|
||||
{
|
||||
[BsonId]
|
||||
public long Id { get; set; }
|
||||
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
public string IpAddress { get; set; } = "unknown";
|
||||
public long? AccountId { get; set; }
|
||||
public string? SteamId { get; set; }
|
||||
public string? Build { get; set; }
|
||||
public string Flags { get; set; } = "";
|
||||
public string? UserAgent { get; set; }
|
||||
}
|
||||
|
||||
private static readonly LiteDatabase DB = new(
|
||||
Path.Combine(Program.dataDir, "DBs", "AnticheatLogs.db"));
|
||||
|
||||
public static readonly ILiteCollection<AnticheatEntry> Entries =
|
||||
DB.GetCollection<AnticheatEntry>("Entries");
|
||||
|
||||
private const int MaxStored = 20_000;
|
||||
private const int TrimBatchSize = 1_000;
|
||||
|
||||
public static void Log(
|
||||
string ipAddress,
|
||||
long? accountId,
|
||||
string? steamId,
|
||||
string? build,
|
||||
string flags,
|
||||
string? userAgent)
|
||||
{
|
||||
Entries.Insert(new AnticheatEntry
|
||||
{
|
||||
IpAddress = ipAddress,
|
||||
AccountId = accountId,
|
||||
SteamId = steamId,
|
||||
Build = build,
|
||||
Flags = flags,
|
||||
UserAgent = userAgent
|
||||
});
|
||||
|
||||
long count = Entries.LongCount();
|
||||
if (count > MaxStored)
|
||||
{
|
||||
List<long> oldestIds = Entries.Query()
|
||||
.OrderBy(a => a.Id)
|
||||
.Limit(TrimBatchSize)
|
||||
.ToList()
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (long id in oldestIds)
|
||||
Entries.Delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using LiteDB;
|
||||
using SystemJsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class ChatDB
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "Chat.db"));
|
||||
|
||||
private static readonly ILiteCollection<ChatThread> Threads =
|
||||
Database.GetCollection<ChatThread>("Threads", BsonAutoId.Int64);
|
||||
|
||||
private static readonly ILiteCollection<ChatMessage> Messages =
|
||||
Database.GetCollection<ChatMessage>("Messages", BsonAutoId.Int64);
|
||||
|
||||
private static readonly ILiteCollection<ChatReadState> ReadStates =
|
||||
Database.GetCollection<ChatReadState>("ReadStates");
|
||||
|
||||
static ChatDB()
|
||||
{
|
||||
Threads.EnsureIndex(value => value.UpdatedAt);
|
||||
Messages.EnsureIndex(value => value.ThreadId);
|
||||
Messages.EnsureIndex(value => value.CreatedAt);
|
||||
ReadStates.EnsureIndex(value => value.ThreadId);
|
||||
ReadStates.EnsureIndex(value => value.AccountId);
|
||||
}
|
||||
|
||||
public static int DeveloperThreadCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Sync)
|
||||
return Threads.Count();
|
||||
}
|
||||
}
|
||||
|
||||
public static int DeveloperMessageCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Sync)
|
||||
return Messages.Count();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ChatThread
|
||||
{
|
||||
[BsonId]
|
||||
public long ThreadId { get; set; }
|
||||
public int Type { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public long CreatedByAccountId { get; set; }
|
||||
public List<long> MemberIds { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class ChatMessage
|
||||
{
|
||||
[BsonId]
|
||||
public long MessageId { get; set; }
|
||||
public long ThreadId { get; set; }
|
||||
public long SenderAccountId { get; set; }
|
||||
public string Body { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class ChatReadState
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long ThreadId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public long LastReadMessageId { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static List<ChatThread> GetThreadsForPlayer(long accountId, int maxCount)
|
||||
{
|
||||
maxCount = Math.Clamp(maxCount, 1, 100);
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
List<ChatThread> threads = Threads.FindAll()
|
||||
.Where(value => value.MemberIds?.Contains(accountId) == true)
|
||||
.OrderByDescending(value => value.UpdatedAt)
|
||||
.Take(maxCount)
|
||||
.ToList();
|
||||
|
||||
foreach (ChatThread thread in threads)
|
||||
EnsureStarterMessageNoLock(thread);
|
||||
|
||||
return threads;
|
||||
}
|
||||
}
|
||||
|
||||
public static ChatThread? GetThread(long threadId, long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? thread = Threads.FindById(threadId);
|
||||
if (thread?.MemberIds?.Contains(accountId) != true)
|
||||
return null;
|
||||
|
||||
EnsureStarterMessageNoLock(thread);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
|
||||
public static ChatThread? FindThreadWithMembers(
|
||||
long requestingAccountId,
|
||||
IEnumerable<long> requestedMemberIds)
|
||||
{
|
||||
long[] normalized = NormalizeMembers(
|
||||
requestingAccountId,
|
||||
requestedMemberIds);
|
||||
|
||||
if (normalized.Length < 2)
|
||||
return null;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
return Threads.FindAll().FirstOrDefault(thread =>
|
||||
NormalizeMembers(0, thread.MemberIds).SequenceEqual(normalized));
|
||||
}
|
||||
}
|
||||
|
||||
public static ChatThread GetOrCreateThread(
|
||||
long requestingAccountId,
|
||||
IEnumerable<long> requestedMemberIds,
|
||||
string? name = null,
|
||||
int type = 0)
|
||||
{
|
||||
long[] normalized = NormalizeMembers(
|
||||
requestingAccountId,
|
||||
requestedMemberIds);
|
||||
|
||||
if (normalized.Length < 2)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A chat thread requires at least two distinct members.",
|
||||
nameof(requestedMemberIds));
|
||||
}
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? existing = Threads.FindAll().FirstOrDefault(thread =>
|
||||
NormalizeMembers(0, thread.MemberIds).SequenceEqual(normalized));
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
EnsureStarterMessageNoLock(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var thread = new ChatThread
|
||||
{
|
||||
ThreadId = GetNextThreadIdNoLock(),
|
||||
Type = type,
|
||||
Name = string.IsNullOrWhiteSpace(name) ? null : name.Trim(),
|
||||
CreatedByAccountId = requestingAccountId,
|
||||
MemberIds = normalized.ToList(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Threads.Insert(thread);
|
||||
EnsureStarterMessageNoLock(thread);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<ChatMessage> GetMessages(
|
||||
long threadId,
|
||||
long requestingAccountId,
|
||||
int maxCount = 50,
|
||||
long? beforeMessageId = null)
|
||||
{
|
||||
maxCount = Math.Clamp(maxCount, 1, 100);
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? thread = Threads.FindById(threadId);
|
||||
if (thread?.MemberIds?.Contains(requestingAccountId) != true)
|
||||
return new List<ChatMessage>();
|
||||
|
||||
IEnumerable<ChatMessage> query =
|
||||
Messages.Find(value => value.ThreadId == threadId);
|
||||
|
||||
if (beforeMessageId.HasValue && beforeMessageId.Value > 0)
|
||||
query = query.Where(value => value.MessageId < beforeMessageId.Value);
|
||||
|
||||
return query
|
||||
.OrderByDescending(value => value.MessageId)
|
||||
.Take(maxCount)
|
||||
.OrderBy(value => value.MessageId)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static ChatMessage? GetLastMessage(long threadId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
return Messages.Find(value => value.ThreadId == threadId)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<ChatMessage> GetMessagesForDeveloper(
|
||||
int maxCount = 100,
|
||||
long? beforeMessageId = null)
|
||||
{
|
||||
maxCount = Math.Clamp(maxCount, 1, 250);
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
IEnumerable<ChatMessage> query = Messages.FindAll();
|
||||
if (beforeMessageId.HasValue && beforeMessageId.Value > 0)
|
||||
query = query.Where(value =>
|
||||
value.MessageId < beforeMessageId.Value);
|
||||
|
||||
return query
|
||||
.OrderByDescending(value => value.MessageId)
|
||||
.Take(maxCount)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static Dictionary<long, ChatThread> GetThreadsForDeveloper(
|
||||
IEnumerable<long> threadIds)
|
||||
{
|
||||
long[] ids = threadIds
|
||||
.Where(value => value > 0)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
return Threads.FindAll()
|
||||
.Where(value => ids.Contains(value.ThreadId))
|
||||
.ToDictionary(value => value.ThreadId);
|
||||
}
|
||||
}
|
||||
|
||||
public static long GetLastReadMessageId(long threadId, long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? thread = Threads.FindById(threadId);
|
||||
if (thread?.MemberIds?.Contains(accountId) != true)
|
||||
return 0;
|
||||
|
||||
return ReadStates.FindById(BuildReadStateId(threadId, accountId))
|
||||
?.LastReadMessageId ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool MarkMessageRead(
|
||||
long threadId,
|
||||
long messageId,
|
||||
long accountId)
|
||||
{
|
||||
if (threadId <= 0 || messageId <= 0 || accountId <= 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? thread = Threads.FindById(threadId);
|
||||
if (thread?.MemberIds?.Contains(accountId) != true)
|
||||
return false;
|
||||
|
||||
ChatMessage? message = Messages.FindById(messageId);
|
||||
if (message == null || message.ThreadId != threadId)
|
||||
return false;
|
||||
|
||||
SetLastReadNoLock(threadId, accountId, messageId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static ChatMessage? AddMessage(
|
||||
long threadId,
|
||||
long senderAccountId,
|
||||
string body)
|
||||
{
|
||||
body = body?.Trim() ?? string.Empty;
|
||||
if (body.Length is < 1 or > 2000 || body.Any(char.IsControl))
|
||||
return null;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? thread = Threads.FindById(threadId);
|
||||
if (thread?.MemberIds?.Contains(senderAccountId) != true)
|
||||
return null;
|
||||
|
||||
var message = new ChatMessage
|
||||
{
|
||||
MessageId = GetNextMessageIdNoLock(),
|
||||
ThreadId = threadId,
|
||||
SenderAccountId = senderAccountId,
|
||||
Body = body,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Messages.Insert(message);
|
||||
thread.UpdatedAt = message.CreatedAt;
|
||||
Threads.Update(thread);
|
||||
SetLastReadNoLock(threadId, senderAccountId, message.MessageId);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool LeaveThread(long threadId, long requestingAccountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
ChatThread? thread = Threads.FindById(threadId);
|
||||
if (thread?.MemberIds?.Contains(requestingAccountId) != true)
|
||||
return false;
|
||||
|
||||
if (thread.MemberIds.Count <= 2)
|
||||
{
|
||||
Messages.DeleteMany(value => value.ThreadId == threadId);
|
||||
ReadStates.DeleteMany(value => value.ThreadId == threadId);
|
||||
return Threads.Delete(threadId);
|
||||
}
|
||||
|
||||
thread.MemberIds.Remove(requestingAccountId);
|
||||
ReadStates.Delete(BuildReadStateId(threadId, requestingAccountId));
|
||||
thread.UpdatedAt = DateTime.UtcNow;
|
||||
return Threads.Update(thread);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool DeleteThread(long threadId, long requestingAccountId) =>
|
||||
LeaveThread(threadId, requestingAccountId);
|
||||
|
||||
private static string BuildReadStateId(long threadId, long accountId) =>
|
||||
$"{threadId}:{accountId}";
|
||||
|
||||
private static void SetLastReadNoLock(
|
||||
long threadId,
|
||||
long accountId,
|
||||
long messageId)
|
||||
{
|
||||
string id = BuildReadStateId(threadId, accountId);
|
||||
ChatReadState? state = ReadStates.FindById(id);
|
||||
|
||||
if (state == null)
|
||||
{
|
||||
state = new ChatReadState
|
||||
{
|
||||
Id = id,
|
||||
ThreadId = threadId,
|
||||
AccountId = accountId,
|
||||
LastReadMessageId = messageId,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
state.LastReadMessageId = Math.Max(
|
||||
state.LastReadMessageId,
|
||||
messageId);
|
||||
state.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
ReadStates.Upsert(state);
|
||||
}
|
||||
|
||||
private static ChatMessage EnsureStarterMessageNoLock(ChatThread thread)
|
||||
{
|
||||
ChatMessage? existing = Messages
|
||||
.Find(value => value.ThreadId == thread.ThreadId)
|
||||
.OrderBy(value => value.MessageId)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
long creatorId = thread.CreatedByAccountId > 0
|
||||
? thread.CreatedByAccountId
|
||||
: thread.MemberIds.FirstOrDefault();
|
||||
|
||||
string contents = SystemJsonSerializer.Serialize(new
|
||||
{
|
||||
Type = 0,
|
||||
Version = 1,
|
||||
Data = $"Player <@U{creatorId}> started a chat"
|
||||
});
|
||||
|
||||
var starter = new ChatMessage
|
||||
{
|
||||
MessageId = GetNextMessageIdNoLock(),
|
||||
ThreadId = thread.ThreadId,
|
||||
SenderAccountId = -5,
|
||||
Body = contents,
|
||||
CreatedAt = thread.CreatedAt == default
|
||||
? DateTime.UtcNow
|
||||
: thread.CreatedAt
|
||||
};
|
||||
|
||||
Messages.Insert(starter);
|
||||
|
||||
if (creatorId > 0)
|
||||
SetLastReadNoLock(thread.ThreadId, creatorId, starter.MessageId);
|
||||
|
||||
if (thread.UpdatedAt < starter.CreatedAt)
|
||||
{
|
||||
thread.UpdatedAt = starter.CreatedAt;
|
||||
Threads.Update(thread);
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"[CHAT] backfilled starter message={starter.MessageId} thread={thread.ThreadId}");
|
||||
|
||||
return starter;
|
||||
}
|
||||
|
||||
private static long[] NormalizeMembers(
|
||||
long requestingAccountId,
|
||||
IEnumerable<long>? memberIds)
|
||||
{
|
||||
IEnumerable<long> values = memberIds ?? Array.Empty<long>();
|
||||
|
||||
if (requestingAccountId > 0)
|
||||
values = values.Append(requestingAccountId);
|
||||
|
||||
return values
|
||||
.Where(value => value > 0)
|
||||
.Distinct()
|
||||
.OrderBy(value => value)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static long GetNextThreadIdNoLock() =>
|
||||
Threads.Count() == 0
|
||||
? 1
|
||||
: Threads.Max(value => value.ThreadId) + 1;
|
||||
|
||||
private static long GetNextMessageIdNoLock() =>
|
||||
Messages.Count() == 0
|
||||
? 1
|
||||
: Messages.Max(value => value.MessageId) + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
using LiteDB;
|
||||
using static Mocha2023.Classes.DBs.DBClasses.ClubDBClasses;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
public static class ClubDB
|
||||
{
|
||||
private const int MaxCreatedClubsPerAccount = 100;
|
||||
private static readonly object Sync = new();
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "Clubs.db"));
|
||||
|
||||
public static readonly ILiteCollection<ClubRecord> Clubs =
|
||||
Database.GetCollection<ClubRecord>("Clubs");
|
||||
public static readonly ILiteCollection<ClubMembershipRecord> Memberships =
|
||||
Database.GetCollection<ClubMembershipRecord>("Memberships");
|
||||
public static readonly ILiteCollection<HomeClubRecord> HomeClubs =
|
||||
Database.GetCollection<HomeClubRecord>("HomeClubs");
|
||||
|
||||
public static readonly IReadOnlyList<string> CategoryTags = new[]
|
||||
{
|
||||
"Social",
|
||||
"Creative",
|
||||
"Competitive",
|
||||
"Casual",
|
||||
"Educational",
|
||||
"Entertainment",
|
||||
"Lifestyle"
|
||||
};
|
||||
|
||||
private static bool _legacyDataRepaired;
|
||||
|
||||
static ClubDB()
|
||||
{
|
||||
Clubs.EnsureIndex(value => value.CreatorAccountId);
|
||||
Clubs.EnsureIndex(value => value.Name);
|
||||
Clubs.EnsureIndex(value => value.State);
|
||||
Memberships.EnsureIndex(value => value.ClubId);
|
||||
Memberships.EnsureIndex(value => value.AccountId);
|
||||
Memberships.EnsureIndex(value => value.MembershipType);
|
||||
|
||||
RepairLegacyData();
|
||||
}
|
||||
|
||||
public static void RepairLegacyData()
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
if (_legacyDataRepaired)
|
||||
return;
|
||||
|
||||
int clubsChanged = 0;
|
||||
int membershipsChanged = 0;
|
||||
int malformedMembershipsRemoved = 0;
|
||||
|
||||
foreach (ClubMembershipRecord membership in Memberships.FindAll().ToList())
|
||||
{
|
||||
if (membership.ClubId <= 0 || membership.AccountId <= 0)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(membership.Id))
|
||||
Memberships.Delete(membership.Id);
|
||||
malformedMembershipsRemoved++;
|
||||
continue;
|
||||
}
|
||||
|
||||
string expectedId = MembershipId(membership.ClubId, membership.AccountId);
|
||||
if (string.Equals(membership.Id, expectedId, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
ClubMembershipRecord? existing = Memberships.FindById(expectedId);
|
||||
ClubMembershipType mergedType = existing == null
|
||||
? membership.MembershipType
|
||||
: (ClubMembershipType)Math.Max(
|
||||
(int)existing.MembershipType,
|
||||
(int)membership.MembershipType);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(membership.Id))
|
||||
Memberships.Delete(membership.Id);
|
||||
|
||||
UpsertMembershipUnsafe(
|
||||
membership.ClubId,
|
||||
membership.AccountId,
|
||||
mergedType);
|
||||
membershipsChanged++;
|
||||
}
|
||||
|
||||
foreach (ClubRecord club in Clubs.FindAll().ToList())
|
||||
{
|
||||
bool changed = false;
|
||||
|
||||
club.Name ??= string.Empty;
|
||||
club.Description ??= string.Empty;
|
||||
club.MainImageName = NormalizeImageName(club.MainImageName);
|
||||
club.AdditionalImages ??= new List<string>();
|
||||
club.CategoryTags ??= new List<string>();
|
||||
|
||||
string resolvedPrimary = ResolveLegacyPrimaryCategory(
|
||||
club.PrimaryCategory,
|
||||
club.CategoryTags);
|
||||
List<string> normalizedTags = NormalizeTags(
|
||||
new[] { resolvedPrimary }
|
||||
.Concat(club.CategoryTags))
|
||||
.ToList();
|
||||
|
||||
if (normalizedTags.Count == 0)
|
||||
normalizedTags.Add(resolvedPrimary);
|
||||
|
||||
if (!string.Equals(
|
||||
club.PrimaryCategory,
|
||||
resolvedPrimary,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[CLUB CATEGORY REPAIR] club={club.ClubId} " +
|
||||
$"old={club.PrimaryCategory ?? "null"} new={resolvedPrimary} " +
|
||||
$"tags={string.Join(',', club.CategoryTags)}");
|
||||
club.PrimaryCategory = resolvedPrimary;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!club.CategoryTags.SequenceEqual(
|
||||
normalizedTags,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
club.CategoryTags = normalizedTags;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
ClubMembershipRecord? creatorMembership =
|
||||
Memberships.FindById(MembershipId(
|
||||
club.ClubId,
|
||||
club.CreatorAccountId));
|
||||
|
||||
if (club.CreatorAccountId > 0 &&
|
||||
creatorMembership?.MembershipType != ClubMembershipType.Creator)
|
||||
{
|
||||
UpsertMembershipUnsafe(
|
||||
club.ClubId,
|
||||
club.CreatorAccountId,
|
||||
ClubMembershipType.Creator);
|
||||
membershipsChanged++;
|
||||
}
|
||||
|
||||
int actualMemberCount = Memberships.Count(value =>
|
||||
value.ClubId == club.ClubId &&
|
||||
value.MembershipType >= ClubMembershipType.Member);
|
||||
|
||||
if (club.MemberCount != actualMemberCount)
|
||||
{
|
||||
club.MemberCount = actualMemberCount;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
club.UpdatedAt = DateTime.UtcNow;
|
||||
Clubs.Update(club);
|
||||
clubsChanged++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (HomeClubRecord home in HomeClubs.FindAll().ToList())
|
||||
{
|
||||
ClubRecord? club = Clubs.FindById(home.ClubId);
|
||||
if (club == null)
|
||||
{
|
||||
HomeClubs.Delete(home.AccountId);
|
||||
continue;
|
||||
}
|
||||
|
||||
ClubMembershipRecord? membership =
|
||||
Memberships.FindById(MembershipId(
|
||||
home.ClubId,
|
||||
home.AccountId));
|
||||
|
||||
if (membership == null ||
|
||||
membership.MembershipType < ClubMembershipType.Member)
|
||||
{
|
||||
ClubMembershipType type =
|
||||
club.CreatorAccountId == home.AccountId
|
||||
? ClubMembershipType.Creator
|
||||
: ClubMembershipType.Member;
|
||||
UpsertMembershipUnsafe(home.ClubId, home.AccountId, type);
|
||||
membershipsChanged++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ClubRecord club in Clubs.FindAll().ToList())
|
||||
{
|
||||
int actualMemberCount = Memberships.Count(value =>
|
||||
value.ClubId == club.ClubId &&
|
||||
value.MembershipType >= ClubMembershipType.Member);
|
||||
|
||||
if (club.MemberCount == actualMemberCount)
|
||||
continue;
|
||||
|
||||
club.MemberCount = actualMemberCount;
|
||||
club.UpdatedAt = DateTime.UtcNow;
|
||||
Clubs.Update(club);
|
||||
clubsChanged++;
|
||||
}
|
||||
|
||||
_legacyDataRepaired = true;
|
||||
|
||||
Console.WriteLine(
|
||||
$"[CLUB REPAIR] clubs={clubsChanged} " +
|
||||
$"memberships={membershipsChanged} " +
|
||||
$"malformedRemoved={malformedMembershipsRemoved}");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryCreate(
|
||||
long creatorAccountId,
|
||||
string name,
|
||||
string description,
|
||||
string? mainImageName,
|
||||
ClubVisibility visibility,
|
||||
ClubJoinability joinability,
|
||||
bool allowJuniors,
|
||||
int minLevel,
|
||||
ClubType clubType,
|
||||
IEnumerable<string>? categoryTags,
|
||||
out ClubRecord? club,
|
||||
out string? error)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
club = null;
|
||||
error = null;
|
||||
|
||||
string normalizedName = name.Trim();
|
||||
|
||||
ClubRecord? existingOwnedClub = Clubs.FindAll().FirstOrDefault(value =>
|
||||
value.CreatorAccountId == creatorAccountId &&
|
||||
value.State != ClubState.MarkedForDelete &&
|
||||
string.Equals(
|
||||
value.Name,
|
||||
normalizedName,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingOwnedClub != null)
|
||||
{
|
||||
UpsertMembershipUnsafe(
|
||||
existingOwnedClub.ClubId,
|
||||
creatorAccountId,
|
||||
ClubMembershipType.Creator);
|
||||
|
||||
club = existingOwnedClub;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Clubs.Count(value =>
|
||||
value.CreatorAccountId == creatorAccountId &&
|
||||
value.State != ClubState.MarkedForDelete) >=
|
||||
MaxCreatedClubsPerAccount)
|
||||
{
|
||||
error = "club_creation_limit";
|
||||
return false;
|
||||
}
|
||||
|
||||
List<string> tags = NormalizeTags(categoryTags).ToList();
|
||||
if (tags.Count == 0)
|
||||
tags.Add("Social");
|
||||
|
||||
club = new ClubRecord
|
||||
{
|
||||
ClubId = NextClubId(),
|
||||
Name = normalizedName,
|
||||
Description = description.Trim(),
|
||||
MainImageName = NormalizeImageName(mainImageName),
|
||||
MemberCount = 1,
|
||||
PrimaryCategory = tags[0],
|
||||
Visibility = visibility,
|
||||
Joinability = joinability,
|
||||
AllowJuniors = allowJuniors,
|
||||
MinLevel = Math.Clamp(minLevel, 0, 50),
|
||||
ClubType = clubType,
|
||||
CreatorAccountId = creatorAccountId,
|
||||
CategoryTags = tags,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Clubs.Insert(club);
|
||||
UpsertMembershipUnsafe(
|
||||
club.ClubId,
|
||||
creatorAccountId,
|
||||
ClubMembershipType.Creator);
|
||||
HomeClubs.Upsert(new HomeClubRecord
|
||||
{
|
||||
AccountId = creatorAccountId,
|
||||
ClubId = club.ClubId,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static ClubRecord? Get(long clubId)
|
||||
{
|
||||
lock (Sync)
|
||||
return Clubs.FindById(clubId);
|
||||
}
|
||||
|
||||
public static List<ClubRecord> GetCreatedBy(long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
return Clubs.Find(value =>
|
||||
value.CreatorAccountId == accountId &&
|
||||
value.State != ClubState.MarkedForDelete)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static List<ClubRecord> GetMemberClubs(long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
HashSet<long> clubIds = Memberships.Find(value =>
|
||||
value.AccountId == accountId &&
|
||||
value.MembershipType >= ClubMembershipType.Member)
|
||||
.Select(value => value.ClubId)
|
||||
.ToHashSet();
|
||||
|
||||
long? homeClubId = HomeClubs.FindById(accountId)?.ClubId;
|
||||
|
||||
return Clubs.Find(value => value.State == ClubState.Active)
|
||||
.Where(value =>
|
||||
clubIds.Contains(value.ClubId) ||
|
||||
value.CreatorAccountId == accountId ||
|
||||
homeClubId == value.ClubId)
|
||||
.OrderByDescending(value => value.MemberCount)
|
||||
.ThenBy(value => value.Name)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<ClubRecord> Search(
|
||||
string? category,
|
||||
string? query,
|
||||
int sort,
|
||||
int skip,
|
||||
int count) =>
|
||||
SearchWithTotal(category, query, sort, skip, count).Results;
|
||||
|
||||
public static (List<ClubRecord> Results, int TotalResults) SearchWithTotal(
|
||||
string? category,
|
||||
string? query,
|
||||
int sort,
|
||||
int skip,
|
||||
int count)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
IEnumerable<ClubRecord> clubs = Clubs.Find(value =>
|
||||
value.State == ClubState.Active &&
|
||||
value.Visibility == ClubVisibility.Public);
|
||||
|
||||
string? normalizedCategory = NormalizeCategory(category);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedCategory))
|
||||
{
|
||||
clubs = clubs.Where(value =>
|
||||
ClubMatchesCategory(value, normalizedCategory));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
string trimmedQuery = query.Trim();
|
||||
clubs = clubs.Where(value =>
|
||||
(value.Name ?? string.Empty).Contains(
|
||||
trimmedQuery,
|
||||
StringComparison.OrdinalIgnoreCase) ||
|
||||
(value.Description ?? string.Empty).Contains(
|
||||
trimmedQuery,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
clubs = sort switch
|
||||
{
|
||||
1 => clubs.OrderByDescending(value => value.CreatedAt),
|
||||
2 => clubs.OrderBy(value => value.CreatedAt),
|
||||
_ => clubs.OrderByDescending(value => value.MemberCount)
|
||||
.ThenBy(value => value.Name)
|
||||
};
|
||||
|
||||
List<ClubRecord> all = clubs.ToList();
|
||||
List<ClubRecord> page = all
|
||||
.Skip(Math.Max(0, skip))
|
||||
.Take(Math.Clamp(count, 1, 100))
|
||||
.ToList();
|
||||
|
||||
return (page, all.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public static ClubMembershipRecord? GetMembership(long clubId, long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
ClubMembershipRecord? membership =
|
||||
Memberships.FindById(MembershipId(clubId, accountId));
|
||||
|
||||
if (membership != null)
|
||||
return membership;
|
||||
|
||||
ClubRecord? club = Clubs.FindById(clubId);
|
||||
if (club?.CreatorAccountId != accountId)
|
||||
return null;
|
||||
|
||||
UpsertMembershipUnsafe(
|
||||
clubId,
|
||||
accountId,
|
||||
ClubMembershipType.Creator);
|
||||
return Memberships.FindById(MembershipId(clubId, accountId));
|
||||
}
|
||||
}
|
||||
|
||||
public static List<ClubMembershipRecord> GetMemberships(
|
||||
long clubId,
|
||||
bool includePending = false)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
return Memberships.Find(value => value.ClubId == clubId &&
|
||||
(includePending || value.MembershipType >= ClubMembershipType.Member))
|
||||
.OrderByDescending(value => value.MembershipType)
|
||||
.ThenBy(value => value.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SetMembership(
|
||||
long clubId,
|
||||
long accountId,
|
||||
ClubMembershipType membershipType)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
if (Clubs.FindById(clubId) == null)
|
||||
return false;
|
||||
UpsertMembershipUnsafe(clubId, accountId, membershipType);
|
||||
RefreshMemberCountUnsafe(clubId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool RemoveMembership(long clubId, long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
ClubRecord? club = Clubs.FindById(clubId);
|
||||
if (club == null || club.CreatorAccountId == accountId)
|
||||
return false;
|
||||
Memberships.Delete(MembershipId(clubId, accountId));
|
||||
if (HomeClubs.FindById(accountId)?.ClubId == clubId)
|
||||
HomeClubs.Delete(accountId);
|
||||
RefreshMemberCountUnsafe(clubId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SetHomeClub(long accountId, long? clubId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
if (!clubId.HasValue || clubId.Value <= 0)
|
||||
return HomeClubs.Delete(accountId);
|
||||
|
||||
ClubMembershipRecord? membership = GetMembership(clubId.Value, accountId);
|
||||
if (membership == null ||
|
||||
membership.MembershipType < ClubMembershipType.Member)
|
||||
return false;
|
||||
|
||||
HomeClubs.Upsert(new HomeClubRecord
|
||||
{
|
||||
AccountId = accountId,
|
||||
ClubId = clubId.Value,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static ClubRecord? GetHomeClub(long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
HomeClubRecord? home = HomeClubs.FindById(accountId);
|
||||
return home == null ? null : Clubs.FindById(home.ClubId);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Update(ClubRecord club)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
club.Name = (club.Name ?? string.Empty).Trim();
|
||||
club.Description = (club.Description ?? string.Empty).Trim();
|
||||
club.MainImageName = NormalizeImageName(club.MainImageName);
|
||||
|
||||
string primary = NormalizeCategory(club.PrimaryCategory) ??
|
||||
NormalizeTags(club.CategoryTags).FirstOrDefault() ??
|
||||
"Social";
|
||||
List<string> normalizedTags = NormalizeTags(
|
||||
new[] { primary }
|
||||
.Concat(club.CategoryTags ?? new List<string>()))
|
||||
.ToList();
|
||||
if (normalizedTags.Count == 0)
|
||||
normalizedTags.Add(primary);
|
||||
|
||||
club.PrimaryCategory = primary;
|
||||
club.CategoryTags = normalizedTags;
|
||||
club.MinLevel = Math.Clamp(club.MinLevel, 0, 50);
|
||||
club.UpdatedAt = DateTime.UtcNow;
|
||||
return Clubs.Update(club);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Delete(long clubId, long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
ClubRecord? club = Clubs.FindById(clubId);
|
||||
if (club == null || club.CreatorAccountId != accountId)
|
||||
return false;
|
||||
|
||||
Clubs.Delete(clubId);
|
||||
foreach (ClubMembershipRecord membership in
|
||||
Memberships.Find(value => value.ClubId == clubId).ToList())
|
||||
{
|
||||
Memberships.Delete(membership.Id);
|
||||
}
|
||||
foreach (HomeClubRecord home in
|
||||
HomeClubs.Find(value => value.ClubId == clubId).ToList())
|
||||
{
|
||||
HomeClubs.Delete(home.AccountId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static ClubSummaryDto ToSummary(ClubRecord club) =>
|
||||
ToSummary(club, null);
|
||||
|
||||
public static ClubSummaryDto ToSummary(
|
||||
ClubRecord club,
|
||||
long? viewerAccountId)
|
||||
{
|
||||
ClubMembershipType membershipType = ClubMembershipType.None;
|
||||
if (viewerAccountId.HasValue)
|
||||
{
|
||||
membershipType = GetMembership(
|
||||
club.ClubId,
|
||||
viewerAccountId.Value)?.MembershipType ??
|
||||
(club.CreatorAccountId == viewerAccountId.Value
|
||||
? ClubMembershipType.Creator
|
||||
: ClubMembershipType.None);
|
||||
}
|
||||
|
||||
string primary = NormalizeCategory(club.PrimaryCategory) ??
|
||||
NormalizeTags(club.CategoryTags).FirstOrDefault() ??
|
||||
"Social";
|
||||
List<string> normalizedTags = NormalizeTags(
|
||||
new[] { primary }
|
||||
.Concat(club.CategoryTags ?? new List<string>()))
|
||||
.ToList();
|
||||
if (normalizedTags.Count == 0)
|
||||
normalizedTags.Add(primary);
|
||||
|
||||
return new ClubSummaryDto
|
||||
{
|
||||
ClubId = club.ClubId,
|
||||
Name = club.Name ?? string.Empty,
|
||||
Description = club.Description ?? string.Empty,
|
||||
MainImageName = NormalizeImageName(club.MainImageName),
|
||||
State = (int)club.State,
|
||||
MemberCount = club.MemberCount,
|
||||
PrimaryCategory = primary,
|
||||
CategoryTags = normalizedTags,
|
||||
Visibility = (int)club.Visibility,
|
||||
Joinability = (int)club.Joinability,
|
||||
AllowJuniors = club.AllowJuniors,
|
||||
MinLevel = club.MinLevel,
|
||||
ClubChatEnabled = club.ClubChatEnabled,
|
||||
ClubhouseRoomId = club.ClubhouseRoomId,
|
||||
ClubType = (int)club.ClubType,
|
||||
CreatorAccountId = club.CreatorAccountId,
|
||||
ClubMembershipType = (int)membershipType,
|
||||
CreatedAt = club.CreatedAt,
|
||||
UpdatedAt = club.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public static ClubMembershipDto ToMembership(ClubMembershipRecord membership) => new()
|
||||
{
|
||||
AccountId = membership.AccountId,
|
||||
ClubId = membership.ClubId,
|
||||
ClubMembershipType = (int)membership.MembershipType,
|
||||
CreatedAt = membership.CreatedAt
|
||||
};
|
||||
|
||||
public static ClubDetailsDto ToDetails(ClubRecord club, long? viewerAccountId)
|
||||
{
|
||||
int myType = viewerAccountId.HasValue
|
||||
? (int)(GetMembership(club.ClubId, viewerAccountId.Value)?.MembershipType ??
|
||||
ClubMembershipType.None)
|
||||
: (int)ClubMembershipType.None;
|
||||
|
||||
return new ClubDetailsDto
|
||||
{
|
||||
Club = ToSummary(club, viewerAccountId),
|
||||
CategoryTags = ToSummary(club).CategoryTags,
|
||||
AdditionalImages = (club.AdditionalImages ?? new List<string>())
|
||||
.Select((imageName, imageIndex) => new ClubImageDto
|
||||
{
|
||||
ImageName = imageName,
|
||||
ImageIndex = imageIndex
|
||||
})
|
||||
.ToList(),
|
||||
CreatorPermissions = Permissions(club.ClubId, ClubMembershipType.Creator),
|
||||
CoOwnerPermissions = Permissions(club.ClubId, ClubMembershipType.CoOwner),
|
||||
ModeratorPermissions = Permissions(club.ClubId, ClubMembershipType.Moderator),
|
||||
MyMembershipType = myType
|
||||
};
|
||||
}
|
||||
|
||||
public static ClubPermissionsDto Permissions(
|
||||
long clubId,
|
||||
ClubMembershipType membershipType)
|
||||
{
|
||||
bool moderator = membershipType >= ClubMembershipType.Moderator;
|
||||
bool coOwner = membershipType >= ClubMembershipType.CoOwner;
|
||||
return new ClubPermissionsDto
|
||||
{
|
||||
ClubId = clubId,
|
||||
MembershipType = (int)membershipType,
|
||||
CanInvite = moderator,
|
||||
CanKick = moderator,
|
||||
CanBan = moderator,
|
||||
CanModify = coOwner,
|
||||
CanManageRoles = coOwner,
|
||||
CanManageClubhouse = coOwner
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveLegacyPrimaryCategory(
|
||||
string? storedPrimary,
|
||||
IEnumerable<string>? storedTags)
|
||||
{
|
||||
string? primary = NormalizeCategory(storedPrimary);
|
||||
List<string> tags = NormalizeTags(storedTags).ToList();
|
||||
|
||||
if (primary == null)
|
||||
return tags.FirstOrDefault() ?? "Social";
|
||||
|
||||
if (string.Equals(primary, "Social", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string? recovered = tags.FirstOrDefault(value =>
|
||||
!string.Equals(value, "Social", StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(recovered))
|
||||
return recovered;
|
||||
}
|
||||
|
||||
return primary;
|
||||
}
|
||||
|
||||
private static bool ClubMatchesCategory(
|
||||
ClubRecord club,
|
||||
string normalizedCategory)
|
||||
{
|
||||
string primary = NormalizeCategory(club.PrimaryCategory) ?? "Social";
|
||||
if (string.Equals(primary, normalizedCategory,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return (club.CategoryTags ?? new List<string>())
|
||||
.Select(NormalizeCategory)
|
||||
.Any(tag => string.Equals(
|
||||
tag,
|
||||
normalizedCategory,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static long NextClubId() =>
|
||||
Clubs.Count() == 0 ? 1 : Clubs.Max(value => value.ClubId) + 1;
|
||||
|
||||
private static string MembershipId(long clubId, long accountId) =>
|
||||
$"{clubId}:{accountId}";
|
||||
|
||||
private static void UpsertMembershipUnsafe(
|
||||
long clubId,
|
||||
long accountId,
|
||||
ClubMembershipType membershipType)
|
||||
{
|
||||
string id = MembershipId(clubId, accountId);
|
||||
ClubMembershipRecord record = Memberships.FindById(id) ??
|
||||
new ClubMembershipRecord
|
||||
{
|
||||
Id = id,
|
||||
ClubId = clubId,
|
||||
AccountId = accountId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
record.Id = id;
|
||||
record.ClubId = clubId;
|
||||
record.AccountId = accountId;
|
||||
record.MembershipType = membershipType;
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
Memberships.Upsert(record);
|
||||
}
|
||||
|
||||
private static void RefreshMemberCountUnsafe(long clubId)
|
||||
{
|
||||
ClubRecord? club = Clubs.FindById(clubId);
|
||||
if (club == null)
|
||||
return;
|
||||
club.MemberCount = Memberships.Count(value =>
|
||||
value.ClubId == clubId &&
|
||||
value.MembershipType >= ClubMembershipType.Member);
|
||||
club.UpdatedAt = DateTime.UtcNow;
|
||||
Clubs.Update(club);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> NormalizeTags(IEnumerable<string>? tags) =>
|
||||
(tags ?? Array.Empty<string>())
|
||||
.Select(NormalizeCategory)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(3);
|
||||
|
||||
private static string? NormalizeCategory(string? category)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category))
|
||||
return null;
|
||||
|
||||
return category.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"social" => "Social",
|
||||
"hangout" => "Social",
|
||||
"creative" => "Creative",
|
||||
"art" => "Creative",
|
||||
"competitive" => "Competitive",
|
||||
"casual" => "Casual",
|
||||
"games" => "Casual",
|
||||
"roleplay" => "Casual",
|
||||
"educational" => "Educational",
|
||||
"education" => "Educational",
|
||||
"entertainment" => "Entertainment",
|
||||
"quest" => "Entertainment",
|
||||
"music" => "Entertainment",
|
||||
"lifestyle" => "Lifestyle",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeImageName(string? imageName)
|
||||
{
|
||||
string normalized = (imageName ?? string.Empty).Trim().Replace('\\', '/');
|
||||
if (string.IsNullOrWhiteSpace(normalized) ||
|
||||
normalized.Length > 260 ||
|
||||
normalized.Contains("..", StringComparison.Ordinal) ||
|
||||
Uri.TryCreate(normalized, UriKind.Absolute, out _))
|
||||
return "DefaultClubImage2k.jpg";
|
||||
return normalized.TrimStart('/');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1981 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class CreatorFeatureDB
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly LiteDatabase Database = OpenDatabase();
|
||||
|
||||
private static readonly ILiteCollection<InventionRecord> Inventions =
|
||||
Database.GetCollection<InventionRecord>("Inventions");
|
||||
|
||||
private const long MaxClientInventionId = 1_000_000_000;
|
||||
|
||||
private static readonly ILiteCollection<InventionCheerRecord> InventionCheers =
|
||||
Database.GetCollection<InventionCheerRecord>("InventionCheers");
|
||||
private static readonly ILiteCollection<InventionAliasRecord> InventionAliases =
|
||||
Database.GetCollection<InventionAliasRecord>("InventionAliases");
|
||||
private static readonly ILiteCollection<InventionPurchaseRecord> InventionPurchases =
|
||||
Database.GetCollection<InventionPurchaseRecord>("InventionPurchases");
|
||||
private static readonly ILiteCollection<MeetupCodeRecord> MeetupCodes =
|
||||
Database.GetCollection<MeetupCodeRecord>("MeetupCodes");
|
||||
private static readonly ILiteCollection<CloudVariableRecord> CloudVariables =
|
||||
Database.GetCollection<CloudVariableRecord>("CloudVariables");
|
||||
private static readonly ILiteCollection<RoomPlayerDataRecord> RoomPlayerData =
|
||||
Database.GetCollection<RoomPlayerDataRecord>("RoomPlayerData");
|
||||
|
||||
private static LiteDatabase OpenDatabase()
|
||||
{
|
||||
string directory = Path.Combine(Program.dataDir, "DBs");
|
||||
Directory.CreateDirectory(directory);
|
||||
return new LiteDatabase(Path.Combine(directory, "CreatorFeatures.db"));
|
||||
}
|
||||
|
||||
static CreatorFeatureDB()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(Program.dataDir, "DBs"));
|
||||
LiteDbMaintenance.StartPeriodicCheckpoint("CreatorFeatures.db", Database);
|
||||
|
||||
Inventions.EnsureIndex(value => value.CreatorAccountId);
|
||||
Inventions.EnsureIndex(value => value.RoomId);
|
||||
Inventions.EnsureIndex(value => value.UpdatedAtUtc);
|
||||
InventionCheers.EnsureIndex(value => value.InventionId);
|
||||
InventionCheers.EnsureIndex(value => value.AccountId);
|
||||
InventionAliases.EnsureIndex(value => value.CurrentInventionId);
|
||||
InventionPurchases.EnsureIndex(value => value.InventionId);
|
||||
InventionPurchases.EnsureIndex(value => value.AccountId);
|
||||
MeetupCodes.EnsureIndex(value => value.CreatorAccountId);
|
||||
MeetupCodes.EnsureIndex(value => value.RoomInstanceId);
|
||||
MeetupCodes.EnsureIndex(value => value.ExpiresAtUtc);
|
||||
CloudVariables.EnsureIndex(value => value.RoomId);
|
||||
CloudVariables.EnsureIndex(value => value.AccountId);
|
||||
CloudVariables.EnsureIndex(value => value.UpdatedAtUtc);
|
||||
RoomPlayerData.EnsureIndex(value => value.RoomId);
|
||||
RoomPlayerData.EnsureIndex(value => value.AccountId);
|
||||
RoomPlayerData.EnsureIndex(value => value.UpdatedAtUtc);
|
||||
|
||||
MigrateLegacyInventionFiles();
|
||||
RepairOversizedInventionIds();
|
||||
BackfillInventionSaveMetadata();
|
||||
RepairStoredInventionBlobs();
|
||||
}
|
||||
|
||||
public sealed class InventionRecord
|
||||
{
|
||||
[BsonId]
|
||||
public long InventionId { get; set; }
|
||||
public long CreatorAccountId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
public string DataBlob { get; set; } = string.Empty;
|
||||
public string DataBlobHash { get; set; } = string.Empty;
|
||||
public long DataBlobSize { get; set; }
|
||||
|
||||
public int InstantiationCost { get; set; }
|
||||
public int LightsCost { get; set; }
|
||||
public int ChipsCost { get; set; }
|
||||
public int CloudVariablesCost { get; set; }
|
||||
public int AiCost { get; set; }
|
||||
public List<long> ReferencedInventionIds { get; set; } = new();
|
||||
public int CreatorAccountRole { get; set; }
|
||||
public List<string> Tags { get; set; } = new();
|
||||
public int Version { get; set; } = 1;
|
||||
public bool IsPublished { get; set; } = true;
|
||||
public int Uses { get; set; }
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public string RawPayloadJson { get; set; } = "{}";
|
||||
}
|
||||
|
||||
public sealed class InventionAliasRecord
|
||||
{
|
||||
[BsonId]
|
||||
public long LegacyInventionId { get; set; }
|
||||
public long CurrentInventionId { get; set; }
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class InventionPurchaseRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long InventionId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public int PricePaid { get; set; }
|
||||
public DateTime PurchasedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class InventionCheerRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long InventionId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class MeetupCodeRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public long CreatorAccountId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public long SubRoomId { get; set; }
|
||||
public long RoomInstanceId { get; set; }
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ExpiresAtUtc { get; set; } = DateTime.UtcNow.AddHours(6);
|
||||
public int UseCount { get; set; }
|
||||
public int MaxUses { get; set; } = 100;
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class CloudVariableRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long RoomId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string ValueJson { get; set; } = "null";
|
||||
public int Version { get; set; } = 1;
|
||||
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public long UpdatedByAccountId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RoomPlayerDataRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long RoomId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public string DataJson { get; set; } = "{}";
|
||||
public int Version { get; set; } = 1;
|
||||
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public long UpdatedByAccountId { get; set; }
|
||||
}
|
||||
|
||||
public static InventionRecord SaveInvention(
|
||||
long accountId,
|
||||
JsonElement payload,
|
||||
long? forcedInventionId = null)
|
||||
{
|
||||
if (accountId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(accountId));
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
bool isForcedImport = forcedInventionId.GetValueOrDefault() > 0;
|
||||
long requestedId = isForcedImport
|
||||
? forcedInventionId!.Value
|
||||
: FindLong(payload,
|
||||
"InventionId", "inventionId", "Id", "id") ?? 0;
|
||||
|
||||
InventionRecord? existing = requestedId > 0
|
||||
? FindInventionNoLock(requestedId)
|
||||
: null;
|
||||
|
||||
if (existing != null && existing.CreatorAccountId != accountId)
|
||||
throw new UnauthorizedAccessException(
|
||||
"An invention can only be edited by its creator.");
|
||||
|
||||
bool canPreserveImportedId = isForcedImport &&
|
||||
requestedId > 0 &&
|
||||
requestedId <= MaxClientInventionId &&
|
||||
Inventions.FindById(requestedId) == null;
|
||||
long inventionId = existing?.InventionId ??
|
||||
(canPreserveImportedId ? requestedId : NextInventionIdNoLock());
|
||||
DateTime now = DateTime.UtcNow;
|
||||
List<string> tags = ReadStringList(payload,
|
||||
"Tags", "tags", "TagNames", "tagNames");
|
||||
|
||||
var record = existing ?? new InventionRecord
|
||||
{
|
||||
InventionId = inventionId,
|
||||
CreatorAccountId = accountId,
|
||||
CreatedAtUtc = now
|
||||
};
|
||||
|
||||
record.RoomId = FindLong(payload,
|
||||
"CreationRoomId", "creationRoomId",
|
||||
"RoomId", "roomId", "SourceRoomId", "sourceRoomId")
|
||||
?? record.RoomId;
|
||||
record.InstantiationCost = ReadNonNegativeInt(
|
||||
payload,
|
||||
record.InstantiationCost,
|
||||
"InstantiationCost", "instantiationCost", "InstantiateCost", "instantiateCost");
|
||||
record.LightsCost = ReadNonNegativeInt(
|
||||
payload,
|
||||
record.LightsCost,
|
||||
"LightsCost", "lightsCost");
|
||||
record.ChipsCost = ReadNonNegativeInt(
|
||||
payload,
|
||||
record.ChipsCost,
|
||||
"ChipsCost", "chipsCost");
|
||||
record.CloudVariablesCost = ReadNonNegativeInt(
|
||||
payload,
|
||||
record.CloudVariablesCost,
|
||||
"CloudVariablesCost", "cloudVariablesCost", "CloudVariableCost", "cloudVariableCost");
|
||||
record.AiCost = ReadNonNegativeInt(
|
||||
payload,
|
||||
record.AiCost,
|
||||
"AiCost", "aiCost", "AICost");
|
||||
record.CreatorAccountRole = ReadNonNegativeInt(
|
||||
payload,
|
||||
record.CreatorAccountRole,
|
||||
"CreatorAccountRole", "creatorAccountRole");
|
||||
List<long> referencedInventions = ReadLongList(
|
||||
payload,
|
||||
"ReferencedInventions", "referencedInventions",
|
||||
"ReferencedInventionIds", "referencedInventionIds");
|
||||
if (referencedInventions.Count > 0)
|
||||
record.ReferencedInventionIds = referencedInventions.Take(512).ToList();
|
||||
record.Name = Limit(
|
||||
FindString(payload,
|
||||
"Name", "name", "Title", "title",
|
||||
"InventionName", "inventionName")
|
||||
?? record.Name,
|
||||
128);
|
||||
if (string.IsNullOrWhiteSpace(record.Name))
|
||||
record.Name = $"Invention {inventionId}";
|
||||
|
||||
record.Description = Limit(
|
||||
FindString(payload, "Description", "description")
|
||||
?? record.Description,
|
||||
1_000);
|
||||
record.ImageName = Limit(
|
||||
FindString(payload,
|
||||
"ImageName", "imageName", "Image", "image")
|
||||
?? record.ImageName,
|
||||
512);
|
||||
record.DataBlob = Limit(
|
||||
FindString(payload,
|
||||
"ObjectDataFilename", "objectDataFilename",
|
||||
"ObjectDataBlob", "objectDataBlob",
|
||||
"SubRoomDataFilename", "subRoomDataFilename",
|
||||
"DataBlob", "dataBlob", "DataBlobPath", "dataBlobPath",
|
||||
"Filename", "filename", "FileName", "fileName",
|
||||
"DataBlobName", "dataBlobName", "BlobName", "blobName")
|
||||
?? record.DataBlob,
|
||||
512);
|
||||
|
||||
string? requestedBlobHash = FindString(payload,
|
||||
"DataBlobHash", "dataBlobHash",
|
||||
"ObjectDataBlobHash", "objectDataBlobHash",
|
||||
"ObjectDataHash", "objectDataHash", "Hash", "hash");
|
||||
if (!string.IsNullOrWhiteSpace(requestedBlobHash))
|
||||
record.DataBlobHash = Limit(requestedBlobHash, 256);
|
||||
|
||||
long? requestedBlobSize = FindLong(payload,
|
||||
"DataBlobSize", "dataBlobSize", "ObjectDataSize", "objectDataSize",
|
||||
"ContentLength", "contentLength", "Length", "length");
|
||||
if (requestedBlobSize is >= 0)
|
||||
record.DataBlobSize = requestedBlobSize.Value;
|
||||
|
||||
if (tags.Count > 0)
|
||||
record.Tags = tags.Take(30).ToList();
|
||||
|
||||
bool? requestedPublished = FindBool(payload,
|
||||
"IsPublished", "isPublished", "Published", "published");
|
||||
if (requestedPublished.HasValue)
|
||||
record.IsPublished = requestedPublished.Value;
|
||||
|
||||
int requestedVersion = (int)Math.Clamp(
|
||||
FindLong(payload, "Version", "version") ?? 0,
|
||||
0,
|
||||
int.MaxValue);
|
||||
record.Version = Math.Max(
|
||||
Math.Max(1, record.Version + (existing == null ? 0 : 1)),
|
||||
requestedVersion);
|
||||
record.UpdatedAtUtc = now;
|
||||
record.RawPayloadJson = payload.GetRawText();
|
||||
RepairInventionBlobMetadataNoLock(record);
|
||||
|
||||
Inventions.Upsert(record);
|
||||
|
||||
if (existing == null && requestedId > 0 && requestedId != inventionId)
|
||||
{
|
||||
InventionAliases.Upsert(new InventionAliasRecord
|
||||
{
|
||||
LegacyInventionId = requestedId,
|
||||
CurrentInventionId = inventionId,
|
||||
CreatedAtUtc = now
|
||||
});
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public static InventionRecord? GetInvention(long inventionId)
|
||||
{
|
||||
if (inventionId <= 0)
|
||||
return null;
|
||||
|
||||
lock (Sync)
|
||||
return FindInventionNoLock(inventionId);
|
||||
}
|
||||
|
||||
public static long ResolveInventionId(long inventionId)
|
||||
{
|
||||
InventionRecord? record = GetInvention(inventionId);
|
||||
return record?.InventionId ?? inventionId;
|
||||
}
|
||||
|
||||
public static bool RecordInventionPurchase(
|
||||
long accountId,
|
||||
long inventionId,
|
||||
int pricePaid = 0)
|
||||
{
|
||||
if (accountId <= 0 || inventionId <= 0 || pricePaid < 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
InventionRecord? invention = FindInventionNoLock(inventionId);
|
||||
if (invention == null ||
|
||||
(!invention.IsPublished && invention.CreatorAccountId != accountId) ||
|
||||
!HasValidInventionBlob(invention))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
InventionPurchases.Upsert(new InventionPurchaseRecord
|
||||
{
|
||||
Id = InventionPurchaseId(invention.InventionId, accountId),
|
||||
InventionId = invention.InventionId,
|
||||
AccountId = accountId,
|
||||
PricePaid = pricePaid,
|
||||
PurchasedAtUtc = DateTime.UtcNow
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasInventionAccess(long accountId, long inventionId)
|
||||
{
|
||||
if (accountId <= 0 || inventionId <= 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
InventionRecord? invention = FindInventionNoLock(inventionId);
|
||||
if (invention == null)
|
||||
return false;
|
||||
return invention.CreatorAccountId == accountId ||
|
||||
InventionPurchases.Exists(value =>
|
||||
value.Id == InventionPurchaseId(invention.InventionId, accountId));
|
||||
}
|
||||
}
|
||||
|
||||
public static InventionRecord? SetInventionTags(
|
||||
long inventionId,
|
||||
long accountId,
|
||||
IEnumerable<string> tags)
|
||||
{
|
||||
if (inventionId <= 0)
|
||||
return null;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
InventionRecord? record = Inventions.FindById(inventionId);
|
||||
if (record == null)
|
||||
return null;
|
||||
if (record.CreatorAccountId != accountId)
|
||||
{
|
||||
throw new UnauthorizedAccessException(
|
||||
"An invention can only be edited by its creator.");
|
||||
}
|
||||
|
||||
record.Tags = tags
|
||||
.Select(value => Limit(value.Trim(), 32))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(30)
|
||||
.ToList();
|
||||
record.Version = Math.Max(1, record.Version + 1);
|
||||
record.UpdatedAtUtc = DateTime.UtcNow;
|
||||
Inventions.Update(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public static InventionRecord? IncrementInventionUse(long inventionId)
|
||||
{
|
||||
if (inventionId <= 0)
|
||||
return null;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
InventionRecord? record = Inventions.FindById(inventionId);
|
||||
if (record == null)
|
||||
return null;
|
||||
record.Uses = Math.Max(0, record.Uses) + 1;
|
||||
record.UpdatedAtUtc = DateTime.UtcNow;
|
||||
Inventions.Update(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool DeleteInvention(long inventionId, long accountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
InventionRecord? record = Inventions.FindById(inventionId);
|
||||
if (record == null || record.CreatorAccountId != accountId ||
|
||||
!Inventions.Delete(inventionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
InventionCheers.DeleteMany(value => value.InventionId == inventionId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SetInventionCheer(
|
||||
long inventionId,
|
||||
long accountId,
|
||||
bool cheered)
|
||||
{
|
||||
if (inventionId <= 0 || accountId <= 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
if (Inventions.FindById(inventionId) == null)
|
||||
return false;
|
||||
|
||||
string id = InventionCheerId(inventionId, accountId);
|
||||
if (!cheered)
|
||||
{
|
||||
InventionCheers.Delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
InventionCheers.Upsert(new InventionCheerRecord
|
||||
{
|
||||
Id = id,
|
||||
InventionId = inventionId,
|
||||
AccountId = accountId,
|
||||
CreatedAtUtc = DateTime.UtcNow
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsInventionCheered(long inventionId, long accountId)
|
||||
{
|
||||
if (inventionId <= 0 || accountId <= 0)
|
||||
return false;
|
||||
return InventionCheers.Exists(value =>
|
||||
value.Id == InventionCheerId(inventionId, accountId));
|
||||
}
|
||||
|
||||
public static int GetInventionCheerCount(long inventionId) =>
|
||||
inventionId <= 0
|
||||
? 0
|
||||
: InventionCheers.Count(value => value.InventionId == inventionId);
|
||||
|
||||
private static string InventionCheerId(long inventionId, long accountId) =>
|
||||
$"{accountId}:{inventionId}";
|
||||
|
||||
public static List<InventionRecord> SearchInventions(
|
||||
string? query = null,
|
||||
long? creatorAccountId = null,
|
||||
long? roomId = null,
|
||||
string? tag = null,
|
||||
bool includeUnpublished = false,
|
||||
int skip = 0,
|
||||
int take = 100)
|
||||
{
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
string q = query?.Trim() ?? string.Empty;
|
||||
string wantedTag = tag?.Trim() ?? string.Empty;
|
||||
|
||||
IEnumerable<InventionRecord> rows = Inventions.FindAll();
|
||||
|
||||
if (!includeUnpublished)
|
||||
rows = rows.Where(HasValidInventionBlob);
|
||||
if (!includeUnpublished)
|
||||
rows = rows.Where(value => value.IsPublished);
|
||||
if (creatorAccountId.HasValue && creatorAccountId.Value > 0)
|
||||
rows = rows.Where(value => value.CreatorAccountId == creatorAccountId.Value);
|
||||
if (roomId.HasValue && roomId.Value > 0)
|
||||
rows = rows.Where(value => value.RoomId == roomId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(wantedTag))
|
||||
{
|
||||
rows = rows.Where(value => value.Tags?.Any(item =>
|
||||
string.Equals(item, wantedTag, StringComparison.OrdinalIgnoreCase)) == true);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
rows = rows.Where(value =>
|
||||
value.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Description.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Tags.Any(item =>
|
||||
item.Contains(q, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
return rows
|
||||
.OrderByDescending(value => value.Uses)
|
||||
.ThenByDescending(value => value.UpdatedAtUtc)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static int CountInventions(
|
||||
string? query = null,
|
||||
long? creatorAccountId = null,
|
||||
long? roomId = null,
|
||||
string? tag = null,
|
||||
bool includeUnpublished = false)
|
||||
{
|
||||
string q = query?.Trim() ?? string.Empty;
|
||||
string wantedTag = tag?.Trim() ?? string.Empty;
|
||||
IEnumerable<InventionRecord> rows = Inventions.FindAll();
|
||||
if (!includeUnpublished)
|
||||
rows = rows.Where(HasValidInventionBlob);
|
||||
if (!includeUnpublished)
|
||||
rows = rows.Where(value => value.IsPublished);
|
||||
if (creatorAccountId.HasValue && creatorAccountId.Value > 0)
|
||||
rows = rows.Where(value => value.CreatorAccountId == creatorAccountId.Value);
|
||||
if (roomId.HasValue && roomId.Value > 0)
|
||||
rows = rows.Where(value => value.RoomId == roomId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(wantedTag))
|
||||
{
|
||||
rows = rows.Where(value => value.Tags?.Any(item =>
|
||||
string.Equals(item, wantedTag, StringComparison.OrdinalIgnoreCase)) == true);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
rows = rows.Where(value =>
|
||||
value.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Description.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Tags.Any(item => item.Contains(q, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
return rows.Count();
|
||||
}
|
||||
|
||||
public static object? ToClientInventionVersion(InventionRecord value)
|
||||
{
|
||||
if (!TryGetInventionBlob(
|
||||
value,
|
||||
out _,
|
||||
out string dataBlobPath,
|
||||
out string dataBlobName,
|
||||
out string dataBlobHash,
|
||||
out long dataBlobSize))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int versionNumber = Math.Max(1, value.Version);
|
||||
return new
|
||||
{
|
||||
InventionId = value.InventionId,
|
||||
Id = value.InventionId,
|
||||
ReplicationId = StableReplicationId(
|
||||
"invention-version",
|
||||
value.InventionId,
|
||||
versionNumber),
|
||||
VersionNumber = versionNumber,
|
||||
Version = versionNumber,
|
||||
InventionVersionId = value.InventionId,
|
||||
VersionId = value.InventionId,
|
||||
BlobName = dataBlobName,
|
||||
DataBlob = dataBlobName,
|
||||
DataBlobName = dataBlobName,
|
||||
DataBlobPath = dataBlobPath,
|
||||
ObjectDataFilename = dataBlobName,
|
||||
BlobHash = dataBlobHash,
|
||||
DataBlobHash = dataBlobHash,
|
||||
BlobSize = dataBlobSize,
|
||||
DataBlobSize = dataBlobSize,
|
||||
BlobUrl = "/cdn/" + dataBlobPath.TrimStart('/'),
|
||||
DownloadUrl = "/cdn/" + dataBlobPath.TrimStart('/'),
|
||||
InstantiationCost = Math.Max(0, value.InstantiationCost),
|
||||
LightsCost = Math.Max(0, value.LightsCost),
|
||||
ChipsCost = Math.Max(0, value.ChipsCost),
|
||||
CloudVariablesCost = Math.Max(0, value.CloudVariablesCost),
|
||||
AiCost = Math.Max(0, value.AiCost),
|
||||
CanSpawn = true
|
||||
};
|
||||
}
|
||||
|
||||
public static object ToClientInvention(InventionRecord value)
|
||||
{
|
||||
bool hasBlob = TryGetInventionBlob(
|
||||
value,
|
||||
out _,
|
||||
out string dataBlobPath,
|
||||
out string dataBlobName,
|
||||
out string dataBlobHash,
|
||||
out long dataBlobSize);
|
||||
|
||||
if (hasBlob &&
|
||||
(!string.Equals(value.DataBlob, dataBlobPath, StringComparison.Ordinal) ||
|
||||
!string.Equals(value.DataBlobHash, dataBlobHash, StringComparison.Ordinal) ||
|
||||
value.DataBlobSize != dataBlobSize))
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
value.DataBlob = dataBlobPath;
|
||||
value.DataBlobHash = dataBlobHash;
|
||||
value.DataBlobSize = dataBlobSize;
|
||||
Inventions.Upsert(value);
|
||||
}
|
||||
}
|
||||
|
||||
int currentVersionNumber = Math.Max(1, value.Version);
|
||||
string inventionReplicationId = StableReplicationId(
|
||||
"invention",
|
||||
value.InventionId,
|
||||
0);
|
||||
object? currentVersion = hasBlob
|
||||
? ToClientInventionVersion(value)
|
||||
: null;
|
||||
|
||||
return new
|
||||
{
|
||||
InventionId = value.InventionId,
|
||||
Id = value.InventionId,
|
||||
ReplicationId = inventionReplicationId,
|
||||
Name = value.Name,
|
||||
InventionName = value.Name,
|
||||
Description = value.Description,
|
||||
CreatorAccountId = value.CreatorAccountId,
|
||||
CreatorPlayerId = value.CreatorAccountId,
|
||||
CreatorId = value.CreatorAccountId,
|
||||
RoomId = value.RoomId,
|
||||
ImageName = value.ImageName,
|
||||
Image = value.ImageName,
|
||||
PreviewImageName = value.ImageName,
|
||||
DataBlob = hasBlob ? dataBlobName : string.Empty,
|
||||
DataBlobPath = hasBlob ? dataBlobPath : string.Empty,
|
||||
DataBlobName = hasBlob ? dataBlobName : string.Empty,
|
||||
Filename = hasBlob ? dataBlobName : string.Empty,
|
||||
ObjectDataFilename = hasBlob ? dataBlobName : string.Empty,
|
||||
ObjectDataBlob = hasBlob ? dataBlobName : string.Empty,
|
||||
DataBlobHash = hasBlob ? dataBlobHash : string.Empty,
|
||||
ObjectDataBlobHash = hasBlob ? dataBlobHash : string.Empty,
|
||||
DataBlobSize = hasBlob ? dataBlobSize : 0,
|
||||
InstantiationCost = Math.Max(0, value.InstantiationCost),
|
||||
LightsCost = Math.Max(0, value.LightsCost),
|
||||
ChipsCost = Math.Max(0, value.ChipsCost),
|
||||
CloudVariablesCost = Math.Max(0, value.CloudVariablesCost),
|
||||
AiCost = Math.Max(0, value.AiCost),
|
||||
ReferencedInventions = value.ReferencedInventionIds ?? new List<long>(),
|
||||
ReferencedInventionIds = value.ReferencedInventionIds ?? new List<long>(),
|
||||
DataBlobUrl = hasBlob
|
||||
? "/cdn/" + dataBlobPath.TrimStart('/')
|
||||
: string.Empty,
|
||||
HasValidObjects = hasBlob,
|
||||
CanSpawn = hasBlob,
|
||||
Tags = value.Tags ?? new List<string>(),
|
||||
TagNames = value.Tags ?? new List<string>(),
|
||||
CurrentVersionNumber = currentVersionNumber,
|
||||
CurrentVersion = currentVersion,
|
||||
InventionVersion = currentVersion,
|
||||
LatestVersion = currentVersion,
|
||||
Version = currentVersionNumber,
|
||||
Accessibility = 1,
|
||||
CreationRoomId = value.RoomId,
|
||||
CreatorPermission = 100,
|
||||
|
||||
GeneralPermission = 20,
|
||||
IsFeatured = false,
|
||||
IsAGInvention = false,
|
||||
IsCertifiedInvention = false,
|
||||
|
||||
Price = 0,
|
||||
AllowTrial = true,
|
||||
HideFromPlayer = false,
|
||||
IsPublished = value.IsPublished,
|
||||
Published = value.IsPublished,
|
||||
Uses = value.Uses,
|
||||
NumDownloads = value.Uses,
|
||||
NumPlayersHaveUsedInRoom = value.Uses,
|
||||
CheerCount = GetInventionCheerCount(value.InventionId),
|
||||
CreatedAt = value.CreatedAtUtc,
|
||||
FirstPublishedAt = value.CreatedAtUtc,
|
||||
CreationDate = value.CreatedAtUtc,
|
||||
ModifiedAt = value.UpdatedAtUtc,
|
||||
UpdatedAt = value.UpdatedAtUtc,
|
||||
LastUpdated = value.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
public static bool HasValidInventionBlob(InventionRecord value) =>
|
||||
TryGetInventionBlob(
|
||||
value,
|
||||
out _,
|
||||
out _,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
public static bool TryGetInventionBlob(
|
||||
InventionRecord value,
|
||||
out string fullPath,
|
||||
out string relativePath,
|
||||
out string filename,
|
||||
out string hashBase64,
|
||||
out long size)
|
||||
{
|
||||
fullPath = string.Empty;
|
||||
relativePath = string.Empty;
|
||||
filename = string.Empty;
|
||||
hashBase64 = string.Empty;
|
||||
size = 0;
|
||||
|
||||
var candidates = new List<(string Source, string Value)>();
|
||||
if (!string.IsNullOrWhiteSpace(value.DataBlob))
|
||||
candidates.Add(("storedDataBlob", value.DataBlob));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(value.RawPayloadJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(value.RawPayloadJson);
|
||||
CollectInventionBlobCandidates(document.RootElement, candidates);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
string cdnRoot = Path.GetFullPath(Path.Combine(Program.dataDir, "CDN"));
|
||||
string inventionDirectory = Path.Combine(cdnRoot, "invention");
|
||||
string roomDirectory = Path.Combine(cdnRoot, "room");
|
||||
Directory.CreateDirectory(inventionDirectory);
|
||||
|
||||
(string FullPath, string Name, long Size, int Score)? best = null;
|
||||
foreach ((string source, string rawValue) in candidates)
|
||||
{
|
||||
string normalized = NormalizeCandidateBlobValue(rawValue);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
continue;
|
||||
|
||||
string safeName = Path.GetFileName(normalized);
|
||||
if (safeName.Length == 0 || safeName.Length > 180 ||
|
||||
safeName.Any(character =>
|
||||
!char.IsLetterOrDigit(character) &&
|
||||
character is not '-' and not '_' and not '.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var blobNames = new List<string> { safeName };
|
||||
if (safeName.EndsWith(".inv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string extensionless = safeName[..^4];
|
||||
if (!string.IsNullOrWhiteSpace(extensionless))
|
||||
blobNames.Add(extensionless);
|
||||
}
|
||||
else
|
||||
{
|
||||
string baseName = Path.GetFileNameWithoutExtension(safeName);
|
||||
if (string.IsNullOrWhiteSpace(baseName))
|
||||
baseName = safeName;
|
||||
blobNames.Add(baseName + ".inv");
|
||||
}
|
||||
|
||||
var paths = new List<string>();
|
||||
if (normalized.Contains('/'))
|
||||
paths.Add(Path.Combine(cdnRoot, normalized.Replace('/', Path.DirectorySeparatorChar)));
|
||||
foreach (string blobName in blobNames.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
paths.Add(Path.Combine(inventionDirectory, blobName));
|
||||
paths.Add(Path.Combine(roomDirectory, blobName));
|
||||
paths.Add(Path.Combine(cdnRoot, blobName));
|
||||
}
|
||||
|
||||
foreach (string candidatePath in paths.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
string resolved;
|
||||
try
|
||||
{
|
||||
resolved = Path.GetFullPath(candidatePath);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!resolved.StartsWith(cdnRoot + Path.DirectorySeparatorChar,
|
||||
StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(resolved))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = new FileInfo(resolved);
|
||||
if (info.Length <= 0 || LooksLikeImageFile(resolved))
|
||||
continue;
|
||||
|
||||
int score = ScoreInventionBlobCandidate(source, resolved, info.Length);
|
||||
if (best == null || score > best.Value.Score)
|
||||
best = (resolved, info.Name, info.Length, score);
|
||||
}
|
||||
}
|
||||
|
||||
if (best == null)
|
||||
return false;
|
||||
|
||||
string canonicalBaseName = Path.GetFileNameWithoutExtension(best.Value.Name);
|
||||
if (string.IsNullOrWhiteSpace(canonicalBaseName))
|
||||
canonicalBaseName = best.Value.Name;
|
||||
string canonicalName = best.Value.Name.EndsWith(
|
||||
".inv",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? best.Value.Name
|
||||
: canonicalBaseName + ".inv";
|
||||
string canonicalPath = Path.Combine(inventionDirectory, canonicalName);
|
||||
if (!string.Equals(best.Value.FullPath, canonicalPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(canonicalPath))
|
||||
File.Copy(best.Value.FullPath, canonicalPath, overwrite: false);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
fullPath = File.Exists(canonicalPath) ? canonicalPath : best.Value.FullPath;
|
||||
var finalInfo = new FileInfo(fullPath);
|
||||
if (!finalInfo.Exists || finalInfo.Length <= 0 || LooksLikeImageFile(fullPath))
|
||||
return false;
|
||||
|
||||
filename = finalInfo.Name;
|
||||
relativePath = string.Equals(
|
||||
Path.GetDirectoryName(fullPath),
|
||||
inventionDirectory,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? "invention/" + filename
|
||||
: "room/" + filename;
|
||||
size = finalInfo.Length;
|
||||
if (!string.IsNullOrWhiteSpace(value.DataBlobHash) &&
|
||||
value.DataBlobSize == size)
|
||||
{
|
||||
hashBase64 = value.DataBlobHash;
|
||||
}
|
||||
else
|
||||
{
|
||||
using FileStream stream = File.OpenRead(fullPath);
|
||||
hashBase64 = Convert.ToBase64String(SHA256.HashData(stream));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void RepairInventionBlobMetadataNoLock(InventionRecord value)
|
||||
{
|
||||
if (!TryGetInventionBlob(
|
||||
value,
|
||||
out _,
|
||||
out string relativePath,
|
||||
out _,
|
||||
out string hashBase64,
|
||||
out long size))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
value.DataBlob = relativePath;
|
||||
value.DataBlobHash = hashBase64;
|
||||
value.DataBlobSize = size;
|
||||
}
|
||||
|
||||
private static void BackfillInventionSaveMetadata()
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
int repaired = 0;
|
||||
foreach (InventionRecord record in Inventions.FindAll().ToList())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.RawPayloadJson))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(record.RawPayloadJson);
|
||||
JsonElement payload = document.RootElement;
|
||||
bool changed = false;
|
||||
|
||||
long roomId = FindLong(payload,
|
||||
"CreationRoomId", "creationRoomId",
|
||||
"RoomId", "roomId", "SourceRoomId", "sourceRoomId") ?? 0;
|
||||
if (record.RoomId <= 0 && roomId > 0)
|
||||
{
|
||||
record.RoomId = roomId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed |= TryBackfillNonNegativeInt(
|
||||
payload,
|
||||
record.InstantiationCost,
|
||||
value => record.InstantiationCost = value,
|
||||
"InstantiationCost", "instantiationCost", "InstantiateCost", "instantiateCost");
|
||||
changed |= TryBackfillNonNegativeInt(
|
||||
payload,
|
||||
record.LightsCost,
|
||||
value => record.LightsCost = value,
|
||||
"LightsCost", "lightsCost");
|
||||
changed |= TryBackfillNonNegativeInt(
|
||||
payload,
|
||||
record.ChipsCost,
|
||||
value => record.ChipsCost = value,
|
||||
"ChipsCost", "chipsCost");
|
||||
changed |= TryBackfillNonNegativeInt(
|
||||
payload,
|
||||
record.CloudVariablesCost,
|
||||
value => record.CloudVariablesCost = value,
|
||||
"CloudVariablesCost", "cloudVariablesCost", "CloudVariableCost", "cloudVariableCost");
|
||||
changed |= TryBackfillNonNegativeInt(
|
||||
payload,
|
||||
record.AiCost,
|
||||
value => record.AiCost = value,
|
||||
"AiCost", "aiCost", "AICost");
|
||||
changed |= TryBackfillNonNegativeInt(
|
||||
payload,
|
||||
record.CreatorAccountRole,
|
||||
value => record.CreatorAccountRole = value,
|
||||
"CreatorAccountRole", "creatorAccountRole");
|
||||
|
||||
List<long> references = ReadLongList(payload,
|
||||
"ReferencedInventions", "referencedInventions",
|
||||
"ReferencedInventionIds", "referencedInventionIds");
|
||||
if ((record.ReferencedInventionIds == null || record.ReferencedInventionIds.Count == 0) &&
|
||||
references.Count > 0)
|
||||
{
|
||||
record.ReferencedInventionIds = references.Take(512).ToList();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
continue;
|
||||
|
||||
Inventions.Update(record);
|
||||
repaired++;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (repaired > 0)
|
||||
Console.WriteLine($"[INVENTION METADATA REPAIR] repaired={repaired}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryBackfillNonNegativeInt(
|
||||
JsonElement payload,
|
||||
int currentValue,
|
||||
Action<int> setter,
|
||||
params string[] names)
|
||||
{
|
||||
long? parsed = FindLong(payload, names);
|
||||
if (!parsed.HasValue)
|
||||
return false;
|
||||
|
||||
int value = (int)Math.Clamp(parsed.Value, 0, int.MaxValue);
|
||||
if (currentValue == value)
|
||||
return false;
|
||||
|
||||
setter(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int ReadNonNegativeInt(
|
||||
JsonElement payload,
|
||||
int fallback,
|
||||
params string[] names)
|
||||
{
|
||||
long? parsed = FindLong(payload, names);
|
||||
return parsed.HasValue
|
||||
? (int)Math.Clamp(parsed.Value, 0, int.MaxValue)
|
||||
: Math.Max(0, fallback);
|
||||
}
|
||||
|
||||
private static void RepairStoredInventionBlobs()
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
int repaired = 0;
|
||||
foreach (InventionRecord record in Inventions.FindAll().ToList())
|
||||
{
|
||||
string oldBlob = record.DataBlob;
|
||||
string oldHash = record.DataBlobHash;
|
||||
long oldSize = record.DataBlobSize;
|
||||
RepairInventionBlobMetadataNoLock(record);
|
||||
if (!string.Equals(oldBlob, record.DataBlob, StringComparison.Ordinal) ||
|
||||
!string.Equals(oldHash, record.DataBlobHash, StringComparison.Ordinal) ||
|
||||
oldSize != record.DataBlobSize)
|
||||
{
|
||||
Inventions.Update(record);
|
||||
repaired++;
|
||||
}
|
||||
}
|
||||
|
||||
if (repaired > 0)
|
||||
Console.WriteLine($"[INVENTION BLOB REPAIR] repaired={repaired}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectInventionBlobCandidates(
|
||||
JsonElement element,
|
||||
ICollection<(string Source, string Value)> output)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
string lowerName = property.Name.ToLowerInvariant();
|
||||
bool looksLikeBlobField =
|
||||
lowerName.Contains("blob", StringComparison.Ordinal) ||
|
||||
lowerName.Contains("filename", StringComparison.Ordinal) ||
|
||||
lowerName is "file" or "datafile" or "objectdata";
|
||||
bool looksLikePreview =
|
||||
lowerName.Contains("image", StringComparison.Ordinal) ||
|
||||
lowerName.Contains("preview", StringComparison.Ordinal) ||
|
||||
lowerName.Contains("thumbnail", StringComparison.Ordinal) ||
|
||||
lowerName.Contains("icon", StringComparison.Ordinal);
|
||||
|
||||
if (looksLikeBlobField && !looksLikePreview)
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
string? text = property.Value.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
output.Add((property.Name, text));
|
||||
}
|
||||
else if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
{
|
||||
CollectInventionBlobCandidates(property.Value, output);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
CollectInventionBlobCandidates(property.Value, output);
|
||||
}
|
||||
|
||||
if (TryParseEmbeddedJson(property.Value, out JsonElement embedded))
|
||||
CollectInventionBlobCandidates(embedded, output);
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
CollectInventionBlobCandidates(item, output);
|
||||
}
|
||||
else if (TryParseEmbeddedJson(element, out JsonElement embedded))
|
||||
{
|
||||
CollectInventionBlobCandidates(embedded, output);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeCandidateBlobValue(string? value)
|
||||
{
|
||||
string normalized = (value ?? string.Empty).Replace('\\', '/').Trim();
|
||||
if (normalized.StartsWith("/cdn/", StringComparison.OrdinalIgnoreCase))
|
||||
normalized = normalized[5..];
|
||||
normalized = normalized.TrimStart('/');
|
||||
if (normalized.Contains("..", StringComparison.Ordinal))
|
||||
return string.Empty;
|
||||
|
||||
if (normalized.StartsWith("room/invention/", StringComparison.OrdinalIgnoreCase))
|
||||
normalized = normalized[5..];
|
||||
if (normalized.StartsWith("invention/room/", StringComparison.OrdinalIgnoreCase))
|
||||
normalized = normalized[10..];
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static int ScoreInventionBlobCandidate(
|
||||
string source,
|
||||
string fullPath,
|
||||
long length)
|
||||
{
|
||||
string lower = source.ToLowerInvariant();
|
||||
int score = 0;
|
||||
if (lower.Contains("object", StringComparison.Ordinal)) score += 500;
|
||||
if (lower.Contains("data", StringComparison.Ordinal)) score += 300;
|
||||
if (lower.Contains("blob", StringComparison.Ordinal)) score += 200;
|
||||
if (lower.Contains("filename", StringComparison.Ordinal)) score += 100;
|
||||
if (fullPath.Contains(
|
||||
Path.DirectorySeparatorChar + "invention" + Path.DirectorySeparatorChar,
|
||||
StringComparison.OrdinalIgnoreCase)) score += 50;
|
||||
score += (int)Math.Min(length / 1024, 100);
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool LooksLikeImageFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Span<byte> header = stackalloc byte[12];
|
||||
using FileStream stream = File.OpenRead(path);
|
||||
int read = stream.Read(header);
|
||||
if (read >= 8 &&
|
||||
header[0] == 0x89 && header[1] == 0x50 &&
|
||||
header[2] == 0x4E && header[3] == 0x47)
|
||||
return true;
|
||||
if (read >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF)
|
||||
return true;
|
||||
if (read >= 6 &&
|
||||
header[0] == (byte)'G' && header[1] == (byte)'I' &&
|
||||
header[2] == (byte)'F')
|
||||
return true;
|
||||
if (read >= 12 &&
|
||||
header[0] == (byte)'R' && header[1] == (byte)'I' &&
|
||||
header[2] == (byte)'F' && header[3] == (byte)'F' &&
|
||||
header[8] == (byte)'W' && header[9] == (byte)'E' &&
|
||||
header[10] == (byte)'B' && header[11] == (byte)'P')
|
||||
return true;
|
||||
return read >= 2 && header[0] == (byte)'B' && header[1] == (byte)'M';
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static MeetupCodeRecord CreateMeetupCode(
|
||||
long creatorAccountId,
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
long roomInstanceId,
|
||||
int maxUses = 100,
|
||||
int lifetimeMinutes = 360)
|
||||
{
|
||||
if (creatorAccountId <= 0 || roomId <= 0 || roomInstanceId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(roomInstanceId));
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredMeetupCodesNoLock();
|
||||
|
||||
MeetupCodeRecord? existing = MeetupCodes.Find(value =>
|
||||
value.CreatorAccountId == creatorAccountId &&
|
||||
value.RoomInstanceId == roomInstanceId &&
|
||||
value.IsActive)
|
||||
.OrderByDescending(value => value.CreatedAtUtc)
|
||||
.FirstOrDefault(value => value.ExpiresAtUtc > DateTime.UtcNow);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
string code;
|
||||
do
|
||||
{
|
||||
code = GenerateMeetupCode();
|
||||
}
|
||||
while (MeetupCodes.Exists(value => value.Code == code));
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
var record = new MeetupCodeRecord
|
||||
{
|
||||
Code = code,
|
||||
CreatorAccountId = creatorAccountId,
|
||||
RoomId = roomId,
|
||||
SubRoomId = subRoomId,
|
||||
RoomInstanceId = roomInstanceId,
|
||||
CreatedAtUtc = now,
|
||||
ExpiresAtUtc = now.AddMinutes(Math.Clamp(lifetimeMinutes, 5, 1_440)),
|
||||
MaxUses = Math.Clamp(maxUses, 1, 1_000),
|
||||
IsActive = true
|
||||
};
|
||||
MeetupCodes.Insert(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public static MeetupCodeRecord? GetMeetupCode(string? code)
|
||||
{
|
||||
string normalized = NormalizeMeetupCode(code);
|
||||
if (normalized.Length == 0)
|
||||
return null;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredMeetupCodesNoLock();
|
||||
MeetupCodeRecord? record = MeetupCodes.FindById(normalized);
|
||||
return record != null && record.IsActive &&
|
||||
record.ExpiresAtUtc > DateTime.UtcNow &&
|
||||
record.UseCount < record.MaxUses
|
||||
? record
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecordMeetupCodeUse(string code)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
MeetupCodeRecord? record = MeetupCodes.FindById(NormalizeMeetupCode(code));
|
||||
if (record == null)
|
||||
return;
|
||||
record.UseCount++;
|
||||
if (record.UseCount >= record.MaxUses)
|
||||
record.IsActive = false;
|
||||
MeetupCodes.Update(record);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool RevokeMeetupCode(string code, long callerAccountId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
MeetupCodeRecord? record = MeetupCodes.FindById(NormalizeMeetupCode(code));
|
||||
if (record == null || record.CreatorAccountId != callerAccountId)
|
||||
return false;
|
||||
record.IsActive = false;
|
||||
return MeetupCodes.Update(record);
|
||||
}
|
||||
}
|
||||
|
||||
public static object ToClientMeetupCode(MeetupCodeRecord value) => new
|
||||
{
|
||||
Code = value.Code,
|
||||
MeetupCode = value.Code,
|
||||
CreatorAccountId = value.CreatorAccountId,
|
||||
RoomId = value.RoomId,
|
||||
SubRoomId = value.SubRoomId,
|
||||
RoomInstanceId = value.RoomInstanceId,
|
||||
CreatedAt = value.CreatedAtUtc,
|
||||
ExpiresAt = value.ExpiresAtUtc,
|
||||
UseCount = value.UseCount,
|
||||
MaxUses = value.MaxUses,
|
||||
IsActive = value.IsActive
|
||||
};
|
||||
|
||||
public static CloudVariableRecord SetCloudVariable(
|
||||
long roomId,
|
||||
long accountId,
|
||||
string key,
|
||||
string valueJson,
|
||||
long updatedByAccountId)
|
||||
{
|
||||
if (roomId <= 0 || accountId < 0 || updatedByAccountId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(roomId));
|
||||
|
||||
string normalizedKey = NormalizeCloudKey(key);
|
||||
if (normalizedKey.Length == 0)
|
||||
throw new ArgumentException("A cloud variable key is required.", nameof(key));
|
||||
if (Encoding.UTF8.GetByteCount(valueJson) > 16 * 1024)
|
||||
throw new ArgumentException("Cloud variable values are limited to 16 KiB.", nameof(valueJson));
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
string id = BuildCloudVariableId(roomId, accountId, normalizedKey);
|
||||
CloudVariableRecord? existing = CloudVariables.FindById(id);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
int count = CloudVariables.Count(value =>
|
||||
value.RoomId == roomId && value.AccountId == accountId);
|
||||
if (count >= 512)
|
||||
throw new InvalidOperationException(
|
||||
"This room/player scope already has 512 cloud variables.");
|
||||
}
|
||||
|
||||
var record = existing ?? new CloudVariableRecord
|
||||
{
|
||||
Id = id,
|
||||
RoomId = roomId,
|
||||
AccountId = accountId,
|
||||
Key = normalizedKey,
|
||||
Version = 0
|
||||
};
|
||||
|
||||
record.ValueJson = string.IsNullOrWhiteSpace(valueJson) ? "null" : valueJson;
|
||||
record.Version = Math.Max(1, record.Version + 1);
|
||||
record.UpdatedAtUtc = DateTime.UtcNow;
|
||||
record.UpdatedByAccountId = updatedByAccountId;
|
||||
CloudVariables.Upsert(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public static CloudVariableRecord? GetCloudVariable(
|
||||
long roomId,
|
||||
long accountId,
|
||||
string key)
|
||||
{
|
||||
if (roomId <= 0 || accountId < 0)
|
||||
return null;
|
||||
return CloudVariables.FindById(
|
||||
BuildCloudVariableId(roomId, accountId, NormalizeCloudKey(key)));
|
||||
}
|
||||
|
||||
public static List<CloudVariableRecord> GetCloudVariables(
|
||||
long roomId,
|
||||
long accountId,
|
||||
bool includeShared = true)
|
||||
{
|
||||
if (roomId <= 0 || accountId < 0)
|
||||
return new List<CloudVariableRecord>();
|
||||
|
||||
return CloudVariables.Find(value =>
|
||||
value.RoomId == roomId &&
|
||||
(value.AccountId == accountId ||
|
||||
(includeShared && value.AccountId == 0)))
|
||||
.OrderBy(value => value.Key)
|
||||
.ThenBy(value => value.AccountId)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static bool DeleteCloudVariable(
|
||||
long roomId,
|
||||
long accountId,
|
||||
string key) =>
|
||||
CloudVariables.Delete(
|
||||
BuildCloudVariableId(roomId, accountId, NormalizeCloudKey(key)));
|
||||
|
||||
public static RoomPlayerDataRecord SaveRoomPlayerData(
|
||||
long roomId,
|
||||
long accountId,
|
||||
string dataJson,
|
||||
long updatedByAccountId)
|
||||
{
|
||||
if (roomId <= 0 || accountId <= 0 || updatedByAccountId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(roomId));
|
||||
|
||||
string normalizedJson = string.IsNullOrWhiteSpace(dataJson)
|
||||
? "{}"
|
||||
: dataJson;
|
||||
if (Encoding.UTF8.GetByteCount(normalizedJson) > 256 * 1024)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Room player data is limited to 256 KiB.",
|
||||
nameof(dataJson));
|
||||
}
|
||||
|
||||
using (JsonDocument.Parse(normalizedJson))
|
||||
{
|
||||
}
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
string id = BuildRoomPlayerDataId(roomId, accountId);
|
||||
RoomPlayerDataRecord? existing = RoomPlayerData.FindById(id);
|
||||
var record = existing ?? new RoomPlayerDataRecord
|
||||
{
|
||||
Id = id,
|
||||
RoomId = roomId,
|
||||
AccountId = accountId,
|
||||
Version = 0
|
||||
};
|
||||
|
||||
record.DataJson = normalizedJson;
|
||||
record.Version = Math.Max(1, record.Version + 1);
|
||||
record.UpdatedAtUtc = DateTime.UtcNow;
|
||||
record.UpdatedByAccountId = updatedByAccountId;
|
||||
RoomPlayerData.Upsert(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
public static RoomPlayerDataRecord? GetRoomPlayerData(
|
||||
long roomId,
|
||||
long accountId)
|
||||
{
|
||||
if (roomId <= 0 || accountId <= 0)
|
||||
return null;
|
||||
return RoomPlayerData.FindById(BuildRoomPlayerDataId(roomId, accountId));
|
||||
}
|
||||
|
||||
public static bool DeleteRoomPlayerData(long roomId, long accountId)
|
||||
{
|
||||
if (roomId <= 0 || accountId <= 0)
|
||||
return false;
|
||||
return RoomPlayerData.Delete(BuildRoomPlayerDataId(roomId, accountId));
|
||||
}
|
||||
|
||||
public static object ToClientCloudVariable(CloudVariableRecord value)
|
||||
{
|
||||
object? parsedValue;
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(value.ValueJson);
|
||||
parsedValue = document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
parsedValue = value.ValueJson;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
Key = value.Key,
|
||||
Name = value.Key,
|
||||
VariableName = value.Key,
|
||||
CloudVariableName = value.Key,
|
||||
Value = parsedValue,
|
||||
VariableValue = parsedValue,
|
||||
CloudVariableValue = parsedValue,
|
||||
ValueJson = value.ValueJson,
|
||||
RoomId = value.RoomId,
|
||||
AccountId = value.AccountId,
|
||||
PlayerId = value.AccountId,
|
||||
IsShared = value.AccountId == 0,
|
||||
IsPlayerVariable = value.AccountId != 0,
|
||||
Scope = value.AccountId == 0 ? "room" : "player",
|
||||
Version = value.Version,
|
||||
UpdatedAt = value.UpdatedAtUtc,
|
||||
UpdatedByAccountId = value.UpdatedByAccountId
|
||||
};
|
||||
}
|
||||
|
||||
private static void MigrateLegacyInventionFiles()
|
||||
{
|
||||
string root = Path.Combine(Program.dataDir, "Inventions");
|
||||
if (!Directory.Exists(root))
|
||||
return;
|
||||
|
||||
int imported = 0;
|
||||
foreach (string accountDirectory in Directory.EnumerateDirectories(root))
|
||||
{
|
||||
if (!long.TryParse(Path.GetFileName(accountDirectory), out long accountId) ||
|
||||
accountId <= 0)
|
||||
continue;
|
||||
|
||||
foreach (string file in Directory.EnumerateFiles(
|
||||
accountDirectory,
|
||||
"*.json",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(
|
||||
File.ReadAllText(file));
|
||||
long fileId = long.TryParse(
|
||||
Path.GetFileNameWithoutExtension(file),
|
||||
out long parsedId)
|
||||
? parsedId
|
||||
: 0;
|
||||
if (fileId > 0 && Inventions.FindById(fileId) != null)
|
||||
continue;
|
||||
|
||||
JsonElement payload = document.RootElement.Clone();
|
||||
SaveInvention(
|
||||
accountId,
|
||||
payload,
|
||||
fileId > 0 ? fileId : null);
|
||||
imported++;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[INVENTION MIGRATION] skipped={file} error={exception.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imported > 0)
|
||||
Console.WriteLine($"[INVENTION MIGRATION] imported={imported}");
|
||||
}
|
||||
|
||||
private static InventionRecord? FindInventionNoLock(long inventionId)
|
||||
{
|
||||
InventionRecord? direct = Inventions.FindById(inventionId);
|
||||
if (direct != null)
|
||||
return direct;
|
||||
|
||||
InventionAliasRecord? alias = InventionAliases.FindById(inventionId);
|
||||
return alias == null
|
||||
? null
|
||||
: Inventions.FindById(alias.CurrentInventionId);
|
||||
}
|
||||
|
||||
private static string InventionPurchaseId(long inventionId, long accountId) =>
|
||||
$"{accountId}:{inventionId}";
|
||||
|
||||
private static long NextInventionIdNoLock()
|
||||
{
|
||||
long max = Inventions.FindAll()
|
||||
.Where(value => value.InventionId > 0 &&
|
||||
value.InventionId <= MaxClientInventionId)
|
||||
.Select(value => value.InventionId)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
|
||||
long candidate = Math.Max(1, max + 1);
|
||||
while (candidate <= MaxClientInventionId &&
|
||||
Inventions.FindById(candidate) != null)
|
||||
{
|
||||
candidate++;
|
||||
}
|
||||
|
||||
if (candidate > MaxClientInventionId)
|
||||
throw new InvalidOperationException("No client-compatible invention IDs remain.");
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static void RepairOversizedInventionIds()
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
List<InventionRecord> oversized = Inventions.FindAll()
|
||||
.Where(value => value.InventionId > MaxClientInventionId)
|
||||
.OrderBy(value => value.CreatedAtUtc)
|
||||
.ThenBy(value => value.InventionId)
|
||||
.ToList();
|
||||
if (oversized.Count == 0)
|
||||
return;
|
||||
|
||||
int repaired = 0;
|
||||
foreach (InventionRecord record in oversized)
|
||||
{
|
||||
long oldId = record.InventionId;
|
||||
long newId = NextInventionIdNoLock();
|
||||
|
||||
record.InventionId = newId;
|
||||
Inventions.Insert(record);
|
||||
Inventions.Delete(oldId);
|
||||
|
||||
foreach (InventionCheerRecord cheer in InventionCheers
|
||||
.Find(value => value.InventionId == oldId)
|
||||
.ToList())
|
||||
{
|
||||
string oldCheerId = cheer.Id;
|
||||
cheer.InventionId = newId;
|
||||
cheer.Id = InventionCheerId(newId, cheer.AccountId);
|
||||
InventionCheers.Upsert(cheer);
|
||||
if (!string.Equals(oldCheerId, cheer.Id, StringComparison.Ordinal))
|
||||
InventionCheers.Delete(oldCheerId);
|
||||
}
|
||||
|
||||
foreach (InventionPurchaseRecord purchase in InventionPurchases
|
||||
.Find(value => value.InventionId == oldId)
|
||||
.ToList())
|
||||
{
|
||||
string oldPurchaseId = purchase.Id;
|
||||
purchase.InventionId = newId;
|
||||
purchase.Id = InventionPurchaseId(newId, purchase.AccountId);
|
||||
InventionPurchases.Upsert(purchase);
|
||||
if (!string.Equals(oldPurchaseId, purchase.Id, StringComparison.Ordinal))
|
||||
InventionPurchases.Delete(oldPurchaseId);
|
||||
}
|
||||
|
||||
foreach (InventionAliasRecord existingAlias in InventionAliases
|
||||
.Find(value => value.CurrentInventionId == oldId)
|
||||
.ToList())
|
||||
{
|
||||
existingAlias.CurrentInventionId = newId;
|
||||
InventionAliases.Update(existingAlias);
|
||||
}
|
||||
|
||||
InventionAliases.Upsert(new InventionAliasRecord
|
||||
{
|
||||
LegacyInventionId = oldId,
|
||||
CurrentInventionId = newId,
|
||||
CreatedAtUtc = DateTime.UtcNow
|
||||
});
|
||||
repaired++;
|
||||
Console.WriteLine(
|
||||
$"[INVENTION ID REPAIR] old={oldId} new={newId} name={record.Name}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"[INVENTION ID REPAIR] repaired={repaired}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateMeetupCode()
|
||||
{
|
||||
const string alphabet = "23456789abcdefghjkmnpqrstuvwxyz";
|
||||
byte[] bytes = RandomNumberGenerator.GetBytes(8);
|
||||
var chars = new char[8];
|
||||
for (int i = 0; i < chars.Length; i++)
|
||||
chars[i] = alphabet[bytes[i] % alphabet.Length];
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
private static string NormalizeMeetupCode(string? code) =>
|
||||
new((code ?? string.Empty)
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.Select(char.ToLowerInvariant)
|
||||
.Take(16)
|
||||
.ToArray());
|
||||
|
||||
private static void DeleteExpiredMeetupCodesNoLock()
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
MeetupCodes.DeleteMany(value =>
|
||||
!value.IsActive ||
|
||||
value.ExpiresAtUtc <= now ||
|
||||
value.UseCount >= value.MaxUses);
|
||||
}
|
||||
|
||||
private static string NormalizeCloudKey(string? key)
|
||||
{
|
||||
string value = (key ?? string.Empty).Trim();
|
||||
if (value.Length > 128)
|
||||
value = value[..128];
|
||||
return value.Replace('\0', '_');
|
||||
}
|
||||
|
||||
public static void MigrateRoomId(long oldRoomId, long newRoomId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
foreach (InventionRecord invention in Inventions.Find(value => value.RoomId == oldRoomId).ToList())
|
||||
{
|
||||
invention.RoomId = newRoomId;
|
||||
Inventions.Update(invention);
|
||||
}
|
||||
|
||||
foreach (CloudVariableRecord variable in CloudVariables.Find(value => value.RoomId == oldRoomId).ToList())
|
||||
{
|
||||
CloudVariables.Delete(variable.Id);
|
||||
variable.Id = BuildCloudVariableId(newRoomId, variable.AccountId, variable.Key);
|
||||
variable.RoomId = newRoomId;
|
||||
CloudVariables.Upsert(variable);
|
||||
}
|
||||
|
||||
foreach (RoomPlayerDataRecord data in RoomPlayerData.Find(value => value.RoomId == oldRoomId).ToList())
|
||||
{
|
||||
RoomPlayerData.Delete(data.Id);
|
||||
data.Id = BuildRoomPlayerDataId(newRoomId, data.AccountId);
|
||||
data.RoomId = newRoomId;
|
||||
RoomPlayerData.Upsert(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildCloudVariableId(
|
||||
long roomId,
|
||||
long accountId,
|
||||
string key)
|
||||
{
|
||||
byte[] digest = SHA256.HashData(
|
||||
Encoding.UTF8.GetBytes(key.ToLowerInvariant()));
|
||||
return $"{roomId}:{accountId}:{Convert.ToHexString(digest)}";
|
||||
}
|
||||
|
||||
private static string BuildRoomPlayerDataId(long roomId, long accountId) =>
|
||||
$"{roomId}:{accountId}";
|
||||
|
||||
private static string Limit(string? value, int maxLength)
|
||||
{
|
||||
string result = value?.Trim() ?? string.Empty;
|
||||
return result.Length <= maxLength ? result : result[..maxLength];
|
||||
}
|
||||
|
||||
private static string StableReplicationId(
|
||||
string scope,
|
||||
long inventionId,
|
||||
int version)
|
||||
{
|
||||
byte[] source = Encoding.UTF8.GetBytes(
|
||||
$"{scope}:{inventionId}:{version}");
|
||||
byte[] hash = SHA256.HashData(source);
|
||||
var guidBytes = new byte[16];
|
||||
Array.Copy(hash, guidBytes, guidBytes.Length);
|
||||
return new Guid(guidBytes).ToString();
|
||||
}
|
||||
|
||||
private static long? FindLong(JsonElement element, params string[] names)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
if (names.Any(name => string.Equals(
|
||||
name,
|
||||
property.Name,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.Number &&
|
||||
property.Value.TryGetInt64(out long number))
|
||||
return number;
|
||||
if (long.TryParse(property.Value.ToString(), out number))
|
||||
return number;
|
||||
}
|
||||
|
||||
long? nested = FindLong(property.Value, names);
|
||||
if (nested.HasValue)
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
long? nested = FindLong(item, names);
|
||||
if (nested.HasValue)
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
else if (TryParseEmbeddedJson(element, out JsonElement embedded))
|
||||
{
|
||||
return FindLong(embedded, names);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindString(JsonElement element, params string[] names)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
if (names.Any(name => string.Equals(
|
||||
name,
|
||||
property.Name,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.String)
|
||||
return property.Value.GetString();
|
||||
if (property.Value.ValueKind is JsonValueKind.Number or
|
||||
JsonValueKind.True or JsonValueKind.False)
|
||||
return property.Value.ToString();
|
||||
}
|
||||
|
||||
string? nested = FindString(property.Value, names);
|
||||
if (nested != null)
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
string? nested = FindString(item, names);
|
||||
if (nested != null)
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
else if (TryParseEmbeddedJson(element, out JsonElement embedded))
|
||||
{
|
||||
return FindString(embedded, names);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? FindBool(JsonElement element, params string[] names)
|
||||
{
|
||||
string? raw = FindString(element, names);
|
||||
if (bool.TryParse(raw, out bool boolean))
|
||||
return boolean;
|
||||
if (int.TryParse(raw, out int number))
|
||||
return number != 0;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
if (!names.Any(name => string.Equals(
|
||||
name,
|
||||
property.Name,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
if (property.Value.ValueKind == JsonValueKind.True)
|
||||
return true;
|
||||
if (property.Value.ValueKind == JsonValueKind.False)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseEmbeddedJson(
|
||||
JsonElement element,
|
||||
out JsonElement parsed)
|
||||
{
|
||||
parsed = default;
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
return false;
|
||||
|
||||
string? raw = element.GetString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(raw) ||
|
||||
(raw[0] != '{' && raw[0] != '['))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(raw);
|
||||
parsed = document.RootElement.Clone();
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<long> ReadLongList(
|
||||
JsonElement element,
|
||||
params string[] names)
|
||||
{
|
||||
var output = new List<long>();
|
||||
if (TryParseEmbeddedJson(element, out JsonElement embeddedRoot))
|
||||
return ReadLongList(embeddedRoot, names);
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out long number))
|
||||
{
|
||||
if (number > 0)
|
||||
output.Add(number);
|
||||
}
|
||||
else if (long.TryParse(item.ToString(), out number) && number > 0)
|
||||
{
|
||||
output.Add(number);
|
||||
}
|
||||
else if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
{
|
||||
output.AddRange(ReadLongList(item, names));
|
||||
}
|
||||
}
|
||||
return output.Distinct().ToList();
|
||||
}
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
return output;
|
||||
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
bool requested = names.Any(name => string.Equals(
|
||||
name,
|
||||
property.Name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (requested)
|
||||
{
|
||||
JsonElement value = property.Value;
|
||||
if (TryParseEmbeddedJson(value, out JsonElement embedded))
|
||||
value = embedded;
|
||||
|
||||
if (value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
output.AddRange(ReadLongList(value, names));
|
||||
}
|
||||
else if (value.ValueKind == JsonValueKind.Number &&
|
||||
value.TryGetInt64(out long number) && number > 0)
|
||||
{
|
||||
output.Add(number);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (string part in value.ToString().Split(
|
||||
new[] { ',', ';' },
|
||||
StringSplitOptions.RemoveEmptyEntries |
|
||||
StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (long.TryParse(part, out number) && number > 0)
|
||||
output.Add(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
{
|
||||
output.AddRange(ReadLongList(property.Value, names));
|
||||
}
|
||||
else if (TryParseEmbeddedJson(property.Value, out JsonElement embedded))
|
||||
{
|
||||
output.AddRange(ReadLongList(embedded, names));
|
||||
}
|
||||
}
|
||||
|
||||
return output.Distinct().ToList();
|
||||
}
|
||||
|
||||
private static List<string> ReadStringList(
|
||||
JsonElement element,
|
||||
params string[] names)
|
||||
{
|
||||
var output = new List<string>();
|
||||
if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
output.AddRange(ReadStringList(item, names));
|
||||
return output
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (TryParseEmbeddedJson(element, out JsonElement embeddedRoot))
|
||||
return ReadStringList(embeddedRoot, names);
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
return output;
|
||||
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
bool isRequestedProperty = names.Any(name => string.Equals(
|
||||
name,
|
||||
property.Name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (!isRequestedProperty)
|
||||
{
|
||||
if (property.Value.ValueKind is JsonValueKind.Object or
|
||||
JsonValueKind.Array)
|
||||
{
|
||||
output.AddRange(ReadStringList(property.Value, names));
|
||||
}
|
||||
else if (TryParseEmbeddedJson(
|
||||
property.Value,
|
||||
out JsonElement embedded))
|
||||
{
|
||||
output.AddRange(ReadStringList(embedded, names));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonElement tagValue = property.Value;
|
||||
if (TryParseEmbeddedJson(tagValue, out JsonElement parsedTags))
|
||||
tagValue = parsedTags;
|
||||
|
||||
if (tagValue.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in tagValue.EnumerateArray())
|
||||
{
|
||||
string? tag = item.ValueKind == JsonValueKind.String
|
||||
? item.GetString()
|
||||
: item.ValueKind == JsonValueKind.Object
|
||||
? FindString(item, "Tag", "tag", "Name", "name")
|
||||
: item.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(tag))
|
||||
output.Add(Limit(tag, 32));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (string item in tagValue.ToString().Split(
|
||||
new[] { ',', ';' },
|
||||
StringSplitOptions.RemoveEmptyEntries |
|
||||
StringSplitOptions.TrimEntries))
|
||||
{
|
||||
output.Add(Limit(item, 32));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs.DBClasses
|
||||
{
|
||||
public static class ClubDBClasses
|
||||
{
|
||||
public enum ClubState
|
||||
{
|
||||
Active = 0,
|
||||
PendingJunior = 11,
|
||||
ModerationPendingReview = 100,
|
||||
ModerationClosed = 101,
|
||||
MarkedForDelete = 1000
|
||||
}
|
||||
|
||||
public enum ClubVisibility
|
||||
{
|
||||
Private = 0,
|
||||
Public = 1
|
||||
}
|
||||
|
||||
public enum ClubJoinability
|
||||
{
|
||||
Open = 0,
|
||||
InviteOnly = 1,
|
||||
AskToJoin = 2
|
||||
}
|
||||
|
||||
public enum ClubType
|
||||
{
|
||||
Generic = 0,
|
||||
Creator = 1
|
||||
}
|
||||
|
||||
public enum ClubMembershipType
|
||||
{
|
||||
Banned = -1,
|
||||
None = 0,
|
||||
PendingRequested = 1,
|
||||
PendingInvited = 2,
|
||||
PendingDenied = 3,
|
||||
Member = 10,
|
||||
Moderator = 20,
|
||||
CoOwner = 30,
|
||||
Creator = 100
|
||||
}
|
||||
|
||||
public sealed class ClubRecord
|
||||
{
|
||||
[BsonId]
|
||||
public long ClubId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string MainImageName { get; set; } = "DefaultClubImage2k.jpg";
|
||||
public ClubState State { get; set; } = ClubState.Active;
|
||||
public int MemberCount { get; set; }
|
||||
public string PrimaryCategory { get; set; } = "Social";
|
||||
public ClubVisibility Visibility { get; set; } = ClubVisibility.Public;
|
||||
public ClubJoinability Joinability { get; set; } = ClubJoinability.Open;
|
||||
public bool AllowJuniors { get; set; } = true;
|
||||
public int MinLevel { get; set; }
|
||||
public bool ClubChatEnabled { get; set; } = true;
|
||||
public long? ClubhouseRoomId { get; set; }
|
||||
public ClubType ClubType { get; set; } = ClubType.Generic;
|
||||
public long CreatorAccountId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public List<string> CategoryTags { get; set; } = new();
|
||||
public List<string> AdditionalImages { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ClubMembershipRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long ClubId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public ClubMembershipType MembershipType { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class HomeClubRecord
|
||||
{
|
||||
[BsonId]
|
||||
public long AccountId { get; set; }
|
||||
public long ClubId { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class ClubSummaryDto
|
||||
{
|
||||
public long ClubId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string MainImageName { get; set; } = string.Empty;
|
||||
public string ImageName => MainImageName;
|
||||
public int State { get; set; }
|
||||
public int MemberCount { get; set; }
|
||||
public string PrimaryCategory { get; set; } = string.Empty;
|
||||
public string PrimaryTag => PrimaryCategory;
|
||||
public string Category => PrimaryCategory;
|
||||
public string ClubCategory => PrimaryCategory;
|
||||
public List<string> CategoryTags { get; set; } = new();
|
||||
public List<string> CustomTags => CategoryTags;
|
||||
public List<string> Tags => CategoryTags;
|
||||
public int Visibility { get; set; }
|
||||
public int Joinability { get; set; }
|
||||
public bool AllowJuniors { get; set; }
|
||||
public int MinLevel { get; set; }
|
||||
public bool ClubChatEnabled { get; set; }
|
||||
public long? ClubhouseRoomId { get; set; }
|
||||
public int ClubType { get; set; }
|
||||
public long CreatorAccountId { get; set; }
|
||||
public long CreatorPlayerId => CreatorAccountId;
|
||||
public int ClubMembershipType { get; set; }
|
||||
public int MembershipType => ClubMembershipType;
|
||||
public int MyMembershipType => ClubMembershipType;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClubMembershipDto
|
||||
{
|
||||
public long AccountId { get; set; }
|
||||
public long ClubId { get; set; }
|
||||
public int ClubMembershipType { get; set; }
|
||||
public int MembershipType => ClubMembershipType;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClubPermissionsDto
|
||||
{
|
||||
public long ClubId { get; set; }
|
||||
public int MembershipType { get; set; }
|
||||
public bool CanInvite { get; set; }
|
||||
public bool CanKick { get; set; }
|
||||
public bool CanBan { get; set; }
|
||||
public bool CanModify { get; set; }
|
||||
public bool CanManageRoles { get; set; }
|
||||
public bool CanManageClubhouse { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClubImageDto
|
||||
{
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
public int ImageIndex { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClubDetailsDto
|
||||
{
|
||||
public ClubSummaryDto Club { get; set; } = new();
|
||||
public ClubSummaryDto ClubModel => Club;
|
||||
|
||||
public long ClubId => Club.ClubId;
|
||||
public string Name => Club.Name;
|
||||
public string Description => Club.Description;
|
||||
public string ImageName => Club.ImageName;
|
||||
public string MainImageName => Club.MainImageName;
|
||||
public int State => Club.State;
|
||||
public int MemberCount => Club.MemberCount;
|
||||
public string PrimaryCategory => Club.PrimaryCategory;
|
||||
public int Visibility => Club.Visibility;
|
||||
public int Joinability => Club.Joinability;
|
||||
public bool AllowJuniors => Club.AllowJuniors;
|
||||
public int MinLevel => Club.MinLevel;
|
||||
public int ClubType => Club.ClubType;
|
||||
public List<string> CategoryTags { get; set; } = new();
|
||||
public List<string> CustomTags => CategoryTags;
|
||||
public List<ClubImageDto> AdditionalImages { get; set; } = new();
|
||||
public List<ClubImageDto> Images => AdditionalImages;
|
||||
public ClubPermissionsDto CreatorPermissions { get; set; } = new();
|
||||
public ClubPermissionsDto CoOwnerPermissions { get; set; } = new();
|
||||
public ClubPermissionsDto ModeratorPermissions { get; set; } = new();
|
||||
public int MyMembershipType { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Mocha2023.Classes;
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs.DBClasses
|
||||
{
|
||||
public class PlayerDBClasses
|
||||
{
|
||||
public class FullPlayer
|
||||
{
|
||||
[BsonId]
|
||||
public long PlayerId { get; set; }
|
||||
public List<mPlatformID> PlatformIds { get; set; } = new();
|
||||
[JsonIgnore]
|
||||
public List<string>? DeviceIds { get; set; } = new();
|
||||
[JsonIgnore]
|
||||
public string? AuthToken { get; set; }
|
||||
[JsonIgnore]
|
||||
public string? Password { get; set; }
|
||||
public List<PlayerRoles> PlayerRoles { get; set; } = new();
|
||||
public Player? Player { get; set; }
|
||||
}
|
||||
|
||||
public class Player
|
||||
{
|
||||
public string? Username { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Bio { get; set; }
|
||||
public int AvailableUsernameChanges { get; set; } = 3;
|
||||
public bool? IsJunior { get; set; }
|
||||
public bool IsAgeVerified { get; set; }
|
||||
public DateTime? AgeVerifiedAt { get; set; }
|
||||
public int Level { get; set; } = 1;
|
||||
public int XP { get; set; } = 0;
|
||||
public string? ProfileImage { get; set; }
|
||||
public string? BannerImage { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime LastLoginAt { get; set; }
|
||||
public DateTime? Birthday { get; set; }
|
||||
public CurrentAuthSession? CurrentAuthSession { get; set; } = new CurrentAuthSession();
|
||||
public Reputation Reputation { get; set; } = new Reputation();
|
||||
public List<long> VisitedRooms { get; set; } = new();
|
||||
public List<long> CheeredRooms { get; set; } = new List<long>();
|
||||
public List<long> FavoritedRooms { get; set; } = new List<long>();
|
||||
public List<PlayerRelationship> Relationships { get; set; } = new();
|
||||
public List<PlayerCheerRecord> ReceivedCheers { get; set; } = new();
|
||||
public List<long> SubscribedAccountIds { get; set; } = new();
|
||||
public int SupportedInfluencerId { get; set; }
|
||||
public string? InfluencerCode { get; set; }
|
||||
public PlayerExtra PlayerExtra { get; set; } = new PlayerExtra();
|
||||
public string DisplayEmoji { get; set; } = "";
|
||||
public int PersonalPronouns { get; set; }
|
||||
|
||||
public int IdentityFlags { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerRelationship
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerID { get; set; }
|
||||
public RelationshipType RelationshipType { get; set; }
|
||||
public bool Favorited { get; set; }
|
||||
public bool Muted { get; set; }
|
||||
public bool Ignored { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClientRelationshipDTO
|
||||
{
|
||||
[JsonPropertyName("PlayerID")]
|
||||
public int PlayerID { get; set; }
|
||||
|
||||
[JsonPropertyName("RelationshipType")]
|
||||
public int RelationshipType { get; set; }
|
||||
|
||||
[JsonPropertyName("Favorited")]
|
||||
public int Favorited { get; set; }
|
||||
|
||||
[JsonPropertyName("Muted")]
|
||||
public int Muted { get; set; }
|
||||
|
||||
[JsonPropertyName("Ignored")]
|
||||
public int Ignored { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerCheerRecord
|
||||
{
|
||||
public long CheerId { get; set; }
|
||||
public long NotificationId { get; set; }
|
||||
public long GiverPlayerId { get; set; }
|
||||
public long ReceiverPlayerId { get; set; }
|
||||
public CheerCategory CheerCategory { get; set; }
|
||||
public bool Anonymous { get; set; }
|
||||
public CheerStatus Status { get; set; } = CheerStatus.Pending;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? HandledAt { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerDTOBase
|
||||
{
|
||||
public long accountId { get; set; }
|
||||
public DateTime createdAt { get; set; }
|
||||
public string? displayName { get; set; }
|
||||
public bool? isJunior { get; set; }
|
||||
public int platforms { get; set; }
|
||||
public string? profileImage { get; set; }
|
||||
public string? bannerImage { get; set; }
|
||||
public string? username { get; set; }
|
||||
public int personalPronouns { get; set; }
|
||||
public int identityFlags { get; set; }
|
||||
public string? displayEmoji { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerDTO : PlayerDTOBase { }
|
||||
public class PlayerMeDTO : PlayerDTOBase
|
||||
{
|
||||
public int availableUsernameChanges { get; set; } = 3;
|
||||
public DateTime? birthday { get; set; }
|
||||
public string? email { get; set; }
|
||||
public string? phone { get; set; }
|
||||
}
|
||||
|
||||
public class CurrentAuthSession { }
|
||||
|
||||
public class PlayerExtra
|
||||
{
|
||||
public Avatar Avatar { get; set; } = new Avatar();
|
||||
public List<string> AvatarItems { get; set; } = new();
|
||||
public List<SavedOutfit> SavedAvatars { get; set; } = new();
|
||||
public ModerationBlockDetails? ModerationBlockDetails { get; set; } = new ModerationBlockDetails();
|
||||
public List<Setting> Settings { get; set; } = new();
|
||||
public Heartbeat Heartbeat { get; set; } = new Heartbeat();
|
||||
public List<PlayerCurrency> Currencies { get; set; } = new();
|
||||
public List<RoomVisit> RoomVisits { get; set; } = new();
|
||||
public List<EquipmentItem> Equipment { get; set; } = new();
|
||||
public List<GiftPackage> PendingGiftPackages { get; set; } = new();
|
||||
|
||||
[JsonIgnore]
|
||||
public long FriendotronLastSentUtcDay { get; set; }
|
||||
|
||||
public long LevelingRoomInstanceId { get; set; }
|
||||
public int LevelingRoomActiveSeconds { get; set; }
|
||||
public long LevelingLastHeartbeatUnixTime { get; set; }
|
||||
|
||||
public long PartyId { get; set; }
|
||||
public long PartyLeaderPlayerId { get; set; }
|
||||
public List<long> PartyMemberPlayerIds { get; set; } = new();
|
||||
public List<PartyInviteRecord> PendingPartyInvites { get; set; } = new();
|
||||
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
public List<string> ItemWishlist { get; set; } = new();
|
||||
}
|
||||
|
||||
public enum GiftContext
|
||||
{
|
||||
None = -1,
|
||||
Default,
|
||||
First_Activity,
|
||||
Game_Drop,
|
||||
All_Daily_Challenges_Complete,
|
||||
All_Weekly_Challenge_Complete,
|
||||
Daily_Challenge_Complete,
|
||||
Weekly_Challenge_Complete,
|
||||
Unassigned_Equipment = 10,
|
||||
Unassigned_Avatar,
|
||||
Unassigned_Consumable,
|
||||
Reacquisition = 20,
|
||||
Membership,
|
||||
NUX_TokensAndDressUp = 30,
|
||||
NUX_Experiment1,
|
||||
NUX_Experiment2,
|
||||
NUX_Experiment3,
|
||||
NUX_Experiment4,
|
||||
NUX_Experiment5,
|
||||
GameRewards = 50,
|
||||
GameRewards_Tokens,
|
||||
LevelUp = 100,
|
||||
Purchased_Gift_A = 500,
|
||||
Purchased_Gift_B,
|
||||
Purchased_Gift_C,
|
||||
Purchased_Gift_D,
|
||||
Holiday = 1000,
|
||||
Contest,
|
||||
Promotion,
|
||||
SubscribersOnly,
|
||||
Deprecated = 1100,
|
||||
RecRoyale = 1200,
|
||||
DEPRECATED_Paintball_ClearCut = 2000,
|
||||
DEPRECATED_Paintball_Homestead,
|
||||
DEPRECATED_Paintball_Quarry,
|
||||
DEPRECATED_Paintball_River,
|
||||
DEPRECATED_Paintball_Dam,
|
||||
DEPRECATED_Paintball_DriveIn,
|
||||
Paintball_ClearCut = 2010,
|
||||
Paintball_Homestead,
|
||||
Paintball_Quarry,
|
||||
Paintball_River,
|
||||
Paintball_Dam,
|
||||
Paintball_DriveIn,
|
||||
DEPRECATED_Discgolf_Propulsion = 3000,
|
||||
DEPRECATED_Discgolf_Lake,
|
||||
Discgolf_Propulsion = 3010,
|
||||
Discgolf_Lake,
|
||||
Discgolf_Mode_CoopCatch = 3500,
|
||||
Quest_Goblin_A = 4000,
|
||||
Quest_Goblin_B,
|
||||
Quest_Goblin_C,
|
||||
Quest_Goblin_S,
|
||||
Quest_Goblin_Consumable,
|
||||
Quest_Cauldron_A = 4010,
|
||||
Quest_Cauldron_B,
|
||||
Quest_Cauldron_C,
|
||||
Quest_Cauldron_S,
|
||||
Quest_Cauldron_Consumable,
|
||||
Quest_Pirate1_A = 4100,
|
||||
Quest_Pirate1_B,
|
||||
Quest_Pirate1_C,
|
||||
Quest_Pirate1_S,
|
||||
Quest_Pirate1_X,
|
||||
Quest_Pirate1_Consumable,
|
||||
Quest_Dracula1_A = 4200,
|
||||
Quest_Dracula1_B,
|
||||
Quest_Dracula1_C,
|
||||
Quest_Dracula1_S,
|
||||
Quest_Dracula1_X,
|
||||
Quest_Dracula1_Consumable,
|
||||
Quest_Dracula1_SS,
|
||||
Quest_SciFi_A = 4500,
|
||||
Quest_SciFi_B,
|
||||
Quest_SciFi_C,
|
||||
Quest_SciFi_S,
|
||||
Quest_Scifi_Consumable,
|
||||
DEPRECATED_Charades = 5000,
|
||||
Charades,
|
||||
DEPRECATED_Soccer = 6000,
|
||||
Soccer,
|
||||
DEPRECATED_Paddleball = 7000,
|
||||
Paddleball,
|
||||
DEPRECATED_Dodgeball = 8000,
|
||||
Dodgeball,
|
||||
DEPRECATED_Lasertag = 9000,
|
||||
Lasertag,
|
||||
DEPRECATED_Bowling = 10000,
|
||||
Bowling,
|
||||
StuntRunner_TheMainEvent_A = 11000,
|
||||
StuntRunner_TheMainEvent_B,
|
||||
StuntRunner_TheMainEvent_C,
|
||||
StuntRunner_TheMainEvent_D,
|
||||
StuntRunner_TheMainEvent_S,
|
||||
StuntRunner_TheMainEvent_X,
|
||||
StuntRunner_TheMainEvent_Consumable,
|
||||
StuntRunner_TheMainEvent_SS,
|
||||
Store_LaserTag = 100000,
|
||||
Store_RecCenter = 100010,
|
||||
Consumable = 110000,
|
||||
Token = 110100,
|
||||
Punchcard_Challenge_Complete = 110200,
|
||||
All_Punchcard_Challenges_Complete,
|
||||
Commerce_Purchase = 200000
|
||||
}
|
||||
|
||||
public sealed class GiftPackage
|
||||
{
|
||||
public long GiftPackageId { get; set; }
|
||||
public int FromPlayerId { get; set; }
|
||||
public int PlayerId { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string AvatarItemDesc { get; set; } = string.Empty;
|
||||
public string ConsumableItemDesc { get; set; } = string.Empty;
|
||||
public int ConsumableQuantity { get; set; } = 1;
|
||||
public string EquipmentPrefabName { get; set; } = string.Empty;
|
||||
public string EquipmentModificationGuid { get; set; } = string.Empty;
|
||||
public string FriendlyName { get; set; } = string.Empty;
|
||||
public string Tooltip { get; set; } = string.Empty;
|
||||
public string ThumbnailImage { get; set; } = string.Empty;
|
||||
public int CurrencyType { get; set; } = 2;
|
||||
public int Currency { get; set; }
|
||||
public int XP { get; set; }
|
||||
public int GiftContext { get; set; }
|
||||
public int Rarity { get; set; }
|
||||
public int Platform { get; set; } = -1;
|
||||
public int PlatformMask { get; set; } = -1;
|
||||
public int? BalanceType { get; set; } = -1;
|
||||
public bool IsQuery { get; set; }
|
||||
public bool Unique { get; set; } = true;
|
||||
}
|
||||
|
||||
public class PartyInviteRecord
|
||||
{
|
||||
public long PartyId { get; set; }
|
||||
public long InviterPlayerId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class PartySnapshot
|
||||
{
|
||||
public long PartyId { get; set; }
|
||||
public long LeaderPlayerId { get; set; }
|
||||
public List<long> MemberPlayerIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public class EquipmentItem
|
||||
{
|
||||
public string PrefabName { get; set; } = "";
|
||||
public string ModificationGuid { get; set; } = "";
|
||||
public int UnlockedLevel { get; set; } = 0;
|
||||
public bool Favorited { get; set; } = false;
|
||||
public int PlatformMask { get; set; } = -1;
|
||||
public string FriendlyName { get; set; } = "";
|
||||
public string Tooltip { get; set; } = "";
|
||||
public int Rarity { get; set; } = 0;
|
||||
public string ThumbnailImage { get; set; } = "";
|
||||
}
|
||||
|
||||
public class RoomVisit
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public DateTime VisitedAt { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerCurrency
|
||||
{
|
||||
public int Balance { get; set; }
|
||||
public CurrencyType CurrencyType { get; set; }
|
||||
public BalanceType BalanceType { get; set; }
|
||||
}
|
||||
|
||||
public class Avatar
|
||||
{
|
||||
public string OutfitSelections { get; set; } = "";
|
||||
public string FaceFeatures { get; set; } = "";
|
||||
public string SkinColor { get; set; } = "";
|
||||
public string HairColor { get; set; } = "";
|
||||
}
|
||||
|
||||
public class Heartbeat
|
||||
{
|
||||
public string appVersion { get; set; } = ServerConfig.GameVersion.ToString();
|
||||
public DeviceClasses? deviceClass { get; set; } = DeviceClasses.Unknown;
|
||||
public MatchmakingErrorCode? errorCode { get; set; } = null;
|
||||
public bool isOnline { get; set; } = false;
|
||||
public long playerId { get; set; } = 0;
|
||||
public RoomInstance? roomInstance { get; set; } = null;
|
||||
public StatusVisibility statusVisibility { get; set; } = StatusVisibility.Online;
|
||||
public int vrMovementMode { get; set; } = 0;
|
||||
|
||||
[JsonIgnore]
|
||||
public long lastHeartbeatUnixTime { get; set; } = 0;
|
||||
}
|
||||
|
||||
public class RoomInstance
|
||||
{
|
||||
public bool encryptVoiceChat { get; set; }
|
||||
public long clubId { get; set; } = 0;
|
||||
public string? dataBlob { get; set; }
|
||||
public long eventId { get; set; } = 0;
|
||||
public bool isFull { get; set; }
|
||||
public bool isInProgress { get; set; }
|
||||
public bool isPrivate { get; set; }
|
||||
public string location { get; set; }
|
||||
public int maxCapacity { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string photonRegion { get; set; }
|
||||
public string photonRegionId { get; set; }
|
||||
public string photonRoomId { get; set; }
|
||||
public string roomCode { get; set; } = "";
|
||||
public long roomId { get; set; }
|
||||
public long roomInstanceId { get; set; }
|
||||
public RoomInstanceType roomInstanceType { get; set; }
|
||||
public long subRoomId { get; set; }
|
||||
public long subRoomDataSaveId { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public DateTime createdAt { get; set; }
|
||||
}
|
||||
|
||||
public class Reputation
|
||||
{
|
||||
public long AccountId { get; set; }
|
||||
public bool IsCheerful { get; set; }
|
||||
public double Noteriety { get; set; }
|
||||
public CheerCategory SelectedCheer { get; set; }
|
||||
public int CheerCredit { get; set; } = 15;
|
||||
|
||||
[JsonIgnore]
|
||||
public long CheerCreditRefreshAtUnixTime { get; set; }
|
||||
public int CheerGeneral { get; set; }
|
||||
public int CheerHelpful { get; set; }
|
||||
public int CheerCreative { get; set; }
|
||||
public int CheerGreatHost { get; set; }
|
||||
public int CheerSportsman { get; set; }
|
||||
public int SubscriberCount { get; set; }
|
||||
public int SubscribedCount { get; set; }
|
||||
}
|
||||
public class ModerationBlockDetails
|
||||
{
|
||||
public ReportCategory ReportCategory { get; set; } = ReportCategory.Moderator;
|
||||
public int Duration { get; set; } = 0;
|
||||
public long GameSessionId { get; set; } = 0;
|
||||
public bool? IsBan { get; set; } = false;
|
||||
public bool? IsHostKick { get; set; } = false;
|
||||
public string? Message { get; set; } = "";
|
||||
public ulong? PlayerIdReporter { get; set; } = null;
|
||||
[JsonIgnore]
|
||||
public long ModerationSetUnixTime { get; set; } = 0;
|
||||
[JsonIgnore]
|
||||
public ulong BannedByPlayerId { get; set; } = 0;
|
||||
[JsonIgnore]
|
||||
public string? AppealCode { get; set; }
|
||||
[JsonIgnore]
|
||||
public bool AppealSubmitted { get; set; }
|
||||
[JsonIgnore]
|
||||
public DateTime? AppealSubmittedAt { get; set; }
|
||||
}
|
||||
|
||||
public class Setting
|
||||
{
|
||||
public required string Key { get; set; }
|
||||
public required string Value { get; set; }
|
||||
}
|
||||
|
||||
public class mPlatformID
|
||||
{
|
||||
public Platforms Platform { get; set; }
|
||||
public ulong PlatformId { get; set; }
|
||||
}
|
||||
|
||||
public class CachedLogins
|
||||
{
|
||||
public Platforms platform { get; set; }
|
||||
public string? platformId { get; set; }
|
||||
public long accountId { get; set; }
|
||||
public DateTime? lastLoginTime { get; set; }
|
||||
public bool requirePassword { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerProgressionDTO
|
||||
{
|
||||
public long PlayerId { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int XP { get; set; }
|
||||
}
|
||||
|
||||
public enum Platforms
|
||||
{
|
||||
All = -1,
|
||||
Steam,
|
||||
Oculus,
|
||||
PlayStation,
|
||||
Xbox,
|
||||
HeadlessBot,
|
||||
IOS,
|
||||
GooglePlay
|
||||
}
|
||||
|
||||
public enum PlayerRoles
|
||||
{
|
||||
Screenshare,
|
||||
Moderator,
|
||||
Developer,
|
||||
Influencer,
|
||||
RRPlus,
|
||||
Keepsake,
|
||||
|
||||
CommunityTeam
|
||||
}
|
||||
|
||||
public enum ReportCategory
|
||||
{
|
||||
Moderator = -1,
|
||||
Unknown,
|
||||
DEPRECATED_MicrophoneAbuse,
|
||||
Harassment,
|
||||
Cheating,
|
||||
DEPRECATED_ImmatureBehavior,
|
||||
AFK,
|
||||
Misc,
|
||||
Underage,
|
||||
VoteKick = 10,
|
||||
MisleadingPurchases,
|
||||
CoC_Underage = 100,
|
||||
CoC_Sexual,
|
||||
CoC_Discrimination,
|
||||
CoC_Trolling,
|
||||
CoC_NameOrProfile,
|
||||
IssuingInaccurateReports = 1000
|
||||
}
|
||||
|
||||
public enum CheerCategory
|
||||
{
|
||||
General,
|
||||
Helpful = 10,
|
||||
Sportmanship = 20,
|
||||
GreatHost = 30,
|
||||
Creative = 40,
|
||||
|
||||
RecRoomDeveloper = 9000,
|
||||
RecRoomCommunityTeam = 9001
|
||||
}
|
||||
|
||||
public enum CheerStatus
|
||||
{
|
||||
Pending,
|
||||
Accepted,
|
||||
Dismissed
|
||||
}
|
||||
|
||||
public enum RelationshipType
|
||||
{
|
||||
None,
|
||||
OutgoingFriendRequest,
|
||||
IncomingFriendRequest,
|
||||
Friend
|
||||
}
|
||||
|
||||
public enum MatchmakingErrorCode
|
||||
{
|
||||
UnknownError = -1,
|
||||
Success,
|
||||
NoSuchGame,
|
||||
PlayerNotOnline,
|
||||
InsufficientSpace,
|
||||
EventNotStarted,
|
||||
EventAlreadyFinished,
|
||||
BlockedFromRoom = 7,
|
||||
JuniorNotAllowed = 11,
|
||||
Banned,
|
||||
AlreadyInBestInstance,
|
||||
InsufficientRelationship,
|
||||
UpdateRequired = 16,
|
||||
AlreadyInTargetInstance,
|
||||
UGCNotAllowed = 19,
|
||||
NoSuchRoom,
|
||||
RoomIsNotActive = 22,
|
||||
RoomBlockedByCreator,
|
||||
RoomIsPrivate = 25,
|
||||
RoomInstanceIsPrivate,
|
||||
DeviceClassNotSupported = 30,
|
||||
DeviceClassNotSupportedByRoomOwner,
|
||||
MovementModeNotSupportedByRoomOwner,
|
||||
EventIsPrivate = 35,
|
||||
RoomInviteExpired = 40,
|
||||
NoAvailableRegion = 45,
|
||||
NotorietyTooPoor = 50,
|
||||
BannedFromRoom = 55,
|
||||
NoSuchRoomPlaylist = 60,
|
||||
RoomPlaylistIsNotActive,
|
||||
RoomPlaylistIsPrivate,
|
||||
NoSuchClub = 70,
|
||||
ClubHasNoClubhouse,
|
||||
ClubIsNotActive = 73,
|
||||
NotAMemberOfClub,
|
||||
BannedFromClub,
|
||||
InstanceJoinNotPermitted,
|
||||
LevelTooLow
|
||||
}
|
||||
|
||||
public enum DeviceClasses
|
||||
{
|
||||
Unknown,
|
||||
VR,
|
||||
Screen,
|
||||
Mobile,
|
||||
VRLow,
|
||||
Quest2
|
||||
}
|
||||
|
||||
public enum CurrencyType
|
||||
{
|
||||
Invalid,
|
||||
LaserTagTickets,
|
||||
RecCenterTokens,
|
||||
LostSkullsGold = 100,
|
||||
DraculaSilver,
|
||||
RecRoyale_Season1 = 200,
|
||||
RoomCurrency = 300
|
||||
}
|
||||
|
||||
public enum BalanceType
|
||||
{
|
||||
NonPurchasedNotUsableInP2P = -2,
|
||||
NonPurchasedDefault,
|
||||
SteamPurchased,
|
||||
OculusPurchased,
|
||||
PlayStationPurchased,
|
||||
MicrosoftPurchased,
|
||||
IOSPurchased = 5,
|
||||
GooglePlayPurchased,
|
||||
PlayStationNonPurchasedP2P = 100,
|
||||
NonPlayStationNonPurchasedP2P,
|
||||
NonPurchasedEarnedByP2P = 1000
|
||||
}
|
||||
|
||||
public enum StatusVisibility
|
||||
{
|
||||
Online,
|
||||
Away,
|
||||
Offline,
|
||||
Unknown = 100
|
||||
}
|
||||
|
||||
public enum RoomInstanceType
|
||||
{
|
||||
Public,
|
||||
Private,
|
||||
Dormroom,
|
||||
Event,
|
||||
Meetup,
|
||||
Clubhouse
|
||||
}
|
||||
|
||||
public class SavedOutfit : Avatar
|
||||
{
|
||||
public int Slot { get; set; }
|
||||
public string? PreviewImageName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public static class RoleUtils
|
||||
{
|
||||
public static bool TryParseRole(string roleName, out PlayerDBClasses.PlayerRoles role)
|
||||
{
|
||||
role = default;
|
||||
if (string.IsNullOrWhiteSpace(roleName))
|
||||
return false;
|
||||
|
||||
switch (roleName.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "developer":
|
||||
case "dev":
|
||||
role = PlayerDBClasses.PlayerRoles.Developer;
|
||||
return true;
|
||||
case "communityteam":
|
||||
case "community-team":
|
||||
case "community_team":
|
||||
case "community":
|
||||
case "ct":
|
||||
role = PlayerDBClasses.PlayerRoles.CommunityTeam;
|
||||
return true;
|
||||
case "moderator":
|
||||
role = PlayerDBClasses.PlayerRoles.Moderator;
|
||||
return true;
|
||||
case "screenshare":
|
||||
role = PlayerDBClasses.PlayerRoles.Screenshare;
|
||||
return true;
|
||||
case "influencer":
|
||||
role = PlayerDBClasses.PlayerRoles.Influencer;
|
||||
return true;
|
||||
case "rrplus":
|
||||
case "rr+":
|
||||
role = PlayerDBClasses.PlayerRoles.RRPlus;
|
||||
return true;
|
||||
case "keepsake":
|
||||
role = PlayerDBClasses.PlayerRoles.Keepsake;
|
||||
return true;
|
||||
default:
|
||||
return Enum.TryParse(roleName, true, out role);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs.DBClasses
|
||||
{
|
||||
public class RoomDBClasses
|
||||
{
|
||||
public const string RoomsCollectionName = "Rooms";
|
||||
public const string SubRoomDataSavesCollectionName = "SubRoomDataSaves";
|
||||
public const string RoomBansCollectionName = "RoomBans";
|
||||
|
||||
public class Room
|
||||
{
|
||||
[BsonId]
|
||||
public long RoomId { get; set; }
|
||||
|
||||
public bool IsDorm { get; set; }
|
||||
|
||||
public int MaxPlayerCalculationMode { get; set; }
|
||||
|
||||
public int MaxPlayers { get; set; }
|
||||
|
||||
public bool CloningAllowed { get; set; }
|
||||
|
||||
public bool DisableMicAutoMute { get; set; }
|
||||
|
||||
public bool DisableRoomComments { get; set; }
|
||||
|
||||
public bool EncryptVoiceChat { get; set; }
|
||||
|
||||
public bool ToxmodEnabled { get; set; }
|
||||
|
||||
public bool LoadScreenLocked { get; set; }
|
||||
|
||||
public int PersistenceVersion { get; set; }
|
||||
|
||||
public bool AutoLocalizeRoom { get; set; }
|
||||
|
||||
public bool IsDeveloperOwned { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
public WarningMaskType WarningMask { get; set; }
|
||||
|
||||
public string? CustomWarning { get; set; }
|
||||
|
||||
public long CreatorAccountId { get; set; }
|
||||
|
||||
public RoomState? State { get; set; }
|
||||
|
||||
public RoomAccessibility Accessibility { get; set; }
|
||||
|
||||
public bool SupportsLevelVoting { get; set; }
|
||||
|
||||
public bool IsRRO { get; set; }
|
||||
|
||||
public bool IsBaseRoom { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool CreativeToolsBetaEnabled { get; set; }
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("CreativeToolsBetaEnabled")]
|
||||
public bool CreativeToolsBetaEnabledForClient
|
||||
{
|
||||
get => CreativeToolsBetaEnabled;
|
||||
set => CreativeToolsBetaEnabled = value;
|
||||
}
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("SupportsBetaContent")]
|
||||
public bool SupportsBetaContentForClient
|
||||
{
|
||||
get => CreativeToolsBetaEnabled;
|
||||
set => CreativeToolsBetaEnabled = value;
|
||||
}
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("IsBeta")]
|
||||
public bool IsBetaForClient
|
||||
{
|
||||
get => CreativeToolsBetaEnabled;
|
||||
set => CreativeToolsBetaEnabled = value;
|
||||
}
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("BetaContentEnabled")]
|
||||
public bool BetaContentEnabledForClient
|
||||
{
|
||||
get => CreativeToolsBetaEnabled;
|
||||
set => CreativeToolsBetaEnabled = value;
|
||||
}
|
||||
|
||||
public bool SupportsScreens { get; set; }
|
||||
|
||||
public bool SupportsWalkVR { get; set; }
|
||||
|
||||
public bool SupportsTeleportVR { get; set; }
|
||||
|
||||
public bool SupportsVRLow { get; set; }
|
||||
|
||||
public bool SupportsQuest2 { get; set; }
|
||||
|
||||
public bool SupportsMobile { get; set; }
|
||||
|
||||
public bool SupportsJuniors { get; set; }
|
||||
|
||||
public int MinLevel { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public Stats Stats { get; set; } = new();
|
||||
|
||||
public string? RankedEntityId { get; set; }
|
||||
|
||||
public string? RankingContext { get; set; }
|
||||
|
||||
public List<SubRooms> SubRooms { get; set; } = new();
|
||||
|
||||
public List<Roles> Roles { get; set; } = new();
|
||||
|
||||
public string? DataBlob { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public int UgcVersion { get; set; }
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("Version")]
|
||||
public int VersionForClient
|
||||
{
|
||||
get => Math.Max(1, UgcVersion);
|
||||
set => UgcVersion = Math.Max(1, value);
|
||||
}
|
||||
|
||||
public List<Tags> Tags { get; set; } = new();
|
||||
|
||||
public List<string> PromoImages { get; set; } = new();
|
||||
|
||||
public List<PromoExternalContent> PromoExternalContent { get; set; }
|
||||
= new();
|
||||
|
||||
public List<LoadScreens> LoadScreens { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SubRooms
|
||||
{
|
||||
public long SubRoomId { get; set; }
|
||||
|
||||
public long RoomId { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? DataBlob { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public long SubRoomDataSaveId { get; set; }
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("CurrentSave")]
|
||||
public SubRoomDataSave? SubRoomDataSave { get; set; }
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("DataSavedAt")]
|
||||
public DateTime? DataSavedAtForClient =>
|
||||
SubRoomDataSave?.CreatedAt;
|
||||
|
||||
public bool IsSandbox { get; set; }
|
||||
|
||||
public int MaxPlayers { get; set; }
|
||||
|
||||
public RoomAccessibility Accessibility { get; set; }
|
||||
|
||||
public string UnitySceneId { get; set; } = string.Empty;
|
||||
|
||||
public List<SubRoomPermission> Permissions { get; set; } = new();
|
||||
|
||||
[JsonIgnore]
|
||||
public long SavedByAccountId { get; set; }
|
||||
}
|
||||
|
||||
public class SubRoomPermission
|
||||
{
|
||||
public bool Override { get; set; }
|
||||
|
||||
public string Permission { get; set; } = string.Empty;
|
||||
|
||||
public int Role { get; set; }
|
||||
|
||||
public int Type { get; set; }
|
||||
|
||||
public string Value { get; set; } = "True";
|
||||
}
|
||||
|
||||
public class SubRoomDataSave
|
||||
{
|
||||
public sealed class SubRoomSaveResponse
|
||||
{
|
||||
[JsonPropertyName("Success")]
|
||||
public bool Success { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("RoomId")]
|
||||
public long RoomId { get; set; }
|
||||
|
||||
[JsonPropertyName("SubRoomId")]
|
||||
public long SubRoomId { get; set; }
|
||||
|
||||
[JsonPropertyName("SubRoomDataSaveId")]
|
||||
public long SubRoomDataSaveId { get; set; }
|
||||
|
||||
[JsonPropertyName("SavedByAccountId")]
|
||||
public long SavedByAccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("DataBlob")]
|
||||
public string DataBlob { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("SubRoomDataFilename")]
|
||||
public string SubRoomDataFilename { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("DataBlobHash")]
|
||||
public string? DataBlobHash { get; set; }
|
||||
|
||||
[JsonPropertyName("SubRoomDataHash")]
|
||||
public string? SubRoomDataHash { get; set; }
|
||||
|
||||
[JsonPropertyName("UnityAssetId")]
|
||||
public string? UnityAssetId { get; set; }
|
||||
|
||||
[JsonPropertyName("ReferencedUnityAssetIds")]
|
||||
public List<string> ReferencedUnityAssetIds { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("BakedUnityAssets")]
|
||||
public List<BakedUnityAsset> BakedUnityAssets { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("PersistenceVersion")]
|
||||
public int PersistenceVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("SavedOnPlatform")]
|
||||
public int? SavedOnPlatform { get; set; }
|
||||
|
||||
[JsonPropertyName("SavedOnDeviceClass")]
|
||||
public int? SavedOnDeviceClass { get; set; }
|
||||
|
||||
[JsonPropertyName("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("DataSavedAt")]
|
||||
public DateTime DataSavedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("CurrentSave")]
|
||||
public SubRoomDataSave CurrentSave { get; set; } = new();
|
||||
}
|
||||
[BsonId]
|
||||
[JsonPropertyName("SubRoomDataSaveId")]
|
||||
public long SubRoomDataSaveId { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public long RoomId { get; set; }
|
||||
|
||||
[JsonPropertyName("SubRoomId")]
|
||||
public long SubRoomId { get; set; }
|
||||
|
||||
[JsonPropertyName("SavedByAccountId")]
|
||||
public long SavedByAccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("DataBlob")]
|
||||
public string DataBlob { get; set; } = string.Empty;
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("SubRoomDataFilename")]
|
||||
public string SubRoomDataFilename => DataBlob;
|
||||
|
||||
[JsonIgnore]
|
||||
public string? RoomDataBlob { get; set; }
|
||||
|
||||
[JsonPropertyName("DataBlobHash")]
|
||||
public string? DataBlobHash { get; set; }
|
||||
|
||||
[BsonIgnore]
|
||||
[JsonPropertyName("SubRoomDataHash")]
|
||||
public string? SubRoomDataHash => DataBlobHash;
|
||||
|
||||
[JsonPropertyName("PersistenceVersion")]
|
||||
public int? PersistenceVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("SavedOnPlatform")]
|
||||
public int? SavedOnPlatform { get; set; }
|
||||
|
||||
[JsonPropertyName("SavedOnDeviceClass")]
|
||||
public int? SavedOnDeviceClass { get; set; }
|
||||
|
||||
[JsonPropertyName("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("UnityAssetId")]
|
||||
public string? UnityAssetId { get; set; }
|
||||
|
||||
[JsonPropertyName("ReferencedUnityAssetIds")]
|
||||
public List<string> ReferencedUnityAssetIds { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("OMVersion")]
|
||||
public int OMVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("UgcSubVersion")]
|
||||
public int UgcSubVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("ModerationState")]
|
||||
public int ModerationState { get; set; }
|
||||
|
||||
[JsonPropertyName("Tags")]
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("BakedUnityAssets")]
|
||||
public List<BakedUnityAsset> BakedUnityAssets { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsPublished { get; set; }
|
||||
}
|
||||
|
||||
public class BakedUnityAsset
|
||||
{
|
||||
[JsonPropertyName("UnityAssetId")]
|
||||
public string UnityAssetId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Target")]
|
||||
public int Target { get; set; }
|
||||
|
||||
[JsonPropertyName("Version")]
|
||||
public int Version { get; set; }
|
||||
|
||||
[JsonPropertyName("Filename")]
|
||||
public string Filename { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Hash")]
|
||||
public string? Hash { get; set; }
|
||||
|
||||
[JsonPropertyName("UnityVersion")]
|
||||
public string? UnityVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("IsAvailable")]
|
||||
public bool IsAvailable { get; set; }
|
||||
}
|
||||
|
||||
public static void HydrateCurrentSaves(
|
||||
LiteDatabase database,
|
||||
Room room)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
ArgumentNullException.ThrowIfNull(room);
|
||||
|
||||
room.SubRooms ??= new List<SubRooms>();
|
||||
|
||||
var saves = database.GetCollection<SubRoomDataSave>(
|
||||
SubRoomDataSavesCollectionName,
|
||||
BsonAutoId.Int64
|
||||
);
|
||||
|
||||
SubRoomDataSave? newestRoomSave = null;
|
||||
|
||||
foreach (var subRoom in room.SubRooms)
|
||||
{
|
||||
SubRoomDataSave? currentSave = null;
|
||||
|
||||
if (subRoom.SubRoomDataSaveId > 0)
|
||||
{
|
||||
currentSave = saves.FindById(
|
||||
subRoom.SubRoomDataSaveId
|
||||
);
|
||||
|
||||
if (currentSave != null &&
|
||||
(currentSave.RoomId != room.RoomId ||
|
||||
currentSave.SubRoomId != subRoom.SubRoomId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM LOAD WARNING] Stale save pointer " +
|
||||
$"room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId} " +
|
||||
$"saveId={subRoom.SubRoomDataSaveId}"
|
||||
);
|
||||
|
||||
currentSave = null;
|
||||
}
|
||||
}
|
||||
|
||||
currentSave ??= FindNewestSubRoomSave(
|
||||
saves,
|
||||
room.RoomId,
|
||||
subRoom.SubRoomId
|
||||
);
|
||||
|
||||
if (currentSave == null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM LOAD] No save found " +
|
||||
$"room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId}"
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
ApplyCurrentSave(subRoom, currentSave);
|
||||
|
||||
if (newestRoomSave == null ||
|
||||
currentSave.CreatedAt > newestRoomSave.CreatedAt)
|
||||
{
|
||||
newestRoomSave = currentSave;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ROOM LOAD] " +
|
||||
$"room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId} " +
|
||||
$"saveId={currentSave.SubRoomDataSaveId} " +
|
||||
$"superRoomBlob={currentSave.RoomDataBlob ?? "null"} " +
|
||||
$"subRoomBlob={currentSave.DataBlob} " +
|
||||
$"persistenceVersion=" +
|
||||
$"{currentSave.PersistenceVersion?.ToString() ?? "null"}"
|
||||
);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(newestRoomSave?.RoomDataBlob))
|
||||
{
|
||||
room.DataBlob = newestRoomSave.RoomDataBlob;
|
||||
}
|
||||
|
||||
if (newestRoomSave?.PersistenceVersion is int persistenceVersion)
|
||||
{
|
||||
room.PersistenceVersion = persistenceVersion;
|
||||
}
|
||||
|
||||
room.UgcVersion = Math.Max(1, room.UgcVersion);
|
||||
}
|
||||
|
||||
public static SubRoomDataSave? FindNewestSubRoomSave(
|
||||
ILiteCollection<SubRoomDataSave> saves,
|
||||
long roomId,
|
||||
long subRoomId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(saves);
|
||||
|
||||
var query = Query.And(
|
||||
Query.EQ(
|
||||
nameof(SubRoomDataSave.RoomId),
|
||||
roomId
|
||||
),
|
||||
Query.EQ(
|
||||
nameof(SubRoomDataSave.SubRoomId),
|
||||
subRoomId
|
||||
)
|
||||
);
|
||||
|
||||
return saves
|
||||
.Find(query)
|
||||
.OrderByDescending(save => save.CreatedAt)
|
||||
.ThenByDescending(save => save.SubRoomDataSaveId)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public static void ApplyCurrentSave(
|
||||
SubRooms subRoom,
|
||||
SubRoomDataSave currentSave)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subRoom);
|
||||
ArgumentNullException.ThrowIfNull(currentSave);
|
||||
|
||||
subRoom.SubRoomDataSaveId =
|
||||
currentSave.SubRoomDataSaveId;
|
||||
|
||||
subRoom.SubRoomDataSave =
|
||||
currentSave;
|
||||
|
||||
subRoom.DataBlob =
|
||||
currentSave.DataBlob;
|
||||
|
||||
subRoom.SavedByAccountId =
|
||||
currentSave.SavedByAccountId;
|
||||
}
|
||||
|
||||
public class Tags
|
||||
{
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
|
||||
public TagType Type { get; set; }
|
||||
}
|
||||
|
||||
public class PromoExternalContent
|
||||
{
|
||||
public PromoExternalContentType Type { get; set; }
|
||||
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class LoadScreens
|
||||
{
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string Subtitle { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class Stats
|
||||
{
|
||||
public int CheerCount { get; set; }
|
||||
|
||||
public int FavoriteCount { get; set; }
|
||||
|
||||
public int VisitorCount { get; set; }
|
||||
|
||||
public int VisitCount { get; set; }
|
||||
}
|
||||
|
||||
public class Roles
|
||||
{
|
||||
public long AccountId { get; set; }
|
||||
|
||||
public Role Role { get; set; }
|
||||
|
||||
public Role InvitedRole { get; set; }
|
||||
}
|
||||
|
||||
public class RoomEditPermission
|
||||
{
|
||||
public bool CanEditRoom { get; set; }
|
||||
|
||||
public string Error { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class RoomBan
|
||||
{
|
||||
[BsonId]
|
||||
public long RoomBanId { get; set; }
|
||||
|
||||
public long RoomId { get; set; }
|
||||
|
||||
public long AccountId { get; set; }
|
||||
|
||||
public long BannedByAccountId { get; set; }
|
||||
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public DateTime BannedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum Role : byte
|
||||
{
|
||||
None = 0,
|
||||
Banned = 1,
|
||||
|
||||
Host = 10,
|
||||
Moderator = 20,
|
||||
CoOwner = 30,
|
||||
TemporaryCoOwner = 31,
|
||||
|
||||
Creator = 255
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum WarningMaskType
|
||||
{
|
||||
None = 0,
|
||||
Scary = 1,
|
||||
Mature = 2,
|
||||
FlashingLights = 4,
|
||||
IntenseMotion = 8,
|
||||
Violence = 16,
|
||||
Custom = 32,
|
||||
Reports = 64
|
||||
}
|
||||
|
||||
public enum RoomAccessibility
|
||||
{
|
||||
Private = 0,
|
||||
Public = 1,
|
||||
Unlisted = 2
|
||||
}
|
||||
|
||||
public enum RoomState
|
||||
{
|
||||
Active = 0,
|
||||
|
||||
PendingJunior = 11,
|
||||
|
||||
Moderation_PendingReview = 100,
|
||||
Moderation_Closed = 101,
|
||||
|
||||
MarkedForDelete = 1000
|
||||
}
|
||||
|
||||
public enum TagType
|
||||
{
|
||||
General = 0,
|
||||
Auto = 1,
|
||||
AGOnly = 2,
|
||||
Banned = 3
|
||||
}
|
||||
|
||||
public enum PromoExternalContentType
|
||||
{
|
||||
YouTube = 0
|
||||
}
|
||||
|
||||
public enum JoinMode
|
||||
{
|
||||
PublicMatchmaking = 0,
|
||||
PublicNewInstance = 1,
|
||||
PrivateNewInstance = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class LiteDbMaintenance
|
||||
{
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(2);
|
||||
|
||||
private static readonly List<Timer> ActiveTimers = new();
|
||||
|
||||
public static void StartPeriodicCheckpoint(string name, LiteDatabase database)
|
||||
{
|
||||
var timer = new Timer(_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
database.Checkpoint();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine($"[LITEDB CHECKPOINT] {name} failed: {exception.Message}");
|
||||
}
|
||||
}, null, Interval, Interval);
|
||||
|
||||
lock (ActiveTimers)
|
||||
ActiveTimers.Add(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Mocha2023.Classes.DBs;
|
||||
|
||||
public static class MassImportJobs
|
||||
{
|
||||
public sealed class RoomOutcome
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? SourceUrl { get; init; }
|
||||
public bool Success { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
public int SubRoomsImported { get; set; }
|
||||
public int SavesImported { get; set; }
|
||||
public int BakedAssetsImported { get; set; }
|
||||
public int AssetBundlesCopied { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Job
|
||||
{
|
||||
public string Id { get; } = Guid.NewGuid().ToString("N");
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
public DateTime StartedAt { get; } = DateTime.UtcNow;
|
||||
public DateTime? FinishedAt { get; set; }
|
||||
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? FatalError { get; set; }
|
||||
public int TotalFound { get; set; }
|
||||
public string? CurrentRoomName { get; set; }
|
||||
|
||||
private readonly object listLock = new();
|
||||
private readonly List<RoomOutcome> results = new();
|
||||
|
||||
public void AddResult(RoomOutcome outcome)
|
||||
{
|
||||
lock (listLock)
|
||||
results.Add(outcome);
|
||||
}
|
||||
|
||||
public List<RoomOutcome> SnapshotResults()
|
||||
{
|
||||
lock (listLock)
|
||||
return new List<RoomOutcome>(results);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Job> Jobs = new();
|
||||
|
||||
private static readonly TimeSpan Retention = TimeSpan.FromHours(6);
|
||||
|
||||
public static Job Create(string kind)
|
||||
{
|
||||
var job = new Job { Kind = kind };
|
||||
Jobs[job.Id] = job;
|
||||
|
||||
DateTime cutoff = DateTime.UtcNow - Retention;
|
||||
foreach (var pair in Jobs)
|
||||
{
|
||||
if (pair.Value.FinishedAt is DateTime finishedAt && finishedAt < cutoff)
|
||||
Jobs.TryRemove(pair.Key, out _);
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
public static Job? Get(string id) => Jobs.TryGetValue(id, out Job? job) ? job : null;
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
using LiteDB;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class NotificationDB
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly TimeSpan AcknowledgedInviteGrace =
|
||||
TimeSpan.FromMinutes(5);
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "Notifications.db"));
|
||||
private static readonly ILiteCollection<StoredNotification> Messages =
|
||||
Database.GetCollection<StoredNotification>("Messages", BsonAutoId.Int64);
|
||||
private static readonly ILiteCollection<StoredRoomInviteGrant> RoomInviteGrants =
|
||||
Database.GetCollection<StoredRoomInviteGrant>("RoomInviteGrants");
|
||||
|
||||
static NotificationDB()
|
||||
{
|
||||
Messages.EnsureIndex(value => value.RecipientPlayerId);
|
||||
Messages.EnsureIndex(value => value.CreatedAt);
|
||||
RoomInviteGrants.EnsureIndex(value => value.RecipientPlayerId);
|
||||
RoomInviteGrants.EnsureIndex(value => value.RoomInstanceId);
|
||||
RoomInviteGrants.EnsureIndex(value => value.ExpirationTime);
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
foreach (StoredNotification message in Messages
|
||||
.FindAll()
|
||||
.Where(value =>
|
||||
IsRoomInviteMessageType(value.Type))
|
||||
.ToList())
|
||||
{
|
||||
if (message.FromPlayerId <= 0 ||
|
||||
message.RecipientPlayerId <= 0 ||
|
||||
(message.RoomId ?? 0) <= 0 ||
|
||||
!TryReadRoomInviteId(message, out long roomInstanceId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string wireData = roomInstanceId.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (message.Type != (int)MessageType.GameInvite ||
|
||||
!string.Equals(
|
||||
message.Message,
|
||||
wireData,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
message.Type = (int)MessageType.GameInvite;
|
||||
message.Message = wireData;
|
||||
Messages.Update(message);
|
||||
}
|
||||
|
||||
RoomInviteGrants.Upsert(new StoredRoomInviteGrant
|
||||
{
|
||||
MessageId = message.Id,
|
||||
RecipientPlayerId = message.RecipientPlayerId,
|
||||
InviterPlayerId = message.FromPlayerId,
|
||||
RoomId = message.RoomId!.Value,
|
||||
RoomInstanceId = roomInstanceId,
|
||||
CreatedAt = message.CreatedAt,
|
||||
ExpirationTime = message.ExpirationTime
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum MessageType
|
||||
{
|
||||
GameInvite = 0,
|
||||
GameInviteDeclined = 1,
|
||||
GameJoinFailed = 2,
|
||||
PartyActivitySwitch = 3,
|
||||
FriendInvite = 4,
|
||||
VoteToKick = 5,
|
||||
GameInviteV2 = 6,
|
||||
PartyActivitySwitchV2 = 7,
|
||||
RequestGameInvite = 10,
|
||||
RequestGameInviteDeclined = 11,
|
||||
FriendStatusOnline = 20,
|
||||
TextMessage = 30,
|
||||
FriendRequestAccepted = 40,
|
||||
PlayerCheer = 50,
|
||||
PlayerCheerAnonymous = 51,
|
||||
PartyUpRequest = 120,
|
||||
FriendIntroduction = 130
|
||||
}
|
||||
|
||||
public sealed class StoredNotification
|
||||
{
|
||||
[BsonId]
|
||||
public long Id { get; set; }
|
||||
public long RecipientPlayerId { get; set; }
|
||||
public int FromPlayerId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public int Type { get; set; }
|
||||
public string Message { get; set; } = "{}";
|
||||
public long? RoomId { get; set; }
|
||||
public long? RoomInstanceId { get; set; }
|
||||
public long? PlayerEventId { get; set; }
|
||||
public long? ClubId { get; set; }
|
||||
public DateTime ExpirationTime { get; set; } = DateTime.UtcNow.AddDays(7);
|
||||
}
|
||||
|
||||
public sealed class StoredRoomInviteGrant
|
||||
{
|
||||
[BsonId]
|
||||
public long MessageId { get; set; }
|
||||
public long RecipientPlayerId { get; set; }
|
||||
public long InviterPlayerId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public long RoomInstanceId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ExpirationTime { get; set; } = DateTime.UtcNow.AddHours(2);
|
||||
}
|
||||
|
||||
public sealed class ClientNotification
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public int FromPlayerId { get; set; }
|
||||
public long ToPlayerId { get; set; }
|
||||
|
||||
[JsonPropertyName("SentTime")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public int Type { get; set; }
|
||||
|
||||
[JsonPropertyName("Data")]
|
||||
public string Message { get; set; } = "{}";
|
||||
|
||||
[JsonIgnore]
|
||||
public long? FromAccountId { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? RoomId { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public long? RoomInstanceId { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? PlayerEventId { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public long? ClubId { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public DateTime ExpirationTime { get; set; }
|
||||
}
|
||||
|
||||
public static ClientNotification CreateRoomInvite(
|
||||
long inviterPlayerId,
|
||||
long receiverPlayerId,
|
||||
long roomId,
|
||||
long roomInstanceId,
|
||||
string? photonRoomId)
|
||||
{
|
||||
|
||||
var inviterInstance = PlayerDB
|
||||
.GetPlayerHeartbeat(inviterPlayerId)
|
||||
.roomInstance;
|
||||
bool isExactInviterInstance =
|
||||
inviterInstance != null &&
|
||||
inviterInstance.roomId == roomId &&
|
||||
inviterInstance.roomInstanceId == roomInstanceId;
|
||||
string roomName = RoomDB.GetRoom(roomId)?.Name ?? "Room";
|
||||
return CreateRoomInvite(
|
||||
inviterPlayerId,
|
||||
receiverPlayerId,
|
||||
roomId,
|
||||
roomInstanceId,
|
||||
roomName,
|
||||
inviteMode:
|
||||
isExactInviterInstance &&
|
||||
Mocha2023.Classes.Sessions.IsRestrictedInstance(
|
||||
inviterInstance)
|
||||
? 1
|
||||
: 0,
|
||||
photonRoomId: isExactInviterInstance
|
||||
? inviterInstance!.photonRoomId
|
||||
: photonRoomId);
|
||||
}
|
||||
|
||||
public static ClientNotification CreateRoomInvite(
|
||||
long inviterPlayerId,
|
||||
long receiverPlayerId,
|
||||
long roomId,
|
||||
long roomInstanceId,
|
||||
string? roomName,
|
||||
int inviteMode,
|
||||
string? photonRoomId = null)
|
||||
{
|
||||
if (inviterPlayerId is <= 0 or > int.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(inviterPlayerId));
|
||||
if (receiverPlayerId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(receiverPlayerId));
|
||||
if (roomId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(roomId));
|
||||
if (roomInstanceId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(roomInstanceId));
|
||||
|
||||
var stored = new StoredNotification
|
||||
{
|
||||
RecipientPlayerId = receiverPlayerId,
|
||||
FromPlayerId = checked((int)inviterPlayerId),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Type = (int)MessageType.GameInvite,
|
||||
Message = roomInstanceId.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
RoomId = roomId,
|
||||
RoomInstanceId = roomInstanceId,
|
||||
PlayerEventId = null,
|
||||
ExpirationTime = DateTime.UtcNow.AddHours(2)
|
||||
};
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
|
||||
List<StoredRoomInviteGrant> replacedGrants = RoomInviteGrants
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == receiverPlayerId &&
|
||||
value.InviterPlayerId == inviterPlayerId &&
|
||||
value.RoomInstanceId == roomInstanceId)
|
||||
.ToList();
|
||||
foreach (StoredRoomInviteGrant grant in replacedGrants)
|
||||
{
|
||||
Messages.Delete(grant.MessageId);
|
||||
RoomInviteGrants.Delete(grant.MessageId);
|
||||
}
|
||||
|
||||
Messages.Insert(stored);
|
||||
|
||||
RoomInviteGrants.Upsert(new StoredRoomInviteGrant
|
||||
{
|
||||
MessageId = stored.Id,
|
||||
RecipientPlayerId = receiverPlayerId,
|
||||
InviterPlayerId = inviterPlayerId,
|
||||
RoomId = roomId,
|
||||
RoomInstanceId = roomInstanceId,
|
||||
CreatedAt = stored.CreatedAt,
|
||||
ExpirationTime = stored.ExpirationTime
|
||||
});
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ROOM INVITE GRANT] issued message={stored.Id} " +
|
||||
$"from={inviterPlayerId} to={receiverPlayerId} " +
|
||||
$"room={roomId} instance={roomInstanceId} " +
|
||||
$"replaced={replacedGrants.Count}");
|
||||
|
||||
return ToClient(stored);
|
||||
}
|
||||
}
|
||||
|
||||
public static ClientNotification CreatePartyInvite(
|
||||
long inviterPlayerId,
|
||||
long receiverPlayerId,
|
||||
long partyId)
|
||||
{
|
||||
var messageData = new
|
||||
{
|
||||
FromAccountId = checked((int)inviterPlayerId),
|
||||
InviterPlayerId = inviterPlayerId,
|
||||
PartyId = partyId
|
||||
};
|
||||
|
||||
return Insert(
|
||||
inviterPlayerId,
|
||||
receiverPlayerId,
|
||||
MessageType.PartyUpRequest,
|
||||
messageData);
|
||||
}
|
||||
|
||||
public static ClientNotification CreatePlayerMessage(
|
||||
long senderPlayerId,
|
||||
long receiverPlayerId,
|
||||
MessageType type,
|
||||
string? message,
|
||||
long? roomId = null)
|
||||
{
|
||||
return InsertMessage(
|
||||
senderPlayerId,
|
||||
receiverPlayerId,
|
||||
type,
|
||||
message ?? string.Empty,
|
||||
roomId);
|
||||
}
|
||||
|
||||
public static ClientNotification CreateFriendInvite(
|
||||
long senderPlayerId,
|
||||
long receiverPlayerId)
|
||||
{
|
||||
return Insert(
|
||||
senderPlayerId,
|
||||
receiverPlayerId,
|
||||
MessageType.FriendInvite,
|
||||
new
|
||||
{
|
||||
FromAccountId = checked((int)senderPlayerId),
|
||||
FromPlayerId = checked((int)senderPlayerId),
|
||||
FriendPlayerId = checked((int)senderPlayerId)
|
||||
});
|
||||
}
|
||||
|
||||
public static ClientNotification CreateFriendAccepted(
|
||||
long acceptingPlayerId,
|
||||
long receiverPlayerId)
|
||||
{
|
||||
return Insert(
|
||||
acceptingPlayerId,
|
||||
receiverPlayerId,
|
||||
MessageType.FriendRequestAccepted,
|
||||
new
|
||||
{
|
||||
FromAccountId = checked((int)acceptingPlayerId),
|
||||
FromPlayerId = checked((int)acceptingPlayerId),
|
||||
FriendPlayerId = checked((int)acceptingPlayerId)
|
||||
});
|
||||
}
|
||||
|
||||
public static ClientNotification CreateFriendIntroduction(
|
||||
long senderPlayerId,
|
||||
long receiverPlayerId,
|
||||
long aboutPlayerId)
|
||||
{
|
||||
if (aboutPlayerId is <= 0 or > int.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(aboutPlayerId));
|
||||
|
||||
int aboutId = checked((int)aboutPlayerId);
|
||||
return Insert(
|
||||
senderPlayerId,
|
||||
receiverPlayerId,
|
||||
MessageType.FriendIntroduction,
|
||||
new
|
||||
{
|
||||
FromAccountId = checked((int)senderPlayerId),
|
||||
FromPlayerId = checked((int)senderPlayerId),
|
||||
AboutAccountId = aboutId,
|
||||
AboutPlayerId = aboutId,
|
||||
IntroducedPlayerId = aboutId
|
||||
});
|
||||
}
|
||||
|
||||
public static ClientNotification CreatePlayerCheer(
|
||||
long senderPlayerId,
|
||||
long receiverPlayerId,
|
||||
int category,
|
||||
bool anonymous)
|
||||
{
|
||||
return InsertMessage(
|
||||
senderPlayerId,
|
||||
receiverPlayerId,
|
||||
anonymous
|
||||
? MessageType.PlayerCheerAnonymous
|
||||
: MessageType.PlayerCheer,
|
||||
category.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
hideSender: anonymous);
|
||||
}
|
||||
|
||||
public static List<ClientNotification> GetMessages(long recipientPlayerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
return Messages.Find(value => value.RecipientPlayerId == recipientPlayerId)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.Select(ToClient)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static int DeleteMessages(
|
||||
long recipientPlayerId,
|
||||
IEnumerable<long> messageIds)
|
||||
{
|
||||
HashSet<long> ids = messageIds
|
||||
.Where(value => value > 0)
|
||||
.ToHashSet();
|
||||
if (ids.Count == 0)
|
||||
return 0;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
DateTime graceExpiration =
|
||||
DateTime.UtcNow.Add(AcknowledgedInviteGrace);
|
||||
int deleted = 0;
|
||||
var acknowledgedGrantIds = new HashSet<long>();
|
||||
foreach (StoredNotification message in Messages
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.ToList())
|
||||
{
|
||||
bool matchesMessageId = ids.Contains(message.Id);
|
||||
bool matchesInviteId =
|
||||
IsRoomInviteMessageType(message.Type) &&
|
||||
TryReadRoomInviteId(message, out long roomInviteId) &&
|
||||
ids.Contains(roomInviteId);
|
||||
|
||||
if ((matchesMessageId || matchesInviteId) &&
|
||||
Messages.Delete(message.Id))
|
||||
{
|
||||
deleted++;
|
||||
|
||||
if (IsRoomInviteMessageType(message.Type))
|
||||
acknowledgedGrantIds.Add(message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (StoredRoomInviteGrant grant in RoomInviteGrants
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.Where(value =>
|
||||
acknowledgedGrantIds.Contains(value.MessageId) ||
|
||||
ids.Contains(value.MessageId) ||
|
||||
ids.Contains(value.RoomInstanceId))
|
||||
.ToList())
|
||||
{
|
||||
if (grant.ExpirationTime > graceExpiration)
|
||||
{
|
||||
grant.ExpirationTime = graceExpiration;
|
||||
RoomInviteGrants.Update(grant);
|
||||
}
|
||||
|
||||
acknowledgedGrantIds.Add(grant.MessageId);
|
||||
}
|
||||
|
||||
if (acknowledgedGrantIds.Count > 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM INVITE ACK] recipient={recipientPlayerId} " +
|
||||
$"grants={acknowledgedGrantIds.Count} " +
|
||||
$"graceSeconds={(int)AcknowledgedInviteGrace.TotalSeconds}");
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
public static int ConsumeRoomInvite(
|
||||
long recipientPlayerId,
|
||||
long inviteId)
|
||||
{
|
||||
if (recipientPlayerId <= 0 || inviteId <= 0)
|
||||
return 0;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
|
||||
List<StoredRoomInviteGrant> grants = RoomInviteGrants
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.Where(value =>
|
||||
value.MessageId == inviteId ||
|
||||
value.RoomInstanceId == inviteId)
|
||||
.ToList();
|
||||
|
||||
HashSet<long> messageIds = grants
|
||||
.Select(value => value.MessageId)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (StoredNotification message in Messages
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.Where(value =>
|
||||
IsRoomInviteMessageType(value.Type))
|
||||
.ToList())
|
||||
{
|
||||
if (message.Id == inviteId ||
|
||||
(TryReadRoomInviteId(message, out long roomInviteId) &&
|
||||
roomInviteId == inviteId))
|
||||
{
|
||||
messageIds.Add(message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
int removed = 0;
|
||||
foreach (long messageId in messageIds)
|
||||
{
|
||||
if (Messages.Delete(messageId))
|
||||
removed++;
|
||||
RoomInviteGrants.Delete(messageId);
|
||||
}
|
||||
|
||||
foreach (StoredRoomInviteGrant grant in grants)
|
||||
RoomInviteGrants.Delete(grant.MessageId);
|
||||
|
||||
return removed + grants.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetRoomInvite(
|
||||
long recipientPlayerId,
|
||||
long inviteId,
|
||||
out long inviterPlayerId,
|
||||
out long roomId,
|
||||
out long roomInstanceId)
|
||||
{
|
||||
inviterPlayerId = 0;
|
||||
roomId = 0;
|
||||
roomInstanceId = 0;
|
||||
|
||||
if (recipientPlayerId <= 0 || inviteId <= 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
|
||||
StoredNotification? message = Messages
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.Where(value =>
|
||||
IsRoomInviteMessageType(value.Type))
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefault(value =>
|
||||
value.Id == inviteId ||
|
||||
(TryReadRoomInviteId(value, out long candidateInviteId) &&
|
||||
candidateInviteId == inviteId));
|
||||
|
||||
if (message != null)
|
||||
{
|
||||
inviterPlayerId = message.FromPlayerId;
|
||||
roomId = message.RoomId ?? 0;
|
||||
|
||||
if (!TryReadRoomInviteId(message, out roomInstanceId))
|
||||
return false;
|
||||
|
||||
RoomInviteGrants.Upsert(new StoredRoomInviteGrant
|
||||
{
|
||||
MessageId = message.Id,
|
||||
RecipientPlayerId = message.RecipientPlayerId,
|
||||
InviterPlayerId = message.FromPlayerId,
|
||||
RoomId = roomId,
|
||||
RoomInstanceId = roomInstanceId,
|
||||
CreatedAt = message.CreatedAt,
|
||||
ExpirationTime = message.ExpirationTime
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
StoredRoomInviteGrant? grant = RoomInviteGrants
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefault(value =>
|
||||
value.MessageId == inviteId ||
|
||||
value.RoomInstanceId == inviteId);
|
||||
|
||||
if (grant == null)
|
||||
return false;
|
||||
|
||||
inviterPlayerId = grant.InviterPlayerId;
|
||||
roomId = grant.RoomId;
|
||||
roomInstanceId = grant.RoomInstanceId;
|
||||
}
|
||||
|
||||
return inviterPlayerId > 0 &&
|
||||
roomId > 0 &&
|
||||
roomInstanceId > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetLatestRoomInviteForRoom(
|
||||
long recipientPlayerId,
|
||||
long roomId,
|
||||
out long inviteId,
|
||||
out long inviterPlayerId,
|
||||
out long roomInstanceId)
|
||||
{
|
||||
inviteId = 0;
|
||||
inviterPlayerId = 0;
|
||||
roomInstanceId = 0;
|
||||
|
||||
if (recipientPlayerId <= 0 || roomId <= 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
|
||||
StoredRoomInviteGrant? grant = RoomInviteGrants
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId &&
|
||||
value.RoomId == roomId)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (grant == null)
|
||||
return false;
|
||||
|
||||
inviteId = grant.MessageId;
|
||||
inviterPlayerId = grant.InviterPlayerId;
|
||||
roomInstanceId = grant.RoomInstanceId;
|
||||
return inviteId > 0 &&
|
||||
inviterPlayerId > 0 &&
|
||||
roomInstanceId > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasActiveRoomInvite(
|
||||
long recipientPlayerId,
|
||||
long inviterPlayerId,
|
||||
long roomInstanceId,
|
||||
long? expectedRoomId = null)
|
||||
{
|
||||
if (recipientPlayerId <= 0 ||
|
||||
inviterPlayerId <= 0 ||
|
||||
roomInstanceId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryGetRoomInvite(
|
||||
recipientPlayerId,
|
||||
roomInstanceId,
|
||||
out long resolvedInviterPlayerId,
|
||||
out long resolvedRoomId,
|
||||
out long resolvedRoomInstanceId) &&
|
||||
resolvedInviterPlayerId == inviterPlayerId &&
|
||||
resolvedRoomInstanceId == roomInstanceId &&
|
||||
(!expectedRoomId.HasValue ||
|
||||
resolvedRoomId == expectedRoomId.Value);
|
||||
}
|
||||
|
||||
private static bool TryReadRoomInviteId(
|
||||
StoredNotification message,
|
||||
out long roomInstanceId)
|
||||
{
|
||||
roomInstanceId = message.RoomInstanceId ?? 0;
|
||||
if (roomInstanceId > 0)
|
||||
return true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message.Message))
|
||||
return false;
|
||||
|
||||
if (long.TryParse(
|
||||
message.Message,
|
||||
System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out roomInstanceId) &&
|
||||
roomInstanceId > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document =
|
||||
JsonDocument.Parse(message.Message);
|
||||
JsonElement root = document.RootElement;
|
||||
|
||||
foreach (string propertyName in new[]
|
||||
{
|
||||
"InviteId",
|
||||
"inviteId",
|
||||
"RoomInstanceId",
|
||||
"roomInstanceId",
|
||||
"TargetRoomInstanceId",
|
||||
"targetRoomInstanceId"
|
||||
})
|
||||
{
|
||||
if (!root.TryGetProperty(
|
||||
propertyName,
|
||||
out JsonElement value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.Number &&
|
||||
value.TryGetInt64(out long numericValue) &&
|
||||
numericValue > 0)
|
||||
{
|
||||
roomInstanceId = numericValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String &&
|
||||
long.TryParse(value.GetString(), out long stringValue) &&
|
||||
stringValue > 0)
|
||||
{
|
||||
roomInstanceId = stringValue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static int DeletePlayerCheerMessages(long recipientPlayerId)
|
||||
{
|
||||
if (recipientPlayerId <= 0)
|
||||
return 0;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
int deleted = 0;
|
||||
foreach (StoredNotification message in Messages
|
||||
.Find(value =>
|
||||
value.RecipientPlayerId == recipientPlayerId)
|
||||
.Where(value =>
|
||||
value.Type == (int)MessageType.PlayerCheer ||
|
||||
value.Type ==
|
||||
(int)MessageType.PlayerCheerAnonymous)
|
||||
.ToList())
|
||||
{
|
||||
if (Messages.Delete(message.Id))
|
||||
deleted++;
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
private static ClientNotification Insert(
|
||||
long fromPlayerId,
|
||||
long recipientPlayerId,
|
||||
MessageType type,
|
||||
object messageData,
|
||||
long? roomId = null,
|
||||
long? playerEventId = null,
|
||||
long? clubId = null)
|
||||
{
|
||||
return InsertMessage(
|
||||
fromPlayerId,
|
||||
recipientPlayerId,
|
||||
type,
|
||||
JsonSerializer.Serialize(messageData),
|
||||
roomId,
|
||||
playerEventId,
|
||||
clubId);
|
||||
}
|
||||
|
||||
private static ClientNotification InsertMessage(
|
||||
long fromPlayerId,
|
||||
long recipientPlayerId,
|
||||
MessageType type,
|
||||
string message,
|
||||
long? roomId = null,
|
||||
long? playerEventId = null,
|
||||
long? clubId = null,
|
||||
bool hideSender = false)
|
||||
{
|
||||
if (fromPlayerId is <= 0 or > int.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(fromPlayerId));
|
||||
if (recipientPlayerId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(recipientPlayerId));
|
||||
|
||||
var stored = new StoredNotification
|
||||
{
|
||||
RecipientPlayerId = recipientPlayerId,
|
||||
FromPlayerId = hideSender
|
||||
? 0
|
||||
: checked((int)fromPlayerId),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Type = (int)type,
|
||||
Message = message,
|
||||
RoomId = roomId,
|
||||
PlayerEventId = playerEventId,
|
||||
ClubId = clubId,
|
||||
ExpirationTime = DateTime.UtcNow.AddDays(7)
|
||||
};
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
DeleteExpiredNoLock();
|
||||
Messages.Insert(stored);
|
||||
return ToClient(stored);
|
||||
}
|
||||
}
|
||||
|
||||
private static ClientNotification ToClient(StoredNotification value) => new()
|
||||
{
|
||||
Id = value.Id,
|
||||
FromPlayerId = value.FromPlayerId,
|
||||
ToPlayerId = value.RecipientPlayerId,
|
||||
CreatedAt = value.CreatedAt,
|
||||
Type = value.Type,
|
||||
Message = value.Message,
|
||||
FromAccountId = value.FromPlayerId,
|
||||
RoomId = value.RoomId,
|
||||
RoomInstanceId = value.RoomInstanceId,
|
||||
PlayerEventId = value.PlayerEventId,
|
||||
ClubId = value.ClubId,
|
||||
ExpirationTime = value.ExpirationTime
|
||||
};
|
||||
|
||||
public static bool IsRoomInviteMessageType(int type) =>
|
||||
type is
|
||||
(int)MessageType.GameInvite or
|
||||
(int)MessageType.GameInviteV2;
|
||||
|
||||
private static void DeleteExpiredNoLock()
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
foreach (StoredNotification message in Messages.Find(value =>
|
||||
value.ExpirationTime < now).ToList())
|
||||
{
|
||||
Messages.Delete(message.Id);
|
||||
}
|
||||
|
||||
foreach (StoredRoomInviteGrant grant in RoomInviteGrants.Find(value =>
|
||||
value.ExpirationTime < now).ToList())
|
||||
{
|
||||
RoomInviteGrants.Delete(grant.MessageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3732 @@
|
||||
using System;
|
||||
using LiteDB;
|
||||
using Mocha2023.Classes;
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
using Mocha2023.Controllers;
|
||||
using static Mocha2023.Classes.DBs.DBClasses.PlayerDBClasses;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
public class PlayerDB
|
||||
{
|
||||
private static readonly object SocialLock = new();
|
||||
private static readonly object ProgressionLock = new();
|
||||
private static readonly object PartyLock = new();
|
||||
private static readonly object GiftLock = new();
|
||||
private static long PartySequence = DateTime.UtcNow.Ticks & long.MaxValue;
|
||||
|
||||
private static long GiftSequence =
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
private static long CheerSequence = DateTime.UtcNow.Ticks & long.MaxValue;
|
||||
private const int RoomLevelSeconds = 15 * 60;
|
||||
private const int MaximumLevel = 50;
|
||||
private const int MaximumCreditableHeartbeatGapSeconds = 120;
|
||||
private const int PresenceFreshnessSeconds = 120;
|
||||
private const int MaximumPendingGiftPackages = 200;
|
||||
public const int CheerCreditLimit = 15;
|
||||
private const long CheerCreditRefreshSeconds = 5 * 60 * 60;
|
||||
|
||||
public static LiteDatabase PlayerDBFile =
|
||||
new LiteDatabase(Path.Combine(Program.dataDir, "DBs", "Players.db"));
|
||||
|
||||
public static readonly ILiteCollection<FullPlayer> Players =
|
||||
PlayerDBFile.GetCollection<FullPlayer>("Players");
|
||||
|
||||
private const string RRPlusSettingKey = "RecRoomPlus.IsActive";
|
||||
private const string InitialTokensSettingKey =
|
||||
"Mocha.InitialTokensGranted";
|
||||
private const string AlpacaShirtOwnershipResetSettingKey =
|
||||
"Mocha.AlpacaShirtOwnershipResetV1";
|
||||
public const long AlpacaShirtAvatarItemId = 1521;
|
||||
public const string AlpacaShirtAvatarItemDescriptor =
|
||||
"d0a9262f-5504-46a7-bb10-7507503db58e,941c046e-4e95-49f8-a7d7-19071fcc3c94,0440f08f-ef1d-49d8-942b-523056e8bb45,703ff56b-560d-4ff4-8c63-d195e879a328";
|
||||
public const int InitialTokenBalance = 1_000;
|
||||
private const string PlaceholderDisplayName =
|
||||
"Displaynamecouldbeverylong";
|
||||
private static readonly Random LevelBoxRng = new();
|
||||
|
||||
private static int GetLevelBoxRarity(int level) => level switch
|
||||
{
|
||||
<= 19 => 10,
|
||||
<= 29 => 20,
|
||||
<= 39 => 30,
|
||||
<= 49 => 40,
|
||||
_ => 50
|
||||
};
|
||||
|
||||
static PlayerDB()
|
||||
{
|
||||
LiteDbMaintenance.StartPeriodicCheckpoint("Players.db", PlayerDBFile);
|
||||
|
||||
try
|
||||
{
|
||||
int repaired = RepairPlaceholderDisplayNames();
|
||||
if (repaired > 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ACCOUNT DISPLAYNAME MIGRATION] repaired={repaired}");
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ACCOUNT DISPLAYNAME MIGRATION] failed={exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int RepairPlaceholderDisplayNames()
|
||||
{
|
||||
int repaired = 0;
|
||||
|
||||
foreach (FullPlayer account in Players.FindAll().ToList())
|
||||
{
|
||||
Player? player = account?.Player;
|
||||
if (player == null)
|
||||
continue;
|
||||
|
||||
string currentDisplayName =
|
||||
player.DisplayName?.Trim() ?? string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(currentDisplayName) &&
|
||||
!IsPlaceholderDisplayName(currentDisplayName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string username =
|
||||
player.Username?.Trim().TrimStart('@') ?? string.Empty;
|
||||
string replacement = string.IsNullOrWhiteSpace(username)
|
||||
? $"Player{account.PlayerId}"
|
||||
: username;
|
||||
|
||||
if (string.Equals(
|
||||
player.DisplayName,
|
||||
replacement,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
player.DisplayName = replacement;
|
||||
if (Players.Update(account))
|
||||
repaired++;
|
||||
}
|
||||
|
||||
return repaired;
|
||||
}
|
||||
|
||||
private static bool IsPlaceholderDisplayName(string? value)
|
||||
{
|
||||
return string.Equals(
|
||||
value?.Trim(),
|
||||
PlaceholderDisplayName,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static FullPlayer CreateAccount(
|
||||
Platforms platform,
|
||||
ulong platformId,
|
||||
bool isJunior,
|
||||
long? accountId = null,
|
||||
bool completeAccountCreation = true)
|
||||
{
|
||||
string username = NameGen.GetRandomName();
|
||||
|
||||
var newPlayerData = new Player
|
||||
{
|
||||
Username = username,
|
||||
DisplayName = username,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastLoginAt = DateTime.UtcNow,
|
||||
ProfileImage = "DefaultPFP.png",
|
||||
AvailableUsernameChanges = 3,
|
||||
IsJunior = isJunior,
|
||||
Level = 1,
|
||||
XP = 0,
|
||||
|
||||
PlayerExtra = new PlayerExtra
|
||||
{
|
||||
Currencies = new List<PlayerCurrency>
|
||||
{
|
||||
new PlayerCurrency
|
||||
{
|
||||
CurrencyType = CurrencyType.RecCenterTokens,
|
||||
BalanceType = BalanceType.NonPurchasedDefault,
|
||||
Balance = InitialTokenBalance
|
||||
}
|
||||
},
|
||||
Settings = new List<Setting>
|
||||
{
|
||||
new Setting
|
||||
{
|
||||
Key = InitialTokensSettingKey,
|
||||
Value = "True"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = AlpacaShirtOwnershipResetSettingKey,
|
||||
Value = "True"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = "Recroom.AccountCreation.HasStarted",
|
||||
Value = "True"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = "Recroom.AccountCreation.HasChosenUsername",
|
||||
Value = completeAccountCreation ? "True" : "False"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = "Recroom.AccountCreation.HasCreatedPassword",
|
||||
Value = completeAccountCreation ? "True" : "False"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = "Recroom.AccountCreation.HasFinished",
|
||||
Value = completeAccountCreation ? "True" : "False"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = "TUTORIAL_COMPLETE_MASK",
|
||||
Value = completeAccountCreation ? "57" : "0"
|
||||
},
|
||||
new Setting
|
||||
{
|
||||
Key = "HAS_COMPLETED_ORIENTATION",
|
||||
Value = "False"
|
||||
},
|
||||
|
||||
new Setting
|
||||
{
|
||||
Key = RRPlusSettingKey,
|
||||
Value = "False"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var newFullPlayer = new FullPlayer
|
||||
{
|
||||
PlayerId = accountId ?? 0,
|
||||
|
||||
PlatformIds = new List<mPlatformID>
|
||||
{
|
||||
new mPlatformID
|
||||
{
|
||||
Platform = platform,
|
||||
PlatformId = platformId
|
||||
}
|
||||
},
|
||||
|
||||
Player = newPlayerData,
|
||||
|
||||
PlayerRoles = new List<PlayerRoles>(),
|
||||
|
||||
AuthToken = Guid.NewGuid().ToString()
|
||||
};
|
||||
|
||||
Players.Insert(newFullPlayer);
|
||||
EquipmentInventoryStore.EnsureInitialized(newFullPlayer.PlayerId);
|
||||
PlayerInventoryStore.EnsureInitialized(
|
||||
newFullPlayer.PlayerId,
|
||||
newFullPlayer.Player.PlayerExtra?.AvatarItems);
|
||||
RoomDB.GetOrCreatePlayerDorm(newFullPlayer.PlayerId);
|
||||
|
||||
return newFullPlayer;
|
||||
}
|
||||
|
||||
public static bool HasRRPlus(long playerId)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
var setting = player.Player.PlayerExtra?.Settings?
|
||||
.FirstOrDefault(x => string.Equals(
|
||||
x.Key,
|
||||
RRPlusSettingKey,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return setting != null &&
|
||||
string.Equals(setting.Value, "True", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool SetRRPlus(long playerId, bool enabled)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Settings ??= new List<Setting>();
|
||||
|
||||
var setting = player.Player.PlayerExtra.Settings
|
||||
.FirstOrDefault(x => string.Equals(
|
||||
x.Key,
|
||||
RRPlusSettingKey,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (setting == null)
|
||||
{
|
||||
player.Player.PlayerExtra.Settings.Add(new Setting
|
||||
{
|
||||
Key = RRPlusSettingKey,
|
||||
Value = enabled ? "True" : "False"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
setting.Key = RRPlusSettingKey;
|
||||
setting.Value = enabled ? "True" : "False";
|
||||
}
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static int ForceRRPlusForAllPlayers()
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[RR+ MEMBERSHIP] ForceRRPlusForAllPlayers called but is disabled - no players were updated.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static bool ChangeAccountId(
|
||||
long currentAccountId,
|
||||
long newAccountId)
|
||||
{
|
||||
if (currentAccountId <= 0 ||
|
||||
newAccountId <= 0 ||
|
||||
currentAccountId == newAccountId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Players.FindById(newAccountId) != null)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(currentAccountId);
|
||||
|
||||
if (player == null)
|
||||
return false;
|
||||
|
||||
Players.Delete(currentAccountId);
|
||||
|
||||
player.PlayerId = newAccountId;
|
||||
|
||||
Players.Insert(player);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool GetLogins(
|
||||
Platforms platform,
|
||||
ulong platformId,
|
||||
out List<CachedLogins> accounts)
|
||||
{
|
||||
var results = Players
|
||||
.Find(x => x.PlatformIds
|
||||
.Select(p => p.PlatformId)
|
||||
.Any(id => id == platformId))
|
||||
.Where(p => p.PlatformIds.Any(
|
||||
pid => pid.Platform == platform))
|
||||
.OrderByDescending(x => x.Player.LastLoginAt)
|
||||
.ToList();
|
||||
|
||||
accounts = results
|
||||
.Select(p => new CachedLogins
|
||||
{
|
||||
accountId = p.PlayerId,
|
||||
lastLoginTime = p.Player.LastLoginAt,
|
||||
platform = platform,
|
||||
platformId = platformId.ToString(),
|
||||
|
||||
requirePassword = false
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return accounts.Count > 0;
|
||||
}
|
||||
|
||||
private static PlayerDTOBase MapToDTO(
|
||||
FullPlayer player,
|
||||
bool accountMe)
|
||||
{
|
||||
int platformFlags = player.PlatformIds?.Aggregate(
|
||||
0,
|
||||
(acc, pid) => acc | (int)pid.Platform) ?? 0;
|
||||
|
||||
var p = player.Player ?? new Player();
|
||||
|
||||
PlayerDTOBase dto = accountMe
|
||||
? new PlayerMeDTO()
|
||||
: new PlayerDTO();
|
||||
|
||||
dto.accountId = player.PlayerId;
|
||||
dto.username = p.Username;
|
||||
dto.displayEmoji = p.DisplayEmoji ?? "";
|
||||
dto.displayName = GetClientDisplayName(p, player.PlayerId);
|
||||
dto.profileImage = p.ProfileImage;
|
||||
dto.bannerImage = p.BannerImage;
|
||||
dto.isJunior = p.IsJunior;
|
||||
dto.createdAt = p.CreatedAt;
|
||||
dto.platforms = platformFlags;
|
||||
dto.personalPronouns = p.PersonalPronouns;
|
||||
dto.identityFlags = p.IdentityFlags;
|
||||
|
||||
if (accountMe && dto is PlayerMeDTO meDto)
|
||||
{
|
||||
meDto.availableUsernameChanges =
|
||||
p.AvailableUsernameChanges;
|
||||
|
||||
meDto.birthday = p.Birthday;
|
||||
meDto.email = p.Email;
|
||||
meDto.phone = null;
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
private static string GetClientDisplayName(Player player, long playerId)
|
||||
{
|
||||
string displayName = player.DisplayName?.Trim() ?? string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(displayName) &&
|
||||
!IsPlaceholderDisplayName(displayName))
|
||||
{
|
||||
return displayName;
|
||||
}
|
||||
|
||||
string username = player.Username?.Trim().TrimStart('@') ??
|
||||
string.Empty;
|
||||
return string.IsNullOrWhiteSpace(username)
|
||||
? $"Player{playerId}"
|
||||
: username;
|
||||
}
|
||||
|
||||
public static List<PlayerDTOBase> GetAccountsBulk(
|
||||
List<long> playerIds,
|
||||
long? callerId = null)
|
||||
{
|
||||
var players = Players
|
||||
.Find(x => playerIds.Contains(x.PlayerId))
|
||||
.ToList();
|
||||
|
||||
var results = players
|
||||
.Select(p => MapToDTO(
|
||||
p,
|
||||
callerId.HasValue &&
|
||||
p.PlayerId == callerId.Value))
|
||||
.ToList();
|
||||
|
||||
if (playerIds.Contains(1) &&
|
||||
!results.Any(account => account.accountId == 1))
|
||||
{
|
||||
results.Add(BuildCoachSystemAccount());
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderBy(a => a.accountId)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static PlayerDTOBase BuildCoachSystemAccount() => new PlayerDTO
|
||||
{
|
||||
accountId = 1,
|
||||
username = "Coach",
|
||||
displayName = "Coach",
|
||||
profileImage = "DefaultPFP.png",
|
||||
bannerImage = null,
|
||||
displayEmoji = string.Empty,
|
||||
isJunior = false,
|
||||
platforms = 0,
|
||||
personalPronouns = 0,
|
||||
identityFlags = 0,
|
||||
createdAt = new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
public static PlayerMeDTO? GetAccountMe(long accountId)
|
||||
{
|
||||
var player = Players.FindById(accountId);
|
||||
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
return MapToDTO(player, true) as PlayerMeDTO;
|
||||
}
|
||||
|
||||
public static bool UpdateUsername(
|
||||
long playerId,
|
||||
string username,
|
||||
bool initialAccountSetup = false)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
username = username.Trim().TrimStart('@');
|
||||
if (username.Length is < 3 or > 20 ||
|
||||
username.Any(ch => !char.IsLetterOrDigit(ch) && ch != '_'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Players.FindAll().Any(candidate =>
|
||||
candidate.PlayerId != playerId &&
|
||||
string.Equals(
|
||||
candidate.Player?.Username,
|
||||
username,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!initialAccountSetup && player.Player.AvailableUsernameChanges <= 0)
|
||||
return false;
|
||||
|
||||
player.Player.Username = username;
|
||||
if (initialAccountSetup)
|
||||
{
|
||||
player.Player.DisplayName = username;
|
||||
SetSettingValue(
|
||||
player,
|
||||
"Recroom.AccountCreation.HasChosenUsername",
|
||||
"True");
|
||||
}
|
||||
else
|
||||
{
|
||||
player.Player.AvailableUsernameChanges--;
|
||||
}
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool SetPassword(
|
||||
long playerId,
|
||||
string newPassword,
|
||||
string? oldPassword = null)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null || string.IsNullOrEmpty(newPassword) ||
|
||||
newPassword.Length < PasswordSecurity.MinPasswordLength ||
|
||||
newPassword.Length > PasswordSecurity.MaxPasswordLength)
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(player.Password) &&
|
||||
!PasswordSecurity.Verify(oldPassword, player.Password, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
player.Password = PasswordSecurity.Hash(newPassword);
|
||||
SetSettingValue(
|
||||
player,
|
||||
"Recroom.AccountCreation.HasCreatedPassword",
|
||||
"True");
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool HasPassword(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
return !string.IsNullOrEmpty(player?.Password);
|
||||
}
|
||||
|
||||
public static bool UpdateBirthday(long playerId, DateTime birthday)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null || birthday.Date > DateTime.UtcNow.Date)
|
||||
return false;
|
||||
|
||||
player.Player.Birthday = birthday.Date;
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
int age = today.Year - birthday.Year;
|
||||
if (birthday.Date > today.AddYears(-age))
|
||||
age--;
|
||||
player.Player.IsJunior = age < 13;
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
private static void SetSettingValue(
|
||||
FullPlayer player,
|
||||
string key,
|
||||
string value)
|
||||
{
|
||||
if (player.Player == null)
|
||||
return;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Settings ??= new List<Setting>();
|
||||
|
||||
var setting = player.Player.PlayerExtra.Settings.FirstOrDefault(item =>
|
||||
string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||
if (setting == null)
|
||||
{
|
||||
player.Player.PlayerExtra.Settings.Add(new Setting
|
||||
{
|
||||
Key = key,
|
||||
Value = value
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
setting.Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool UpdateDisplayName(
|
||||
long playerId,
|
||||
string displayName)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
displayName = displayName?.Trim() ?? string.Empty;
|
||||
player.Player.DisplayName = string.IsNullOrWhiteSpace(displayName) ||
|
||||
IsPlaceholderDisplayName(displayName)
|
||||
? GetClientDisplayName(player.Player, playerId)
|
||||
: displayName;
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool UpdatePersonalPronouns(long playerId, int personalPronouns)
|
||||
{
|
||||
|
||||
if (personalPronouns < 0 || (personalPronouns & ~0x3F) != 0)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PersonalPronouns = personalPronouns;
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool UpdateIdentityFlags(
|
||||
long playerId,
|
||||
int identityFlags)
|
||||
{
|
||||
if (identityFlags < 0)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.IdentityFlags = identityFlags;
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<int> GetWishlist(long accountId)
|
||||
{
|
||||
var player = Players.FindById(accountId);
|
||||
string? stored = player?.Player?.PlayerExtra?.Settings?
|
||||
.FirstOrDefault(setting => string.Equals(
|
||||
setting.Key,
|
||||
"Store.Wishlist",
|
||||
StringComparison.OrdinalIgnoreCase))?.Value;
|
||||
if (string.IsNullOrWhiteSpace(stored))
|
||||
return Array.Empty<int>();
|
||||
|
||||
return stored.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(value => int.TryParse(value, out int id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(500)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static bool SetWishlistItem(long accountId, int purchasableItemId, bool wished)
|
||||
{
|
||||
if (purchasableItemId <= 0)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(accountId);
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
var ids = GetWishlist(accountId).ToHashSet();
|
||||
if (wished)
|
||||
{
|
||||
if (ids.Count >= 500 && !ids.Contains(purchasableItemId))
|
||||
return false;
|
||||
ids.Add(purchasableItemId);
|
||||
}
|
||||
else
|
||||
{
|
||||
ids.Remove(purchasableItemId);
|
||||
}
|
||||
|
||||
SetSettingValue(
|
||||
player,
|
||||
"Store.Wishlist",
|
||||
string.Join(',', ids.OrderBy(value => value)));
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool SetAvatar(
|
||||
long accountId,
|
||||
Avatar avatar)
|
||||
{
|
||||
var player = Players.FindById(accountId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Avatar = avatar;
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static void SetPlayerSetting(
|
||||
string key,
|
||||
string value,
|
||||
long playerId)
|
||||
{
|
||||
key = key?.Trim() ?? string.Empty;
|
||||
value ??= string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(key) || key.Length > 128 ||
|
||||
value.Length > 4_096 || key.Any(char.IsControl) ||
|
||||
value.Any(ch => ch == '\0') || playerId <= 0)
|
||||
return;
|
||||
|
||||
if (key is
|
||||
"SplitTestAssignedSegments" or
|
||||
"Growth.LastEmailPromptTime")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Settings ??= new List<Setting>();
|
||||
|
||||
var settings = player.Player.PlayerExtra.Settings;
|
||||
|
||||
var existingSetting = settings.FirstOrDefault(
|
||||
s => s.Key == key);
|
||||
|
||||
if (existingSetting != null)
|
||||
{
|
||||
existingSetting.Value = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
settings.Add(new Setting
|
||||
{
|
||||
Key = key,
|
||||
Value = value
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
key,
|
||||
"HAS_COMPLETED_ORIENTATION",
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
bool.TryParse(value, out bool completedOrientation) &&
|
||||
completedOrientation)
|
||||
{
|
||||
SetSettingValue(
|
||||
player,
|
||||
"Recroom.AccountCreation.HasFinished",
|
||||
"True");
|
||||
SetSettingValue(player, "TUTORIAL_COMPLETE_MASK", "57");
|
||||
}
|
||||
|
||||
Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool NeedsOrientation(FullPlayer? player)
|
||||
{
|
||||
var settings = player?.Player?.PlayerExtra?.Settings;
|
||||
if (settings == null)
|
||||
return false;
|
||||
|
||||
var orientation = settings.FirstOrDefault(setting =>
|
||||
string.Equals(
|
||||
setting.Key,
|
||||
"HAS_COMPLETED_ORIENTATION",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (orientation != null)
|
||||
{
|
||||
return !bool.TryParse(orientation.Value, out bool completed) ||
|
||||
!completed;
|
||||
}
|
||||
|
||||
var accountCreation = settings.FirstOrDefault(setting =>
|
||||
string.Equals(
|
||||
setting.Key,
|
||||
"Recroom.AccountCreation.HasFinished",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
return accountCreation != null &&
|
||||
!string.Equals(
|
||||
accountCreation.Value,
|
||||
"True",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static List<PlayerProgressionDTO> GetProgressionBulk(
|
||||
List<long> playerIds)
|
||||
{
|
||||
var players = Players
|
||||
.Find(x => playerIds.Contains(x.PlayerId))
|
||||
.ToList();
|
||||
|
||||
return players
|
||||
.Select(p => new PlayerProgressionDTO
|
||||
{
|
||||
PlayerId = p.PlayerId,
|
||||
Level = p.Player?.Level ?? 1,
|
||||
XP = p.Player?.XP ?? 0
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static PlayerProgressionDTO? SetProgression(
|
||||
long playerId,
|
||||
int level,
|
||||
int xp)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return null;
|
||||
|
||||
int oldLevel;
|
||||
PlayerProgressionDTO result;
|
||||
|
||||
lock (ProgressionLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
oldLevel = player.Player.Level;
|
||||
player.Player.Level = Math.Clamp(level, 1, 50);
|
||||
player.Player.XP = Math.Max(0, xp);
|
||||
|
||||
if (!Players.Update(player))
|
||||
return null;
|
||||
|
||||
result = new PlayerProgressionDTO
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Level = player.Player.Level,
|
||||
XP = player.Player.XP
|
||||
};
|
||||
}
|
||||
|
||||
_ = NotiController.NotifyProgressionAsync(
|
||||
playerId,
|
||||
oldLevel,
|
||||
result.Level,
|
||||
result.XP);
|
||||
|
||||
if (result.Level > oldLevel)
|
||||
GrantLevelUpBox(playerId, oldLevel, result.Level);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PlayerProgressionDTO? AddExperience(
|
||||
long playerId,
|
||||
int xpToAdd,
|
||||
int? resultingLevel = null)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
int level = resultingLevel ?? player.Player.Level;
|
||||
int xp = Math.Max(0, player.Player.XP + xpToAdd);
|
||||
return SetProgression(playerId, level, xp);
|
||||
}
|
||||
|
||||
public static List<GiftPackage> GetPendingGiftPackages(long playerId)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return new List<GiftPackage>();
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return new List<GiftPackage>();
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.PendingGiftPackages ??=
|
||||
new List<GiftPackage>();
|
||||
|
||||
bool repaired = RepairPendingGiftIdsNoLock(
|
||||
player.Player.PlayerExtra.PendingGiftPackages);
|
||||
if (repaired)
|
||||
Players.Update(player);
|
||||
|
||||
return player.Player.PlayerExtra.PendingGiftPackages
|
||||
.OrderBy(gift => gift.GiftPackageId)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static ClearOutgoingGiftsResult ClearPendingGiftsFromSender(
|
||||
long fromPlayerId)
|
||||
{
|
||||
var result = new ClearOutgoingGiftsResult();
|
||||
if (fromPlayerId <= 0)
|
||||
return result;
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
foreach (var account in Players.FindAll())
|
||||
{
|
||||
if (account?.Player?.PlayerExtra?.PendingGiftPackages == null ||
|
||||
account.Player.PlayerExtra.PendingGiftPackages.Count == 0)
|
||||
continue;
|
||||
|
||||
int removed = account.Player.PlayerExtra.PendingGiftPackages
|
||||
.RemoveAll(gift => gift.FromPlayerId == fromPlayerId);
|
||||
|
||||
if (removed <= 0)
|
||||
continue;
|
||||
|
||||
if (Players.Update(account))
|
||||
{
|
||||
result.RemovedBoxes += removed;
|
||||
result.AffectedPlayers++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public class ClearOutgoingGiftsResult
|
||||
{
|
||||
public int RemovedBoxes { get; set; }
|
||||
public int AffectedPlayers { get; set; }
|
||||
}
|
||||
|
||||
public static bool RemoveGiftPackage(long playerId, long giftPackageId)
|
||||
{
|
||||
if (playerId <= 0 || giftPackageId <= 0)
|
||||
return false;
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
var pending = player?.Player?.PlayerExtra?.PendingGiftPackages;
|
||||
if (pending == null)
|
||||
return false;
|
||||
|
||||
int removed = pending.RemoveAll(gift => gift.GiftPackageId == giftPackageId);
|
||||
return removed > 0 && Players.Update(player!);
|
||||
}
|
||||
}
|
||||
|
||||
public static int ClearPendingGiftPackages(long playerId)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return 0;
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
var pending = player?.Player?.PlayerExtra?.PendingGiftPackages;
|
||||
if (pending == null || pending.Count == 0)
|
||||
return 0;
|
||||
|
||||
int removed = pending.Count;
|
||||
pending.Clear();
|
||||
return Players.Update(player!) ? removed : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static GiftPackage? QueueGiftPackage(
|
||||
long playerId,
|
||||
GiftPackage gift)
|
||||
{
|
||||
if (playerId <= 0 || gift == null)
|
||||
return null;
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.PendingGiftPackages ??=
|
||||
new List<GiftPackage>();
|
||||
|
||||
if (player.Player.PlayerExtra.PendingGiftPackages.Count >=
|
||||
MaximumPendingGiftPackages)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[GIFT QUEUE] recipient={playerId} rejected=queue_full " +
|
||||
$"limit={MaximumPendingGiftPackages}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (gift.GiftPackageId <= 0 ||
|
||||
gift.GiftPackageId > int.MaxValue ||
|
||||
player.Player.PlayerExtra.PendingGiftPackages.Any(value =>
|
||||
value.GiftPackageId == gift.GiftPackageId))
|
||||
{
|
||||
gift.GiftPackageId = NextGiftPackageIdNoLock(
|
||||
player.Player.PlayerExtra.PendingGiftPackages);
|
||||
}
|
||||
|
||||
gift.PlayerId = checked((int)playerId);
|
||||
player.Player.PlayerExtra.PendingGiftPackages.Add(gift);
|
||||
|
||||
return Players.Update(player) ? gift : null;
|
||||
}
|
||||
}
|
||||
|
||||
public enum FriendotronGiftStatus
|
||||
{
|
||||
Success,
|
||||
InvalidRequest,
|
||||
SenderNotFound,
|
||||
RecipientNotFound,
|
||||
DailyLimitReached,
|
||||
RecipientQueueFull,
|
||||
PersistenceFailed
|
||||
}
|
||||
|
||||
public static FriendotronGiftStatus QueueFriendotronGift(
|
||||
long senderPlayerId,
|
||||
long recipientPlayerId,
|
||||
GiftPackage gift,
|
||||
out GiftPackage? queuedGift,
|
||||
out DateTime nextAvailableAtUtc)
|
||||
{
|
||||
queuedGift = null;
|
||||
nextAvailableAtUtc = DateTime.UtcNow;
|
||||
|
||||
if (senderPlayerId is <= 0 or > int.MaxValue ||
|
||||
recipientPlayerId is <= 0 or > int.MaxValue ||
|
||||
gift == null)
|
||||
{
|
||||
return FriendotronGiftStatus.InvalidRequest;
|
||||
}
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
FullPlayer? sender = Players.FindById(senderPlayerId);
|
||||
if (sender?.Player == null)
|
||||
return FriendotronGiftStatus.SenderNotFound;
|
||||
|
||||
FullPlayer? recipient = senderPlayerId == recipientPlayerId
|
||||
? sender
|
||||
: Players.FindById(recipientPlayerId);
|
||||
if (recipient?.Player == null)
|
||||
return FriendotronGiftStatus.RecipientNotFound;
|
||||
|
||||
sender.Player.PlayerExtra ??= new PlayerExtra();
|
||||
recipient.Player.PlayerExtra ??= new PlayerExtra();
|
||||
recipient.Player.PlayerExtra.PendingGiftPackages ??=
|
||||
new List<GiftPackage>();
|
||||
|
||||
long utcDay = DateTimeOffset.UtcNow.ToUnixTimeSeconds() /
|
||||
(24L * 60L * 60L);
|
||||
nextAvailableAtUtc = DateTimeOffset.FromUnixTimeSeconds(
|
||||
checked((utcDay + 1) * 24L * 60L * 60L))
|
||||
.UtcDateTime;
|
||||
|
||||
if (sender.Player.PlayerExtra.FriendotronLastSentUtcDay >=
|
||||
utcDay)
|
||||
{
|
||||
return FriendotronGiftStatus.DailyLimitReached;
|
||||
}
|
||||
|
||||
if (recipient.Player.PlayerExtra.PendingGiftPackages.Count >=
|
||||
MaximumPendingGiftPackages)
|
||||
{
|
||||
return FriendotronGiftStatus.RecipientQueueFull;
|
||||
}
|
||||
|
||||
if (gift.GiftPackageId <= 0 ||
|
||||
gift.GiftPackageId > int.MaxValue ||
|
||||
recipient.Player.PlayerExtra.PendingGiftPackages.Any(value =>
|
||||
value.GiftPackageId == gift.GiftPackageId))
|
||||
{
|
||||
gift.GiftPackageId = NextGiftPackageIdNoLock(
|
||||
recipient.Player.PlayerExtra.PendingGiftPackages);
|
||||
}
|
||||
|
||||
gift.FromPlayerId = checked((int)senderPlayerId);
|
||||
gift.PlayerId = checked((int)recipientPlayerId);
|
||||
recipient.Player.PlayerExtra.PendingGiftPackages.Add(gift);
|
||||
long previousSentUtcDay =
|
||||
sender.Player.PlayerExtra.FriendotronLastSentUtcDay;
|
||||
sender.Player.PlayerExtra.FriendotronLastSentUtcDay = utcDay;
|
||||
|
||||
int expectedUpdates = senderPlayerId == recipientPlayerId
|
||||
? 1
|
||||
: 2;
|
||||
int updated = expectedUpdates == 1
|
||||
? (Players.Update(sender) ? 1 : 0)
|
||||
: Players.Update(new[] { sender, recipient });
|
||||
if (updated != expectedUpdates)
|
||||
{
|
||||
recipient.Player.PlayerExtra.PendingGiftPackages.Remove(gift);
|
||||
sender.Player.PlayerExtra.FriendotronLastSentUtcDay =
|
||||
previousSentUtcDay;
|
||||
return FriendotronGiftStatus.PersistenceFailed;
|
||||
}
|
||||
|
||||
queuedGift = gift;
|
||||
return FriendotronGiftStatus.Success;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RepairPendingGiftIdsNoLock(
|
||||
List<GiftPackage> gifts)
|
||||
{
|
||||
bool changed = false;
|
||||
var used = new HashSet<long>();
|
||||
foreach (GiftPackage gift in gifts)
|
||||
{
|
||||
if (gift.GiftPackageId > 0 &&
|
||||
gift.GiftPackageId <= int.MaxValue &&
|
||||
used.Add(gift.GiftPackageId))
|
||||
{
|
||||
GiftSequence = Math.Max(GiftSequence, gift.GiftPackageId);
|
||||
continue;
|
||||
}
|
||||
|
||||
gift.GiftPackageId = NextGiftPackageIdNoLock(gifts, used);
|
||||
used.Add(gift.GiftPackageId);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static long NextGiftPackageIdNoLock(
|
||||
IEnumerable<GiftPackage> gifts,
|
||||
HashSet<long>? reserved = null)
|
||||
{
|
||||
var used = reserved ?? gifts
|
||||
.Where(value => value.GiftPackageId > 0 &&
|
||||
value.GiftPackageId <= int.MaxValue)
|
||||
.Select(value => value.GiftPackageId)
|
||||
.ToHashSet();
|
||||
|
||||
for (int attempt = 0; attempt < 100000; attempt++)
|
||||
{
|
||||
GiftSequence++;
|
||||
if (GiftSequence <= 0 || GiftSequence > int.MaxValue)
|
||||
GiftSequence = 100000;
|
||||
if (!used.Contains(GiftSequence))
|
||||
return GiftSequence;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Unable to allocate a client-safe gift package ID.");
|
||||
}
|
||||
|
||||
public static GiftPackage? ConsumeGiftPackage(
|
||||
long playerId,
|
||||
long giftPackageId)
|
||||
{
|
||||
if (playerId <= 0 || giftPackageId <= 0)
|
||||
return null;
|
||||
|
||||
lock (GiftLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.PendingGiftPackages ??=
|
||||
new List<GiftPackage>();
|
||||
player.Player.PlayerExtra.AvatarItems ??= new List<string>();
|
||||
|
||||
GiftPackage? gift =
|
||||
player.Player.PlayerExtra.PendingGiftPackages.FirstOrDefault(
|
||||
value => value.GiftPackageId == giftPackageId);
|
||||
|
||||
if (gift == null)
|
||||
return null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gift.AvatarItemDesc) &&
|
||||
!player.Player.PlayerExtra.AvatarItems.Contains(
|
||||
gift.AvatarItemDesc,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
player.Player.PlayerExtra.AvatarItems.Add(
|
||||
gift.AvatarItemDesc);
|
||||
PlayerInventoryStore.SetAvatarItemOwned(
|
||||
playerId,
|
||||
gift.AvatarItemDesc,
|
||||
avatarItemId: 0,
|
||||
friendlyName: null,
|
||||
owned: true,
|
||||
legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gift.ConsumableItemDesc))
|
||||
{
|
||||
PlayerInventoryStore.AddConsumable(
|
||||
playerId,
|
||||
gift.ConsumableItemDesc,
|
||||
consumableItemId: 0,
|
||||
friendlyName: null,
|
||||
amount: Math.Clamp(gift.ConsumableQuantity, 1, 100000));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gift.EquipmentPrefabName) &&
|
||||
!string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid))
|
||||
{
|
||||
EquipmentInventoryStore.AddOwnedItem(
|
||||
playerId,
|
||||
gift.EquipmentPrefabName,
|
||||
gift.EquipmentModificationGuid,
|
||||
friendlyName: gift.FriendlyName,
|
||||
tooltip: gift.Tooltip,
|
||||
rarity: gift.Rarity > 0 ? gift.Rarity : null,
|
||||
thumbnailImage: gift.ThumbnailImage);
|
||||
}
|
||||
|
||||
if (gift.Currency != 0)
|
||||
{
|
||||
player.Player.PlayerExtra.Currencies ??=
|
||||
new List<PlayerCurrency>();
|
||||
|
||||
CurrencyType currencyType =
|
||||
Enum.IsDefined(typeof(CurrencyType), gift.CurrencyType)
|
||||
? (CurrencyType)gift.CurrencyType
|
||||
: CurrencyType.RecCenterTokens;
|
||||
|
||||
PlayerCurrency? currency =
|
||||
player.Player.PlayerExtra.Currencies.FirstOrDefault(
|
||||
value => value.CurrencyType == currencyType);
|
||||
|
||||
if (currency == null)
|
||||
{
|
||||
currency = new PlayerCurrency
|
||||
{
|
||||
CurrencyType = currencyType,
|
||||
BalanceType = BalanceType.NonPurchasedDefault
|
||||
};
|
||||
player.Player.PlayerExtra.Currencies.Add(currency);
|
||||
}
|
||||
|
||||
long adjustedBalance =
|
||||
(long)currency.Balance + gift.Currency;
|
||||
currency.Balance = (int)Math.Clamp(
|
||||
adjustedBalance,
|
||||
(long)int.MinValue,
|
||||
(long)int.MaxValue);
|
||||
}
|
||||
|
||||
if (gift.XP > 0)
|
||||
player.Player.XP = Math.Max(0, player.Player.XP + gift.XP);
|
||||
|
||||
player.Player.PlayerExtra.PendingGiftPackages.Remove(gift);
|
||||
return Players.Update(player) ? gift : null;
|
||||
}
|
||||
}
|
||||
|
||||
public static PartySnapshot? GetPartySnapshot(long playerId)
|
||||
{
|
||||
lock (PartyLock)
|
||||
return GetPartySnapshotNoLock(playerId);
|
||||
}
|
||||
|
||||
public static PartySnapshot? InviteToParty(
|
||||
long inviterPlayerId,
|
||||
long targetPlayerId)
|
||||
{
|
||||
if (inviterPlayerId <= 0 ||
|
||||
targetPlayerId <= 0 ||
|
||||
inviterPlayerId == targetPlayerId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PartySnapshot? party;
|
||||
|
||||
lock (PartyLock)
|
||||
{
|
||||
var inviter = Players.FindById(inviterPlayerId);
|
||||
var target = Players.FindById(targetPlayerId);
|
||||
if (inviter?.Player == null || target?.Player == null)
|
||||
return null;
|
||||
|
||||
party = EnsurePartyNoLock(inviterPlayerId);
|
||||
if (party == null || party.MemberPlayerIds.Contains(targetPlayerId))
|
||||
return party;
|
||||
|
||||
target.Player.PlayerExtra ??= new PlayerExtra();
|
||||
target.Player.PlayerExtra.PendingPartyInvites ??= new List<PartyInviteRecord>();
|
||||
|
||||
DateTime expiresBefore = DateTime.UtcNow.AddMinutes(-10);
|
||||
target.Player.PlayerExtra.PendingPartyInvites.RemoveAll(invite =>
|
||||
invite.CreatedAt < expiresBefore ||
|
||||
invite.PartyId == party.PartyId ||
|
||||
invite.InviterPlayerId == inviterPlayerId);
|
||||
|
||||
target.Player.PlayerExtra.PendingPartyInvites.Add(new PartyInviteRecord
|
||||
{
|
||||
PartyId = party.PartyId,
|
||||
InviterPlayerId = inviterPlayerId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
if (!Players.Update(target))
|
||||
return null;
|
||||
}
|
||||
|
||||
return party;
|
||||
}
|
||||
|
||||
public static PartySnapshot? AcceptPartyInvite(
|
||||
long inviteePlayerId,
|
||||
long inviterPlayerId)
|
||||
{
|
||||
if (inviteePlayerId <= 0 || inviterPlayerId <= 0)
|
||||
return null;
|
||||
|
||||
PartySnapshot? party;
|
||||
List<long> notifyPlayerIds;
|
||||
|
||||
lock (PartyLock)
|
||||
{
|
||||
var invitee = Players.FindById(inviteePlayerId);
|
||||
var inviter = Players.FindById(inviterPlayerId);
|
||||
if (invitee?.Player == null || inviter?.Player == null)
|
||||
return null;
|
||||
|
||||
invitee.Player.PlayerExtra ??= new PlayerExtra();
|
||||
invitee.Player.PlayerExtra.PendingPartyInvites ??= new List<PartyInviteRecord>();
|
||||
|
||||
PartyInviteRecord? invite = invitee.Player.PlayerExtra.PendingPartyInvites
|
||||
.Where(value => value.CreatedAt >= DateTime.UtcNow.AddMinutes(-10))
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefault(value => value.InviterPlayerId == inviterPlayerId);
|
||||
|
||||
party = EnsurePartyNoLock(inviterPlayerId);
|
||||
if (party == null ||
|
||||
invite == null ||
|
||||
invite.PartyId != party.PartyId ||
|
||||
party.MemberPlayerIds.Count >= 8)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
RemovePlayerFromPartyNoLock(inviteePlayerId);
|
||||
|
||||
party.MemberPlayerIds = party.MemberPlayerIds
|
||||
.Append(inviteePlayerId)
|
||||
.Where(value => value > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
invitee.Player.PlayerExtra.PendingPartyInvites.RemoveAll(value =>
|
||||
value.PartyId == party.PartyId ||
|
||||
value.InviterPlayerId == inviterPlayerId ||
|
||||
value.CreatedAt < DateTime.UtcNow.AddMinutes(-10));
|
||||
|
||||
Players.Update(invitee);
|
||||
PersistPartyNoLock(party);
|
||||
notifyPlayerIds = party.MemberPlayerIds.ToList();
|
||||
}
|
||||
|
||||
_ = NotiController.NotifyPartyUpdatedAsync(
|
||||
notifyPlayerIds,
|
||||
CreatePartyNotificationPayload(party));
|
||||
return party;
|
||||
}
|
||||
|
||||
public static PartySnapshot? LeaveParty(long playerId)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return null;
|
||||
|
||||
PartySnapshot? remainingParty;
|
||||
List<long> notifyPlayerIds;
|
||||
|
||||
lock (PartyLock)
|
||||
{
|
||||
PartySnapshot? existing = GetPartySnapshotNoLock(playerId);
|
||||
if (existing == null)
|
||||
{
|
||||
ClearPartyStateNoLock(playerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
notifyPlayerIds = existing.MemberPlayerIds.ToList();
|
||||
remainingParty = RemovePlayerFromPartyNoLock(playerId);
|
||||
}
|
||||
|
||||
var payload = remainingParty ?? new PartySnapshot
|
||||
{
|
||||
PartyId = 0,
|
||||
LeaderPlayerId = 0,
|
||||
MemberPlayerIds = new List<long>()
|
||||
};
|
||||
|
||||
_ = NotiController.NotifyPartyUpdatedAsync(
|
||||
notifyPlayerIds.Append(playerId),
|
||||
CreatePartyNotificationPayload(payload));
|
||||
|
||||
return remainingParty;
|
||||
}
|
||||
|
||||
public static bool DeclinePartyInvite(
|
||||
long inviteePlayerId,
|
||||
long inviterPlayerId)
|
||||
{
|
||||
lock (PartyLock)
|
||||
{
|
||||
var invitee = Players.FindById(inviteePlayerId);
|
||||
if (invitee?.Player == null)
|
||||
return false;
|
||||
|
||||
invitee.Player.PlayerExtra ??= new PlayerExtra();
|
||||
invitee.Player.PlayerExtra.PendingPartyInvites ??= new List<PartyInviteRecord>();
|
||||
|
||||
int removed = invitee.Player.PlayerExtra.PendingPartyInvites.RemoveAll(value =>
|
||||
value.InviterPlayerId == inviterPlayerId ||
|
||||
value.CreatedAt < DateTime.UtcNow.AddMinutes(-10));
|
||||
|
||||
return removed > 0 && Players.Update(invitee);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<PartyInviteRecord> GetPendingPartyInvites(long playerId)
|
||||
{
|
||||
lock (PartyLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return new List<PartyInviteRecord>();
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.PendingPartyInvites ??= new List<PartyInviteRecord>();
|
||||
player.Player.PlayerExtra.PendingPartyInvites.RemoveAll(value =>
|
||||
value.CreatedAt < DateTime.UtcNow.AddMinutes(-10));
|
||||
Players.Update(player);
|
||||
|
||||
return player.Player.PlayerExtra.PendingPartyInvites
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private static PartySnapshot? EnsurePartyNoLock(long playerId)
|
||||
{
|
||||
PartySnapshot? existing = GetPartySnapshotNoLock(playerId);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
long partyId = Interlocked.Increment(ref PartySequence);
|
||||
if (partyId <= 0)
|
||||
{
|
||||
PartySequence = DateTime.UtcNow.Ticks & long.MaxValue;
|
||||
partyId = Interlocked.Increment(ref PartySequence);
|
||||
}
|
||||
|
||||
var party = new PartySnapshot
|
||||
{
|
||||
PartyId = partyId,
|
||||
LeaderPlayerId = playerId,
|
||||
MemberPlayerIds = new List<long> { playerId }
|
||||
};
|
||||
|
||||
PersistPartyNoLock(party);
|
||||
return party;
|
||||
}
|
||||
|
||||
private static PartySnapshot? GetPartySnapshotNoLock(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
PlayerExtra extra = player.Player.PlayerExtra;
|
||||
extra.PartyMemberPlayerIds ??= new List<long>();
|
||||
|
||||
if (extra.PartyId <= 0)
|
||||
return null;
|
||||
|
||||
List<long> members = extra.PartyMemberPlayerIds
|
||||
.Append(playerId)
|
||||
.Where(value => value > 0 && Players.FindById(value)?.Player != null)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (members.Count == 0)
|
||||
return null;
|
||||
|
||||
long leader = members.Contains(extra.PartyLeaderPlayerId)
|
||||
? extra.PartyLeaderPlayerId
|
||||
: members[0];
|
||||
|
||||
return new PartySnapshot
|
||||
{
|
||||
PartyId = extra.PartyId,
|
||||
LeaderPlayerId = leader,
|
||||
MemberPlayerIds = members
|
||||
};
|
||||
}
|
||||
|
||||
private static PartySnapshot? RemovePlayerFromPartyNoLock(long playerId)
|
||||
{
|
||||
PartySnapshot? party = GetPartySnapshotNoLock(playerId);
|
||||
if (party == null)
|
||||
{
|
||||
ClearPartyStateNoLock(playerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
party.MemberPlayerIds.RemoveAll(value => value == playerId);
|
||||
ClearPartyStateNoLock(playerId);
|
||||
|
||||
if (party.MemberPlayerIds.Count <= 1)
|
||||
{
|
||||
foreach (long remainingPlayerId in party.MemberPlayerIds)
|
||||
ClearPartyStateNoLock(remainingPlayerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (party.LeaderPlayerId == playerId ||
|
||||
!party.MemberPlayerIds.Contains(party.LeaderPlayerId))
|
||||
{
|
||||
party.LeaderPlayerId = party.MemberPlayerIds[0];
|
||||
}
|
||||
|
||||
PersistPartyNoLock(party);
|
||||
return party;
|
||||
}
|
||||
|
||||
private static void PersistPartyNoLock(PartySnapshot party)
|
||||
{
|
||||
party.MemberPlayerIds = party.MemberPlayerIds
|
||||
.Where(value => value > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (!party.MemberPlayerIds.Contains(party.LeaderPlayerId))
|
||||
party.LeaderPlayerId = party.MemberPlayerIds.FirstOrDefault();
|
||||
|
||||
foreach (long memberPlayerId in party.MemberPlayerIds)
|
||||
{
|
||||
var member = Players.FindById(memberPlayerId);
|
||||
if (member?.Player == null)
|
||||
continue;
|
||||
|
||||
member.Player.PlayerExtra ??= new PlayerExtra();
|
||||
member.Player.PlayerExtra.PartyId = party.PartyId;
|
||||
member.Player.PlayerExtra.PartyLeaderPlayerId = party.LeaderPlayerId;
|
||||
member.Player.PlayerExtra.PartyMemberPlayerIds = party.MemberPlayerIds.ToList();
|
||||
Players.Update(member);
|
||||
}
|
||||
}
|
||||
|
||||
private static object CreatePartyNotificationPayload(PartySnapshot party) => new
|
||||
{
|
||||
partyId = party.PartyId,
|
||||
leaderPlayerId = party.LeaderPlayerId,
|
||||
memberPlayerIds = party.MemberPlayerIds.ToArray()
|
||||
};
|
||||
|
||||
private static void ClearPartyStateNoLock(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.PartyId = 0;
|
||||
player.Player.PlayerExtra.PartyLeaderPlayerId = 0;
|
||||
player.Player.PlayerExtra.PartyMemberPlayerIds = new List<long>();
|
||||
Players.Update(player);
|
||||
}
|
||||
|
||||
public static List<Reputation> GetReputationBulk(
|
||||
List<long> playerIds)
|
||||
{
|
||||
lock (SocialLock)
|
||||
{
|
||||
var allPlayers = Players.FindAll().ToList();
|
||||
var players = allPlayers
|
||||
.Where(x => playerIds.Contains(x.PlayerId))
|
||||
.ToList();
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var result = new List<Reputation>(players.Count);
|
||||
|
||||
foreach (FullPlayer p in players)
|
||||
{
|
||||
var rep = p.Player?.Reputation ?? new Reputation();
|
||||
if (p.Player != null)
|
||||
{
|
||||
p.Player.Reputation = rep;
|
||||
if (RefreshCheerCreditsNoLock(rep, p.PlayerId, now))
|
||||
Players.Update(p);
|
||||
}
|
||||
|
||||
bool isDeveloper = HasRole(
|
||||
p,
|
||||
PlayerRoles.Developer);
|
||||
bool isCommunityTeam = HasRole(
|
||||
p,
|
||||
PlayerRoles.CommunityTeam);
|
||||
bool hasStaffBadgeAccess =
|
||||
isDeveloper || isCommunityTeam;
|
||||
|
||||
result.Add(new Reputation
|
||||
{
|
||||
AccountId = p.PlayerId,
|
||||
IsCheerful =
|
||||
hasStaffBadgeAccess || rep.IsCheerful,
|
||||
Noteriety = rep.Noteriety,
|
||||
SelectedCheer = NormalizeSelectedStaffBadge(
|
||||
rep.SelectedCheer,
|
||||
isDeveloper,
|
||||
isCommunityTeam),
|
||||
CheerCredit = Math.Clamp(
|
||||
rep.CheerCredit,
|
||||
0,
|
||||
CheerCreditLimit),
|
||||
CheerGeneral = hasStaffBadgeAccess
|
||||
? Math.Max(10, rep.CheerGeneral)
|
||||
: rep.CheerGeneral,
|
||||
CheerHelpful = hasStaffBadgeAccess
|
||||
? Math.Max(10, rep.CheerHelpful)
|
||||
: rep.CheerHelpful,
|
||||
CheerCreative = hasStaffBadgeAccess
|
||||
? Math.Max(10, rep.CheerCreative)
|
||||
: rep.CheerCreative,
|
||||
CheerGreatHost = hasStaffBadgeAccess
|
||||
? Math.Max(10, rep.CheerGreatHost)
|
||||
: rep.CheerGreatHost,
|
||||
CheerSportsman = hasStaffBadgeAccess
|
||||
? Math.Max(10, rep.CheerSportsman)
|
||||
: rep.CheerSportsman,
|
||||
SubscriberCount =
|
||||
RelationshipDB.GetSubscriberCount(p.PlayerId),
|
||||
SubscribedCount =
|
||||
RelationshipDB.GetSubscribedCount(p.PlayerId)
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GrantDeveloperCheerAccess(
|
||||
FullPlayer account,
|
||||
bool selectDeveloperBadge)
|
||||
{
|
||||
return GrantStaffCheerAccess(
|
||||
account,
|
||||
CheerCategory.RecRoomDeveloper,
|
||||
selectDeveloperBadge);
|
||||
}
|
||||
|
||||
public static bool GrantCommunityTeamCheerAccess(
|
||||
FullPlayer account,
|
||||
bool selectCommunityTeamBadge)
|
||||
{
|
||||
return GrantStaffCheerAccess(
|
||||
account,
|
||||
CheerCategory.RecRoomCommunityTeam,
|
||||
selectCommunityTeamBadge);
|
||||
}
|
||||
|
||||
private static bool GrantStaffCheerAccess(
|
||||
FullPlayer account,
|
||||
CheerCategory staffBadge,
|
||||
bool selectStaffBadge)
|
||||
{
|
||||
if (account.Player == null ||
|
||||
staffBadge is not (
|
||||
CheerCategory.RecRoomDeveloper or
|
||||
CheerCategory.RecRoomCommunityTeam))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
account.Player.Reputation ??= new Reputation();
|
||||
Reputation reputation = account.Player.Reputation;
|
||||
bool changed = false;
|
||||
|
||||
if (!reputation.IsCheerful)
|
||||
{
|
||||
reputation.IsCheerful = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (reputation.CheerGeneral < 10)
|
||||
{
|
||||
reputation.CheerGeneral = 10;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (reputation.CheerHelpful < 10)
|
||||
{
|
||||
reputation.CheerHelpful = 10;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (reputation.CheerCreative < 10)
|
||||
{
|
||||
reputation.CheerCreative = 10;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (reputation.CheerGreatHost < 10)
|
||||
{
|
||||
reputation.CheerGreatHost = 10;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (reputation.CheerSportsman < 10)
|
||||
{
|
||||
reputation.CheerSportsman = 10;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (selectStaffBadge &&
|
||||
reputation.SelectedCheer != staffBadge)
|
||||
{
|
||||
reputation.SelectedCheer = staffBadge;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static bool HasRole(
|
||||
FullPlayer account,
|
||||
PlayerRoles role)
|
||||
{
|
||||
return account.PlayerRoles?.Contains(role) == true;
|
||||
}
|
||||
|
||||
private static CheerCategory NormalizeSelectedStaffBadge(
|
||||
CheerCategory selectedCheer,
|
||||
bool isDeveloper,
|
||||
bool isCommunityTeam)
|
||||
{
|
||||
if (selectedCheer == CheerCategory.RecRoomDeveloper &&
|
||||
!isDeveloper)
|
||||
{
|
||||
return CheerCategory.General;
|
||||
}
|
||||
|
||||
if (selectedCheer ==
|
||||
CheerCategory.RecRoomCommunityTeam &&
|
||||
!isCommunityTeam)
|
||||
{
|
||||
return CheerCategory.General;
|
||||
}
|
||||
|
||||
return selectedCheer;
|
||||
}
|
||||
|
||||
public static int EnsureSocialDefaultsForAllPlayers()
|
||||
{
|
||||
lock (SocialLock)
|
||||
{
|
||||
var allPlayers = Players.FindAll().ToList();
|
||||
int updated = 0;
|
||||
|
||||
updated += RelationshipDB.MigrateLegacyData(allPlayers);
|
||||
|
||||
foreach (var account in allPlayers)
|
||||
{
|
||||
if (account.Player == null)
|
||||
continue;
|
||||
|
||||
bool changed = false;
|
||||
account.Player.Relationships ??= new List<PlayerRelationship>();
|
||||
account.Player.ReceivedCheers ??= new List<PlayerCheerRecord>();
|
||||
account.Player.SubscribedAccountIds ??= new List<long>();
|
||||
account.Player.Reputation ??= new Reputation();
|
||||
|
||||
if (account.Player.Reputation.AccountId != account.PlayerId)
|
||||
{
|
||||
account.Player.Reputation.AccountId = account.PlayerId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed |= RefreshCheerCreditsNoLock(
|
||||
account.Player.Reputation,
|
||||
account.PlayerId,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
|
||||
bool isDeveloper = HasRole(
|
||||
account,
|
||||
PlayerRoles.Developer);
|
||||
bool isCommunityTeam = HasRole(
|
||||
account,
|
||||
PlayerRoles.CommunityTeam);
|
||||
|
||||
Reputation reputation =
|
||||
account.Player.Reputation;
|
||||
bool hasNoEarnedCheer =
|
||||
reputation.CheerGeneral == 0 &&
|
||||
reputation.CheerHelpful == 0 &&
|
||||
reputation.CheerCreative == 0 &&
|
||||
reputation.CheerGreatHost == 0 &&
|
||||
reputation.CheerSportsman == 0;
|
||||
|
||||
if (isDeveloper)
|
||||
{
|
||||
changed |= GrantDeveloperCheerAccess(
|
||||
account,
|
||||
selectDeveloperBadge:
|
||||
hasNoEarnedCheer &&
|
||||
reputation.SelectedCheer ==
|
||||
CheerCategory.General);
|
||||
}
|
||||
else if (isCommunityTeam)
|
||||
{
|
||||
changed |= GrantCommunityTeamCheerAccess(
|
||||
account,
|
||||
selectCommunityTeamBadge:
|
||||
hasNoEarnedCheer &&
|
||||
reputation.SelectedCheer ==
|
||||
CheerCategory.General);
|
||||
}
|
||||
|
||||
CheerCategory normalizedSelectedCheer =
|
||||
NormalizeSelectedStaffBadge(
|
||||
reputation.SelectedCheer,
|
||||
isDeveloper,
|
||||
isCommunityTeam);
|
||||
|
||||
if (normalizedSelectedCheer !=
|
||||
reputation.SelectedCheer)
|
||||
{
|
||||
reputation.SelectedCheer =
|
||||
normalizedSelectedCheer;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
List<PlayerRelationship> authoritativeRelationships =
|
||||
RelationshipDB.GetRelationships(account.PlayerId);
|
||||
if (!RelationshipListsMatch(
|
||||
account.Player.Relationships,
|
||||
authoritativeRelationships))
|
||||
{
|
||||
account.Player.Relationships = authoritativeRelationships;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed && Players.Update(account))
|
||||
updated++;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RelationshipListsMatch(
|
||||
IReadOnlyCollection<PlayerRelationship> first,
|
||||
IReadOnlyCollection<PlayerRelationship> second)
|
||||
{
|
||||
if (first.Count != second.Count)
|
||||
return false;
|
||||
|
||||
var firstByPlayer = first
|
||||
.GroupBy(value => value.PlayerID)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
if (firstByPlayer.Count != first.Count)
|
||||
return false;
|
||||
|
||||
foreach (PlayerRelationship expected in second)
|
||||
{
|
||||
if (!firstByPlayer.TryGetValue(expected.PlayerID, out PlayerRelationship? actual) ||
|
||||
actual.Id != expected.Id ||
|
||||
actual.RelationshipType != expected.RelationshipType ||
|
||||
actual.Favorited != expected.Favorited ||
|
||||
actual.Muted != expected.Muted ||
|
||||
actual.Ignored != expected.Ignored)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static PlayerDBClasses.RoomInstance CloneRoomInstance(
|
||||
PlayerDBClasses.RoomInstance source)
|
||||
{
|
||||
return new PlayerDBClasses.RoomInstance
|
||||
{
|
||||
encryptVoiceChat = source.encryptVoiceChat,
|
||||
clubId = source.clubId,
|
||||
dataBlob = source.dataBlob,
|
||||
eventId = source.eventId,
|
||||
|
||||
isFull = source.isFull,
|
||||
isInProgress = source.isInProgress,
|
||||
isPrivate = source.isPrivate,
|
||||
|
||||
location = source.location,
|
||||
maxCapacity = source.maxCapacity,
|
||||
Name = source.Name,
|
||||
|
||||
photonRegion = source.photonRegion,
|
||||
photonRegionId = source.photonRegionId,
|
||||
photonRoomId = source.photonRoomId,
|
||||
|
||||
roomCode = source.roomCode,
|
||||
roomId = source.roomId,
|
||||
roomInstanceId = source.roomInstanceId,
|
||||
roomInstanceType = source.roomInstanceType,
|
||||
subRoomId = source.subRoomId,
|
||||
subRoomDataSaveId = source.subRoomDataSaveId,
|
||||
createdAt = source.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
public static int EnsureInitialTokensForAllPlayers()
|
||||
{
|
||||
lock (SocialLock)
|
||||
{
|
||||
int updated = 0;
|
||||
|
||||
foreach (var account in Players.FindAll().ToList())
|
||||
{
|
||||
if (account.Player == null)
|
||||
continue;
|
||||
|
||||
account.Player.PlayerExtra ??= new PlayerExtra();
|
||||
account.Player.PlayerExtra.Settings ??= new List<Setting>();
|
||||
account.Player.PlayerExtra.Currencies ??=
|
||||
new List<PlayerCurrency>();
|
||||
|
||||
bool alreadyGranted = account.Player.PlayerExtra.Settings
|
||||
.Any(setting =>
|
||||
string.Equals(
|
||||
setting.Key,
|
||||
InitialTokensSettingKey,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(
|
||||
setting.Value,
|
||||
"True",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (alreadyGranted)
|
||||
continue;
|
||||
|
||||
var tokenCurrency = account.Player.PlayerExtra.Currencies
|
||||
.FirstOrDefault(currency =>
|
||||
currency.CurrencyType ==
|
||||
CurrencyType.RecCenterTokens);
|
||||
|
||||
if (tokenCurrency == null)
|
||||
{
|
||||
tokenCurrency = new PlayerCurrency
|
||||
{
|
||||
CurrencyType = CurrencyType.RecCenterTokens,
|
||||
BalanceType = BalanceType.NonPurchasedDefault,
|
||||
Balance = InitialTokenBalance
|
||||
};
|
||||
account.Player.PlayerExtra.Currencies.Add(tokenCurrency);
|
||||
}
|
||||
else if (tokenCurrency.Balance <= 0)
|
||||
{
|
||||
tokenCurrency.Balance = InitialTokenBalance;
|
||||
tokenCurrency.BalanceType =
|
||||
BalanceType.NonPurchasedDefault;
|
||||
}
|
||||
|
||||
account.Player.PlayerExtra.Settings.Add(new Setting
|
||||
{
|
||||
Key = InitialTokensSettingKey,
|
||||
Value = "True"
|
||||
});
|
||||
|
||||
if (Players.Update(account))
|
||||
updated++;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
public static int ResetAlpacaShirtOwnershipForExistingPlayers()
|
||||
{
|
||||
lock (SocialLock)
|
||||
{
|
||||
int ownershipsRemoved = 0;
|
||||
|
||||
foreach (var account in Players.FindAll().ToList())
|
||||
{
|
||||
if (account.Player == null)
|
||||
continue;
|
||||
|
||||
account.Player.PlayerExtra ??= new PlayerExtra();
|
||||
account.Player.PlayerExtra.Settings ??= new List<Setting>();
|
||||
account.Player.PlayerExtra.AvatarItems ??= new List<string>();
|
||||
|
||||
bool resetAlreadyApplied = account.Player.PlayerExtra.Settings
|
||||
.Any(setting =>
|
||||
string.Equals(
|
||||
setting.Key,
|
||||
AlpacaShirtOwnershipResetSettingKey,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(
|
||||
setting.Value,
|
||||
"True",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (resetAlreadyApplied)
|
||||
continue;
|
||||
|
||||
int removedForAccount = account.Player.PlayerExtra.AvatarItems
|
||||
.RemoveAll(descriptor => string.Equals(
|
||||
descriptor,
|
||||
AlpacaShirtAvatarItemDescriptor,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
account.Player.PlayerExtra.Settings.Add(new Setting
|
||||
{
|
||||
Key = AlpacaShirtOwnershipResetSettingKey,
|
||||
Value = "True"
|
||||
});
|
||||
|
||||
if (Players.Update(account))
|
||||
ownershipsRemoved += removedForAccount;
|
||||
}
|
||||
|
||||
return ownershipsRemoved;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool OwnsAvatarItem(
|
||||
long playerId,
|
||||
string avatarItemDescriptor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(avatarItemDescriptor))
|
||||
return false;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var legacyItems = Players.FindById(playerId)?
|
||||
.Player?.PlayerExtra?.AvatarItems;
|
||||
|
||||
return PlayerInventoryStore.OwnsAvatarItem(
|
||||
playerId,
|
||||
avatarItemDescriptor,
|
||||
legacyAvatarDescriptors: legacyItems);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool RemoveAvatarItem(
|
||||
long playerId,
|
||||
string avatarItemDescriptor,
|
||||
long avatarItemId = 0)
|
||||
{
|
||||
if (playerId <= 0 ||
|
||||
(string.IsNullOrWhiteSpace(avatarItemDescriptor) && avatarItemId <= 0))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.AvatarItems ??= new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(avatarItemDescriptor))
|
||||
{
|
||||
player.Player.PlayerExtra.AvatarItems.RemoveAll(value =>
|
||||
string.Equals(
|
||||
value,
|
||||
avatarItemDescriptor,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
bool saved = PlayerInventoryStore.SetAvatarItemOwned(
|
||||
playerId,
|
||||
avatarItemDescriptor,
|
||||
avatarItemId,
|
||||
friendlyName: null,
|
||||
owned: false,
|
||||
legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems);
|
||||
|
||||
return saved && Players.Update(player);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<PlayerRelationship> GetRelationships(long playerId)
|
||||
{
|
||||
return Players.FindById(playerId)?.Player == null
|
||||
? new List<PlayerRelationship>()
|
||||
: RelationshipDB.GetRelationships(playerId);
|
||||
}
|
||||
|
||||
public static PlayerRelationship? SetRelationshipFlags(
|
||||
long sourcePlayerId,
|
||||
long targetPlayerId,
|
||||
bool? ignored = null,
|
||||
bool? muted = null,
|
||||
bool? favorited = null)
|
||||
{
|
||||
if (!ValidateRelationshipPlayers(sourcePlayerId, targetPlayerId))
|
||||
return null;
|
||||
|
||||
PlayerRelationship relationship;
|
||||
lock (SocialLock)
|
||||
{
|
||||
relationship = RelationshipDB.SetFlags(
|
||||
sourcePlayerId,
|
||||
targetPlayerId,
|
||||
ignored: ignored,
|
||||
muted: muted,
|
||||
favorited: favorited);
|
||||
|
||||
if (!SynchronizeLegacyRelationshipNoLock(sourcePlayerId, targetPlayerId))
|
||||
return null;
|
||||
}
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
private static bool ApplyBanStateToHeartbeat(
|
||||
FullPlayer player,
|
||||
long playerId,
|
||||
out Heartbeat heartbeat)
|
||||
{
|
||||
player.Player ??= new Player();
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Heartbeat ??= new Heartbeat();
|
||||
|
||||
heartbeat = player.Player.PlayerExtra.Heartbeat;
|
||||
heartbeat.playerId = playerId;
|
||||
|
||||
var moderation =
|
||||
player.Player.PlayerExtra.ModerationBlockDetails;
|
||||
|
||||
if (moderation?.IsBan != true)
|
||||
{
|
||||
if (heartbeat.errorCode ==
|
||||
MatchmakingErrorCode.Banned)
|
||||
{
|
||||
heartbeat.errorCode =
|
||||
MatchmakingErrorCode.Success;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
long now = DateTimeOffset.UtcNow
|
||||
.ToUnixTimeSeconds();
|
||||
|
||||
bool permanent = moderation.Duration <= 0;
|
||||
|
||||
bool active =
|
||||
permanent ||
|
||||
moderation.ModerationSetUnixTime +
|
||||
moderation.Duration > now;
|
||||
|
||||
if (!active)
|
||||
{
|
||||
|
||||
moderation.IsBan = false;
|
||||
moderation.Duration = 0;
|
||||
moderation.Message = "";
|
||||
moderation.ModerationSetUnixTime = 0;
|
||||
moderation.BannedByPlayerId = 0;
|
||||
|
||||
if (heartbeat.errorCode ==
|
||||
MatchmakingErrorCode.Banned)
|
||||
{
|
||||
heartbeat.errorCode =
|
||||
MatchmakingErrorCode.Success;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
heartbeat.isOnline = false;
|
||||
heartbeat.roomInstance = null;
|
||||
heartbeat.errorCode =
|
||||
MatchmakingErrorCode.Banned;
|
||||
|
||||
heartbeat.lastHeartbeatUnixTime =
|
||||
now;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static PlayerRelationship? SendFriendRequest(
|
||||
long sourcePlayerId,
|
||||
long targetPlayerId)
|
||||
{
|
||||
if (!ValidateRelationshipPlayers(sourcePlayerId, targetPlayerId))
|
||||
return null;
|
||||
|
||||
PlayerRelationship? result;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
PlayerRelationship? existing = RelationshipDB.GetRelationship(
|
||||
sourcePlayerId,
|
||||
targetPlayerId);
|
||||
|
||||
if (existing?.RelationshipType is RelationshipType.OutgoingFriendRequest
|
||||
or RelationshipType.Friend)
|
||||
{
|
||||
result = existing;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = RelationshipDB.SendFriendRequest(
|
||||
sourcePlayerId,
|
||||
targetPlayerId,
|
||||
out _);
|
||||
}
|
||||
|
||||
if (!SynchronizeLegacyRelationshipNoLock(sourcePlayerId, targetPlayerId))
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PlayerRelationship? AcceptFriendRequest(
|
||||
long receiverPlayerId,
|
||||
long requesterPlayerId)
|
||||
{
|
||||
if (!ValidateRelationshipPlayers(receiverPlayerId, requesterPlayerId))
|
||||
return null;
|
||||
|
||||
PlayerRelationship? result;
|
||||
lock (SocialLock)
|
||||
{
|
||||
result = RelationshipDB.AcceptFriendRequest(
|
||||
receiverPlayerId,
|
||||
requesterPlayerId);
|
||||
if (result == null)
|
||||
return null;
|
||||
|
||||
if (!SynchronizeLegacyRelationshipNoLock(receiverPlayerId, requesterPlayerId))
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PlayerRelationship? AddFriendRequest(
|
||||
long sourcePlayerId,
|
||||
long targetPlayerId) =>
|
||||
SendFriendRequest(sourcePlayerId, targetPlayerId);
|
||||
|
||||
private static bool ValidateRelationshipPlayers(
|
||||
long firstPlayerId,
|
||||
long secondPlayerId)
|
||||
{
|
||||
if (firstPlayerId <= 0 ||
|
||||
secondPlayerId <= 0 ||
|
||||
firstPlayerId == secondPlayerId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Players.FindById(firstPlayerId)?.Player != null &&
|
||||
Players.FindById(secondPlayerId)?.Player != null;
|
||||
}
|
||||
|
||||
private static bool SynchronizeLegacyRelationshipNoLock(
|
||||
long firstPlayerId,
|
||||
long secondPlayerId)
|
||||
{
|
||||
FullPlayer? first = Players.FindById(firstPlayerId);
|
||||
FullPlayer? second = Players.FindById(secondPlayerId);
|
||||
if (first?.Player == null || second?.Player == null)
|
||||
return false;
|
||||
|
||||
first.Player.Relationships ??= new List<PlayerRelationship>();
|
||||
second.Player.Relationships ??= new List<PlayerRelationship>();
|
||||
|
||||
SynchronizeOneLegacyRelationship(
|
||||
first.Player.Relationships,
|
||||
RelationshipDB.GetRelationship(firstPlayerId, secondPlayerId),
|
||||
secondPlayerId);
|
||||
SynchronizeOneLegacyRelationship(
|
||||
second.Player.Relationships,
|
||||
RelationshipDB.GetRelationship(secondPlayerId, firstPlayerId),
|
||||
firstPlayerId);
|
||||
|
||||
return Players.Update(first) && Players.Update(second);
|
||||
}
|
||||
|
||||
private static void SynchronizeOneLegacyRelationship(
|
||||
List<PlayerRelationship> relationships,
|
||||
PlayerRelationship? authoritative,
|
||||
long otherPlayerId)
|
||||
{
|
||||
relationships.RemoveAll(value => value.PlayerID == otherPlayerId);
|
||||
if (authoritative != null && authoritative.RelationshipType != RelationshipType.None)
|
||||
relationships.Add(authoritative);
|
||||
}
|
||||
|
||||
private static long CreateRelationshipId(
|
||||
long firstPlayerId,
|
||||
long secondPlayerId) =>
|
||||
RelationshipDB.GetStableNumericId(firstPlayerId, secondPlayerId);
|
||||
|
||||
public static bool SetSubscription(
|
||||
long subscriberPlayerId,
|
||||
long targetPlayerId,
|
||||
bool subscribe)
|
||||
{
|
||||
if (subscriberPlayerId == targetPlayerId)
|
||||
return false;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var subscriber = Players.FindById(subscriberPlayerId);
|
||||
var target = Players.FindById(targetPlayerId);
|
||||
|
||||
if (subscriber?.Player == null || target?.Player == null)
|
||||
return false;
|
||||
|
||||
if (!RelationshipDB.SetSubscription(
|
||||
subscriberPlayerId,
|
||||
targetPlayerId,
|
||||
subscribe))
|
||||
return false;
|
||||
|
||||
subscriber.Player.SubscribedAccountIds ??= new List<long>();
|
||||
subscriber.Player.Reputation ??= new Reputation();
|
||||
target.Player.Reputation ??= new Reputation();
|
||||
|
||||
if (subscribe)
|
||||
{
|
||||
if (!subscriber.Player.SubscribedAccountIds.Contains(targetPlayerId))
|
||||
subscriber.Player.SubscribedAccountIds.Add(targetPlayerId);
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriber.Player.SubscribedAccountIds.RemoveAll(value =>
|
||||
value == targetPlayerId);
|
||||
}
|
||||
|
||||
subscriber.Player.Reputation.SubscribedCount =
|
||||
RelationshipDB.GetSubscribedCount(subscriberPlayerId);
|
||||
Players.Update(subscriber);
|
||||
|
||||
target.Player.Reputation.SubscriberCount =
|
||||
RelationshipDB.GetSubscriberCount(targetPlayerId);
|
||||
|
||||
Players.Update(target);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static int GetSubscriberCount(long targetPlayerId)
|
||||
{
|
||||
return RelationshipDB.GetSubscriberCount(targetPlayerId);
|
||||
}
|
||||
|
||||
public static int GetCurrencyBalance(
|
||||
long playerId,
|
||||
CurrencyType currencyType)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
return player?.Player?.PlayerExtra?.Currencies?
|
||||
.FirstOrDefault(currency =>
|
||||
currency.CurrencyType == currencyType)?.Balance ?? 0;
|
||||
}
|
||||
|
||||
public static List<(CurrencyType CurrencyType, int Balance)> GetAllCurrencyBalances(
|
||||
long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
var currencies = player?.Player?.PlayerExtra?.Currencies ?? new List<PlayerCurrency>();
|
||||
|
||||
var storedBalances = currencies
|
||||
.GroupBy(currency => currency.CurrencyType)
|
||||
.ToDictionary(group => group.Key, group => group.First().Balance);
|
||||
|
||||
return Enum.GetValues<CurrencyType>()
|
||||
.Where(currency => currency != CurrencyType.Invalid)
|
||||
.Select(currency => (
|
||||
currency,
|
||||
storedBalances.TryGetValue(currency, out int balance) ? balance : 0))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static int? SetCurrencyBalance(
|
||||
long playerId,
|
||||
CurrencyType currencyType,
|
||||
int amount,
|
||||
bool add)
|
||||
{
|
||||
lock (SocialLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return null;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Currencies ??= new List<PlayerCurrency>();
|
||||
|
||||
var currency = player.Player.PlayerExtra.Currencies
|
||||
.FirstOrDefault(value => value.CurrencyType == currencyType);
|
||||
|
||||
if (currency == null)
|
||||
{
|
||||
currency = new PlayerCurrency
|
||||
{
|
||||
CurrencyType = currencyType,
|
||||
BalanceType = BalanceType.NonPurchasedDefault
|
||||
};
|
||||
player.Player.PlayerExtra.Currencies.Add(currency);
|
||||
}
|
||||
|
||||
long requestedBalance = add
|
||||
? (long)currency.Balance + amount
|
||||
: amount;
|
||||
currency.Balance = (int)Math.Clamp(
|
||||
requestedBalance,
|
||||
0L,
|
||||
int.MaxValue);
|
||||
|
||||
return Players.Update(player) ? currency.Balance : null;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TrySpendCurrency(
|
||||
long playerId,
|
||||
CurrencyType currencyType,
|
||||
int amount,
|
||||
out int newBalance)
|
||||
{
|
||||
newBalance = 0;
|
||||
if (amount < 0)
|
||||
return false;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Currencies ??= new List<PlayerCurrency>();
|
||||
|
||||
var currency = player.Player.PlayerExtra.Currencies
|
||||
.FirstOrDefault(value => value.CurrencyType == currencyType);
|
||||
|
||||
newBalance = currency?.Balance ?? 0;
|
||||
if (amount == 0)
|
||||
return true;
|
||||
if (currency == null || currency.Balance < amount)
|
||||
return false;
|
||||
|
||||
int originalBalance = currency.Balance;
|
||||
currency.Balance -= amount;
|
||||
newBalance = currency.Balance;
|
||||
|
||||
if (Players.Update(player))
|
||||
return true;
|
||||
|
||||
currency.Balance = originalBalance;
|
||||
newBalance = originalBalance;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryPurchaseAvatarItem(
|
||||
long playerId,
|
||||
string avatarItemDescriptor,
|
||||
int price,
|
||||
out int newBalance,
|
||||
out bool alreadyOwned)
|
||||
{
|
||||
newBalance = 0;
|
||||
alreadyOwned = false;
|
||||
|
||||
if (price < 0 || string.IsNullOrWhiteSpace(avatarItemDescriptor))
|
||||
return false;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Currencies ??= new List<PlayerCurrency>();
|
||||
player.Player.PlayerExtra.AvatarItems ??= new List<string>();
|
||||
|
||||
var currency = player.Player.PlayerExtra.Currencies
|
||||
.FirstOrDefault(value =>
|
||||
value.CurrencyType == CurrencyType.RecCenterTokens);
|
||||
|
||||
newBalance = currency?.Balance ?? 0;
|
||||
alreadyOwned = PlayerInventoryStore.OwnsAvatarItem(
|
||||
playerId,
|
||||
avatarItemDescriptor,
|
||||
legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems);
|
||||
|
||||
if (alreadyOwned)
|
||||
return true;
|
||||
if (currency == null || currency.Balance < price)
|
||||
return false;
|
||||
|
||||
currency.Balance -= price;
|
||||
player.Player.PlayerExtra.AvatarItems.Add(avatarItemDescriptor);
|
||||
PlayerInventoryStore.SetAvatarItemOwned(
|
||||
playerId,
|
||||
avatarItemDescriptor,
|
||||
avatarItemId: 0,
|
||||
friendlyName: null,
|
||||
owned: true,
|
||||
legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems);
|
||||
newBalance = currency.Balance;
|
||||
return Players.Update(player);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GrantAvatarItem(
|
||||
long playerId,
|
||||
string avatarItemDescriptor)
|
||||
{
|
||||
if (playerId <= 0 || string.IsNullOrWhiteSpace(avatarItemDescriptor))
|
||||
return false;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.AvatarItems ??= new List<string>();
|
||||
|
||||
if (player.Player.PlayerExtra.AvatarItems.Contains(
|
||||
avatarItemDescriptor,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
PlayerInventoryStore.SetAvatarItemOwned(
|
||||
playerId,
|
||||
avatarItemDescriptor,
|
||||
avatarItemId: 0,
|
||||
friendlyName: null,
|
||||
owned: true,
|
||||
legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems);
|
||||
return true;
|
||||
}
|
||||
|
||||
player.Player.PlayerExtra.AvatarItems.Add(avatarItemDescriptor);
|
||||
PlayerInventoryStore.SetAvatarItemOwned(
|
||||
playerId,
|
||||
avatarItemDescriptor,
|
||||
avatarItemId: 0,
|
||||
friendlyName: null,
|
||||
owned: true,
|
||||
legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems);
|
||||
return Players.Update(player);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsSubscribed(long subscriberPlayerId, long targetPlayerId)
|
||||
{
|
||||
return RelationshipDB.IsSubscribed(subscriberPlayerId, targetPlayerId);
|
||||
}
|
||||
|
||||
public static bool RemoveFriend(long sourcePlayerId, long targetPlayerId)
|
||||
{
|
||||
bool removed;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var source = Players.FindById(sourcePlayerId);
|
||||
var target = Players.FindById(targetPlayerId);
|
||||
|
||||
if (source?.Player == null || target?.Player == null)
|
||||
return false;
|
||||
|
||||
source.Player.Relationships ??= new List<PlayerRelationship>();
|
||||
target.Player.Relationships ??= new List<PlayerRelationship>();
|
||||
|
||||
source.Player.Relationships.RemoveAll(value =>
|
||||
value.PlayerID == targetPlayerId);
|
||||
target.Player.Relationships.RemoveAll(value =>
|
||||
value.PlayerID == sourcePlayerId);
|
||||
|
||||
Players.Update(source);
|
||||
Players.Update(target);
|
||||
removed = RelationshipDB.RemoveFriend(sourcePlayerId, targetPlayerId);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
public static PlayerCheerRecord? GivePlayerCheer(
|
||||
long giverPlayerId,
|
||||
long receiverPlayerId,
|
||||
CheerCategory category,
|
||||
out NotificationDB.ClientNotification? notification,
|
||||
bool anonymous = false)
|
||||
{
|
||||
notification = null;
|
||||
|
||||
if (giverPlayerId == receiverPlayerId ||
|
||||
!IsPublicCheerCategory(category))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PlayerCheerRecord? cheer;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var giver = Players.FindById(giverPlayerId);
|
||||
var receiver = Players.FindById(receiverPlayerId);
|
||||
|
||||
if (giver?.Player == null || receiver?.Player == null)
|
||||
return null;
|
||||
|
||||
giver.Player.Reputation ??= new Reputation();
|
||||
receiver.Player.ReceivedCheers ??= new List<PlayerCheerRecord>();
|
||||
receiver.Player.Reputation ??= new Reputation();
|
||||
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
RefreshCheerCreditsNoLock(
|
||||
giver.Player.Reputation,
|
||||
giverPlayerId,
|
||||
now);
|
||||
if (giver.Player.Reputation.CheerCredit <= 0)
|
||||
return null;
|
||||
|
||||
cheer = new PlayerCheerRecord
|
||||
{
|
||||
CheerId = Interlocked.Increment(ref CheerSequence),
|
||||
GiverPlayerId = giverPlayerId,
|
||||
ReceiverPlayerId = receiverPlayerId,
|
||||
CheerCategory = category,
|
||||
Anonymous = anonymous,
|
||||
Status = CheerStatus.Pending,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
notification = NotificationDB.CreatePlayerCheer(
|
||||
giverPlayerId,
|
||||
receiverPlayerId,
|
||||
(int)category,
|
||||
anonymous);
|
||||
|
||||
cheer.NotificationId = notification.Id;
|
||||
receiver.Player.ReceivedCheers.Add(cheer);
|
||||
|
||||
var reputation = receiver.Player.Reputation;
|
||||
reputation.AccountId = receiverPlayerId;
|
||||
reputation.IsCheerful = true;
|
||||
giver.Player.Reputation.AccountId = giverPlayerId;
|
||||
giver.Player.Reputation.CheerCredit--;
|
||||
|
||||
switch (category)
|
||||
{
|
||||
case CheerCategory.General:
|
||||
reputation.CheerGeneral++;
|
||||
break;
|
||||
case CheerCategory.Helpful:
|
||||
reputation.CheerHelpful++;
|
||||
break;
|
||||
case CheerCategory.Sportmanship:
|
||||
reputation.CheerSportsman++;
|
||||
break;
|
||||
case CheerCategory.GreatHost:
|
||||
reputation.CheerGreatHost++;
|
||||
break;
|
||||
case CheerCategory.Creative:
|
||||
reputation.CheerCreative++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (Players.Update(new[] { giver, receiver }) != 2)
|
||||
{
|
||||
NotificationDB.DeleteMessages(
|
||||
receiverPlayerId,
|
||||
new[] { notification.Id });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return cheer;
|
||||
}
|
||||
|
||||
private static bool RefreshCheerCreditsNoLock(
|
||||
Reputation reputation,
|
||||
long accountId,
|
||||
long nowUnixTime)
|
||||
{
|
||||
bool changed = false;
|
||||
|
||||
if (reputation.AccountId != accountId)
|
||||
{
|
||||
reputation.AccountId = accountId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (reputation.CheerCreditRefreshAtUnixTime <= 0)
|
||||
{
|
||||
|
||||
reputation.CheerCredit = CheerCreditLimit;
|
||||
reputation.CheerCreditRefreshAtUnixTime =
|
||||
nowUnixTime + CheerCreditRefreshSeconds;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (nowUnixTime >= reputation.CheerCreditRefreshAtUnixTime)
|
||||
{
|
||||
long elapsed =
|
||||
nowUnixTime - reputation.CheerCreditRefreshAtUnixTime;
|
||||
long completedIntervals =
|
||||
(elapsed / CheerCreditRefreshSeconds) + 1;
|
||||
|
||||
reputation.CheerCredit = CheerCreditLimit;
|
||||
reputation.CheerCreditRefreshAtUnixTime +=
|
||||
completedIntervals * CheerCreditRefreshSeconds;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
int clamped = Math.Clamp(
|
||||
reputation.CheerCredit,
|
||||
0,
|
||||
CheerCreditLimit);
|
||||
if (clamped != reputation.CheerCredit)
|
||||
{
|
||||
reputation.CheerCredit = clamped;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
public static int ResolvePlayerCheers(
|
||||
long receiverPlayerId,
|
||||
IEnumerable<long> cheerOrNotificationIds,
|
||||
CheerStatus status)
|
||||
{
|
||||
if (receiverPlayerId <= 0 ||
|
||||
status == CheerStatus.Pending)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
HashSet<long> ids = cheerOrNotificationIds
|
||||
.Where(value => value > 0)
|
||||
.ToHashSet();
|
||||
if (ids.Count == 0)
|
||||
return 0;
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var receiver = Players.FindById(receiverPlayerId);
|
||||
if (receiver?.Player == null)
|
||||
return 0;
|
||||
|
||||
receiver.Player.ReceivedCheers ??=
|
||||
new List<PlayerCheerRecord>();
|
||||
|
||||
List<PlayerCheerRecord> matching =
|
||||
receiver.Player.ReceivedCheers
|
||||
.Where(value =>
|
||||
value.Status == CheerStatus.Pending &&
|
||||
(ids.Contains(value.CheerId) ||
|
||||
ids.Contains(value.NotificationId)))
|
||||
.ToList();
|
||||
|
||||
if (matching.Count == 0)
|
||||
return 0;
|
||||
|
||||
DateTime handledAt = DateTime.UtcNow;
|
||||
foreach (PlayerCheerRecord cheer in matching)
|
||||
{
|
||||
cheer.Status = status;
|
||||
cheer.HandledAt = handledAt;
|
||||
}
|
||||
|
||||
return Players.Update(receiver)
|
||||
? matching.Count
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<PlayerCheerRecord> GetPendingPlayerCheers(
|
||||
long receiverPlayerId)
|
||||
{
|
||||
if (receiverPlayerId <= 0)
|
||||
return new List<PlayerCheerRecord>();
|
||||
|
||||
lock (SocialLock)
|
||||
{
|
||||
var receiver = Players.FindById(receiverPlayerId);
|
||||
return receiver?.Player?.ReceivedCheers?
|
||||
.Where(value => value.Status == CheerStatus.Pending)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.ToList() ?? new List<PlayerCheerRecord>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPublicCheerCategory(
|
||||
CheerCategory category) =>
|
||||
category is CheerCategory.General or
|
||||
CheerCategory.Helpful or
|
||||
CheerCategory.Sportmanship or
|
||||
CheerCategory.GreatHost or
|
||||
CheerCategory.Creative;
|
||||
|
||||
public static Heartbeat GetPlayerHeartbeat(
|
||||
long playerId,
|
||||
long? viewingPlayerId = null)
|
||||
{
|
||||
var player = Players.FindOne(
|
||||
x => x.PlayerId == playerId);
|
||||
|
||||
return CreateClientHeartbeat(
|
||||
player,
|
||||
playerId,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
viewingPlayerId);
|
||||
}
|
||||
|
||||
public static List<Heartbeat> GetPlayerHeartbeatsBulk(
|
||||
List<long> playerIds,
|
||||
long? viewingPlayerId = null)
|
||||
{
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var players = Players
|
||||
.Find(x => playerIds.Contains(x.PlayerId))
|
||||
.ToList();
|
||||
|
||||
return players
|
||||
.Select(p => CreateClientHeartbeat(
|
||||
p,
|
||||
p.PlayerId,
|
||||
now,
|
||||
viewingPlayerId))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static long[] GetActiveSameInstancePlayerIds(long subjectPlayerId)
|
||||
{
|
||||
if (subjectPlayerId <= 0)
|
||||
return Array.Empty<long>();
|
||||
|
||||
Heartbeat? subjectHeartbeat = Players.FindById(subjectPlayerId)?
|
||||
.Player?.PlayerExtra?.Heartbeat;
|
||||
long roomInstanceId =
|
||||
subjectHeartbeat?.roomInstance?.roomInstanceId ?? 0;
|
||||
|
||||
if (roomInstanceId <= 0)
|
||||
return Array.Empty<long>();
|
||||
|
||||
long activeAfter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() -
|
||||
PresenceFreshnessSeconds;
|
||||
|
||||
return Players.FindAll()
|
||||
.Where(account =>
|
||||
{
|
||||
if (account.PlayerId == subjectPlayerId)
|
||||
return false;
|
||||
|
||||
Heartbeat? heartbeat =
|
||||
account.Player?.PlayerExtra?.Heartbeat;
|
||||
|
||||
return heartbeat?.isOnline == true &&
|
||||
heartbeat.lastHeartbeatUnixTime >= activeAfter &&
|
||||
heartbeat.roomInstance?.roomInstanceId == roomInstanceId;
|
||||
})
|
||||
.Select(account => account.PlayerId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static Heartbeat CreateClientHeartbeat(
|
||||
FullPlayer? player,
|
||||
long playerId,
|
||||
long now,
|
||||
long? viewingPlayerId)
|
||||
{
|
||||
Heartbeat stored =
|
||||
player?.Player?.PlayerExtra?.Heartbeat ??
|
||||
new Heartbeat();
|
||||
|
||||
bool isFresh =
|
||||
stored.lastHeartbeatUnixTime > 0 &&
|
||||
stored.lastHeartbeatUnixTime >=
|
||||
now - PresenceFreshnessSeconds;
|
||||
bool isOnline = stored.isOnline && isFresh;
|
||||
|
||||
RoomInstance? roomInstance = isOnline &&
|
||||
stored.roomInstance != null
|
||||
? CloneRoomInstance(stored.roomInstance)
|
||||
: null;
|
||||
|
||||
bool viewerSharesRestrictedInstance = false;
|
||||
if (roomInstance != null &&
|
||||
viewingPlayerId.HasValue &&
|
||||
viewingPlayerId.Value != playerId)
|
||||
{
|
||||
Heartbeat? viewerHeartbeat = Players
|
||||
.FindById(viewingPlayerId.Value)?
|
||||
.Player?.PlayerExtra?.Heartbeat;
|
||||
viewerSharesRestrictedInstance =
|
||||
viewerHeartbeat?.isOnline == true &&
|
||||
viewerHeartbeat.roomInstance?.roomInstanceId ==
|
||||
roomInstance.roomInstanceId;
|
||||
}
|
||||
|
||||
if (roomInstance != null &&
|
||||
viewingPlayerId.HasValue &&
|
||||
viewingPlayerId.Value != playerId &&
|
||||
Sessions.IsRestrictedInstance(roomInstance) &&
|
||||
!viewerSharesRestrictedInstance &&
|
||||
!NotificationDB.HasActiveRoomInvite(
|
||||
viewingPlayerId.Value,
|
||||
playerId,
|
||||
roomInstance.roomInstanceId))
|
||||
{
|
||||
roomInstance = null;
|
||||
}
|
||||
|
||||
if (roomInstance != null &&
|
||||
roomInstance.roomInstanceType != RoomInstanceType.Dormroom &&
|
||||
roomInstance.roomId > 0)
|
||||
{
|
||||
var room = RoomDB.GetRoom(roomInstance.roomId);
|
||||
if (room != null && !string.IsNullOrWhiteSpace(room.Name))
|
||||
roomInstance.Name = $"^{room.Name.Trim().TrimStart('^')}";
|
||||
}
|
||||
|
||||
return new Heartbeat
|
||||
{
|
||||
appVersion = stored.appVersion,
|
||||
deviceClass = stored.deviceClass,
|
||||
errorCode = stored.errorCode,
|
||||
isOnline = isOnline,
|
||||
playerId = playerId,
|
||||
roomInstance = roomInstance,
|
||||
statusVisibility = isOnline
|
||||
? stored.statusVisibility
|
||||
: StatusVisibility.Offline,
|
||||
vrMovementMode = stored.vrMovementMode,
|
||||
lastHeartbeatUnixTime = stored.lastHeartbeatUnixTime
|
||||
};
|
||||
}
|
||||
|
||||
public static void RecordRoomVisit(
|
||||
long playerId,
|
||||
long roomId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return;
|
||||
|
||||
player.Player.VisitedRooms ??= new List<long>();
|
||||
|
||||
if (!player.Player.VisitedRooms.Contains(roomId))
|
||||
player.Player.VisitedRooms.Add(roomId);
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
|
||||
player.Player.PlayerExtra.RoomVisits ??=
|
||||
new List<RoomVisit>();
|
||||
|
||||
var existingVisit =
|
||||
player.Player.PlayerExtra.RoomVisits
|
||||
.FirstOrDefault(v => v.RoomId == roomId);
|
||||
|
||||
bool isNewVisitor = existingVisit == null;
|
||||
|
||||
if (existingVisit != null)
|
||||
{
|
||||
existingVisit.VisitedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
player.Player.PlayerExtra.RoomVisits.Add(
|
||||
new RoomVisit
|
||||
{
|
||||
RoomId = roomId,
|
||||
VisitedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
var room = RoomDB.GetRoom(roomId);
|
||||
|
||||
if (room != null)
|
||||
{
|
||||
room.Stats ??= new RoomDBClasses.Stats();
|
||||
|
||||
room.Stats.VisitCount++;
|
||||
|
||||
if (isNewVisitor)
|
||||
room.Stats.VisitorCount++;
|
||||
|
||||
RoomDB.Rooms.Update(room);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ToggleCheer(
|
||||
long playerId,
|
||||
long roomId,
|
||||
out bool isNowCheered)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
isNowCheered = false;
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.CheeredRooms ??= new List<long>();
|
||||
|
||||
bool wasCheered =
|
||||
player.Player.CheeredRooms.Contains(roomId);
|
||||
|
||||
if (wasCheered)
|
||||
{
|
||||
player.Player.CheeredRooms.Remove(roomId);
|
||||
}
|
||||
else
|
||||
{
|
||||
player.Player.CheeredRooms.Add(roomId);
|
||||
}
|
||||
|
||||
isNowCheered = !wasCheered;
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
var room = RoomDB.GetRoom(roomId);
|
||||
|
||||
if (room != null)
|
||||
{
|
||||
room.Stats ??= new RoomDBClasses.Stats();
|
||||
|
||||
room.Stats.CheerCount = Math.Max(
|
||||
0,
|
||||
room.Stats.CheerCount +
|
||||
(wasCheered ? -1 : 1));
|
||||
|
||||
RoomDB.Rooms.Update(room);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ToggleFavorite(
|
||||
long playerId,
|
||||
long roomId,
|
||||
out bool isNowFavorited)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
isNowFavorited = false;
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.FavoritedRooms ??= new List<long>();
|
||||
|
||||
bool wasFavorited =
|
||||
player.Player.FavoritedRooms.Contains(roomId);
|
||||
|
||||
if (wasFavorited)
|
||||
{
|
||||
player.Player.FavoritedRooms.Remove(roomId);
|
||||
}
|
||||
else
|
||||
{
|
||||
player.Player.FavoritedRooms.Add(roomId);
|
||||
}
|
||||
|
||||
isNowFavorited = !wasFavorited;
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
var room = RoomDB.GetRoom(roomId);
|
||||
|
||||
if (room != null)
|
||||
{
|
||||
room.Stats ??= new RoomDBClasses.Stats();
|
||||
|
||||
room.Stats.FavoriteCount = Math.Max(
|
||||
0,
|
||||
room.Stats.FavoriteCount +
|
||||
(wasFavorited ? -1 : 1));
|
||||
|
||||
RoomDB.Rooms.Update(room);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static (
|
||||
List<PlayerDTOBase> Results,
|
||||
int Total)
|
||||
SearchAccounts(
|
||||
string query,
|
||||
int skip,
|
||||
int take)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return (
|
||||
new List<PlayerDTOBase>(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
var q = query
|
||||
.Trim()
|
||||
.ToLowerInvariant();
|
||||
|
||||
var matches = Players
|
||||
.FindAll()
|
||||
.Where(p =>
|
||||
(p.Player?.Username?
|
||||
.ToLowerInvariant()
|
||||
.Contains(q) ?? false)
|
||||
||
|
||||
(p.Player?.DisplayName?
|
||||
.ToLowerInvariant()
|
||||
.Contains(q) ?? false))
|
||||
.OrderBy(p => p.Player?.Username)
|
||||
.ToList();
|
||||
|
||||
int total = matches.Count;
|
||||
|
||||
var page = matches
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.Select(p => MapToDTO(p, false))
|
||||
.ToList();
|
||||
|
||||
return (page, total);
|
||||
}
|
||||
|
||||
public static Heartbeat? UpdatePlayerHeartbeat(
|
||||
long playerId,
|
||||
RoomInstance? roomInstance,
|
||||
bool online = true,
|
||||
Platforms platform = Platforms.All,
|
||||
DeviceClasses deviceClasses = DeviceClasses.Unknown)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
player.Player ??= new Player();
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
|
||||
player.Player.PlayerExtra.Heartbeat ??=
|
||||
new Heartbeat();
|
||||
|
||||
var hb = player.Player.PlayerExtra.Heartbeat;
|
||||
long? oldRoomInstanceId = hb.roomInstance?.roomInstanceId;
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
hb.playerId = playerId;
|
||||
hb.roomInstance = online ? roomInstance : null;
|
||||
hb.errorCode = MatchmakingErrorCode.Success;
|
||||
hb.isOnline = online;
|
||||
hb.lastHeartbeatUnixTime = now;
|
||||
|
||||
long newRoomInstanceId =
|
||||
online ? roomInstance?.roomInstanceId ?? 0 : 0;
|
||||
if (player.Player.PlayerExtra.LevelingRoomInstanceId !=
|
||||
newRoomInstanceId)
|
||||
{
|
||||
ResetRoomLevelTimer(
|
||||
player.Player.PlayerExtra,
|
||||
newRoomInstanceId,
|
||||
newRoomInstanceId > 0 ? now : 0);
|
||||
}
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
if (oldRoomInstanceId != hb.roomInstance?.roomInstanceId)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM MEMBERSHIP] player={playerId} " +
|
||||
$"oldInstance={oldRoomInstanceId?.ToString() ?? "none"} " +
|
||||
$"newInstance={hb.roomInstance?.roomInstanceId.ToString() ?? "none"} " +
|
||||
"broadcast=false");
|
||||
}
|
||||
|
||||
return hb;
|
||||
}
|
||||
public static Heartbeat? LeaveCurrentRoom(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
player.Player ??= new Player();
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Heartbeat ??= new Heartbeat();
|
||||
|
||||
var heartbeat = player.Player.PlayerExtra.Heartbeat;
|
||||
long? oldRoomInstanceId = heartbeat.roomInstance?.roomInstanceId;
|
||||
|
||||
heartbeat.playerId = playerId;
|
||||
|
||||
heartbeat.isOnline = true;
|
||||
heartbeat.roomInstance = null;
|
||||
heartbeat.lastHeartbeatUnixTime =
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
ResetRoomLevelTimer(player.Player.PlayerExtra, 0, 0);
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ROOM LEAVE] player={playerId} " +
|
||||
$"instance={oldRoomInstanceId?.ToString() ?? "none"} " +
|
||||
"isolated=true presencePush=controller-awaited");
|
||||
|
||||
return heartbeat;
|
||||
}
|
||||
|
||||
public static Heartbeat? TouchPlayerHeartbeat(long playerId)
|
||||
{
|
||||
int? oldProgressionLevel = null;
|
||||
int? newProgressionLevel = null;
|
||||
int progressionXp = 0;
|
||||
Heartbeat? result;
|
||||
|
||||
lock (ProgressionLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
bool oldOnline =
|
||||
player.Player?.PlayerExtra?.Heartbeat?.isOnline ?? false;
|
||||
MatchmakingErrorCode? oldErrorCode =
|
||||
player.Player?.PlayerExtra?.Heartbeat?.errorCode;
|
||||
|
||||
if (ApplyBanStateToHeartbeat(
|
||||
player,
|
||||
playerId,
|
||||
out var heartbeat))
|
||||
{
|
||||
if (player.Player?.PlayerExtra != null)
|
||||
ResetRoomLevelTimer(
|
||||
player.Player.PlayerExtra,
|
||||
0,
|
||||
0);
|
||||
|
||||
Players.Update(player);
|
||||
if (oldOnline != heartbeat.isOnline ||
|
||||
oldErrorCode != heartbeat.errorCode)
|
||||
{
|
||||
_ = NotiController.NotifyPlayerPresenceUpdatedAsync(
|
||||
playerId);
|
||||
}
|
||||
|
||||
return heartbeat;
|
||||
}
|
||||
|
||||
heartbeat.playerId = playerId;
|
||||
heartbeat.isOnline = true;
|
||||
heartbeat.errorCode =
|
||||
MatchmakingErrorCode.Success;
|
||||
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
heartbeat.lastHeartbeatUnixTime = now;
|
||||
|
||||
if (player.Player != null &&
|
||||
ApplyRoomTimeLeveling(
|
||||
player.Player,
|
||||
now,
|
||||
out int awardedFromLevel,
|
||||
out int awardedToLevel))
|
||||
{
|
||||
oldProgressionLevel = awardedFromLevel;
|
||||
newProgressionLevel = awardedToLevel;
|
||||
progressionXp = player.Player.XP;
|
||||
}
|
||||
|
||||
Players.Update(player);
|
||||
if (oldOnline != heartbeat.isOnline ||
|
||||
oldErrorCode != heartbeat.errorCode)
|
||||
{
|
||||
_ = NotiController.NotifyPlayerPresenceUpdatedAsync(
|
||||
playerId);
|
||||
}
|
||||
|
||||
result = heartbeat;
|
||||
}
|
||||
|
||||
if (oldProgressionLevel.HasValue &&
|
||||
newProgressionLevel.HasValue)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM LEVEL] player={playerId} " +
|
||||
$"level={oldProgressionLevel.Value}->{newProgressionLevel.Value} " +
|
||||
$"interval={RoomLevelSeconds}s");
|
||||
|
||||
GrantLevelUpBox(
|
||||
playerId,
|
||||
oldProgressionLevel.Value,
|
||||
newProgressionLevel.Value);
|
||||
|
||||
_ = NotiController.NotifyProgressionAsync(
|
||||
playerId,
|
||||
oldProgressionLevel.Value,
|
||||
newProgressionLevel.Value,
|
||||
progressionXp);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Heartbeat? ResumePlayerHeartbeat(long playerId)
|
||||
{
|
||||
lock (ProgressionLock)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
player.Player ??= new Player();
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
Heartbeat heartbeat = player.Player.PlayerExtra.Heartbeat ??=
|
||||
new Heartbeat();
|
||||
|
||||
if (ApplyBanStateToHeartbeat(
|
||||
player,
|
||||
playerId,
|
||||
out heartbeat))
|
||||
{
|
||||
ResetRoomLevelTimer(
|
||||
player.Player.PlayerExtra,
|
||||
0,
|
||||
0);
|
||||
Players.Update(player);
|
||||
return heartbeat;
|
||||
}
|
||||
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
bool existingPresenceIsFresh =
|
||||
heartbeat.isOnline &&
|
||||
heartbeat.lastHeartbeatUnixTime > 0 &&
|
||||
heartbeat.lastHeartbeatUnixTime >=
|
||||
now - PresenceFreshnessSeconds;
|
||||
|
||||
if (!existingPresenceIsFresh)
|
||||
{
|
||||
heartbeat.roomInstance = null;
|
||||
ResetRoomLevelTimer(
|
||||
player.Player.PlayerExtra,
|
||||
0,
|
||||
0);
|
||||
}
|
||||
|
||||
heartbeat.playerId = playerId;
|
||||
heartbeat.isOnline = true;
|
||||
heartbeat.errorCode = MatchmakingErrorCode.Success;
|
||||
heartbeat.lastHeartbeatUnixTime = now;
|
||||
Players.Update(player);
|
||||
|
||||
return heartbeat;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetPlayerOffline(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
var heartbeat = player?.Player?.PlayerExtra?.Heartbeat;
|
||||
|
||||
if (player == null || heartbeat == null)
|
||||
return;
|
||||
|
||||
long? preservedRoomInstanceId = heartbeat.roomInstance?.roomInstanceId;
|
||||
|
||||
heartbeat.isOnline = false;
|
||||
heartbeat.lastHeartbeatUnixTime =
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
if (player.Player?.PlayerExtra != null)
|
||||
ResetRoomLevelTimer(player.Player.PlayerExtra, 0, 0);
|
||||
Players.Update(player);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[PLAYER OFFLINE] player={playerId} " +
|
||||
$"preservedInstance={preservedRoomInstanceId?.ToString() ?? "none"} " +
|
||||
"broadcast=controller-awaited");
|
||||
}
|
||||
|
||||
private static bool ApplyRoomTimeLeveling(
|
||||
Player player,
|
||||
long now,
|
||||
out int oldLevel,
|
||||
out int newLevel)
|
||||
{
|
||||
oldLevel = player.Level;
|
||||
newLevel = player.Level;
|
||||
|
||||
player.PlayerExtra ??= new PlayerExtra();
|
||||
Heartbeat heartbeat = player.PlayerExtra.Heartbeat ??=
|
||||
new Heartbeat();
|
||||
long roomInstanceId =
|
||||
heartbeat.isOnline
|
||||
? heartbeat.roomInstance?.roomInstanceId ?? 0
|
||||
: 0;
|
||||
|
||||
if (roomInstanceId <= 0 || player.Level >= MaximumLevel)
|
||||
{
|
||||
ResetRoomLevelTimer(
|
||||
player.PlayerExtra,
|
||||
roomInstanceId,
|
||||
roomInstanceId > 0 ? now : 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (player.PlayerExtra.LevelingRoomInstanceId != roomInstanceId)
|
||||
{
|
||||
ResetRoomLevelTimer(
|
||||
player.PlayerExtra,
|
||||
roomInstanceId,
|
||||
now);
|
||||
return false;
|
||||
}
|
||||
|
||||
long previous =
|
||||
player.PlayerExtra.LevelingLastHeartbeatUnixTime;
|
||||
player.PlayerExtra.LevelingLastHeartbeatUnixTime = now;
|
||||
|
||||
if (previous <= 0 || now <= previous)
|
||||
return false;
|
||||
|
||||
long elapsed = now - previous;
|
||||
if (elapsed > MaximumCreditableHeartbeatGapSeconds)
|
||||
{
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
player.PlayerExtra.LevelingRoomActiveSeconds = Math.Min(
|
||||
RoomLevelSeconds,
|
||||
player.PlayerExtra.LevelingRoomActiveSeconds +
|
||||
checked((int)elapsed));
|
||||
|
||||
if (player.PlayerExtra.LevelingRoomActiveSeconds <
|
||||
RoomLevelSeconds)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
oldLevel = player.Level;
|
||||
player.Level = Math.Min(MaximumLevel, player.Level + 1);
|
||||
newLevel = player.Level;
|
||||
player.PlayerExtra.LevelingRoomActiveSeconds = 0;
|
||||
|
||||
return newLevel > oldLevel;
|
||||
}
|
||||
|
||||
private static void GrantLevelUpBox(
|
||||
long playerId,
|
||||
int oldLevel,
|
||||
int newLevel)
|
||||
{
|
||||
if (playerId <= 0 || newLevel <= oldLevel)
|
||||
return;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
if (player?.Player == null)
|
||||
return;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Currencies ??= new List<PlayerCurrency>();
|
||||
|
||||
for (int level = oldLevel + 1; level <= newLevel; level++)
|
||||
{
|
||||
int rarity = GetLevelBoxRarity(level);
|
||||
|
||||
APIController.StorefrontAdminItem? boxItem =
|
||||
APIController.GetWebsiteStorefrontItems()
|
||||
.Where(item =>
|
||||
item.Rarity == rarity &&
|
||||
!string.IsNullOrWhiteSpace(item.ConsumableItemDesc))
|
||||
.OrderBy(_ => LevelBoxRng.Next())
|
||||
.FirstOrDefault();
|
||||
|
||||
if (boxItem != null)
|
||||
{
|
||||
PlayerInventoryStore.AddConsumable(
|
||||
playerId,
|
||||
boxItem.ConsumableItemDesc,
|
||||
consumableItemId: 0,
|
||||
friendlyName: boxItem.FriendlyName,
|
||||
amount: 1);
|
||||
}
|
||||
|
||||
APIController.StorefrontAdminItem? avatarBoxItem =
|
||||
APIController.GetWebsiteStorefrontItems()
|
||||
.Where(item =>
|
||||
item.Rarity == rarity &&
|
||||
!string.IsNullOrWhiteSpace(item.AvatarItemDesc))
|
||||
.OrderBy(_ => LevelBoxRng.Next())
|
||||
.FirstOrDefault() ??
|
||||
APIController.GetWebsiteStorefrontItems()
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.AvatarItemDesc))
|
||||
.OrderBy(_ => LevelBoxRng.Next())
|
||||
.FirstOrDefault();
|
||||
|
||||
if (avatarBoxItem != null)
|
||||
{
|
||||
PlayerInventoryStore.SetAvatarItemOwned(
|
||||
playerId,
|
||||
avatarBoxItem.AvatarItemDesc,
|
||||
avatarBoxItem.AvatarItemId,
|
||||
avatarBoxItem.FriendlyName,
|
||||
owned: true);
|
||||
}
|
||||
|
||||
PlayerCurrency? currency =
|
||||
player.Player.PlayerExtra.Currencies.FirstOrDefault(
|
||||
value => value.CurrencyType == CurrencyType.RecCenterTokens);
|
||||
if (currency == null)
|
||||
{
|
||||
currency = new PlayerCurrency
|
||||
{
|
||||
CurrencyType = CurrencyType.RecCenterTokens,
|
||||
BalanceType = BalanceType.NonPurchasedDefault
|
||||
};
|
||||
player.Player.PlayerExtra.Currencies.Add(currency);
|
||||
}
|
||||
|
||||
long adjustedBalance = (long)currency.Balance + 1000;
|
||||
currency.Balance = (int)Math.Clamp(
|
||||
adjustedBalance,
|
||||
(long)int.MinValue,
|
||||
(long)int.MaxValue);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[LEVEL BOX] player={playerId} level={level} " +
|
||||
$"rarity={rarity} box={boxItem?.FriendlyName ?? "none"} " +
|
||||
$"avatarItem={avatarBoxItem?.FriendlyName ?? "none"} " +
|
||||
"tokens=1000");
|
||||
}
|
||||
|
||||
Players.Update(player);
|
||||
}
|
||||
|
||||
private static void ResetRoomLevelTimer(
|
||||
PlayerExtra playerExtra,
|
||||
long roomInstanceId,
|
||||
long lastHeartbeatUnixTime)
|
||||
{
|
||||
playerExtra.LevelingRoomInstanceId = roomInstanceId;
|
||||
playerExtra.LevelingRoomActiveSeconds = 0;
|
||||
playerExtra.LevelingLastHeartbeatUnixTime =
|
||||
lastHeartbeatUnixTime;
|
||||
}
|
||||
|
||||
public static void BanPlayer(
|
||||
long playerId,
|
||||
int durationSeconds,
|
||||
string reason,
|
||||
ulong bannedByPlayerId = 0)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
|
||||
string appealCode = GenerateAppealCode();
|
||||
string reasonWithAppeal =
|
||||
$"{reason}\n\nAppeal this ban: /recnet/banappeal?code={appealCode}";
|
||||
|
||||
player.Player.PlayerExtra.ModerationBlockDetails =
|
||||
new ModerationBlockDetails
|
||||
{
|
||||
ReportCategory =
|
||||
ReportCategory.CoC_Discrimination,
|
||||
|
||||
Duration = durationSeconds,
|
||||
IsBan = true,
|
||||
Message = reasonWithAppeal,
|
||||
|
||||
ModerationSetUnixTime =
|
||||
DateTimeOffset.UtcNow
|
||||
.ToUnixTimeSeconds(),
|
||||
|
||||
BannedByPlayerId = bannedByPlayerId,
|
||||
AppealCode = appealCode
|
||||
};
|
||||
|
||||
ApplyBanStateToHeartbeat(
|
||||
player,
|
||||
playerId,
|
||||
out _);
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
NotiController.ForceDisconnectPlayer(playerId);
|
||||
}
|
||||
|
||||
private const string AppealCodeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
private static string GenerateAppealCode()
|
||||
{
|
||||
Span<char> buffer = stackalloc char[10];
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
buffer[i] = AppealCodeChars[System.Random.Shared.Next(AppealCodeChars.Length)];
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
public static bool UnbanPlayer(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?
|
||||
.Player?
|
||||
.PlayerExtra?
|
||||
.ModerationBlockDetails == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
player.Player.PlayerExtra.ModerationBlockDetails =
|
||||
new ModerationBlockDetails();
|
||||
|
||||
Players.Update(player);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool UpdateEquipment(
|
||||
long playerId,
|
||||
List<EquipmentItem> equipment)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.PlayerExtra ??= new PlayerExtra();
|
||||
player.Player.PlayerExtra.Equipment = equipment;
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static string? GetEmail(long playerId)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
return player?.Player?.Email;
|
||||
}
|
||||
|
||||
public static bool UpdateDisplayEmoji(
|
||||
long playerId,
|
||||
string displayEmoji)
|
||||
{
|
||||
if (playerId <= 0)
|
||||
return false;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.DisplayEmoji = displayEmoji ?? "";
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool UpdateEmail(
|
||||
long playerId,
|
||||
string email)
|
||||
{
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
if (player?.Player == null)
|
||||
return false;
|
||||
|
||||
player.Player.Email = email;
|
||||
|
||||
return Players.Update(player);
|
||||
}
|
||||
|
||||
public static bool IsPlayerBanned(
|
||||
long playerId,
|
||||
out ModerationBlockDetails? details)
|
||||
{
|
||||
details = null;
|
||||
|
||||
var player = Players.FindById(playerId);
|
||||
|
||||
var mod = player?
|
||||
.Player?
|
||||
.PlayerExtra?
|
||||
.ModerationBlockDetails;
|
||||
|
||||
if (mod == null || mod.IsBan != true)
|
||||
return false;
|
||||
|
||||
long now = DateTimeOffset.UtcNow
|
||||
.ToUnixTimeSeconds();
|
||||
|
||||
bool permanentBan = mod.Duration <= 0;
|
||||
|
||||
bool stillBanned =
|
||||
permanentBan ||
|
||||
(mod.ModerationSetUnixTime + mod.Duration) > now;
|
||||
|
||||
details = stillBanned ? mod : null;
|
||||
|
||||
return stillBanned;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class PlayerEventDB
|
||||
{
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "PlayerEvents.db"));
|
||||
|
||||
private static readonly ILiteCollection<PlayerEvent> Events =
|
||||
Database.GetCollection<PlayerEvent>("Events");
|
||||
private static readonly ILiteCollection<PlayerEventResponse> Responses =
|
||||
Database.GetCollection<PlayerEventResponse>("Responses");
|
||||
|
||||
private static readonly object Sync = new();
|
||||
|
||||
static PlayerEventDB()
|
||||
{
|
||||
LiteDbMaintenance.StartPeriodicCheckpoint("PlayerEvents.db", Database);
|
||||
Events.EnsureIndex(value => value.CreatorAccountId);
|
||||
Events.EnsureIndex(value => value.ClubId);
|
||||
Events.EnsureIndex(value => value.StartsAt);
|
||||
Responses.EnsureIndex(value => value.EventId);
|
||||
Responses.EnsureIndex(value => value.AccountId);
|
||||
}
|
||||
|
||||
public sealed class PlayerEvent
|
||||
{
|
||||
[BsonId]
|
||||
public long EventId { get; set; }
|
||||
public long CreatorAccountId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
public DateTime StartsAt { get; set; }
|
||||
public DateTime? EndsAt { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
public long? ClubId { get; set; }
|
||||
|
||||
public string Accessibility { get; set; } = "Public";
|
||||
public bool MultiInstance { get; set; }
|
||||
public List<string> Tags { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerEventResponse
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long EventId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
|
||||
public string ResponseType { get; set; } = "Going";
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static string ResponseId(long eventId, long accountId) => $"{eventId}:{accountId}";
|
||||
|
||||
public static long GetNextEventId()
|
||||
{
|
||||
lock (Sync)
|
||||
return Events.Count() == 0 ? 1 : Events.Max(value => value.EventId) + 1;
|
||||
}
|
||||
|
||||
public static PlayerEvent Create(PlayerEvent evt)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
evt.EventId = GetNextEventId();
|
||||
evt.CreatedAt = DateTime.UtcNow;
|
||||
evt.UpdatedAt = evt.CreatedAt;
|
||||
Events.Insert(evt);
|
||||
return evt;
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerEvent? Get(long eventId) => Events.FindById(eventId);
|
||||
|
||||
public static List<PlayerEvent> GetByIds(IEnumerable<long> ids)
|
||||
{
|
||||
var idSet = ids.ToHashSet();
|
||||
return Events.Find(value => idSet.Contains(value.EventId)).ToList();
|
||||
}
|
||||
|
||||
public static List<PlayerEvent> GetByCreator(long accountId) =>
|
||||
Events.Find(value => value.CreatorAccountId == accountId)
|
||||
.OrderByDescending(value => value.StartsAt)
|
||||
.ToList();
|
||||
|
||||
public static List<PlayerEvent> GetByClub(long clubId) =>
|
||||
Events.Find(value => value.ClubId == clubId)
|
||||
.OrderByDescending(value => value.StartsAt)
|
||||
.ToList();
|
||||
|
||||
public static bool Update(PlayerEvent evt)
|
||||
{
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
return Events.Update(evt);
|
||||
}
|
||||
|
||||
public static bool Delete(long eventId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
Responses.DeleteMany(value => value.EventId == eventId);
|
||||
return Events.Delete(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerEventResponse SetResponse(long eventId, long accountId, string responseType)
|
||||
{
|
||||
var response = new PlayerEventResponse
|
||||
{
|
||||
Id = ResponseId(eventId, accountId),
|
||||
EventId = eventId,
|
||||
AccountId = accountId,
|
||||
ResponseType = responseType,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
Responses.Upsert(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
public static bool DeleteResponse(long eventId, long accountId) =>
|
||||
Responses.Delete(ResponseId(eventId, accountId));
|
||||
|
||||
public static List<PlayerEventResponse> GetResponses(long eventId) =>
|
||||
Responses.Find(value => value.EventId == eventId).ToList();
|
||||
|
||||
public static PlayerEventResponse? GetResponse(long eventId, long accountId) =>
|
||||
Responses.FindById(ResponseId(eventId, accountId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
using LiteDB;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Mocha2023.Classes;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
public static class RecNetDB
|
||||
{
|
||||
private const int ChallengeDefinitionVersion = 2;
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "RecNet.db"));
|
||||
|
||||
public static readonly ILiteCollection<PhotoCheer> PhotoCheers =
|
||||
Database.GetCollection<PhotoCheer>("PhotoCheers");
|
||||
|
||||
public static readonly ILiteCollection<PhotoComment> PhotoComments =
|
||||
Database.GetCollection<PhotoComment>("PhotoComments");
|
||||
|
||||
public static readonly ILiteCollection<SavedImage> SavedImages =
|
||||
Database.GetCollection<SavedImage>("SavedImages");
|
||||
|
||||
public static readonly ILiteCollection<ModerationLock> ModerationLocks =
|
||||
Database.GetCollection<ModerationLock>("ModerationLocks");
|
||||
|
||||
public static readonly ILiteCollection<Event> Events =
|
||||
Database.GetCollection<Event>("Events");
|
||||
|
||||
public static readonly ILiteCollection<Announcement> Announcements =
|
||||
Database.GetCollection<Announcement>("Announcements");
|
||||
|
||||
public static readonly ILiteCollection<ChallengeProgress> ChallengeProgresses =
|
||||
Database.GetCollection<ChallengeProgress>("ChallengeProgresses");
|
||||
|
||||
public static readonly ILiteCollection<ChallengeCompletionEvent> ChallengeCompletionEvents =
|
||||
Database.GetCollection<ChallengeCompletionEvent>("ChallengeCompletionEvents");
|
||||
|
||||
public static readonly ILiteCollection<FeatureSetting> FeatureSettings =
|
||||
Database.GetCollection<FeatureSetting>("FeatureSettings");
|
||||
|
||||
public static readonly ILiteCollection<IpBan> IpBans =
|
||||
Database.GetCollection<IpBan>("IpBans");
|
||||
|
||||
private static readonly object ChallengeProgressLock = new();
|
||||
private static readonly object FeatureSettingsLock = new();
|
||||
private const string RecNetSignupSettingKey = "RecNet.SignupEnabled";
|
||||
private const string AccountCreationSettingKey = "Accounts.CreationEnabled";
|
||||
private const string VpnBlockingSettingKey = "Security.VpnBlockingEnabled";
|
||||
|
||||
static RecNetDB()
|
||||
{
|
||||
SavedImages.EnsureIndex(image => image.AccountId);
|
||||
SavedImages.EnsureIndex(image => image.ImageId);
|
||||
SavedImages.EnsureIndex(image => image.ContentHash);
|
||||
SavedImages.EnsureIndex(image => image.LookupName);
|
||||
SavedImages.EnsureIndex(image => image.CreatedAt);
|
||||
PhotoCheers.EnsureIndex(cheer => cheer.PhotoPath);
|
||||
PhotoCheers.EnsureIndex(cheer => cheer.AccountId);
|
||||
IpBans.EnsureIndex(ban => ban.Network, unique: true);
|
||||
IpBans.EnsureIndex(ban => ban.CreatedAt);
|
||||
|
||||
foreach (PhotoCheer cheer in PhotoCheers.FindAll().ToList())
|
||||
{
|
||||
string normalizedPath = NormalizePhotoPath(cheer.PhotoPath);
|
||||
if (string.IsNullOrWhiteSpace(normalizedPath))
|
||||
{
|
||||
PhotoCheers.Delete(cheer.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
string normalizedId = PhotoCheerId(normalizedPath, cheer.AccountId);
|
||||
if (string.Equals(cheer.Id, normalizedId, StringComparison.Ordinal) &&
|
||||
string.Equals(cheer.PhotoPath, normalizedPath, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PhotoCheers.Delete(cheer.Id);
|
||||
cheer.Id = normalizedId;
|
||||
cheer.PhotoPath = normalizedPath;
|
||||
PhotoCheers.Upsert(cheer);
|
||||
}
|
||||
|
||||
foreach (var image in SavedImages.FindAll()
|
||||
.Where(image => string.IsNullOrWhiteSpace(image.LookupName)))
|
||||
{
|
||||
string lookupName = Path.GetFileName(image.PhotoPath ?? string.Empty)
|
||||
?.ToLowerInvariant() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(lookupName))
|
||||
continue;
|
||||
|
||||
image.LookupName = lookupName;
|
||||
SavedImages.Update(image);
|
||||
}
|
||||
}
|
||||
|
||||
public class PhotoCheer
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string PhotoPath { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public static string NormalizePhotoPath(string? photoPath) =>
|
||||
(photoPath ?? string.Empty)
|
||||
.Replace('\\', '/')
|
||||
.Trim()
|
||||
.TrimStart('/')
|
||||
.ToLowerInvariant();
|
||||
|
||||
public static string PhotoCheerId(string? photoPath, long accountId) =>
|
||||
$"{accountId}:{NormalizePhotoPath(photoPath)}";
|
||||
|
||||
public static bool SetPhotoCheer(
|
||||
string? photoPath,
|
||||
long accountId,
|
||||
bool cheered)
|
||||
{
|
||||
string normalizedPath = NormalizePhotoPath(photoPath);
|
||||
if (accountId <= 0 || string.IsNullOrWhiteSpace(normalizedPath))
|
||||
return false;
|
||||
|
||||
string id = PhotoCheerId(normalizedPath, accountId);
|
||||
if (!cheered)
|
||||
{
|
||||
PhotoCheers.Delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
PhotoCheers.Upsert(new PhotoCheer
|
||||
{
|
||||
Id = id,
|
||||
PhotoPath = normalizedPath,
|
||||
AccountId = accountId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool HasPhotoCheer(string? photoPath, long accountId)
|
||||
{
|
||||
if (accountId <= 0)
|
||||
return false;
|
||||
return PhotoCheers.Exists(value =>
|
||||
value.Id == PhotoCheerId(photoPath, accountId));
|
||||
}
|
||||
|
||||
public static int CountPhotoCheers(string? photoPath)
|
||||
{
|
||||
string normalizedPath = NormalizePhotoPath(photoPath);
|
||||
return string.IsNullOrWhiteSpace(normalizedPath)
|
||||
? 0
|
||||
: PhotoCheers.Count(value => value.PhotoPath == normalizedPath);
|
||||
}
|
||||
|
||||
public class PhotoComment
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string PhotoPath { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class SavedImage
|
||||
{
|
||||
[BsonId]
|
||||
public string PhotoPath { get; set; } = string.Empty;
|
||||
public long ImageId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
public long? PlayerEventId { get; set; }
|
||||
public int SavedImageType { get; set; }
|
||||
public int Accessibility { get; set; }
|
||||
public List<int> TaggedPlayerIds { get; set; } = new();
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
public string LookupName { get; set; } = string.Empty;
|
||||
public long ByteLength { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class ModerationLock
|
||||
{
|
||||
[BsonId]
|
||||
public long AccountId { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public long IssuedByAccountId { get; set; }
|
||||
public DateTime IssuedAt { get; set; }
|
||||
public string BanGroupId { get; set; } = string.Empty;
|
||||
public long? RelatedAccountId { get; set; }
|
||||
public string? RelatedUsername { get; set; }
|
||||
}
|
||||
|
||||
public class Event
|
||||
{
|
||||
[BsonId]
|
||||
public long Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string? ImageName { get; set; }
|
||||
public DateTime StartsAt { get; set; }
|
||||
public DateTime? EndsAt { get; set; }
|
||||
public bool Pinned { get; set; }
|
||||
public long CreatedByAccountId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class Announcement
|
||||
{
|
||||
[BsonId]
|
||||
public long Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string BodyMarkdown { get; set; } = string.Empty;
|
||||
public string Kind { get; set; } = "info";
|
||||
public bool Pinned { get; set; }
|
||||
public bool Published { get; set; } = true;
|
||||
public long CreatedByAccountId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class ChallengeProgress
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
public int ChallengeMapId { get; set; }
|
||||
public int ChallengeId { get; set; }
|
||||
public int Progress { get; set; }
|
||||
public int Goal { get; set; }
|
||||
public bool Complete { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class ChallengeCompletionEvent
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
public int ChallengeMapId { get; set; }
|
||||
public int ChallengeId { get; set; }
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class FeatureSetting
|
||||
{
|
||||
[BsonId]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public bool Enabled { get; set; }
|
||||
public long UpdatedByAccountId { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class IpBan
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Network { get; set; } = string.Empty;
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public long CreatedByAccountId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static bool IsFeatureEnabled(string key, bool defaultValue)
|
||||
{
|
||||
lock (FeatureSettingsLock)
|
||||
return FeatureSettings.FindById(key)?.Enabled ?? defaultValue;
|
||||
}
|
||||
|
||||
private static void SetFeatureEnabled(
|
||||
string key,
|
||||
bool enabled,
|
||||
long updatedByAccountId)
|
||||
{
|
||||
lock (FeatureSettingsLock)
|
||||
{
|
||||
FeatureSettings.Upsert(new FeatureSetting
|
||||
{
|
||||
Key = key,
|
||||
Enabled = enabled,
|
||||
UpdatedByAccountId = updatedByAccountId,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsAccountCreationEnabled() =>
|
||||
IsFeatureEnabled(AccountCreationSettingKey, true);
|
||||
|
||||
public static void SetAccountCreationEnabled(bool enabled, long updatedByAccountId) =>
|
||||
SetFeatureEnabled(AccountCreationSettingKey, enabled, updatedByAccountId);
|
||||
|
||||
public static bool IsVpnBlockingEnabled() =>
|
||||
IsFeatureEnabled(VpnBlockingSettingKey, true);
|
||||
|
||||
public static void SetVpnBlockingEnabled(bool enabled, long updatedByAccountId) =>
|
||||
SetFeatureEnabled(VpnBlockingSettingKey, enabled, updatedByAccountId);
|
||||
|
||||
public static IReadOnlyList<IpBan> GetIpBans() =>
|
||||
IpBans.FindAll().OrderByDescending(value => value.CreatedAt).ToList();
|
||||
|
||||
public static IpBan AddIpBan(
|
||||
string network,
|
||||
string? reason,
|
||||
long createdByAccountId)
|
||||
{
|
||||
if (!IpNetwork.TryNormalize(network, out string normalized))
|
||||
throw new ArgumentException("Enter a valid IPv4/IPv6 address or CIDR range.");
|
||||
|
||||
string cleanedReason = (reason ?? string.Empty).Trim();
|
||||
if (cleanedReason.Length > 500)
|
||||
cleanedReason = cleanedReason[..500];
|
||||
|
||||
IpBan? existing = IpBans.FindOne(value => value.Network == normalized);
|
||||
if (existing != null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cleanedReason))
|
||||
existing.Reason = cleanedReason;
|
||||
existing.CreatedByAccountId = createdByAccountId;
|
||||
existing.CreatedAt = DateTime.UtcNow;
|
||||
IpBans.Update(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var record = new IpBan
|
||||
{
|
||||
Network = normalized,
|
||||
Reason = cleanedReason,
|
||||
CreatedByAccountId = createdByAccountId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
IpBans.Insert(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
public static bool RemoveIpBan(string id) =>
|
||||
!string.IsNullOrWhiteSpace(id) && IpBans.Delete(id);
|
||||
|
||||
public static bool TryGetIpBan(IPAddress address, out IpBan? record)
|
||||
{
|
||||
record = IpBans.FindAll().FirstOrDefault(value =>
|
||||
IpNetwork.Contains(value.Network, address));
|
||||
return record != null;
|
||||
}
|
||||
|
||||
public static bool IsRecNetSignupEnabled() =>
|
||||
IsFeatureEnabled(RecNetSignupSettingKey, true);
|
||||
|
||||
public static void SetRecNetSignupEnabled(bool enabled, long updatedByAccountId) =>
|
||||
SetFeatureEnabled(RecNetSignupSettingKey, enabled, updatedByAccountId);
|
||||
|
||||
public static (int MapId, DateTime StartsAt, DateTime EndsAt) GetCurrentChallengeWindow()
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
int daysSinceMonday = ((int)now.DayOfWeek + 6) % 7;
|
||||
DateTime startsAt = now.Date.AddDays(-daysSinceMonday);
|
||||
DateTime endsAt = startsAt.AddDays(7);
|
||||
int mapId = ((startsAt.Year * 1000) + startsAt.DayOfYear) * 10 +
|
||||
ChallengeDefinitionVersion;
|
||||
return (mapId, startsAt, endsAt);
|
||||
}
|
||||
|
||||
public static ChallengeProgress GetChallengeProgress(
|
||||
long accountId,
|
||||
int challengeMapId,
|
||||
int challengeId,
|
||||
int goal)
|
||||
{
|
||||
string id = ChallengeProgressId(accountId, challengeMapId, challengeId);
|
||||
var saved = ChallengeProgresses.FindById(id);
|
||||
return saved ?? new ChallengeProgress
|
||||
{
|
||||
Id = id,
|
||||
AccountId = accountId,
|
||||
ChallengeMapId = challengeMapId,
|
||||
ChallengeId = challengeId,
|
||||
Goal = Math.Max(1, goal)
|
||||
};
|
||||
}
|
||||
|
||||
public static ChallengeProgress SetChallengeProgress(
|
||||
long accountId,
|
||||
int challengeMapId,
|
||||
int challengeId,
|
||||
int progress,
|
||||
int goal,
|
||||
bool? complete = null)
|
||||
{
|
||||
goal = Math.Max(1, goal);
|
||||
progress = Math.Clamp(progress, 0, goal);
|
||||
|
||||
lock (ChallengeProgressLock)
|
||||
{
|
||||
var saved = GetChallengeProgress(accountId, challengeMapId, challengeId, goal);
|
||||
saved.Goal = goal;
|
||||
saved.Progress = progress;
|
||||
saved.Complete = complete ?? progress >= goal;
|
||||
if (saved.Complete)
|
||||
saved.Progress = goal;
|
||||
saved.UpdatedAt = DateTime.UtcNow;
|
||||
ChallengeProgresses.Upsert(saved);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
public static ChallengeProgress AddChallengeProgress(
|
||||
long accountId,
|
||||
int challengeMapId,
|
||||
int challengeId,
|
||||
int amount,
|
||||
int goal)
|
||||
{
|
||||
lock (ChallengeProgressLock)
|
||||
{
|
||||
var saved = GetChallengeProgress(accountId, challengeMapId, challengeId, goal);
|
||||
int next = Math.Clamp(saved.Progress + Math.Max(0, amount), 0, Math.Max(1, goal));
|
||||
saved.Goal = Math.Max(1, goal);
|
||||
saved.Progress = next;
|
||||
saved.Complete = next >= saved.Goal;
|
||||
saved.UpdatedAt = DateTime.UtcNow;
|
||||
ChallengeProgresses.Upsert(saved);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
public static void ApplyChallengeTelemetry(long accountId, string eventType, JsonElement eventData)
|
||||
{
|
||||
if (accountId <= 0 || eventData.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
return;
|
||||
|
||||
var (mapId, _, _) = GetCurrentChallengeWindow();
|
||||
string searchable = eventData.GetRawText();
|
||||
|
||||
bool isFinishedEvent =
|
||||
eventType.Contains("complete", StringComparison.OrdinalIgnoreCase) ||
|
||||
eventType.Contains("end", StringComparison.OrdinalIgnoreCase) ||
|
||||
HasTruthyMatchingValue(eventData, "complete", "victory", "won", "win");
|
||||
|
||||
bool isCrimsonCauldron =
|
||||
searchable.Contains("CrimsonCauldron", StringComparison.OrdinalIgnoreCase) ||
|
||||
searchable.Contains("Crimson Cauldron", StringComparison.OrdinalIgnoreCase) ||
|
||||
searchable.Contains("Goblin2", StringComparison.OrdinalIgnoreCase);
|
||||
if (isCrimsonCauldron && isFinishedEvent)
|
||||
{
|
||||
SetChallengeProgress(accountId, mapId, challengeId: 1, progress: 1, goal: 1);
|
||||
}
|
||||
|
||||
bool isGoldenTrophy = searchable.Contains("GoldenTrophy", StringComparison.OrdinalIgnoreCase) ||
|
||||
searchable.Contains("Golden Trophy", StringComparison.OrdinalIgnoreCase);
|
||||
if (isGoldenTrophy && isFinishedEvent)
|
||||
{
|
||||
SetChallengeProgress(accountId, mapId, challengeId: 2, progress: 1, goal: 1);
|
||||
}
|
||||
|
||||
bool isJumbotron = searchable.Contains("Jumbotron", StringComparison.OrdinalIgnoreCase);
|
||||
if (isJumbotron && isFinishedEvent)
|
||||
{
|
||||
SetChallengeProgress(accountId, mapId, challengeId: 3, progress: 1, goal: 1);
|
||||
}
|
||||
|
||||
bool isPaintball = searchable.Contains("Paintball", StringComparison.OrdinalIgnoreCase);
|
||||
if (isPaintball && isFinishedEvent)
|
||||
{
|
||||
AddUniqueGameCompletion(accountId, mapId, challengeId: 4, eventData);
|
||||
}
|
||||
|
||||
bool isDodgeball = searchable.Contains("Dodgeball", StringComparison.OrdinalIgnoreCase);
|
||||
if (isDodgeball && isFinishedEvent)
|
||||
{
|
||||
AddUniqueGameCompletion(accountId, mapId, challengeId: 5, eventData);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddUniqueGameCompletion(
|
||||
long accountId,
|
||||
int challengeMapId,
|
||||
int challengeId,
|
||||
JsonElement eventData)
|
||||
{
|
||||
string? sessionId = FindMatchingScalar(
|
||||
eventData,
|
||||
"_GameSessionId",
|
||||
"GameSessionId",
|
||||
"SubroomInstanceId",
|
||||
"subroom_instance_id");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
AddChallengeProgress(accountId, challengeMapId, challengeId, amount: 1, goal: 5);
|
||||
return;
|
||||
}
|
||||
|
||||
string completionId = $"{accountId}:{challengeMapId}:{challengeId}:{sessionId}";
|
||||
lock (ChallengeProgressLock)
|
||||
{
|
||||
if (ChallengeCompletionEvents.FindById(completionId) != null)
|
||||
return;
|
||||
|
||||
ChallengeCompletionEvents.Insert(new ChallengeCompletionEvent
|
||||
{
|
||||
Id = completionId,
|
||||
AccountId = accountId,
|
||||
ChallengeMapId = challengeMapId,
|
||||
ChallengeId = challengeId,
|
||||
SessionId = sessionId
|
||||
});
|
||||
AddChallengeProgress(accountId, challengeMapId, challengeId, amount: 1, goal: 5);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindMatchingScalar(JsonElement element, params string[] names)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (names.Any(name => string.Equals(
|
||||
property.Name,
|
||||
name,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.String)
|
||||
return property.Value.GetString();
|
||||
if (property.Value.ValueKind == JsonValueKind.Number)
|
||||
return property.Value.GetRawText();
|
||||
}
|
||||
|
||||
string? nested = FindMatchingScalar(property.Value, names);
|
||||
if (!string.IsNullOrWhiteSpace(nested))
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
string? nested = FindMatchingScalar(item, names);
|
||||
if (!string.IsNullOrWhiteSpace(nested))
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ChallengeProgressId(long accountId, int challengeMapId, int challengeId) =>
|
||||
$"{accountId}:{challengeMapId}:{challengeId}";
|
||||
|
||||
private static int FindLargestMatchingNumber(JsonElement element, params string[] names)
|
||||
{
|
||||
int largest = 0;
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (names.Any(name => property.Name.Contains(name, StringComparison.OrdinalIgnoreCase)) &&
|
||||
property.Value.ValueKind == JsonValueKind.Number &&
|
||||
property.Value.TryGetInt32(out int value))
|
||||
{
|
||||
largest = Math.Max(largest, value);
|
||||
}
|
||||
|
||||
largest = Math.Max(largest, FindLargestMatchingNumber(property.Value, names));
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
largest = Math.Max(largest, FindLargestMatchingNumber(item, names));
|
||||
}
|
||||
|
||||
return largest;
|
||||
}
|
||||
|
||||
private static bool HasTruthyMatchingValue(JsonElement element, params string[] names)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
bool matchingName = names.Any(name =>
|
||||
property.Name.Contains(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (matchingName &&
|
||||
(property.Value.ValueKind == JsonValueKind.True ||
|
||||
(property.Value.ValueKind == JsonValueKind.Number &&
|
||||
property.Value.TryGetInt32(out int value) && value > 0) ||
|
||||
(property.Value.ValueKind == JsonValueKind.String &&
|
||||
bool.TryParse(property.Value.GetString(), out bool parsed) && parsed)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (HasTruthyMatchingValue(property.Value, names))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
if (HasTruthyMatchingValue(item, names))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
using LiteDB;
|
||||
using static Mocha2023.Classes.DBs.DBClasses.PlayerDBClasses;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class RelationshipDB
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "Relationships.db"));
|
||||
|
||||
private static readonly ILiteCollection<RelationshipRecord> Records =
|
||||
Database.GetCollection<RelationshipRecord>("Relationships");
|
||||
|
||||
static RelationshipDB()
|
||||
{
|
||||
|
||||
var rawRecords = Database.GetCollection("Relationships");
|
||||
foreach (BsonDocument document in rawRecords.FindAll().ToList())
|
||||
{
|
||||
BsonValue id = document["_id"];
|
||||
if (!id.IsString)
|
||||
rawRecords.Delete(id);
|
||||
}
|
||||
|
||||
Records.EnsureIndex(value => value.SourcePlayerId);
|
||||
Records.EnsureIndex(value => value.TargetPlayerId);
|
||||
Records.EnsureIndex(value => value.Kind);
|
||||
}
|
||||
|
||||
public enum RelationshipKind
|
||||
{
|
||||
Friendship,
|
||||
Subscription
|
||||
}
|
||||
|
||||
public sealed class RelationshipRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public RelationshipKind Kind { get; set; }
|
||||
public long SourcePlayerId { get; set; }
|
||||
public long TargetPlayerId { get; set; }
|
||||
public RelationshipType RelationshipType { get; set; }
|
||||
public List<long> FavoritedBy { get; set; } = new();
|
||||
public List<long> MutedBy { get; set; } = new();
|
||||
public List<long> IgnoredBy { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static int MigrateLegacyData(IEnumerable<FullPlayer> players)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
int imported = 0;
|
||||
var allPlayers = players.Where(value => value.Player != null).ToList();
|
||||
var validIds = allPlayers.Select(value => value.PlayerId).ToHashSet();
|
||||
|
||||
foreach (FullPlayer account in allPlayers)
|
||||
{
|
||||
foreach (PlayerRelationship legacy in account.Player!.Relationships ?? new())
|
||||
{
|
||||
if (legacy.PlayerID == account.PlayerId || !validIds.Contains(legacy.PlayerID))
|
||||
continue;
|
||||
|
||||
string id = FriendshipId(account.PlayerId, legacy.PlayerID);
|
||||
RelationshipRecord? record = Records.FindById(id);
|
||||
bool isNew = record == null;
|
||||
record ??= NewFriendship(account.PlayerId, legacy.PlayerID);
|
||||
record.Id = id;
|
||||
|
||||
if (legacy.RelationshipType == RelationshipType.OutgoingFriendRequest)
|
||||
{
|
||||
record.SourcePlayerId = account.PlayerId;
|
||||
record.TargetPlayerId = legacy.PlayerID;
|
||||
record.RelationshipType = RelationshipType.OutgoingFriendRequest;
|
||||
}
|
||||
else if (legacy.RelationshipType == RelationshipType.IncomingFriendRequest)
|
||||
{
|
||||
record.SourcePlayerId = legacy.PlayerID;
|
||||
record.TargetPlayerId = account.PlayerId;
|
||||
record.RelationshipType = RelationshipType.OutgoingFriendRequest;
|
||||
}
|
||||
else if (legacy.RelationshipType == RelationshipType.Friend)
|
||||
{
|
||||
record.RelationshipType = RelationshipType.Friend;
|
||||
}
|
||||
else if (isNew)
|
||||
{
|
||||
|
||||
record.RelationshipType = RelationshipType.None;
|
||||
}
|
||||
|
||||
SetFlag(record.FavoritedBy, account.PlayerId, legacy.Favorited);
|
||||
SetFlag(record.MutedBy, account.PlayerId, legacy.Muted);
|
||||
SetFlag(record.IgnoredBy, account.PlayerId, legacy.Ignored);
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
Records.Upsert(record);
|
||||
if (isNew)
|
||||
imported++;
|
||||
}
|
||||
|
||||
foreach (long targetId in account.Player.SubscribedAccountIds ?? new())
|
||||
{
|
||||
if (targetId == account.PlayerId || !validIds.Contains(targetId))
|
||||
continue;
|
||||
|
||||
string id = SubscriptionId(account.PlayerId, targetId);
|
||||
if (Records.Exists(value => value.Id == id))
|
||||
continue;
|
||||
|
||||
Records.Insert(new RelationshipRecord
|
||||
{
|
||||
Id = id,
|
||||
Kind = RelationshipKind.Subscription,
|
||||
SourcePlayerId = account.PlayerId,
|
||||
TargetPlayerId = targetId,
|
||||
RelationshipType = RelationshipType.None
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
return imported;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<PlayerRelationship> GetRelationships(long playerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
return Records.Find(value =>
|
||||
value.Kind == RelationshipKind.Friendship &&
|
||||
(value.SourcePlayerId == playerId || value.TargetPlayerId == playerId))
|
||||
.Select(value => ToClientRelationship(value, playerId))
|
||||
.OrderByDescending(value => value.RelationshipType == RelationshipType.Friend)
|
||||
.ThenByDescending(value => value.RelationshipType == RelationshipType.IncomingFriendRequest)
|
||||
.ThenBy(value => value.PlayerID)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerRelationship? GetRelationship(long playerId, long otherPlayerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
RelationshipRecord? record = Records.FindById(
|
||||
FriendshipId(playerId, otherPlayerId));
|
||||
return record == null ? null : ToClientRelationship(record, playerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<ClientRelationshipDTO> GetClientRelationships(long playerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
return Records.Find(value =>
|
||||
value.Kind == RelationshipKind.Friendship &&
|
||||
(value.SourcePlayerId == playerId || value.TargetPlayerId == playerId))
|
||||
.Select(value => ToClientRelationshipDto(value, playerId))
|
||||
.OrderByDescending(value => value.RelationshipType == (int)RelationshipType.Friend)
|
||||
.ThenByDescending(value => value.RelationshipType == (int)RelationshipType.IncomingFriendRequest)
|
||||
.ThenBy(value => value.PlayerID)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static ClientRelationshipDTO? GetClientRelationship(
|
||||
long playerId,
|
||||
long otherPlayerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
RelationshipRecord? record = Records.FindById(
|
||||
FriendshipId(playerId, otherPlayerId));
|
||||
return record == null ? null : ToClientRelationshipDto(record, playerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerRelationship SendFriendRequest(
|
||||
long requesterPlayerId,
|
||||
long receiverPlayerId,
|
||||
out bool acceptedExistingRequest)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
acceptedExistingRequest = false;
|
||||
string id = FriendshipId(requesterPlayerId, receiverPlayerId);
|
||||
RelationshipRecord? existing = Records.FindById(id);
|
||||
|
||||
if (existing?.RelationshipType == RelationshipType.Friend)
|
||||
return ToClientRelationship(existing, requesterPlayerId);
|
||||
|
||||
if (existing != null &&
|
||||
existing.RelationshipType != RelationshipType.Friend &&
|
||||
existing.SourcePlayerId == receiverPlayerId &&
|
||||
existing.TargetPlayerId == requesterPlayerId)
|
||||
{
|
||||
existing.RelationshipType = RelationshipType.Friend;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
Records.Upsert(existing);
|
||||
acceptedExistingRequest = true;
|
||||
return ToClientRelationship(existing, requesterPlayerId);
|
||||
}
|
||||
|
||||
RelationshipRecord record = existing ?? new RelationshipRecord
|
||||
{
|
||||
Id = id,
|
||||
Kind = RelationshipKind.Friendship,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
record.SourcePlayerId = requesterPlayerId;
|
||||
record.TargetPlayerId = receiverPlayerId;
|
||||
|
||||
record.RelationshipType = RelationshipType.OutgoingFriendRequest;
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
Records.Upsert(record);
|
||||
|
||||
return ToClientRelationship(record, requesterPlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerRelationship? AcceptFriendRequest(
|
||||
long receiverPlayerId,
|
||||
long requesterPlayerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
string id = FriendshipId(receiverPlayerId, requesterPlayerId);
|
||||
RelationshipRecord? record = Records.FindById(id);
|
||||
if (record == null)
|
||||
return null;
|
||||
|
||||
if (record.RelationshipType == RelationshipType.Friend)
|
||||
return ToClientRelationship(record, receiverPlayerId);
|
||||
|
||||
if (record.SourcePlayerId != requesterPlayerId ||
|
||||
record.TargetPlayerId != receiverPlayerId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
record.RelationshipType = RelationshipType.Friend;
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
Records.Upsert(record);
|
||||
return ToClientRelationship(record, receiverPlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerRelationship AddFriend(long firstPlayerId, long secondPlayerId)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
string id = FriendshipId(firstPlayerId, secondPlayerId);
|
||||
RelationshipRecord record = Records.FindById(id) ??
|
||||
NewFriendship(firstPlayerId, secondPlayerId);
|
||||
record.Id = id;
|
||||
record.RelationshipType = RelationshipType.Friend;
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
Records.Upsert(record);
|
||||
return ToClientRelationship(record, firstPlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerRelationship SetFlags(
|
||||
long viewerPlayerId,
|
||||
long otherPlayerId,
|
||||
bool? ignored = null,
|
||||
bool? muted = null,
|
||||
bool? favorited = null)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
string id = FriendshipId(viewerPlayerId, otherPlayerId);
|
||||
RelationshipRecord record = Records.FindById(id) ?? new RelationshipRecord
|
||||
{
|
||||
Id = id,
|
||||
Kind = RelationshipKind.Friendship,
|
||||
SourcePlayerId = viewerPlayerId,
|
||||
TargetPlayerId = otherPlayerId,
|
||||
RelationshipType = RelationshipType.None,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (ignored.HasValue)
|
||||
SetFlag(record.IgnoredBy, viewerPlayerId, ignored.Value);
|
||||
if (muted.HasValue)
|
||||
SetFlag(record.MutedBy, viewerPlayerId, muted.Value);
|
||||
if (favorited.HasValue)
|
||||
SetFlag(record.FavoritedBy, viewerPlayerId, favorited.Value);
|
||||
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
Records.Upsert(record);
|
||||
return ToClientRelationship(record, viewerPlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool RemoveFriend(long firstPlayerId, long secondPlayerId)
|
||||
{
|
||||
lock (Sync)
|
||||
return Records.Delete(FriendshipId(firstPlayerId, secondPlayerId));
|
||||
}
|
||||
|
||||
public static bool SetSubscription(long subscriberId, long targetId, bool subscribe)
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
string id = SubscriptionId(subscriberId, targetId);
|
||||
if (!subscribe)
|
||||
{
|
||||
Records.Delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
Records.Upsert(new RelationshipRecord
|
||||
{
|
||||
Id = id,
|
||||
Kind = RelationshipKind.Subscription,
|
||||
SourcePlayerId = subscriberId,
|
||||
TargetPlayerId = targetId,
|
||||
RelationshipType = RelationshipType.None,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsSubscribed(long subscriberId, long targetId)
|
||||
{
|
||||
lock (Sync)
|
||||
return Records.Exists(value => value.Id == SubscriptionId(subscriberId, targetId));
|
||||
}
|
||||
|
||||
public static int GetSubscriberCount(long targetId)
|
||||
{
|
||||
lock (Sync)
|
||||
return Records.Count(value =>
|
||||
value.Kind == RelationshipKind.Subscription &&
|
||||
value.TargetPlayerId == targetId);
|
||||
}
|
||||
|
||||
public static int GetSubscribedCount(long subscriberId)
|
||||
{
|
||||
lock (Sync)
|
||||
return Records.Count(value =>
|
||||
value.Kind == RelationshipKind.Subscription &&
|
||||
value.SourcePlayerId == subscriberId);
|
||||
}
|
||||
|
||||
public static long[] GetPersistentLiveSubjectIds(long viewerPlayerId)
|
||||
{
|
||||
if (viewerPlayerId <= 0)
|
||||
return Array.Empty<long>();
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
return Records.FindAll()
|
||||
.Where(record =>
|
||||
(record.Kind == RelationshipKind.Friendship &&
|
||||
(record.SourcePlayerId == viewerPlayerId ||
|
||||
record.TargetPlayerId == viewerPlayerId)) ||
|
||||
(record.Kind == RelationshipKind.Subscription &&
|
||||
record.SourcePlayerId == viewerPlayerId))
|
||||
.Select(record => record.Kind == RelationshipKind.Subscription
|
||||
? record.TargetPlayerId
|
||||
: record.SourcePlayerId == viewerPlayerId
|
||||
? record.TargetPlayerId
|
||||
: record.SourcePlayerId)
|
||||
.Where(playerId =>
|
||||
playerId > 0 &&
|
||||
playerId != viewerPlayerId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static long[] GetPersistentLiveWatcherIds(long subjectPlayerId)
|
||||
{
|
||||
if (subjectPlayerId <= 0)
|
||||
return Array.Empty<long>();
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
return Records.FindAll()
|
||||
.Where(record =>
|
||||
(record.Kind == RelationshipKind.Friendship &&
|
||||
(record.SourcePlayerId == subjectPlayerId ||
|
||||
record.TargetPlayerId == subjectPlayerId)) ||
|
||||
(record.Kind == RelationshipKind.Subscription &&
|
||||
record.TargetPlayerId == subjectPlayerId))
|
||||
.Select(record => record.Kind == RelationshipKind.Subscription
|
||||
? record.SourcePlayerId
|
||||
: record.SourcePlayerId == subjectPlayerId
|
||||
? record.TargetPlayerId
|
||||
: record.SourcePlayerId)
|
||||
.Where(playerId =>
|
||||
playerId > 0 &&
|
||||
playerId != subjectPlayerId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static long GetStableNumericId(long firstPlayerId, long secondPlayerId)
|
||||
{
|
||||
|
||||
unchecked
|
||||
{
|
||||
uint hash = 2166136261;
|
||||
long lower = Math.Min(firstPlayerId, secondPlayerId);
|
||||
long upper = Math.Max(firstPlayerId, secondPlayerId);
|
||||
|
||||
foreach (long value in new[] { lower, upper })
|
||||
{
|
||||
ulong bytes = (ulong)value;
|
||||
for (int index = 0; index < sizeof(long); index++)
|
||||
{
|
||||
hash ^= (byte)(bytes >> (index * 8));
|
||||
hash *= 16777619;
|
||||
}
|
||||
}
|
||||
|
||||
int id = (int)(hash & 0x7FFFFFFF);
|
||||
return id == 0 ? 1 : id;
|
||||
}
|
||||
}
|
||||
|
||||
private static ClientRelationshipDTO ToClientRelationshipDto(
|
||||
RelationshipRecord record,
|
||||
long viewerPlayerId)
|
||||
{
|
||||
long otherPlayerId = record.SourcePlayerId == viewerPlayerId
|
||||
? record.TargetPlayerId
|
||||
: record.SourcePlayerId;
|
||||
|
||||
RelationshipType clientType = record.RelationshipType;
|
||||
if (record.RelationshipType == RelationshipType.OutgoingFriendRequest)
|
||||
{
|
||||
clientType = record.SourcePlayerId == viewerPlayerId
|
||||
? RelationshipType.OutgoingFriendRequest
|
||||
: RelationshipType.IncomingFriendRequest;
|
||||
}
|
||||
|
||||
return new ClientRelationshipDTO
|
||||
{
|
||||
PlayerID = CheckedPlayerId(otherPlayerId),
|
||||
RelationshipType = (int)clientType,
|
||||
Favorited = GetPerspectiveStatus(record.FavoritedBy, viewerPlayerId, otherPlayerId),
|
||||
Muted = GetPerspectiveStatus(record.MutedBy, viewerPlayerId, otherPlayerId),
|
||||
Ignored = GetPerspectiveStatus(record.IgnoredBy, viewerPlayerId, otherPlayerId)
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetPerspectiveStatus(
|
||||
IReadOnlyCollection<long> values,
|
||||
long viewerPlayerId,
|
||||
long otherPlayerId)
|
||||
{
|
||||
bool local = values.Contains(viewerPlayerId);
|
||||
bool remote = values.Contains(otherPlayerId);
|
||||
|
||||
if (local && remote)
|
||||
return 3;
|
||||
if (local)
|
||||
return 1;
|
||||
if (remote)
|
||||
return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int CheckedPlayerId(long playerId)
|
||||
{
|
||||
if (playerId <= 0 || playerId > int.MaxValue)
|
||||
throw new InvalidOperationException(
|
||||
$"Player id {playerId} cannot be represented by the April 2023 client.");
|
||||
|
||||
return (int)playerId;
|
||||
}
|
||||
|
||||
private static PlayerRelationship ToClientRelationship(
|
||||
RelationshipRecord record,
|
||||
long viewerPlayerId)
|
||||
{
|
||||
long otherPlayerId = record.SourcePlayerId == viewerPlayerId
|
||||
? record.TargetPlayerId
|
||||
: record.SourcePlayerId;
|
||||
|
||||
RelationshipType clientType = record.RelationshipType;
|
||||
if (record.RelationshipType == RelationshipType.OutgoingFriendRequest)
|
||||
{
|
||||
clientType = record.SourcePlayerId == viewerPlayerId
|
||||
? RelationshipType.OutgoingFriendRequest
|
||||
: RelationshipType.IncomingFriendRequest;
|
||||
}
|
||||
|
||||
return new PlayerRelationship
|
||||
{
|
||||
Id = GetStableNumericId(record.SourcePlayerId, record.TargetPlayerId),
|
||||
PlayerID = otherPlayerId,
|
||||
RelationshipType = clientType,
|
||||
Favorited = record.FavoritedBy.Contains(viewerPlayerId),
|
||||
Muted = record.MutedBy.Contains(viewerPlayerId),
|
||||
Ignored = record.IgnoredBy.Contains(viewerPlayerId)
|
||||
};
|
||||
}
|
||||
|
||||
private static RelationshipRecord NewFriendship(long firstPlayerId, long secondPlayerId)
|
||||
{
|
||||
long source = Math.Min(firstPlayerId, secondPlayerId);
|
||||
long target = Math.Max(firstPlayerId, secondPlayerId);
|
||||
return new RelationshipRecord
|
||||
{
|
||||
Id = FriendshipId(firstPlayerId, secondPlayerId),
|
||||
Kind = RelationshipKind.Friendship,
|
||||
SourcePlayerId = source,
|
||||
TargetPlayerId = target,
|
||||
RelationshipType = RelationshipType.Friend
|
||||
};
|
||||
}
|
||||
|
||||
private static string FriendshipId(long firstPlayerId, long secondPlayerId) =>
|
||||
$"friend:{Math.Min(firstPlayerId, secondPlayerId)}:{Math.Max(firstPlayerId, secondPlayerId)}";
|
||||
|
||||
private static string SubscriptionId(long subscriberId, long targetId) =>
|
||||
$"subscription:{subscriberId}:{targetId}";
|
||||
|
||||
private static void SetFlag(List<long> values, long playerId, bool enabled)
|
||||
{
|
||||
values.RemoveAll(value => value == playerId);
|
||||
if (enabled)
|
||||
values.Add(playerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
public static class ReportsDB
|
||||
{
|
||||
private static readonly LiteDatabase Database =
|
||||
new(Path.Combine(Program.dataDir, "DBs", "Reports.db"));
|
||||
|
||||
public static readonly ILiteCollection<PlayerReport> PlayerReports =
|
||||
Database.GetCollection<PlayerReport>("PlayerReports");
|
||||
|
||||
public static readonly ILiteCollection<BugReport> BugReports =
|
||||
Database.GetCollection<BugReport>("BugReports");
|
||||
|
||||
static ReportsDB()
|
||||
{
|
||||
PlayerReports.EnsureIndex(report => report.Status);
|
||||
PlayerReports.EnsureIndex(report => report.CreatedAt);
|
||||
PlayerReports.EnsureIndex(report => report.ReportedPlayerId);
|
||||
BugReports.EnsureIndex(report => report.Status);
|
||||
BugReports.EnsureIndex(report => report.CreatedAt);
|
||||
}
|
||||
|
||||
public class PlayerReport
|
||||
{
|
||||
[BsonId]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public long ReporterId { get; set; }
|
||||
public string? ReporterUsername { get; set; }
|
||||
public long? ReportedPlayerId { get; set; }
|
||||
public string? ReportedUsername { get; set; }
|
||||
public int? ReportCategory { get; set; }
|
||||
public string? Details { get; set; }
|
||||
public long? RoomId { get; set; }
|
||||
public string? RoomInstanceType { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Pending, Banned, TimedOut, NoAction
|
||||
public string Status { get; set; } = "Pending";
|
||||
|
||||
public long? ResolvedByAccountId { get; set; }
|
||||
public string? ResolvedByUsername { get; set; }
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
public string? ResolutionNote { get; set; }
|
||||
public int? ActionDurationSeconds { get; set; }
|
||||
}
|
||||
|
||||
public class BugReport
|
||||
{
|
||||
[BsonId]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public long ReporterId { get; set; }
|
||||
public string? ReporterUsername { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Open, Closed
|
||||
public string Status { get; set; } = "Open";
|
||||
|
||||
public long? ResolvedByAccountId { get; set; }
|
||||
public string? ResolvedByUsername { get; set; }
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1649 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using LiteDB;
|
||||
using Mocha2023.Classes;
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
using static Mocha2023.Classes.DBs.DBClasses.RoomDBClasses;
|
||||
using SysJson = System.Text.Json;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
public class RoomDB
|
||||
{
|
||||
public const int CompatibleLegacyPersistenceVersion = 38;
|
||||
public const int CompatibleLegacyUgcSubVersion = 0;
|
||||
public const int CompatibleLegacyOMVersion = 0;
|
||||
|
||||
public static int NormalizeLegacyPersistenceVersion(int? sourceVersion)
|
||||
{
|
||||
int value = sourceVersion.GetValueOrDefault(CompatibleLegacyPersistenceVersion);
|
||||
return value <= 0
|
||||
? CompatibleLegacyPersistenceVersion
|
||||
: Math.Min(value, CompatibleLegacyPersistenceVersion);
|
||||
}
|
||||
|
||||
public static int NormalizeLegacyUgcSubVersion(int sourceVersion)
|
||||
{
|
||||
return CompatibleLegacyUgcSubVersion;
|
||||
}
|
||||
|
||||
public static int NormalizeLegacyOMVersion(int sourceVersion)
|
||||
{
|
||||
return CompatibleLegacyOMVersion;
|
||||
}
|
||||
|
||||
public static LiteDatabase RoomDBFile = new LiteDatabase(Path.Combine(Program.dataDir, "DBs", "Rooms.db"));
|
||||
public static readonly ILiteCollection<Room> Rooms = RoomDBFile.GetCollection<Room>("Rooms");
|
||||
public static readonly ILiteCollection<SubRoomDataSave> SubRoomDataSaves = RoomDBFile.GetCollection<SubRoomDataSave>("SubRoomDataSaves");
|
||||
public static readonly ILiteCollection<RoomBan> RoomBans = RoomDBFile.GetCollection<RoomBan>("RoomBans");
|
||||
private static readonly object SaveIdLock = new();
|
||||
private static readonly object DormCreationLock = new();
|
||||
private static readonly object SubRoomMutationLock = new();
|
||||
private static readonly string[] CanonicalBaseRoomNames =
|
||||
{
|
||||
"PerformanceHall",
|
||||
"MakerRoom",
|
||||
"Park",
|
||||
"Lounge",
|
||||
"RecCenter"
|
||||
};
|
||||
|
||||
static RoomDB()
|
||||
{
|
||||
LiteDbMaintenance.StartPeriodicCheckpoint("Rooms.db", RoomDBFile);
|
||||
|
||||
RepairCanonicalBaseRoomMetadata();
|
||||
}
|
||||
|
||||
private static string NormalizeCanonicalRoomName(string? name) =>
|
||||
new((name ?? string.Empty)
|
||||
.Trim()
|
||||
.TrimStart('^')
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.Select(char.ToLowerInvariant)
|
||||
.ToArray());
|
||||
|
||||
public static bool IsCanonicalBaseRoom(Room? room)
|
||||
{
|
||||
if (room == null)
|
||||
return false;
|
||||
if (room.IsBaseRoom)
|
||||
return true;
|
||||
|
||||
string normalized = NormalizeCanonicalRoomName(room.Name);
|
||||
return CanonicalBaseRoomNames.Any(name =>
|
||||
NormalizeCanonicalRoomName(name) == normalized);
|
||||
}
|
||||
|
||||
public static int GetCanonicalBaseRoomOrder(Room room)
|
||||
{
|
||||
string normalized = NormalizeCanonicalRoomName(room.Name);
|
||||
int index = Array.FindIndex(
|
||||
CanonicalBaseRoomNames,
|
||||
name => NormalizeCanonicalRoomName(name) == normalized);
|
||||
return index < 0 ? int.MaxValue : index;
|
||||
}
|
||||
|
||||
public static int RepairCanonicalBaseRoomMetadata()
|
||||
{
|
||||
int changed = 0;
|
||||
foreach (Room room in Rooms.FindAll().Where(IsCanonicalBaseRoom).ToList())
|
||||
{
|
||||
bool dirty = false;
|
||||
if (!room.IsBaseRoom)
|
||||
{
|
||||
room.IsBaseRoom = true;
|
||||
dirty = true;
|
||||
}
|
||||
if (room.IsDorm)
|
||||
{
|
||||
room.IsDorm = false;
|
||||
dirty = true;
|
||||
}
|
||||
if (!room.IsRRO)
|
||||
{
|
||||
room.IsRRO = true;
|
||||
dirty = true;
|
||||
}
|
||||
if (room.Accessibility != RoomAccessibility.Public)
|
||||
{
|
||||
room.Accessibility = RoomAccessibility.Public;
|
||||
dirty = true;
|
||||
}
|
||||
if (room.State != RoomState.Active)
|
||||
{
|
||||
room.State = RoomState.Active;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
room.Tags ??= new List<Tags>();
|
||||
if (!room.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "base", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
room.Tags.Add(new Tags { Tag = "base", Type = TagType.Auto });
|
||||
dirty = true;
|
||||
}
|
||||
if (!room.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "rro", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
room.Tags.Add(new Tags { Tag = "rro", Type = TagType.AGOnly });
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (dirty && Rooms.Update(room))
|
||||
changed++;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public static bool CanPlayerAccessRoom(Room? room, long accountId)
|
||||
{
|
||||
if (room == null || accountId <= 0 ||
|
||||
room.State == RoomState.MarkedForDelete ||
|
||||
room.State == RoomState.Moderation_Closed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsCanonicalBaseRoom(room))
|
||||
return true;
|
||||
|
||||
if (room.IsDorm)
|
||||
return room.CreatorAccountId == accountId || IsConfirmedLiveGuest(room, accountId);
|
||||
|
||||
if (room.Accessibility != RoomAccessibility.Private)
|
||||
return true;
|
||||
|
||||
return IsRoomCollaborator(room, accountId) || IsConfirmedLiveGuest(room, accountId);
|
||||
}
|
||||
|
||||
private static bool IsConfirmedLiveGuest(Room room, long accountId)
|
||||
{
|
||||
PlayerDBClasses.Heartbeat heartbeat = PlayerDB.GetPlayerHeartbeat(accountId);
|
||||
PlayerDBClasses.RoomInstance? instance = heartbeat.roomInstance;
|
||||
|
||||
if (!heartbeat.isOnline || instance == null || instance.roomId != room.RoomId)
|
||||
return false;
|
||||
|
||||
return Sessions.IsConfirmedParticipant(accountId, instance.roomInstanceId);
|
||||
}
|
||||
|
||||
public static bool CanPlayerAccessSubRoom(
|
||||
Room? room,
|
||||
SubRooms? subRoom,
|
||||
long accountId)
|
||||
{
|
||||
if (!CanPlayerAccessRoom(room, accountId) ||
|
||||
room == null ||
|
||||
subRoom == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsCanonicalBaseRoom(room))
|
||||
return true;
|
||||
|
||||
return subRoom.Accessibility != RoomAccessibility.Private ||
|
||||
IsRoomCollaborator(room, accountId) ||
|
||||
IsConfirmedLiveGuest(room, accountId);
|
||||
}
|
||||
|
||||
private static bool IsRoomCollaborator(Room room, long accountId)
|
||||
{
|
||||
return room.CreatorAccountId == accountId ||
|
||||
room.Roles?.Any(role =>
|
||||
role.AccountId == accountId &&
|
||||
role.Role is Role.Host or
|
||||
Role.Moderator or
|
||||
Role.CoOwner or
|
||||
Role.TemporaryCoOwner or
|
||||
Role.Creator) == true;
|
||||
}
|
||||
|
||||
public static RoomBan BanPlayerFromRoom(long roomId, long accountId, long bannedByAccountId, string? reason)
|
||||
{
|
||||
var existing = RoomBans.FindOne(b => b.RoomId == roomId && b.AccountId == accountId);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
var ban = new RoomBan
|
||||
{
|
||||
RoomId = roomId,
|
||||
AccountId = accountId,
|
||||
BannedByAccountId = bannedByAccountId,
|
||||
Reason = reason,
|
||||
BannedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
RoomBans.Insert(ban);
|
||||
Console.WriteLine($"[ROOM BAN] room={roomId} account={accountId} bannedBy={bannedByAccountId}");
|
||||
return ban;
|
||||
}
|
||||
|
||||
public static bool UnbanPlayerFromRoom(long roomId, long accountId)
|
||||
{
|
||||
int removed = RoomBans.DeleteMany(b => b.RoomId == roomId && b.AccountId == accountId);
|
||||
if (removed > 0)
|
||||
Console.WriteLine($"[ROOM UNBAN] room={roomId} account={accountId}");
|
||||
|
||||
return removed > 0;
|
||||
}
|
||||
|
||||
public static bool IsPlayerBannedFromRoom(long roomId, long accountId)
|
||||
{
|
||||
return RoomBans.Exists(b => b.RoomId == roomId && b.AccountId == accountId);
|
||||
}
|
||||
|
||||
public static List<RoomBan> GetActiveBans(long roomId)
|
||||
{
|
||||
return RoomBans.Find(b => b.RoomId == roomId).ToList();
|
||||
}
|
||||
|
||||
public static SubRoomDataSave CreateSubRoomDataSave(long roomId, long subRoomId, long accountId, string dataBlob, int persistenceVersion, bool isPublished, string? dataBlobHash = null, string? description = null, string? roomDataBlob = null)
|
||||
{
|
||||
lock (SaveIdLock)
|
||||
{
|
||||
return InsertSubRoomDataSave(
|
||||
roomId,
|
||||
subRoomId,
|
||||
accountId,
|
||||
dataBlob,
|
||||
persistenceVersion,
|
||||
isPublished,
|
||||
dataBlobHash,
|
||||
description,
|
||||
roomDataBlob);
|
||||
}
|
||||
}
|
||||
|
||||
public static SubRoomDataSave GetOrCreateSubRoomDataSave(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
long accountId,
|
||||
string dataBlob,
|
||||
int persistenceVersion,
|
||||
bool isPublished,
|
||||
string? dataBlobHash,
|
||||
string? description,
|
||||
string? roomDataBlob,
|
||||
out bool created)
|
||||
{
|
||||
lock (SaveIdLock)
|
||||
{
|
||||
|
||||
var existing = SubRoomDataSaves
|
||||
.Find(save =>
|
||||
save.RoomId == roomId &&
|
||||
save.SubRoomId == subRoomId)
|
||||
.OrderByDescending(save => save.CreatedAt)
|
||||
.ThenByDescending(save => save.SubRoomDataSaveId)
|
||||
.FirstOrDefault(save =>
|
||||
save.SavedByAccountId == accountId &&
|
||||
string.Equals(
|
||||
save.DataBlob,
|
||||
dataBlob,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(
|
||||
save.RoomDataBlob,
|
||||
roomDataBlob,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
save.PersistenceVersion == persistenceVersion &&
|
||||
string.Equals(
|
||||
save.Description ?? string.Empty,
|
||||
description ?? string.Empty,
|
||||
StringComparison.Ordinal));
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
bool changed = false;
|
||||
if (string.IsNullOrWhiteSpace(existing.DataBlobHash) &&
|
||||
!string.IsNullOrWhiteSpace(dataBlobHash))
|
||||
{
|
||||
existing.DataBlobHash = dataBlobHash;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (isPublished && !existing.IsPublished)
|
||||
{
|
||||
existing.IsPublished = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed && !SubRoomDataSaves.Update(existing))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to update existing subroom save {existing.SubRoomDataSaveId}.");
|
||||
}
|
||||
|
||||
created = false;
|
||||
return existing;
|
||||
}
|
||||
|
||||
created = true;
|
||||
return InsertSubRoomDataSave(
|
||||
roomId,
|
||||
subRoomId,
|
||||
accountId,
|
||||
dataBlob,
|
||||
persistenceVersion,
|
||||
isPublished,
|
||||
dataBlobHash,
|
||||
description,
|
||||
roomDataBlob);
|
||||
}
|
||||
}
|
||||
|
||||
private static SubRoomDataSave InsertSubRoomDataSave(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
long accountId,
|
||||
string dataBlob,
|
||||
int persistenceVersion,
|
||||
bool isPublished,
|
||||
string? dataBlobHash,
|
||||
string? description,
|
||||
string? roomDataBlob)
|
||||
{
|
||||
long nextId = SubRoomDataSaves.Count() == 0
|
||||
? 1
|
||||
: Convert.ToInt64(SubRoomDataSaves.Max(x => x.SubRoomDataSaveId)) + 1;
|
||||
|
||||
var save = new SubRoomDataSave
|
||||
{
|
||||
SubRoomDataSaveId = nextId,
|
||||
RoomId = roomId,
|
||||
SubRoomId = subRoomId,
|
||||
SavedByAccountId = accountId,
|
||||
DataBlob = dataBlob,
|
||||
RoomDataBlob = roomDataBlob,
|
||||
DataBlobHash = dataBlobHash,
|
||||
PersistenceVersion = persistenceVersion,
|
||||
Description = description,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsPublished = isPublished
|
||||
};
|
||||
SubRoomDataSaves.Insert(save);
|
||||
return save;
|
||||
}
|
||||
|
||||
public static SubRoomDataSave? EnsureSubRoomDataSave(Room room, SubRooms subRoom)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(room);
|
||||
ArgumentNullException.ThrowIfNull(subRoom);
|
||||
|
||||
if (subRoom.SubRoomDataSaveId > 0)
|
||||
{
|
||||
var current = SubRoomDataSaves.FindById(subRoom.SubRoomDataSaveId);
|
||||
if (IsUsablePersistencePair(room, subRoom, current))
|
||||
{
|
||||
NormalizeSubRoomDataSaveHash(current!);
|
||||
SynchronizePersistencePointers(room, subRoom, current!);
|
||||
return current;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"[CV2 SNAPSHOT GUARD] rejected current save room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId} save={subRoom.SubRoomDataSaveId}");
|
||||
}
|
||||
|
||||
var newestValidSave = SubRoomDataSaves
|
||||
.Find(save => save.RoomId == room.RoomId && save.SubRoomId == subRoom.SubRoomId)
|
||||
.OrderByDescending(save => save.CreatedAt)
|
||||
.ThenByDescending(save => save.SubRoomDataSaveId)
|
||||
.FirstOrDefault(save => IsUsablePersistencePair(room, subRoom, save));
|
||||
|
||||
if (newestValidSave != null)
|
||||
{
|
||||
NormalizeSubRoomDataSaveHash(newestValidSave);
|
||||
SynchronizePersistencePointers(room, subRoom, newestValidSave);
|
||||
Console.WriteLine(
|
||||
$"[CV2 SNAPSHOT REPAIR] room={room.RoomId} subroom={subRoom.SubRoomId} " +
|
||||
$"save={newestValidSave.SubRoomDataSaveId}");
|
||||
return newestValidSave;
|
||||
}
|
||||
|
||||
if (HasCompleteDirectBlobPair(room, subRoom))
|
||||
{
|
||||
var save = CreateSubRoomDataSave(
|
||||
room.RoomId,
|
||||
subRoom.SubRoomId,
|
||||
subRoom.SavedByAccountId,
|
||||
subRoom.DataBlob!,
|
||||
room.PersistenceVersion,
|
||||
false,
|
||||
roomDataBlob: room.DataBlob);
|
||||
|
||||
NormalizeSubRoomDataSaveHash(save);
|
||||
SynchronizePersistencePointers(room, subRoom, save);
|
||||
Console.WriteLine(
|
||||
$"[CV2 SNAPSHOT REPAIR] synthesized save room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId} save={save.SubRoomDataSaveId}");
|
||||
return save;
|
||||
}
|
||||
|
||||
bool clearedInvalidSavePointer = subRoom.SubRoomDataSaveId != 0;
|
||||
subRoom.SubRoomDataSave = null;
|
||||
subRoom.SubRoomDataSaveId = 0;
|
||||
if (clearedInvalidSavePointer)
|
||||
Rooms.Update(room);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[CV2 SNAPSHOT GUARD] fresh scene room={room.RoomId} " +
|
||||
$"subroom={subRoom.SubRoomId} roomBlob={room.DataBlob ?? "null"} " +
|
||||
$"subRoomBlob={subRoom.DataBlob ?? "null"}");
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool TryGetPersistencePair(
|
||||
Room room,
|
||||
SubRooms subRoom,
|
||||
out SubRoomDataSave? save,
|
||||
out string roomDataBlob,
|
||||
out string subRoomDataBlob)
|
||||
{
|
||||
save = EnsureSubRoomDataSave(room, subRoom);
|
||||
if (!IsUsablePersistencePair(room, subRoom, save))
|
||||
{
|
||||
roomDataBlob = string.Empty;
|
||||
subRoomDataBlob = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
roomDataBlob = string.IsNullOrWhiteSpace(save!.RoomDataBlob)
|
||||
? string.Empty
|
||||
: save.RoomDataBlob;
|
||||
subRoomDataBlob = save.DataBlob;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsBakedUnitySave(SubRoomDataSave? save) =>
|
||||
save != null && !string.IsNullOrWhiteSpace(save.UnityAssetId);
|
||||
|
||||
private static bool IsUsablePersistencePair(
|
||||
Room room,
|
||||
SubRooms subRoom,
|
||||
SubRoomDataSave? save)
|
||||
{
|
||||
if (save == null ||
|
||||
save.RoomId != room.RoomId ||
|
||||
save.SubRoomId != subRoom.SubRoomId ||
|
||||
string.IsNullOrWhiteSpace(save.DataBlob))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsBakedUnitySave(save))
|
||||
{
|
||||
string bakedRoomBlob = string.IsNullOrWhiteSpace(save.RoomDataBlob)
|
||||
? save.DataBlob
|
||||
: save.RoomDataBlob;
|
||||
return RoomBlobExists(save.DataBlob) && RoomBlobExists(bakedRoomBlob);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(save.RoomDataBlob))
|
||||
{
|
||||
|
||||
return RoomBlobExists(save.DataBlob);
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
save.RoomDataBlob,
|
||||
save.DataBlob,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return RoomBlobExists(save.RoomDataBlob) && RoomBlobExists(save.DataBlob);
|
||||
}
|
||||
|
||||
private static bool HasCompleteDirectBlobPair(Room room, SubRooms subRoom)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(room.DataBlob) &&
|
||||
!string.IsNullOrWhiteSpace(subRoom.DataBlob) &&
|
||||
!string.Equals(
|
||||
room.DataBlob,
|
||||
subRoom.DataBlob,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
RoomBlobExists(room.DataBlob) &&
|
||||
RoomBlobExists(subRoom.DataBlob);
|
||||
}
|
||||
|
||||
private static void SynchronizePersistencePointers(
|
||||
Room room,
|
||||
SubRooms subRoom,
|
||||
SubRoomDataSave save)
|
||||
{
|
||||
bool changed = false;
|
||||
|
||||
string? effectiveRoomBlob;
|
||||
if (IsBakedUnitySave(save))
|
||||
{
|
||||
effectiveRoomBlob = string.IsNullOrWhiteSpace(save.RoomDataBlob)
|
||||
? save.DataBlob
|
||||
: save.RoomDataBlob;
|
||||
}
|
||||
else
|
||||
{
|
||||
effectiveRoomBlob = string.IsNullOrWhiteSpace(save.RoomDataBlob)
|
||||
? null
|
||||
: save.RoomDataBlob;
|
||||
}
|
||||
|
||||
if (!string.Equals(room.DataBlob, effectiveRoomBlob, StringComparison.Ordinal))
|
||||
{
|
||||
room.DataBlob = effectiveRoomBlob;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(subRoom.DataBlob, save.DataBlob, StringComparison.Ordinal))
|
||||
{
|
||||
subRoom.DataBlob = save.DataBlob;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (subRoom.SubRoomDataSaveId != save.SubRoomDataSaveId)
|
||||
{
|
||||
subRoom.SubRoomDataSaveId = save.SubRoomDataSaveId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
subRoom.SubRoomDataSave = save;
|
||||
if (changed)
|
||||
Rooms.Update(room);
|
||||
}
|
||||
|
||||
private static void NormalizeSubRoomDataSaveHash(SubRoomDataSave save)
|
||||
{
|
||||
string? actualHash = ComputeBlobHashBase64(save.DataBlob);
|
||||
if (actualHash == null ||
|
||||
string.Equals(save.DataBlobHash, actualHash, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? oldHash = save.DataBlobHash;
|
||||
save.DataBlobHash = actualHash;
|
||||
SubRoomDataSaves.Update(save);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ROOM HASH REPAIR] save={save.SubRoomDataSaveId} " +
|
||||
$"blob={save.DataBlob} old={oldHash ?? "null"} new={actualHash}");
|
||||
}
|
||||
|
||||
private static string? ComputeBlobHashBase64(string? filename)
|
||||
{
|
||||
if (!RoomBlobExists(filename))
|
||||
return null;
|
||||
|
||||
string path = Path.Combine(
|
||||
Program.dataDir,
|
||||
"CDN",
|
||||
"room",
|
||||
Path.GetFileName(filename));
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
byte[] digest = System.Security.Cryptography.SHA256.HashData(stream);
|
||||
return Convert.ToBase64String(digest);
|
||||
}
|
||||
|
||||
public static Room? PrepareRoomForClient(Room? room)
|
||||
{
|
||||
if (room == null)
|
||||
return null;
|
||||
|
||||
room.PersistenceVersion = NormalizeLegacyPersistenceVersion(
|
||||
room.PersistenceVersion);
|
||||
room.UgcVersion = 1;
|
||||
|
||||
var normalizedFields = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(room.Name))
|
||||
{
|
||||
room.Name = $"Room{room.RoomId}";
|
||||
normalizedFields.Add(nameof(room.Name));
|
||||
}
|
||||
|
||||
if (room.Description == null)
|
||||
{
|
||||
room.Description = string.Equals(
|
||||
room.Name,
|
||||
"RecCenter",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? "A social hub to meet and mingle with friends new and old!"
|
||||
: "No description has been added yet.";
|
||||
normalizedFields.Add(nameof(room.Description));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(room.ImageName))
|
||||
{
|
||||
room.ImageName = "DefaultRoomImage.jpg";
|
||||
normalizedFields.Add(nameof(room.ImageName));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(room.RankedEntityId))
|
||||
{
|
||||
room.RankedEntityId = room.RoomId.ToString();
|
||||
normalizedFields.Add(nameof(room.RankedEntityId));
|
||||
}
|
||||
|
||||
room.Stats ??= new Stats();
|
||||
room.SubRooms ??= new List<SubRooms>();
|
||||
room.Roles ??= new List<Roles>();
|
||||
room.Tags ??= new List<Tags>();
|
||||
room.PromoImages ??= new List<string>();
|
||||
room.PromoExternalContent ??= new List<PromoExternalContent>();
|
||||
room.LoadScreens ??= new List<LoadScreens>();
|
||||
|
||||
if (IsCanonicalBaseRoom(room))
|
||||
{
|
||||
room.IsBaseRoom = true;
|
||||
room.IsDorm = false;
|
||||
room.IsRRO = true;
|
||||
room.Accessibility = RoomAccessibility.Public;
|
||||
room.State = RoomState.Active;
|
||||
if (!room.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "base", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
room.Tags.Add(new Tags { Tag = "base", Type = TagType.Auto });
|
||||
}
|
||||
if (!room.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "rro", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
room.Tags.Add(new Tags { Tag = "rro", Type = TagType.AGOnly });
|
||||
}
|
||||
}
|
||||
|
||||
bool hasBetaTag = room.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "beta", StringComparison.OrdinalIgnoreCase));
|
||||
if (room.CreativeToolsBetaEnabled && !hasBetaTag)
|
||||
{
|
||||
room.Tags.Add(new Tags { Tag = "beta", Type = TagType.Auto });
|
||||
}
|
||||
else if (!room.CreativeToolsBetaEnabled && hasBetaTag)
|
||||
{
|
||||
|
||||
room.CreativeToolsBetaEnabled = true;
|
||||
}
|
||||
|
||||
ResetInvalidPersonalDormToFreshState(room);
|
||||
|
||||
foreach (var subRoom in room.SubRooms)
|
||||
{
|
||||
subRoom.Name ??= string.Empty;
|
||||
subRoom.UnitySceneId ??= string.Empty;
|
||||
subRoom.Permissions ??= new List<SubRoomPermission>();
|
||||
subRoom.SubRoomDataSave = EnsureSubRoomDataSave(room, subRoom);
|
||||
|
||||
if (subRoom.SubRoomDataSave != null)
|
||||
{
|
||||
subRoom.SubRoomDataSave.DataBlob ??= string.Empty;
|
||||
subRoom.SubRoomDataSave.Description ??= room.Description;
|
||||
subRoom.SubRoomDataSave.PersistenceVersion =
|
||||
NormalizeLegacyPersistenceVersion(
|
||||
subRoom.SubRoomDataSave.PersistenceVersion);
|
||||
subRoom.SubRoomDataSave.UgcSubVersion =
|
||||
NormalizeLegacyUgcSubVersion(
|
||||
subRoom.SubRoomDataSave.UgcSubVersion);
|
||||
subRoom.SubRoomDataSave.OMVersion =
|
||||
NormalizeLegacyOMVersion(
|
||||
subRoom.SubRoomDataSave.OMVersion);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var tag in room.Tags)
|
||||
tag.Tag ??= string.Empty;
|
||||
|
||||
foreach (var externalContent in room.PromoExternalContent)
|
||||
externalContent.Reference ??= string.Empty;
|
||||
|
||||
foreach (var loadScreen in room.LoadScreens)
|
||||
{
|
||||
loadScreen.ImageName ??= string.Empty;
|
||||
loadScreen.Title ??= string.Empty;
|
||||
loadScreen.Subtitle ??= string.Empty;
|
||||
}
|
||||
|
||||
if (normalizedFields.Count > 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM DTO NORMALIZE] room={room.RoomId} name={room.Name} " +
|
||||
$"fields={string.Join(',', normalizedFields)}");
|
||||
}
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
private static void ResetInvalidPersonalDormToFreshState(Room room)
|
||||
{
|
||||
if (!room.IsDorm || room.RoomId == 1 || room.CreatorAccountId <= 0 ||
|
||||
room.SubRooms == null || room.SubRooms.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool roomBlobSet = !string.IsNullOrWhiteSpace(room.DataBlob);
|
||||
bool anySubRoomBlobSet = room.SubRooms.Any(subRoom =>
|
||||
!string.IsNullOrWhiteSpace(subRoom.DataBlob));
|
||||
bool hasMissingFile =
|
||||
(roomBlobSet && !RoomBlobExists(room.DataBlob)) ||
|
||||
room.SubRooms.Any(subRoom =>
|
||||
!string.IsNullOrWhiteSpace(subRoom.DataBlob) &&
|
||||
!RoomBlobExists(subRoom.DataBlob));
|
||||
bool hasAliasedPayload = roomBlobSet && room.SubRooms.Any(subRoom =>
|
||||
string.Equals(room.DataBlob, subRoom.DataBlob, StringComparison.OrdinalIgnoreCase));
|
||||
bool hasIncompletePair = roomBlobSet != anySubRoomBlobSet;
|
||||
|
||||
if (!hasMissingFile && !hasAliasedPayload && !hasIncompletePair)
|
||||
return;
|
||||
|
||||
room.DataBlob = null;
|
||||
room.PersistenceVersion = 0;
|
||||
foreach (var subRoom in room.SubRooms)
|
||||
{
|
||||
subRoom.DataBlob = null;
|
||||
subRoom.SubRoomDataSaveId = 0;
|
||||
subRoom.SubRoomDataSave = null;
|
||||
}
|
||||
|
||||
SubRoomDataSaves.DeleteMany(save => save.RoomId == room.RoomId);
|
||||
Rooms.Update(room);
|
||||
Console.WriteLine(
|
||||
$"[DORM FRESH] account={room.CreatorAccountId} room={room.RoomId} " +
|
||||
"discarded invalid persistence data");
|
||||
}
|
||||
|
||||
private static bool RoomBlobExists(string? filename)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filename))
|
||||
return false;
|
||||
|
||||
string safeName = Path.GetFileName(filename);
|
||||
return string.Equals(safeName, filename, StringComparison.Ordinal) &&
|
||||
File.Exists(Path.Combine(Program.dataDir, "CDN", "room", safeName));
|
||||
}
|
||||
|
||||
public static List<Room> PrepareRoomsForClient(IEnumerable<Room> rooms)
|
||||
{
|
||||
var prepared = rooms.ToList();
|
||||
foreach (var room in prepared)
|
||||
PrepareRoomForClient(room);
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
public static async Task ImportRooms(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
string jsonData = await File.ReadAllTextAsync(path);
|
||||
|
||||
var options = new SysJson.JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = SysJson.JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
var roomsList = SysJson.JsonSerializer.Deserialize<List<Room>>(jsonData, options);
|
||||
|
||||
if (roomsList == null) return;
|
||||
|
||||
foreach (var room in roomsList)
|
||||
{
|
||||
if (room == null) continue;
|
||||
if (Rooms.FindById(room.RoomId) != null) continue;
|
||||
|
||||
await AddRoom(room, log: true, shouldAssignNewIds: false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Import Error] Failed to import JSON: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<bool> AddRoom(Room newRoom, bool log = false, bool shouldAssignNewIds = true)
|
||||
{
|
||||
if (newRoom == null)
|
||||
return false;
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (shouldAssignNewIds || newRoom.RoomId <= 0)
|
||||
newRoom.RoomId = GetNextRoomId();
|
||||
|
||||
newRoom.RankedEntityId = newRoom.RoomId.ToString();
|
||||
newRoom.CreatedAt = newRoom.CreatedAt == default
|
||||
? DateTime.UtcNow
|
||||
: newRoom.CreatedAt;
|
||||
|
||||
newRoom.Stats ??= new Stats();
|
||||
newRoom.SubRooms ??= new List<SubRooms>();
|
||||
newRoom.Roles ??= new List<Roles>();
|
||||
newRoom.Tags ??= new List<Tags>();
|
||||
newRoom.PromoImages ??= new List<string>();
|
||||
newRoom.PromoExternalContent ??= new List<PromoExternalContent>();
|
||||
newRoom.LoadScreens ??= new List<LoadScreens>();
|
||||
|
||||
if (IsCanonicalBaseRoom(newRoom))
|
||||
{
|
||||
newRoom.IsBaseRoom = true;
|
||||
newRoom.IsDorm = false;
|
||||
newRoom.IsRRO = true;
|
||||
newRoom.Accessibility = RoomAccessibility.Public;
|
||||
newRoom.State = RoomState.Active;
|
||||
if (!newRoom.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "base", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
newRoom.Tags.Add(new Tags { Tag = "base", Type = TagType.Auto });
|
||||
}
|
||||
if (!newRoom.Tags.Any(tag => string.Equals(
|
||||
tag.Tag, "rro", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
newRoom.Tags.Add(new Tags { Tag = "rro", Type = TagType.AGOnly });
|
||||
}
|
||||
}
|
||||
|
||||
long nextSubRoomId = GetNextSubRoomId();
|
||||
|
||||
foreach (var sub in newRoom.SubRooms)
|
||||
{
|
||||
if (shouldAssignNewIds || sub.SubRoomId <= 0)
|
||||
sub.SubRoomId = nextSubRoomId++;
|
||||
|
||||
sub.RoomId = newRoom.RoomId;
|
||||
sub.Name ??= "Home";
|
||||
sub.UnitySceneId ??= string.Empty;
|
||||
sub.Permissions ??= new List<SubRoomPermission>();
|
||||
}
|
||||
|
||||
Rooms.Insert(newRoom);
|
||||
|
||||
if (log)
|
||||
Console.WriteLine($"[DB] Added room: {newRoom.Name} (ID: {newRoom.RoomId})");
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[DB Error] Failed to add room {newRoom.Name}: {ex}");
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static long GetNextRoomId()
|
||||
{
|
||||
if (Rooms.Count() == 0)
|
||||
return 1;
|
||||
|
||||
var maxId = Rooms.Max(x => x.RoomId);
|
||||
|
||||
return Convert.ToInt64(maxId) + 1;
|
||||
}
|
||||
|
||||
public static bool ChangeRoomId(long oldRoomId, long newRoomId, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (newRoomId <= 0)
|
||||
{
|
||||
error = "The new room ID must be a positive number.";
|
||||
return false;
|
||||
}
|
||||
if (newRoomId == oldRoomId)
|
||||
{
|
||||
error = "That's already this room's ID.";
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (SubRoomMutationLock)
|
||||
{
|
||||
Room? room = Rooms.FindById(oldRoomId);
|
||||
if (room == null)
|
||||
{
|
||||
error = "Room not found.";
|
||||
return false;
|
||||
}
|
||||
if (Rooms.Exists(candidate => candidate.RoomId == newRoomId))
|
||||
{
|
||||
error = $"Room {newRoomId} already exists.";
|
||||
return false;
|
||||
}
|
||||
|
||||
room.RoomId = newRoomId;
|
||||
room.RankedEntityId = newRoomId.ToString();
|
||||
if (room.SubRooms != null)
|
||||
{
|
||||
foreach (SubRooms subRoom in room.SubRooms)
|
||||
subRoom.RoomId = newRoomId;
|
||||
}
|
||||
|
||||
Rooms.Insert(room);
|
||||
Rooms.Delete(oldRoomId);
|
||||
|
||||
foreach (SubRoomDataSave save in SubRoomDataSaves.Find(value => value.RoomId == oldRoomId).ToList())
|
||||
{
|
||||
save.RoomId = newRoomId;
|
||||
SubRoomDataSaves.Update(save);
|
||||
}
|
||||
|
||||
foreach (RoomBan ban in RoomBans.Find(value => value.RoomId == oldRoomId).ToList())
|
||||
{
|
||||
ban.RoomId = newRoomId;
|
||||
RoomBans.Update(ban);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static long GetNextSubRoomId()
|
||||
{
|
||||
|
||||
lock (SubRoomMutationLock)
|
||||
{
|
||||
long maxSafeSubRoomId = 0;
|
||||
int ignoredImportedIds = 0;
|
||||
|
||||
foreach (var room in Rooms.FindAll())
|
||||
{
|
||||
if (room.SubRooms == null)
|
||||
continue;
|
||||
|
||||
foreach (var subRoom in room.SubRooms)
|
||||
{
|
||||
long id = subRoom.SubRoomId;
|
||||
if (id <= 0 || id > int.MaxValue)
|
||||
{
|
||||
ignoredImportedIds++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (id > maxSafeSubRoomId)
|
||||
maxSafeSubRoomId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxSafeSubRoomId >= int.MaxValue)
|
||||
throw new InvalidOperationException(
|
||||
"No client-safe SubRoomId values remain in the positive Int32 range.");
|
||||
|
||||
long nextId = maxSafeSubRoomId + 1;
|
||||
Console.WriteLine(
|
||||
$"[SUBROOM ID ALLOCATOR] next={nextId} " +
|
||||
$"maxSafeExisting={maxSafeSubRoomId} ignoredImported={ignoredImportedIds}");
|
||||
return nextId;
|
||||
}
|
||||
}
|
||||
|
||||
public static Room? AddSubRoom(long roomId, long accountId, string requestedName)
|
||||
{
|
||||
lock (SubRoomMutationLock)
|
||||
{
|
||||
var room = Rooms.FindById(roomId);
|
||||
if (room == null)
|
||||
return null;
|
||||
|
||||
room.SubRooms ??= new List<SubRooms>();
|
||||
string name = GetUniqueSubRoomName(
|
||||
room,
|
||||
string.IsNullOrWhiteSpace(requestedName) ? "New Room" : requestedName.Trim());
|
||||
var template = room.SubRooms.FirstOrDefault(subRoom =>
|
||||
string.Equals(subRoom.Name, "Home", StringComparison.OrdinalIgnoreCase))
|
||||
?? room.SubRooms.FirstOrDefault();
|
||||
|
||||
var subRoom = new SubRooms
|
||||
{
|
||||
SubRoomId = GetNextSubRoomId(),
|
||||
RoomId = room.RoomId,
|
||||
Name = name,
|
||||
DataBlob = null,
|
||||
SubRoomDataSaveId = 0,
|
||||
IsSandbox = template?.IsSandbox ?? true,
|
||||
MaxPlayers = Math.Max(1, template?.MaxPlayers ?? room.MaxPlayers),
|
||||
Accessibility = template?.Accessibility ?? room.Accessibility,
|
||||
UnitySceneId = template?.UnitySceneId ?? string.Empty,
|
||||
Permissions = ClonePermissions(template?.Permissions),
|
||||
SavedByAccountId = accountId
|
||||
};
|
||||
|
||||
room.SubRooms.Add(subRoom);
|
||||
room.UgcVersion = Math.Max(1, room.UgcVersion + 1);
|
||||
Rooms.Update(room);
|
||||
Console.WriteLine(
|
||||
$"[SUBROOM ADD] room={roomId} subroom={subRoom.SubRoomId} name={subRoom.Name} by={accountId}");
|
||||
return PrepareRoomForClient(room);
|
||||
}
|
||||
}
|
||||
|
||||
public static Room? CloneSubRoom(long roomId, long subRoomId, long accountId)
|
||||
{
|
||||
lock (SubRoomMutationLock)
|
||||
{
|
||||
var room = Rooms.FindById(roomId);
|
||||
var source = room?.SubRooms?.FirstOrDefault(subRoom =>
|
||||
subRoom.SubRoomId == subRoomId);
|
||||
if (room == null || source == null)
|
||||
return null;
|
||||
|
||||
string name = GetUniqueSubRoomName(room, $"{source.Name} Copy");
|
||||
var clone = new SubRooms
|
||||
{
|
||||
SubRoomId = GetNextSubRoomId(),
|
||||
RoomId = room.RoomId,
|
||||
Name = name,
|
||||
DataBlob = source.DataBlob,
|
||||
SubRoomDataSaveId = 0,
|
||||
IsSandbox = source.IsSandbox,
|
||||
MaxPlayers = Math.Max(1, source.MaxPlayers),
|
||||
Accessibility = source.Accessibility,
|
||||
UnitySceneId = source.UnitySceneId ?? string.Empty,
|
||||
Permissions = ClonePermissions(source.Permissions),
|
||||
SavedByAccountId = accountId
|
||||
};
|
||||
|
||||
room.SubRooms!.Add(clone);
|
||||
room.UgcVersion = Math.Max(1, room.UgcVersion + 1);
|
||||
Rooms.Update(room);
|
||||
Console.WriteLine(
|
||||
$"[SUBROOM CLONE] room={roomId} source={subRoomId} clone={clone.SubRoomId} name={clone.Name} by={accountId}");
|
||||
return PrepareRoomForClient(room);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetUniqueSubRoomName(Room room, string requestedName)
|
||||
{
|
||||
string baseName = requestedName.Trim();
|
||||
if (baseName.Length > 50)
|
||||
baseName = baseName[..50].TrimEnd();
|
||||
if (string.IsNullOrWhiteSpace(baseName))
|
||||
baseName = "New Room";
|
||||
|
||||
var usedNames = (room.SubRooms ?? new List<SubRooms>())
|
||||
.Select(subRoom => subRoom.Name)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
if (!usedNames.Contains(baseName))
|
||||
return baseName;
|
||||
|
||||
for (int suffix = 2; suffix < 10_000; suffix++)
|
||||
{
|
||||
string suffixText = $" {suffix}";
|
||||
int prefixLength = Math.Min(baseName.Length, 50 - suffixText.Length);
|
||||
string candidate = baseName[..prefixLength].TrimEnd() + suffixText;
|
||||
if (!usedNames.Contains(candidate))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return $"Room {Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
private static List<SubRoomPermission> ClonePermissions(
|
||||
IEnumerable<SubRoomPermission>? permissions)
|
||||
{
|
||||
return permissions?.Select(permission => new SubRoomPermission
|
||||
{
|
||||
Override = permission.Override,
|
||||
Permission = permission.Permission,
|
||||
Role = permission.Role,
|
||||
Type = permission.Type,
|
||||
Value = permission.Value
|
||||
}).ToList() ?? new List<SubRoomPermission>();
|
||||
}
|
||||
|
||||
public static Room GetRoom(long roomId)
|
||||
{
|
||||
return PrepareRoomForClient(Rooms.FindById(roomId));
|
||||
}
|
||||
|
||||
public static Room GetRoomByName(string name)
|
||||
{
|
||||
Room? room = Rooms.FindOne(x =>
|
||||
x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (room == null)
|
||||
{
|
||||
string normalized = NormalizeCanonicalRoomName(name);
|
||||
if (CanonicalBaseRoomNames.Any(canonical =>
|
||||
NormalizeCanonicalRoomName(canonical) == normalized))
|
||||
{
|
||||
room = Rooms.FindAll().FirstOrDefault(candidate =>
|
||||
NormalizeCanonicalRoomName(candidate.Name) == normalized);
|
||||
}
|
||||
}
|
||||
return PrepareRoomForClient(room);
|
||||
}
|
||||
|
||||
public static List<Room> GetRoomsByNames(List<string> names)
|
||||
{
|
||||
if (names == null || names.Count == 0)
|
||||
return new List<Room>();
|
||||
|
||||
return PrepareRoomsForClient(Rooms.Find(room =>
|
||||
names.Contains(room.Name, StringComparer.OrdinalIgnoreCase)
|
||||
));
|
||||
}
|
||||
|
||||
public static Room? GetOrCreatePlayerDorm(long accountId)
|
||||
{
|
||||
if (accountId <= 0)
|
||||
return null;
|
||||
|
||||
lock (DormCreationLock)
|
||||
{
|
||||
|
||||
var existing = Rooms.Find(room =>
|
||||
room.IsDorm &&
|
||||
room.RoomId != 1 &&
|
||||
room.CreatorAccountId == accountId &&
|
||||
room.State != RoomState.MarkedForDelete)
|
||||
.OrderBy(room => room.RoomId)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Roles ??= new List<Roles>();
|
||||
if (!existing.Roles.Any(role =>
|
||||
role.AccountId == accountId &&
|
||||
role.Role == Role.Creator))
|
||||
{
|
||||
existing.Roles.RemoveAll(role => role.AccountId == accountId);
|
||||
existing.Roles.Add(new Roles
|
||||
{
|
||||
AccountId = accountId,
|
||||
Role = Role.Creator,
|
||||
InvitedRole = Role.None
|
||||
});
|
||||
Rooms.Update(existing);
|
||||
}
|
||||
|
||||
return PrepareRoomForClient(existing);
|
||||
}
|
||||
|
||||
var template = Rooms.FindById(1) ??
|
||||
Rooms.FindOne(room => room.IsDorm);
|
||||
if (template == null)
|
||||
return null;
|
||||
|
||||
long roomId = GetNextRoomId();
|
||||
long nextSubRoomId = GetNextSubRoomId();
|
||||
var dorm = new Room
|
||||
{
|
||||
RoomId = roomId,
|
||||
IsDorm = true,
|
||||
MaxPlayerCalculationMode = template.MaxPlayerCalculationMode,
|
||||
MaxPlayers = template.MaxPlayers,
|
||||
CloningAllowed = false,
|
||||
DisableMicAutoMute = template.DisableMicAutoMute,
|
||||
DisableRoomComments = template.DisableRoomComments,
|
||||
EncryptVoiceChat = template.EncryptVoiceChat,
|
||||
ToxmodEnabled = template.ToxmodEnabled,
|
||||
LoadScreenLocked = template.LoadScreenLocked,
|
||||
PersistenceVersion = template.PersistenceVersion,
|
||||
AutoLocalizeRoom = template.AutoLocalizeRoom,
|
||||
IsDeveloperOwned = false,
|
||||
Name = "DormRoom",
|
||||
Description = template.Description,
|
||||
ImageName = template.ImageName,
|
||||
WarningMask = template.WarningMask,
|
||||
CustomWarning = template.CustomWarning,
|
||||
CreatorAccountId = accountId,
|
||||
State = RoomState.Active,
|
||||
Accessibility = RoomAccessibility.Private,
|
||||
SupportsLevelVoting = template.SupportsLevelVoting,
|
||||
IsRRO = template.IsRRO,
|
||||
SupportsScreens = template.SupportsScreens,
|
||||
SupportsWalkVR = template.SupportsWalkVR,
|
||||
SupportsTeleportVR = template.SupportsTeleportVR,
|
||||
SupportsVRLow = template.SupportsVRLow,
|
||||
SupportsQuest2 = template.SupportsQuest2,
|
||||
SupportsMobile = template.SupportsMobile,
|
||||
SupportsJuniors = template.SupportsJuniors,
|
||||
MinLevel = template.MinLevel,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Stats = new Stats(),
|
||||
RankedEntityId = roomId.ToString(),
|
||||
RankingContext = null,
|
||||
DataBlob = template.DataBlob,
|
||||
UgcVersion = Math.Max(1, template.UgcVersion),
|
||||
Tags = template.Tags?
|
||||
.Select(tag => new Tags { Tag = tag.Tag, Type = tag.Type })
|
||||
.ToList() ?? new List<Tags>(),
|
||||
PromoImages = template.PromoImages?.ToList() ?? new List<string>(),
|
||||
PromoExternalContent = template.PromoExternalContent?
|
||||
.Select(item => new PromoExternalContent
|
||||
{
|
||||
Type = item.Type,
|
||||
Reference = item.Reference
|
||||
})
|
||||
.ToList() ?? new List<PromoExternalContent>(),
|
||||
LoadScreens = template.LoadScreens?
|
||||
.Select(screen => new LoadScreens
|
||||
{
|
||||
ImageName = screen.ImageName,
|
||||
Title = screen.Title,
|
||||
Subtitle = screen.Subtitle
|
||||
})
|
||||
.ToList() ?? new List<LoadScreens>(),
|
||||
Roles = new List<Roles>
|
||||
{
|
||||
new Roles
|
||||
{
|
||||
AccountId = accountId,
|
||||
Role = Role.Creator,
|
||||
InvitedRole = Role.None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
dorm.SubRooms = (template.SubRooms ?? new List<SubRooms>())
|
||||
.Select(subRoom => new SubRooms
|
||||
{
|
||||
SubRoomId = nextSubRoomId++,
|
||||
RoomId = roomId,
|
||||
Name = subRoom.Name,
|
||||
DataBlob = subRoom.DataBlob,
|
||||
SubRoomDataSaveId = 0,
|
||||
IsSandbox = subRoom.IsSandbox,
|
||||
MaxPlayers = subRoom.MaxPlayers,
|
||||
Accessibility = RoomAccessibility.Private,
|
||||
UnitySceneId = subRoom.UnitySceneId,
|
||||
Permissions = ClonePermissions(subRoom.Permissions),
|
||||
SavedByAccountId = accountId
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (dorm.SubRooms.Count == 0)
|
||||
return null;
|
||||
|
||||
Rooms.Insert(dorm);
|
||||
Console.WriteLine(
|
||||
$"[DORM CREATE] account={accountId} room={roomId} " +
|
||||
$"subrooms={dorm.SubRooms.Count}");
|
||||
return PrepareRoomForClient(dorm);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> EnsureCanonicalBaseRooms(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return 0;
|
||||
|
||||
string jsonData = await File.ReadAllTextAsync(path);
|
||||
var options = new SysJson.JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = SysJson.JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
var importedRooms = SysJson.JsonSerializer.Deserialize<List<Room>>(jsonData, options) ?? new();
|
||||
var templates = importedRooms
|
||||
.Where(IsCanonicalBaseRoom)
|
||||
.OrderBy(GetCanonicalBaseRoomOrder)
|
||||
.ToList();
|
||||
|
||||
int changed = 0;
|
||||
foreach (var template in templates)
|
||||
{
|
||||
var existing = GetRoomByName(template.Name);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.IsDorm = false;
|
||||
existing.IsRRO = true;
|
||||
existing.IsBaseRoom = true;
|
||||
existing.Accessibility = RoomAccessibility.Public;
|
||||
existing.State = RoomState.Active;
|
||||
existing.Tags ??= new List<Tags>();
|
||||
if (!existing.Tags.Any(tag =>
|
||||
string.Equals(tag.Tag, "base", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
existing.Tags.Add(new Tags { Tag = "base", Type = TagType.Auto });
|
||||
changed++;
|
||||
}
|
||||
if (!existing.Tags.Any(tag =>
|
||||
string.Equals(tag.Tag, "rro", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
existing.Tags.Add(new Tags { Tag = "rro", Type = TagType.AGOnly });
|
||||
changed++;
|
||||
}
|
||||
|
||||
Rooms.Update(existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await AddRoom(
|
||||
template,
|
||||
log: true,
|
||||
shouldAssignNewIds: true))
|
||||
{
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
public static int EnsurePlayerDormsForAllPlayers()
|
||||
{
|
||||
int created = 0;
|
||||
foreach (long accountId in PlayerDB.Players.FindAll()
|
||||
.Select(player => player.PlayerId)
|
||||
.Where(accountId => accountId > 0))
|
||||
{
|
||||
bool alreadyExisted = Rooms.Exists(room =>
|
||||
room.IsDorm &&
|
||||
room.RoomId != 1 &&
|
||||
room.CreatorAccountId == accountId &&
|
||||
room.State != RoomState.MarkedForDelete);
|
||||
|
||||
if (GetOrCreatePlayerDorm(accountId) != null && !alreadyExisted)
|
||||
created++;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public static int DeletePlayerDorms(long accountId)
|
||||
{
|
||||
if (accountId <= 0)
|
||||
return 0;
|
||||
|
||||
lock (DormCreationLock)
|
||||
{
|
||||
var roomIds = Rooms.Find(room =>
|
||||
room.IsDorm &&
|
||||
room.RoomId != 1 &&
|
||||
room.CreatorAccountId == accountId)
|
||||
.Select(room => room.RoomId)
|
||||
.ToList();
|
||||
|
||||
return roomIds.Count(roomId => Rooms.Delete(roomId));
|
||||
}
|
||||
}
|
||||
|
||||
public static int RemoveOrphanedPlayerDorms()
|
||||
{
|
||||
var accountIds = PlayerDB.Players.FindAll()
|
||||
.Select(player => player.PlayerId)
|
||||
.Where(accountId => accountId > 0)
|
||||
.ToHashSet();
|
||||
|
||||
lock (DormCreationLock)
|
||||
{
|
||||
var orphanedRoomIds = Rooms.Find(room =>
|
||||
room.IsDorm &&
|
||||
room.RoomId != 1 &&
|
||||
room.CreatorAccountId > 0 &&
|
||||
!accountIds.Contains(room.CreatorAccountId))
|
||||
.Select(room => room.RoomId)
|
||||
.ToList();
|
||||
|
||||
return orphanedRoomIds.Count(roomId => Rooms.Delete(roomId));
|
||||
}
|
||||
}
|
||||
|
||||
public static (List<Room> Results, int Total) GetHotRooms(string tag, int skip, int take)
|
||||
{
|
||||
|
||||
RepairCanonicalBaseRoomMetadata();
|
||||
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
string t = string.IsNullOrWhiteSpace(tag)
|
||||
? "hot"
|
||||
: tag.Trim().ToLowerInvariant();
|
||||
|
||||
IEnumerable<Room> query = Rooms.FindAll()
|
||||
.Where(r => !r.IsDorm && r.State != RoomState.MarkedForDelete);
|
||||
|
||||
switch (t)
|
||||
{
|
||||
case "hot":
|
||||
query = query.Where(r =>
|
||||
r.Accessibility == RoomAccessibility.Public);
|
||||
break;
|
||||
|
||||
case "rro":
|
||||
case "recroomoriginal":
|
||||
query = query.Where(r =>
|
||||
r.Accessibility == RoomAccessibility.Public &&
|
||||
(r.IsRRO ||
|
||||
(r.Tags?.Any(x =>
|
||||
string.Equals(x.Tag, "rro", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(x.Tag, "recroomoriginal", StringComparison.OrdinalIgnoreCase)) ?? false)));
|
||||
break;
|
||||
|
||||
case "base":
|
||||
query = query.Where(r =>
|
||||
r.Accessibility == RoomAccessibility.Public &&
|
||||
IsCanonicalBaseRoom(r));
|
||||
break;
|
||||
|
||||
case "new":
|
||||
query = query.Where(r => r.Accessibility == RoomAccessibility.Public);
|
||||
break;
|
||||
|
||||
default:
|
||||
query = query.Where(r =>
|
||||
r.Accessibility == RoomAccessibility.Public &&
|
||||
(r.Tags?.Any(x =>
|
||||
string.Equals(x.Tag, t, StringComparison.OrdinalIgnoreCase)) ?? false));
|
||||
break;
|
||||
}
|
||||
|
||||
query = t switch
|
||||
{
|
||||
"new" => query.OrderByDescending(r => r.CreatedAt)
|
||||
.ThenByDescending(r => r.RoomId),
|
||||
"base" => query.OrderBy(GetCanonicalBaseRoomOrder),
|
||||
_ => query.OrderByDescending(r => r.Stats?.VisitCount ?? 0)
|
||||
.ThenByDescending(r => r.CreatedAt)
|
||||
};
|
||||
|
||||
var all = query.ToList();
|
||||
var page = PrepareRoomsForClient(all.Skip(skip).Take(take));
|
||||
|
||||
return (
|
||||
page,
|
||||
all.Count
|
||||
);
|
||||
}
|
||||
|
||||
public static Room? CloneRoom(Room source, long ownerId, string? newName)
|
||||
{
|
||||
if (source == null || ownerId <= 0 || !source.CloningAllowed)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
long nextRoomId = GetNextRoomId();
|
||||
long nextSubRoomId = GetNextSubRoomId();
|
||||
|
||||
string requestedName = string.IsNullOrWhiteSpace(newName)
|
||||
? $"{source.Name} Copy"
|
||||
: newName.Trim();
|
||||
|
||||
string uniqueName = requestedName;
|
||||
int suffix = 2;
|
||||
|
||||
while (GetRoomByName(uniqueName) != null)
|
||||
uniqueName = $"{requestedName} {suffix++}";
|
||||
|
||||
var clonedRoom = new Room
|
||||
{
|
||||
RoomId = nextRoomId,
|
||||
IsDorm = false,
|
||||
MaxPlayerCalculationMode = source.MaxPlayerCalculationMode,
|
||||
MaxPlayers = source.MaxPlayers,
|
||||
CloningAllowed = true,
|
||||
DisableMicAutoMute = source.DisableMicAutoMute,
|
||||
DisableRoomComments = source.DisableRoomComments,
|
||||
EncryptVoiceChat = source.EncryptVoiceChat,
|
||||
ToxmodEnabled = source.ToxmodEnabled,
|
||||
LoadScreenLocked = false,
|
||||
PersistenceVersion = source.PersistenceVersion,
|
||||
AutoLocalizeRoom = source.AutoLocalizeRoom,
|
||||
IsDeveloperOwned = false,
|
||||
Name = uniqueName,
|
||||
Description = source.Description,
|
||||
ImageName = source.ImageName,
|
||||
WarningMask = source.WarningMask,
|
||||
CustomWarning = source.CustomWarning,
|
||||
CreatorAccountId = ownerId,
|
||||
State = RoomState.Active,
|
||||
Accessibility = RoomAccessibility.Private,
|
||||
SupportsLevelVoting = source.SupportsLevelVoting,
|
||||
IsRRO = false,
|
||||
SupportsScreens = source.SupportsScreens,
|
||||
SupportsWalkVR = source.SupportsWalkVR,
|
||||
SupportsTeleportVR = source.SupportsTeleportVR,
|
||||
SupportsVRLow = source.SupportsVRLow,
|
||||
SupportsQuest2 = source.SupportsQuest2,
|
||||
SupportsMobile = source.SupportsMobile,
|
||||
SupportsJuniors = source.SupportsJuniors,
|
||||
MinLevel = source.MinLevel,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Stats = new Stats(),
|
||||
RankedEntityId = nextRoomId.ToString(),
|
||||
RankingContext = null,
|
||||
UgcVersion = Math.Max(1, source.UgcVersion),
|
||||
Tags = source.Tags?
|
||||
.Where(t => !string.Equals(t.Tag, "rro", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(t.Tag, "base", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(t => new Tags { Tag = t.Tag, Type = t.Type })
|
||||
.ToList() ?? new List<Tags>(),
|
||||
PromoImages = source.PromoImages?.ToList() ?? new List<string>(),
|
||||
PromoExternalContent = source.PromoExternalContent?
|
||||
.Select(x => new PromoExternalContent { Type = x.Type, Reference = x.Reference })
|
||||
.ToList() ?? new List<PromoExternalContent>(),
|
||||
LoadScreens = source.LoadScreens?
|
||||
.Select(x => new LoadScreens
|
||||
{
|
||||
ImageName = x.ImageName,
|
||||
Title = x.Title,
|
||||
Subtitle = x.Subtitle
|
||||
})
|
||||
.ToList() ?? new List<LoadScreens>(),
|
||||
Roles = new List<Roles>
|
||||
{
|
||||
new Roles
|
||||
{
|
||||
AccountId = ownerId,
|
||||
Role = Role.Creator,
|
||||
InvitedRole = Role.None
|
||||
}
|
||||
},
|
||||
DataBlob = source.DataBlob
|
||||
};
|
||||
|
||||
clonedRoom.SubRooms = (source.SubRooms ?? new List<SubRooms>())
|
||||
.Select(sr => new SubRooms
|
||||
{
|
||||
SubRoomId = nextSubRoomId++,
|
||||
RoomId = nextRoomId,
|
||||
Name = sr.Name,
|
||||
DataBlob = sr.DataBlob,
|
||||
SubRoomDataSaveId = 0,
|
||||
IsSandbox = sr.IsSandbox,
|
||||
MaxPlayers = sr.MaxPlayers,
|
||||
Accessibility = sr.Accessibility,
|
||||
UnitySceneId = sr.UnitySceneId,
|
||||
Permissions = ClonePermissions(sr.Permissions),
|
||||
SavedByAccountId = ownerId
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (clonedRoom.SubRooms.Count == 0)
|
||||
return null;
|
||||
|
||||
Rooms.Insert(clonedRoom);
|
||||
Console.WriteLine($"[DB] Cloned room {source.RoomId} -> {clonedRoom.RoomId} ({clonedRoom.Name})");
|
||||
return clonedRoom;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[DB Error] Failed to clone room {source.Name}: {ex}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool DeleteRoom(long roomId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Rooms.Delete(roomId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[DB Error] Failed to delete room {roomId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static Room? RenameRoom(long roomId, string newName)
|
||||
{
|
||||
var room = Rooms.FindById(roomId);
|
||||
if (room == null) return null;
|
||||
|
||||
room.Name = newName;
|
||||
|
||||
try
|
||||
{
|
||||
Rooms.Update(room);
|
||||
return room;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[DB Error] Failed to rename room {roomId}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static (int RoomsOwned, int SubRoomSaves, int RoomBans) RenameAccountReferences(
|
||||
long oldAccountId,
|
||||
long newAccountId)
|
||||
{
|
||||
if (oldAccountId <= 0 || newAccountId <= 0 || oldAccountId == newAccountId)
|
||||
return (0, 0, 0);
|
||||
|
||||
int roomsOwned = 0;
|
||||
foreach (Room room in Rooms.Find(value => value.CreatorAccountId == oldAccountId).ToList())
|
||||
{
|
||||
room.CreatorAccountId = newAccountId;
|
||||
if (Rooms.Update(room))
|
||||
roomsOwned++;
|
||||
}
|
||||
|
||||
int subRoomSaves = 0;
|
||||
foreach (SubRoomDataSave save in SubRoomDataSaves
|
||||
.Find(value => value.SavedByAccountId == oldAccountId)
|
||||
.ToList())
|
||||
{
|
||||
save.SavedByAccountId = newAccountId;
|
||||
if (SubRoomDataSaves.Update(save))
|
||||
subRoomSaves++;
|
||||
}
|
||||
|
||||
int roomBans = 0;
|
||||
foreach (RoomBan ban in RoomBans
|
||||
.Find(value =>
|
||||
value.AccountId == oldAccountId ||
|
||||
value.BannedByAccountId == oldAccountId)
|
||||
.ToList())
|
||||
{
|
||||
if (ban.AccountId == oldAccountId)
|
||||
ban.AccountId = newAccountId;
|
||||
if (ban.BannedByAccountId == oldAccountId)
|
||||
ban.BannedByAccountId = newAccountId;
|
||||
|
||||
if (RoomBans.Update(ban))
|
||||
roomBans++;
|
||||
}
|
||||
|
||||
return (roomsOwned, subRoomSaves, roomBans);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Mocha2023.Classes.DBs;
|
||||
|
||||
public static class ScannerLogDB
|
||||
{
|
||||
public class ScannerAttempt
|
||||
{
|
||||
[BsonId]
|
||||
public long Id { get; set; }
|
||||
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
public string IpAddress { get; set; } = "unknown";
|
||||
public string Method { get; set; } = "";
|
||||
public string Path { get; set; } = "";
|
||||
public string? QueryString { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
public string? MatchedPattern { get; set; }
|
||||
}
|
||||
|
||||
private static readonly LiteDatabase DB = new(
|
||||
Path.Combine(Program.dataDir, "DBs", "ScannerLogs.db"));
|
||||
|
||||
public static readonly ILiteCollection<ScannerAttempt> Attempts =
|
||||
DB.GetCollection<ScannerAttempt>("Attempts");
|
||||
|
||||
private const int MaxStored = 20_000;
|
||||
private const int TrimBatchSize = 1_000;
|
||||
|
||||
public static void Log(
|
||||
string ipAddress,
|
||||
string method,
|
||||
string path,
|
||||
string? queryString,
|
||||
string? userAgent,
|
||||
string? matchedPattern)
|
||||
{
|
||||
Attempts.Insert(new ScannerAttempt
|
||||
{
|
||||
IpAddress = ipAddress,
|
||||
Method = method,
|
||||
Path = path,
|
||||
QueryString = queryString,
|
||||
UserAgent = userAgent,
|
||||
MatchedPattern = matchedPattern
|
||||
});
|
||||
|
||||
long count = Attempts.LongCount();
|
||||
if (count > MaxStored)
|
||||
{
|
||||
List<long> oldestIds = Attempts.Query()
|
||||
.OrderBy(a => a.Id)
|
||||
.Limit(TrimBatchSize)
|
||||
.ToList()
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (long id in oldestIds)
|
||||
Attempts.Delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1363 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
using static Mocha2023.Classes.DBs.DBClasses.RoomDBClasses;
|
||||
|
||||
namespace Mocha2023.Classes.DBs;
|
||||
|
||||
public static class ShowdownImporter
|
||||
{
|
||||
private const int CompatiblePersistenceVersion = 38;
|
||||
private const int CompatibleUgcSubVersion = 0;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString
|
||||
};
|
||||
|
||||
public sealed record ImportResult(
|
||||
long RoomId,
|
||||
int SubRoomsImported,
|
||||
int SavesImported,
|
||||
int BlobsCopied,
|
||||
int SavesSkipped,
|
||||
string PlayableSubRoomName,
|
||||
long PlayableSubRoomId)
|
||||
{
|
||||
public int BakedAssetsImported { get; init; }
|
||||
public int AssetBundlesCopied { get; init; }
|
||||
public int AssetBundlesMissing { get; init; }
|
||||
public bool ImageCopied { get; init; }
|
||||
public IReadOnlyList<string> UnityEngineVersions { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public static ImportResult ImportUnityMetadata(
|
||||
string json,
|
||||
long creatorAccountId = 1,
|
||||
bool replaceExisting = true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
throw new InvalidDataException("The room metadata JSON is empty.");
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(json, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
JsonElement root = document.RootElement;
|
||||
foreach (string wrapper in new[] { "room", "Room", "value", "Value", "data", "Data" })
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
TryGetProperty(root, wrapper, out JsonElement nested) &&
|
||||
nested.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
root = nested;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ExportRoom exportRoom = JsonSerializer.Deserialize<ExportRoom>(
|
||||
root.GetRawText(),
|
||||
JsonOptions) ?? throw new InvalidDataException("Could not deserialize the room metadata JSON.");
|
||||
|
||||
return ImportUnityExport(exportRoom, creatorAccountId, replaceExisting);
|
||||
}
|
||||
|
||||
public static ImportResult Import(
|
||||
string exportRoot,
|
||||
long creatorAccountId = 1,
|
||||
bool replaceExisting = true)
|
||||
{
|
||||
exportRoot = Path.GetFullPath(exportRoot.Trim().Trim('"'));
|
||||
string roomJsonPath = Path.Combine(exportRoot, "room.json");
|
||||
|
||||
if (!File.Exists(roomJsonPath))
|
||||
throw new FileNotFoundException("room.json was not found.", roomJsonPath);
|
||||
|
||||
ExportRoom exportRoom = ReadJson<ExportRoom>(roomJsonPath);
|
||||
if (string.IsNullOrWhiteSpace(exportRoom.Name))
|
||||
throw new InvalidDataException("room.json does not contain a valid Name.");
|
||||
|
||||
string roomBlobDestination = Path.Combine(Program.dataDir, "CDN", "room");
|
||||
string assetBundleDestination = Path.Combine(Program.dataDir, "CDN", "assetbundles");
|
||||
string imageDestination = Path.Combine(Program.dataDir, "Images");
|
||||
Directory.CreateDirectory(roomBlobDestination);
|
||||
Directory.CreateDirectory(assetBundleDestination);
|
||||
Directory.CreateDirectory(imageDestination);
|
||||
|
||||
string? assetBundleSource = FindDirectory(exportRoot, "AssetBundles");
|
||||
|
||||
long assignedRoomId = RoomDB.GetNextRoomId();
|
||||
string normalizedRoomName = exportRoom.Name.Trim().TrimStart('^');
|
||||
Room? existingByName = RoomDB.Rooms.FindAll().FirstOrDefault(room =>
|
||||
string.Equals(room.Name, normalizedRoomName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (replaceExisting)
|
||||
{
|
||||
if (existingByName != null)
|
||||
{
|
||||
RoomDB.SubRoomDataSaves.DeleteMany(save => save.RoomId == existingByName.RoomId);
|
||||
RoomDB.Rooms.Delete(existingByName.RoomId);
|
||||
}
|
||||
}
|
||||
else if (existingByName != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A room named \"{normalizedRoomName}\" already exists. Enable replaceExisting to overwrite it.");
|
||||
}
|
||||
|
||||
long nextLocalSaveId = RoomDB.SubRoomDataSaves.Count() == 0
|
||||
? 1
|
||||
: Convert.ToInt64(RoomDB.SubRoomDataSaves.Max(save => save.SubRoomDataSaveId)) + 1;
|
||||
|
||||
var importedSubRooms = new List<SubRooms>();
|
||||
int savesImported = 0;
|
||||
int blobsCopied = 0;
|
||||
int savesSkipped = 0;
|
||||
int bakedAssetsImported = 0;
|
||||
int assetBundlesCopied = 0;
|
||||
int assetBundlesMissing = 0;
|
||||
var unityEngineVersions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
SubRooms? playableSubRoom = null;
|
||||
SubRoomDataSave? playableCurrentSave = null;
|
||||
|
||||
foreach (ExportSubRoom listedSubRoom in exportRoom.SubRooms ?? new List<ExportSubRoom>())
|
||||
{
|
||||
string subRoomDirectory = Path.Combine(exportRoot, listedSubRoom.SubRoomId.ToString());
|
||||
string subRoomJsonPath = Path.Combine(subRoomDirectory, "subRoom.json");
|
||||
if (!File.Exists(subRoomJsonPath))
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Missing subRoom.json for {listedSubRoom.SubRoomId}; skipped.");
|
||||
continue;
|
||||
}
|
||||
|
||||
ExportSubRoom sourceSubRoom = ReadJson<ExportSubRoom>(subRoomJsonPath);
|
||||
MergeSubRoomMetadata(listedSubRoom, sourceSubRoom);
|
||||
|
||||
string savesDirectory = Path.Combine(subRoomDirectory, "Saves");
|
||||
if (!Directory.Exists(savesDirectory))
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Missing Saves directory for {sourceSubRoom.SubRoomId}; skipped.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var importedForThisSubRoom = new List<(long SourceSaveId, SubRoomDataSave LocalSave)>();
|
||||
|
||||
foreach (string saveDirectory in Directory.EnumerateDirectories(savesDirectory))
|
||||
{
|
||||
string directoryName = Path.GetFileName(saveDirectory);
|
||||
string saveJsonPath = Path.Combine(saveDirectory, directoryName + ".json");
|
||||
|
||||
if (!File.Exists(saveJsonPath))
|
||||
{
|
||||
saveJsonPath = Directory.EnumerateFiles(saveDirectory, "*.json")
|
||||
.FirstOrDefault(path =>
|
||||
!Path.GetFileName(path).StartsWith(
|
||||
"bakedUnityAsset-",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
?? string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(saveJsonPath) || !File.Exists(saveJsonPath))
|
||||
{
|
||||
savesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
ExportSave sourceSave;
|
||||
try
|
||||
{
|
||||
sourceSave = ReadJson<ExportSave>(saveJsonPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Invalid save JSON {saveJsonPath}: {exception.Message}");
|
||||
savesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
string safeBlobName = Path.GetFileName(sourceSave.DataBlob ?? string.Empty);
|
||||
string sourceBlobPath = Path.Combine(saveDirectory, safeBlobName);
|
||||
if (string.IsNullOrWhiteSpace(safeBlobName) || !File.Exists(sourceBlobPath))
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Missing blob for source save {sourceSave.SubRoomDataSaveId}; skipped.");
|
||||
savesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
string destinationBlobPath = Path.Combine(roomBlobDestination, safeBlobName);
|
||||
if (CopyIfDifferent(sourceBlobPath, destinationBlobPath))
|
||||
blobsCopied++;
|
||||
|
||||
var bakedAssets = new List<BakedUnityAsset>();
|
||||
foreach (string bakedJsonPath in Directory.EnumerateFiles(
|
||||
saveDirectory,
|
||||
"bakedUnityAsset-*.json",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
BakedUnityAsset? bakedAsset;
|
||||
try
|
||||
{
|
||||
bakedAsset = ReadJson<BakedUnityAsset>(bakedJsonPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Invalid baked asset JSON {bakedJsonPath}: {exception.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
bakedAsset.UnityAssetId = string.IsNullOrWhiteSpace(bakedAsset.UnityAssetId)
|
||||
? sourceSave.UnityAssetId ?? string.Empty
|
||||
: bakedAsset.UnityAssetId.Trim();
|
||||
bakedAsset.Filename = Path.GetFileName(bakedAsset.Filename ?? string.Empty);
|
||||
bakedAsset.UnityVersion = ParseUnityVersionFromMetadataName(Path.GetFileName(bakedJsonPath));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bakedAsset.UnityVersion))
|
||||
unityEngineVersions.Add(bakedAsset.UnityVersion);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bakedAsset.UnityAssetId) ||
|
||||
string.IsNullOrWhiteSpace(bakedAsset.Filename))
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Baked asset metadata {bakedJsonPath} is missing an ID or filename.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assetBundleSource == null)
|
||||
{
|
||||
bakedAsset.IsAvailable = false;
|
||||
assetBundlesMissing++;
|
||||
}
|
||||
else
|
||||
{
|
||||
string sourceBundlePath = Path.Combine(assetBundleSource, bakedAsset.Filename);
|
||||
string destinationBundlePath = Path.Combine(assetBundleDestination, bakedAsset.Filename);
|
||||
if (!File.Exists(sourceBundlePath))
|
||||
{
|
||||
bakedAsset.IsAvailable = false;
|
||||
assetBundlesMissing++;
|
||||
Console.WriteLine(
|
||||
$"[ROOM IMPORT] Missing asset bundle {bakedAsset.Filename} " +
|
||||
$"for UnityAssetId={bakedAsset.UnityAssetId} target={bakedAsset.Target}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (CopyIfDifferent(sourceBundlePath, destinationBundlePath))
|
||||
assetBundlesCopied++;
|
||||
|
||||
string actualBundleHash = ComputeSha256Base64(destinationBundlePath);
|
||||
if (!string.Equals(bakedAsset.Hash, actualBundleHash, StringComparison.Ordinal))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM IMPORT] Asset bundle hash normalized " +
|
||||
$"file={bakedAsset.Filename} old={bakedAsset.Hash ?? "null"} " +
|
||||
$"new={actualBundleHash}");
|
||||
bakedAsset.Hash = actualBundleHash;
|
||||
}
|
||||
|
||||
bakedAsset.IsAvailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
bakedAssets.Add(bakedAsset);
|
||||
bakedAssetsImported++;
|
||||
}
|
||||
|
||||
string actualHash = ComputeSha256Base64(destinationBlobPath);
|
||||
bool isSingleBlobBakedSave = !string.IsNullOrWhiteSpace(sourceSave.UnityAssetId);
|
||||
var localSave = new SubRoomDataSave
|
||||
{
|
||||
SubRoomDataSaveId = nextLocalSaveId++,
|
||||
RoomId = assignedRoomId,
|
||||
SubRoomId = sourceSubRoom.SubRoomId,
|
||||
SavedByAccountId = creatorAccountId,
|
||||
DataBlob = safeBlobName,
|
||||
RoomDataBlob = isSingleBlobBakedSave ? safeBlobName : null,
|
||||
DataBlobHash = actualHash,
|
||||
PersistenceVersion = NormalizePersistenceVersion(sourceSave.PersistenceVersion),
|
||||
SavedOnPlatform = sourceSave.SavedOnPlatform,
|
||||
SavedOnDeviceClass = sourceSave.SavedOnDeviceClass,
|
||||
Description = sourceSave.Description,
|
||||
CreatedAt = NormalizeCreatedAt(sourceSave.CreatedAt),
|
||||
IsPublished = sourceSave.ModerationState != 0,
|
||||
UnityAssetId = sourceSave.UnityAssetId,
|
||||
ReferencedUnityAssetIds = sourceSave.ReferencedUnityAssetIds ?? new List<string>(),
|
||||
OMVersion = RoomDB.NormalizeLegacyOMVersion(sourceSave.OMVersion),
|
||||
UgcSubVersion = NormalizeUgcSubVersion(sourceSave.UgcSubVersion),
|
||||
ModerationState = sourceSave.ModerationState,
|
||||
Tags = sourceSave.Tags ?? new List<string>(),
|
||||
BakedUnityAssets = bakedAssets
|
||||
};
|
||||
|
||||
RoomDB.SubRoomDataSaves.Insert(localSave);
|
||||
importedForThisSubRoom.Add((sourceSave.SubRoomDataSaveId, localSave));
|
||||
savesImported++;
|
||||
}
|
||||
|
||||
if (importedForThisSubRoom.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT] Subroom {sourceSubRoom.Name} has no usable saves; skipped.");
|
||||
continue;
|
||||
}
|
||||
|
||||
long requestedCurrentSaveId = sourceSubRoom.CurrentSave?.SubRoomDataSaveId
|
||||
?? listedSubRoom.CurrentSave?.SubRoomDataSaveId
|
||||
?? 0;
|
||||
|
||||
(long SourceSaveId, SubRoomDataSave LocalSave) selectedItem =
|
||||
importedForThisSubRoom.FirstOrDefault(item => item.SourceSaveId == requestedCurrentSaveId);
|
||||
|
||||
if (selectedItem.LocalSave == null)
|
||||
{
|
||||
selectedItem = importedForThisSubRoom
|
||||
.OrderByDescending(item => item.LocalSave.CreatedAt)
|
||||
.ThenByDescending(item => item.SourceSaveId)
|
||||
.First();
|
||||
}
|
||||
|
||||
SubRoomDataSave currentSave = selectedItem.LocalSave;
|
||||
Console.WriteLine(
|
||||
$"[ROOM IMPORT SELECT] subroom={sourceSubRoom.SubRoomId}:{sourceSubRoom.Name} " +
|
||||
$"sourceSave={selectedItem.SourceSaveId} requested={requestedCurrentSaveId} " +
|
||||
$"created={currentSave.CreatedAt:O} blob={currentSave.DataBlob} " +
|
||||
$"persistence={currentSave.PersistenceVersion} " +
|
||||
$"unityAsset={currentSave.UnityAssetId ?? "none"}");
|
||||
|
||||
int accessibilityValue = Enum.IsDefined(typeof(RoomAccessibility), sourceSubRoom.Accessibility)
|
||||
? sourceSubRoom.Accessibility
|
||||
: (int)RoomAccessibility.Public;
|
||||
|
||||
var localSubRoom = new SubRooms
|
||||
{
|
||||
SubRoomId = sourceSubRoom.SubRoomId,
|
||||
RoomId = assignedRoomId,
|
||||
Name = string.IsNullOrWhiteSpace(sourceSubRoom.Name) ? "Home" : sourceSubRoom.Name,
|
||||
DataBlob = currentSave.DataBlob,
|
||||
SubRoomDataSaveId = currentSave.SubRoomDataSaveId,
|
||||
SubRoomDataSave = currentSave,
|
||||
IsSandbox = sourceSubRoom.IsSandbox,
|
||||
MaxPlayers = Math.Clamp(sourceSubRoom.MaxPlayers > 0 ? sourceSubRoom.MaxPlayers : 6, 1, 100),
|
||||
Accessibility = (RoomAccessibility)accessibilityValue,
|
||||
UnitySceneId = sourceSubRoom.UnitySceneId ?? string.Empty,
|
||||
SavedByAccountId = creatorAccountId,
|
||||
Permissions = new List<SubRoomPermission>()
|
||||
};
|
||||
|
||||
importedSubRooms.Add(localSubRoom);
|
||||
|
||||
bool isNamedHome = string.Equals(localSubRoom.Name, "Home", StringComparison.OrdinalIgnoreCase);
|
||||
bool isPublic = localSubRoom.Accessibility != RoomAccessibility.Private;
|
||||
bool currentIsPublic = playableSubRoom == null ||
|
||||
playableSubRoom.Accessibility != RoomAccessibility.Private;
|
||||
if (playableSubRoom == null ||
|
||||
(isPublic && !currentIsPublic) ||
|
||||
(isPublic == currentIsPublic && isNamedHome))
|
||||
{
|
||||
playableSubRoom = localSubRoom;
|
||||
playableCurrentSave = currentSave;
|
||||
}
|
||||
}
|
||||
|
||||
if (importedSubRooms.Count == 0 &&
|
||||
exportRoom.SubRooms?.Any(subRoom => !string.IsNullOrWhiteSpace(subRoom.UnitySceneId)) == true)
|
||||
{
|
||||
return ImportUnityExport(exportRoom, creatorAccountId, replaceExisting: false);
|
||||
}
|
||||
|
||||
if (importedSubRooms.Count == 0)
|
||||
throw new InvalidDataException("No compatible save blobs or UnitySceneId subrooms could be imported.");
|
||||
|
||||
playableSubRoom ??= importedSubRooms[0];
|
||||
playableCurrentSave ??= RoomDB.SubRoomDataSaves.FindById(playableSubRoom.SubRoomDataSaveId);
|
||||
playableSubRoom.Name = "Home";
|
||||
importedSubRooms.Remove(playableSubRoom);
|
||||
importedSubRooms.Insert(0, playableSubRoom);
|
||||
|
||||
bool imageCopied = CopyRoomImage(exportRoot, exportRoom.ImageName, imageDestination, out string imageName);
|
||||
int roomAccessibility = Enum.IsDefined(typeof(RoomAccessibility), exportRoom.Accessibility)
|
||||
? exportRoom.Accessibility
|
||||
: (int)RoomAccessibility.Public;
|
||||
|
||||
bool hasBakedAssets = importedSubRooms.Any(subRoom =>
|
||||
RoomDB.SubRoomDataSaves.FindById(subRoom.SubRoomDataSaveId)?.BakedUnityAssets?.Count > 0);
|
||||
|
||||
var room = new Room
|
||||
{
|
||||
RoomId = assignedRoomId,
|
||||
IsDorm = false,
|
||||
MaxPlayerCalculationMode = 0,
|
||||
MaxPlayers = Math.Clamp(
|
||||
exportRoom.MaxPlayers > 0 ? exportRoom.MaxPlayers : playableSubRoom.MaxPlayers,
|
||||
1,
|
||||
100),
|
||||
CloningAllowed = false,
|
||||
DisableMicAutoMute = false,
|
||||
DisableRoomComments = false,
|
||||
EncryptVoiceChat = false,
|
||||
ToxmodEnabled = true,
|
||||
LoadScreenLocked = false,
|
||||
PersistenceVersion = NormalizePersistenceVersion(
|
||||
playableCurrentSave?.PersistenceVersion ?? exportRoom.PersistenceVersion),
|
||||
AutoLocalizeRoom = false,
|
||||
IsDeveloperOwned = true,
|
||||
Name = exportRoom.Name.Trim().TrimStart('^'),
|
||||
Description = string.IsNullOrWhiteSpace(exportRoom.Description)
|
||||
? "Imported room export."
|
||||
: exportRoom.Description,
|
||||
ImageName = imageName,
|
||||
WarningMask = 0,
|
||||
CustomWarning = null,
|
||||
CreatorAccountId = creatorAccountId,
|
||||
State = RoomState.Active,
|
||||
Accessibility = (RoomAccessibility)roomAccessibility,
|
||||
SupportsLevelVoting = false,
|
||||
IsRRO = false,
|
||||
IsBaseRoom = false,
|
||||
CreativeToolsBetaEnabled = hasBakedAssets,
|
||||
SupportsScreens = true,
|
||||
SupportsWalkVR = true,
|
||||
SupportsTeleportVR = true,
|
||||
SupportsVRLow = true,
|
||||
SupportsQuest2 = true,
|
||||
SupportsMobile = true,
|
||||
SupportsJuniors = true,
|
||||
MinLevel = 0,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Stats = new Stats(),
|
||||
RankedEntityId = assignedRoomId.ToString(),
|
||||
RankingContext = null,
|
||||
SubRooms = importedSubRooms,
|
||||
Roles = new List<Roles>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AccountId = creatorAccountId,
|
||||
Role = Role.Creator,
|
||||
InvitedRole = Role.None
|
||||
}
|
||||
},
|
||||
|
||||
DataBlob = playableCurrentSave?.RoomDataBlob,
|
||||
UgcVersion = 1,
|
||||
Tags = new List<Tags>(),
|
||||
PromoImages = new List<string>(),
|
||||
PromoExternalContent = new List<PromoExternalContent>(),
|
||||
LoadScreens = new List<LoadScreens>()
|
||||
};
|
||||
|
||||
if (!RoomDB.Rooms.Upsert(room))
|
||||
throw new InvalidOperationException("LiteDB failed to upsert the imported room.");
|
||||
|
||||
Room? verifiedRoom = RoomDB.Rooms.FindById(room.RoomId);
|
||||
if (verifiedRoom?.SubRooms == null || verifiedRoom.SubRooms.Count == 0)
|
||||
throw new InvalidOperationException("The room was written, but LiteDB returned no subrooms after import.");
|
||||
|
||||
Console.WriteLine(
|
||||
$"[ROOM IMPORT] Imported {room.Name} room={room.RoomId} " +
|
||||
$"subrooms={importedSubRooms.Count} saves={savesImported} " +
|
||||
$"bakedAssets={bakedAssetsImported} bundlesCopied={assetBundlesCopied} " +
|
||||
$"bundlesMissing={assetBundlesMissing} playable={playableSubRoom.SubRoomId}:{playableSubRoom.Name}");
|
||||
|
||||
return new ImportResult(
|
||||
room.RoomId,
|
||||
importedSubRooms.Count,
|
||||
savesImported,
|
||||
blobsCopied,
|
||||
savesSkipped,
|
||||
playableSubRoom.Name,
|
||||
playableSubRoom.SubRoomId)
|
||||
{
|
||||
BakedAssetsImported = bakedAssetsImported,
|
||||
AssetBundlesCopied = assetBundlesCopied,
|
||||
AssetBundlesMissing = assetBundlesMissing,
|
||||
ImageCopied = imageCopied,
|
||||
UnityEngineVersions = unityEngineVersions.OrderBy(value => value).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly HttpClient DownloadClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(10)
|
||||
};
|
||||
|
||||
public static async Task<ImportResult> ImportFromUrlAsync(
|
||||
string rootUrl,
|
||||
long creatorAccountId = 1,
|
||||
bool replaceExisting = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootUrl) ||
|
||||
!Uri.TryCreate(rootUrl, UriKind.Absolute, out Uri? rootUri) ||
|
||||
(rootUri.Scheme != Uri.UriSchemeHttp && rootUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidDataException("Enter a valid http:// or https:// URL to the room's own folder.");
|
||||
}
|
||||
|
||||
string normalizedRoot = rootUrl.TrimEnd('/') + "/";
|
||||
string importId = Guid.NewGuid().ToString("N");
|
||||
string stagingRoot = Path.Combine(Program.dataDir, "Temp", "RoomImports", importId);
|
||||
Directory.CreateDirectory(stagingRoot);
|
||||
|
||||
const long maxTotalBytes = 1024L * 1024L * 1024L;
|
||||
const int maxSubRooms = 500;
|
||||
const int maxSavesPerSubRoom = 500;
|
||||
long totalDownloadedBytes = 0;
|
||||
|
||||
async Task DownloadFileAsync(string fileUrl, string destinationPath)
|
||||
{
|
||||
using HttpResponseMessage response = await DownloadClient.GetAsync(
|
||||
fileUrl,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new InvalidDataException($"Could not download {fileUrl} ({(int)response.StatusCode}).");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
await using Stream input = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var output = new FileStream(
|
||||
destinationPath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
81920,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
byte[] buffer = new byte[81920];
|
||||
while (true)
|
||||
{
|
||||
int read = await input.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
|
||||
if (read == 0)
|
||||
break;
|
||||
|
||||
totalDownloadedBytes += read;
|
||||
if (totalDownloadedBytes > maxTotalBytes)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"This room's files add up to more than the 1 GiB limit for URL imports. " +
|
||||
"Download it and use the ZIP import instead.");
|
||||
}
|
||||
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
List<(string Name, bool IsDirectory)> rootEntries =
|
||||
await ListRemoteDirectoryAsync(normalizedRoot, cancellationToken);
|
||||
|
||||
if (!rootEntries.Any(entry => !entry.IsDirectory &&
|
||||
string.Equals(entry.Name, "room.json", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"No room.json was found at that URL - point this at a room's own folder " +
|
||||
"(the page that directly lists room.json and the subroom folders).");
|
||||
}
|
||||
|
||||
await DownloadFileAsync(normalizedRoot + "room.json", Path.Combine(stagingRoot, "room.json"));
|
||||
ExportRoom exportRoom = ReadJson<ExportRoom>(Path.Combine(stagingRoot, "room.json"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(exportRoom.ImageName))
|
||||
{
|
||||
string imageFileName = Path.GetFileName(exportRoom.ImageName);
|
||||
(string Name, bool IsDirectory) imageEntry = rootEntries.FirstOrDefault(entry =>
|
||||
!entry.IsDirectory &&
|
||||
string.Equals(entry.Name, imageFileName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (imageEntry.Name != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DownloadFileAsync(
|
||||
normalizedRoot + Uri.EscapeDataString(imageEntry.Name),
|
||||
Path.Combine(stagingRoot, imageEntry.Name));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Console.WriteLine($"[ROOM IMPORT URL] Could not fetch thumbnail: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var referencedBundleFilenames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
int subRoomsFound = 0;
|
||||
|
||||
foreach ((string Name, bool IsDirectory) entry in rootEntries.Where(entry =>
|
||||
entry.IsDirectory &&
|
||||
!string.Equals(entry.Name, "AssetBundles", StringComparison.OrdinalIgnoreCase) &&
|
||||
long.TryParse(entry.Name, out _)))
|
||||
{
|
||||
if (subRoomsFound >= maxSubRooms)
|
||||
break;
|
||||
|
||||
string subRoomUrl = normalizedRoot + Uri.EscapeDataString(entry.Name) + "/";
|
||||
List<(string Name, bool IsDirectory)> subRoomEntries =
|
||||
await ListRemoteDirectoryAsync(subRoomUrl, cancellationToken);
|
||||
|
||||
if (!subRoomEntries.Any(e => !e.IsDirectory &&
|
||||
string.Equals(e.Name, "subRoom.json", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
subRoomsFound++;
|
||||
string localSubRoomDir = Path.Combine(stagingRoot, entry.Name);
|
||||
await DownloadFileAsync(subRoomUrl + "subRoom.json", Path.Combine(localSubRoomDir, "subRoom.json"));
|
||||
|
||||
(string Name, bool IsDirectory) savesFolder = subRoomEntries.FirstOrDefault(e =>
|
||||
e.IsDirectory && string.Equals(e.Name, "Saves", StringComparison.OrdinalIgnoreCase));
|
||||
if (savesFolder.Name == null)
|
||||
continue;
|
||||
|
||||
string savesUrl = subRoomUrl + "Saves/";
|
||||
List<(string Name, bool IsDirectory)> saveEntries =
|
||||
await ListRemoteDirectoryAsync(savesUrl, cancellationToken);
|
||||
|
||||
int savesForThisSubRoom = 0;
|
||||
foreach ((string Name, bool IsDirectory) saveEntry in saveEntries.Where(e => e.IsDirectory))
|
||||
{
|
||||
if (savesForThisSubRoom >= maxSavesPerSubRoom)
|
||||
break;
|
||||
savesForThisSubRoom++;
|
||||
|
||||
string saveUrl = savesUrl + Uri.EscapeDataString(saveEntry.Name) + "/";
|
||||
List<(string Name, bool IsDirectory)> saveFileEntries =
|
||||
await ListRemoteDirectoryAsync(saveUrl, cancellationToken);
|
||||
string localSaveDir = Path.Combine(localSubRoomDir, "Saves", saveEntry.Name);
|
||||
|
||||
foreach ((string Name, bool IsDirectory) file in saveFileEntries.Where(e => !e.IsDirectory))
|
||||
{
|
||||
await DownloadFileAsync(
|
||||
saveUrl + Uri.EscapeDataString(file.Name),
|
||||
Path.Combine(localSaveDir, file.Name));
|
||||
|
||||
if (file.Name.StartsWith("bakedUnityAsset-", StringComparison.OrdinalIgnoreCase) &&
|
||||
file.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
BakedUnityAsset baked = ReadJson<BakedUnityAsset>(
|
||||
Path.Combine(localSaveDir, file.Name));
|
||||
if (!string.IsNullOrWhiteSpace(baked.Filename))
|
||||
referencedBundleFilenames.Add(Path.GetFileName(baked.Filename));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM IMPORT URL] Unreadable baked asset metadata {file.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (subRoomsFound == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"No subroom folders with a subRoom.json were found under that URL.");
|
||||
}
|
||||
|
||||
if (referencedBundleFilenames.Count > 0 &&
|
||||
rootEntries.Any(entry => entry.IsDirectory &&
|
||||
string.Equals(entry.Name, "AssetBundles", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
string bundlesUrl = normalizedRoot + "AssetBundles/";
|
||||
string localBundlesDir = Path.Combine(stagingRoot, "AssetBundles");
|
||||
foreach (string filename in referencedBundleFilenames)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DownloadFileAsync(
|
||||
bundlesUrl + Uri.EscapeDataString(filename),
|
||||
Path.Combine(localBundlesDir, filename));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM IMPORT URL] Could not fetch asset bundle {filename}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Import(stagingRoot, creatorAccountId, replaceExisting);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(stagingRoot))
|
||||
Directory.Delete(stagingRoot, recursive: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT URL] Could not clean up staging folder: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<List<(string Name, string Url)>> DiscoverRoomFoldersAsync(
|
||||
string rootUrl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootUrl) ||
|
||||
!Uri.TryCreate(rootUrl, UriKind.Absolute, out Uri? rootUri) ||
|
||||
(rootUri.Scheme != Uri.UriSchemeHttp && rootUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidDataException("Enter a valid http:// or https:// URL to the rooms index folder.");
|
||||
}
|
||||
|
||||
string normalizedRoot = rootUrl.TrimEnd('/') + "/";
|
||||
List<(string Name, bool IsDirectory)> entries =
|
||||
await ListRemoteDirectoryAsync(normalizedRoot, cancellationToken);
|
||||
|
||||
if (entries.Any(entry => !entry.IsDirectory &&
|
||||
string.Equals(entry.Name, "room.json", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
string singleName = rootUri.Segments.Length > 0
|
||||
? Uri.UnescapeDataString(rootUri.Segments[^1].Trim('/'))
|
||||
: normalizedRoot;
|
||||
return new List<(string Name, string Url)>
|
||||
{
|
||||
(string.IsNullOrWhiteSpace(singleName) ? normalizedRoot : singleName, normalizedRoot)
|
||||
};
|
||||
}
|
||||
|
||||
const int maxCandidates = 300;
|
||||
var found = new List<(string Name, string Url)>();
|
||||
foreach ((string Name, bool IsDirectory) entry in entries.Where(entry =>
|
||||
entry.IsDirectory &&
|
||||
!string.Equals(entry.Name, "AssetBundles", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (found.Count >= maxCandidates)
|
||||
break;
|
||||
|
||||
string candidateUrl = normalizedRoot + Uri.EscapeDataString(entry.Name) + "/";
|
||||
List<(string Name, bool IsDirectory)> candidateEntries;
|
||||
try
|
||||
{
|
||||
candidateEntries = await ListRemoteDirectoryAsync(candidateUrl, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[ROOM IMPORT URL DISCOVER] Skipping {candidateUrl}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidateEntries.Any(e => !e.IsDirectory &&
|
||||
string.Equals(e.Name, "room.json", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
found.Add((entry.Name, candidateUrl));
|
||||
}
|
||||
}
|
||||
|
||||
if (found.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"No room folders (containing room.json) were found directly under that URL.");
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private static async Task<List<(string Name, bool IsDirectory)>> ListRemoteDirectoryAsync(
|
||||
string url,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string html = await DownloadClient.GetStringAsync(url, cancellationToken);
|
||||
var entries = new List<(string Name, bool IsDirectory)>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (Match match in Regex.Matches(html, "href\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase))
|
||||
{
|
||||
string href = System.Net.WebUtility.HtmlDecode(match.Groups[1].Value).Trim();
|
||||
if (string.IsNullOrWhiteSpace(href) ||
|
||||
href.StartsWith('#') ||
|
||||
href.StartsWith('?') ||
|
||||
href is "../" or ".." or "/" or "./")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (href.Contains("://"))
|
||||
{
|
||||
|
||||
if (!Uri.TryCreate(href, UriKind.Absolute, out Uri? absolute) ||
|
||||
!Uri.TryCreate(url, UriKind.Absolute, out Uri? baseUri) ||
|
||||
!string.Equals(absolute.Host, baseUri.Host, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
bool isDirectory = href.EndsWith('/');
|
||||
string trimmed = href.TrimEnd('/');
|
||||
string rawName = trimmed.Contains('/') ? trimmed[(trimmed.LastIndexOf('/') + 1)..] : trimmed;
|
||||
string name = Uri.UnescapeDataString(rawName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || name is ".." or "." || name.Contains('\0'))
|
||||
continue;
|
||||
|
||||
if (seen.Add(name))
|
||||
entries.Add((name, isDirectory));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static ImportResult ImportUnityExport(
|
||||
ExportRoom exportRoom,
|
||||
long creatorAccountId,
|
||||
bool replaceExisting)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(exportRoom.Name))
|
||||
throw new InvalidDataException("Room metadata must contain a Name.");
|
||||
|
||||
var sourceSubRooms = (exportRoom.SubRooms ?? new List<ExportSubRoom>())
|
||||
.Where(subRoom => !string.IsNullOrWhiteSpace(subRoom.UnitySceneId))
|
||||
.ToList();
|
||||
|
||||
long assignedRoomId = RoomDB.GetNextRoomId();
|
||||
string normalizedRoomName = exportRoom.Name.Trim().TrimStart('^');
|
||||
Room? existingByName = RoomDB.Rooms.FindAll().FirstOrDefault(room =>
|
||||
string.Equals(room.Name, normalizedRoomName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (sourceSubRooms.Count == 0 && !string.IsNullOrWhiteSpace(exportRoom.UnitySceneId))
|
||||
{
|
||||
sourceSubRooms.Add(new ExportSubRoom
|
||||
{
|
||||
RoomId = assignedRoomId,
|
||||
SubRoomId = exportRoom.SubRoomId,
|
||||
Name = "Home",
|
||||
UnitySceneId = exportRoom.UnitySceneId,
|
||||
MaxPlayers = exportRoom.MaxPlayers,
|
||||
Accessibility = exportRoom.Accessibility
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceSubRooms.Count == 0)
|
||||
throw new InvalidDataException("No UnitySceneId was found in the room metadata.");
|
||||
|
||||
if (replaceExisting)
|
||||
{
|
||||
if (existingByName != null)
|
||||
{
|
||||
RoomDB.SubRoomDataSaves.DeleteMany(save => save.RoomId == existingByName.RoomId);
|
||||
RoomDB.Rooms.Delete(existingByName.RoomId);
|
||||
}
|
||||
}
|
||||
else if (existingByName != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A room named \"{normalizedRoomName}\" already exists. Enable replaceExisting to overwrite it.");
|
||||
}
|
||||
|
||||
long generatedSubRoomId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var importedSubRooms = new List<SubRooms>();
|
||||
|
||||
foreach ((ExportSubRoom source, int index) in sourceSubRooms.Select((value, index) => (value, index)))
|
||||
{
|
||||
long subRoomId = source.SubRoomId > 0 ? source.SubRoomId : generatedSubRoomId + index;
|
||||
int accessibilityValue = Enum.IsDefined(typeof(RoomAccessibility), source.Accessibility)
|
||||
? source.Accessibility
|
||||
: (int)RoomAccessibility.Public;
|
||||
|
||||
importedSubRooms.Add(new SubRooms
|
||||
{
|
||||
SubRoomId = subRoomId,
|
||||
RoomId = assignedRoomId,
|
||||
Name = string.IsNullOrWhiteSpace(source.Name) ? $"Scene {index + 1}" : source.Name,
|
||||
DataBlob = null,
|
||||
SubRoomDataSaveId = 0,
|
||||
SubRoomDataSave = null,
|
||||
IsSandbox = source.IsSandbox,
|
||||
MaxPlayers = source.MaxPlayers > 0
|
||||
? Math.Clamp(source.MaxPlayers, 1, 100)
|
||||
: Math.Clamp(exportRoom.MaxPlayers > 0 ? exportRoom.MaxPlayers : 6, 1, 100),
|
||||
Accessibility = (RoomAccessibility)accessibilityValue,
|
||||
UnitySceneId = source.UnitySceneId!.Trim(),
|
||||
SavedByAccountId = creatorAccountId,
|
||||
Permissions = new List<SubRoomPermission>()
|
||||
});
|
||||
}
|
||||
|
||||
SubRooms playableSubRoom = importedSubRooms.FirstOrDefault(subRoom =>
|
||||
string.Equals(subRoom.Name, "Home", StringComparison.OrdinalIgnoreCase)) ?? importedSubRooms[0];
|
||||
playableSubRoom.Name = "Home";
|
||||
importedSubRooms.Remove(playableSubRoom);
|
||||
importedSubRooms.Insert(0, playableSubRoom);
|
||||
|
||||
int roomAccessibility = Enum.IsDefined(typeof(RoomAccessibility), exportRoom.Accessibility)
|
||||
? exportRoom.Accessibility
|
||||
: (int)RoomAccessibility.Public;
|
||||
|
||||
var room = new Room
|
||||
{
|
||||
RoomId = assignedRoomId,
|
||||
IsDorm = false,
|
||||
MaxPlayerCalculationMode = 0,
|
||||
MaxPlayers = Math.Clamp(
|
||||
exportRoom.MaxPlayers > 0 ? exportRoom.MaxPlayers : playableSubRoom.MaxPlayers,
|
||||
1,
|
||||
100),
|
||||
CloningAllowed = false,
|
||||
DisableMicAutoMute = false,
|
||||
DisableRoomComments = false,
|
||||
EncryptVoiceChat = false,
|
||||
ToxmodEnabled = true,
|
||||
LoadScreenLocked = false,
|
||||
PersistenceVersion = NormalizePersistenceVersion(
|
||||
exportRoom.PersistenceVersion > 0 ? exportRoom.PersistenceVersion : 27),
|
||||
AutoLocalizeRoom = false,
|
||||
IsDeveloperOwned = true,
|
||||
Name = exportRoom.Name.Trim().TrimStart('^'),
|
||||
Description = string.IsNullOrWhiteSpace(exportRoom.Description)
|
||||
? "Imported built-in Unity scene room."
|
||||
: exportRoom.Description,
|
||||
ImageName = exportRoom.ImageName ?? string.Empty,
|
||||
WarningMask = 0,
|
||||
CustomWarning = null,
|
||||
CreatorAccountId = creatorAccountId,
|
||||
State = RoomState.Active,
|
||||
Accessibility = (RoomAccessibility)roomAccessibility,
|
||||
SupportsLevelVoting = false,
|
||||
IsRRO = true,
|
||||
IsBaseRoom = true,
|
||||
SupportsScreens = true,
|
||||
SupportsWalkVR = true,
|
||||
SupportsTeleportVR = true,
|
||||
SupportsVRLow = true,
|
||||
SupportsQuest2 = true,
|
||||
SupportsMobile = true,
|
||||
SupportsJuniors = true,
|
||||
MinLevel = 0,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Stats = new Stats(),
|
||||
RankedEntityId = assignedRoomId.ToString(),
|
||||
RankingContext = null,
|
||||
SubRooms = importedSubRooms,
|
||||
Roles = new List<Roles>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AccountId = creatorAccountId,
|
||||
Role = Role.Creator,
|
||||
InvitedRole = Role.None
|
||||
}
|
||||
},
|
||||
DataBlob = null,
|
||||
UgcVersion = 1,
|
||||
Tags = new List<Tags>
|
||||
{
|
||||
new() { Tag = "base", Type = TagType.Auto },
|
||||
new() { Tag = "rro", Type = TagType.AGOnly }
|
||||
},
|
||||
PromoImages = new List<string>(),
|
||||
PromoExternalContent = new List<PromoExternalContent>(),
|
||||
LoadScreens = new List<LoadScreens>()
|
||||
};
|
||||
|
||||
if (!RoomDB.Rooms.Upsert(room))
|
||||
throw new InvalidOperationException("LiteDB failed to save the imported Unity-scene room.");
|
||||
|
||||
return new ImportResult(
|
||||
room.RoomId,
|
||||
importedSubRooms.Count,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
playableSubRoom.Name,
|
||||
playableSubRoom.SubRoomId);
|
||||
}
|
||||
|
||||
public sealed record RoomExportResult(
|
||||
long RoomId,
|
||||
string ExportRoot,
|
||||
int SubRoomsExported,
|
||||
int SavesExported,
|
||||
bool ImageExported,
|
||||
int BakedAssetsExported,
|
||||
int AssetBundlesExported);
|
||||
|
||||
public static RoomExportResult ExportRoomToDirectory(
|
||||
long roomId,
|
||||
string outputRoot)
|
||||
{
|
||||
RoomDBClasses.Room room = RoomDB.GetRoom(roomId);
|
||||
if (room == null || room.RoomId <= 0)
|
||||
throw new FileNotFoundException($"Room {roomId} was not found.");
|
||||
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
|
||||
string roomBlobSource = Path.Combine(Program.dataDir, "CDN", "room");
|
||||
string assetBundleSourceRoot = Path.Combine(Program.dataDir, "CDN", "assetbundles");
|
||||
string imageSourceRoot = Path.Combine(Program.dataDir, "Images");
|
||||
string assetBundleDestination = Path.Combine(outputRoot, "AssetBundles");
|
||||
|
||||
var exportSubRooms = new List<ExportSubRoom>();
|
||||
int savesExported = 0;
|
||||
int bakedAssetsExported = 0;
|
||||
var exportedBundleNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (SubRooms subRoom in room.SubRooms ?? new List<SubRooms>())
|
||||
{
|
||||
string subRoomDirectory = Path.Combine(outputRoot, subRoom.SubRoomId.ToString());
|
||||
string savesDirectory = Path.Combine(subRoomDirectory, "Saves");
|
||||
Directory.CreateDirectory(savesDirectory);
|
||||
|
||||
List<SubRoomDataSave> saves = RoomDB.SubRoomDataSaves
|
||||
.Find(save => save.RoomId == roomId && save.SubRoomId == subRoom.SubRoomId)
|
||||
.OrderBy(save => save.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
foreach (SubRoomDataSave save in saves)
|
||||
{
|
||||
string safeBlobName = Path.GetFileName(save.DataBlob ?? string.Empty);
|
||||
string sourceBlobPath = Path.Combine(roomBlobSource, safeBlobName);
|
||||
if (string.IsNullOrWhiteSpace(safeBlobName) || !File.Exists(sourceBlobPath))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[ROOM EXPORT] Missing blob for save {save.SubRoomDataSaveId}; skipped.");
|
||||
continue;
|
||||
}
|
||||
|
||||
string saveDirectoryName = save.SubRoomDataSaveId.ToString();
|
||||
string saveDirectory = Path.Combine(savesDirectory, saveDirectoryName);
|
||||
Directory.CreateDirectory(saveDirectory);
|
||||
|
||||
File.Copy(
|
||||
sourceBlobPath,
|
||||
Path.Combine(saveDirectory, safeBlobName),
|
||||
overwrite: true);
|
||||
|
||||
var exportSave = new ExportSave
|
||||
{
|
||||
SubRoomDataSaveId = save.SubRoomDataSaveId,
|
||||
SubRoomId = save.SubRoomId,
|
||||
UnityAssetId = save.UnityAssetId,
|
||||
DataBlob = safeBlobName,
|
||||
DataBlobHash = save.DataBlobHash,
|
||||
ReferencedUnityAssetIds = save.ReferencedUnityAssetIds,
|
||||
PersistenceVersion = save.PersistenceVersion.GetValueOrDefault(),
|
||||
OMVersion = save.OMVersion,
|
||||
UgcSubVersion = save.UgcSubVersion,
|
||||
SavedOnPlatform = save.SavedOnPlatform,
|
||||
SavedOnDeviceClass = save.SavedOnDeviceClass,
|
||||
Description = save.Description,
|
||||
Tags = save.Tags,
|
||||
ModerationState = save.ModerationState,
|
||||
CreatedAt = save.CreatedAt
|
||||
};
|
||||
WriteJson(Path.Combine(saveDirectory, saveDirectoryName + ".json"), exportSave);
|
||||
|
||||
int bakedIndex = 0;
|
||||
foreach (BakedUnityAsset bakedAsset in save.BakedUnityAssets ?? new List<BakedUnityAsset>())
|
||||
{
|
||||
string versionSuffix = string.IsNullOrWhiteSpace(bakedAsset.UnityVersion)
|
||||
? string.Empty
|
||||
: $"-Unity_{bakedAsset.UnityVersion.Replace('.', '_')}";
|
||||
string bakedJsonName = $"bakedUnityAsset-{bakedIndex++}{versionSuffix}.json";
|
||||
WriteJson(Path.Combine(saveDirectory, bakedJsonName), bakedAsset);
|
||||
bakedAssetsExported++;
|
||||
|
||||
string bundleFileName = Path.GetFileName(bakedAsset.Filename ?? string.Empty);
|
||||
if (string.IsNullOrWhiteSpace(bundleFileName))
|
||||
continue;
|
||||
|
||||
string bundleSourcePath = Path.Combine(assetBundleSourceRoot, bundleFileName);
|
||||
if (!File.Exists(bundleSourcePath))
|
||||
continue;
|
||||
|
||||
if (exportedBundleNames.Add(bundleFileName))
|
||||
{
|
||||
Directory.CreateDirectory(assetBundleDestination);
|
||||
File.Copy(
|
||||
bundleSourcePath,
|
||||
Path.Combine(assetBundleDestination, bundleFileName),
|
||||
overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
savesExported++;
|
||||
}
|
||||
|
||||
var exportSubRoom = new ExportSubRoom
|
||||
{
|
||||
RoomId = roomId,
|
||||
SubRoomId = subRoom.SubRoomId,
|
||||
Name = subRoom.Name,
|
||||
UnitySceneId = subRoom.UnitySceneId,
|
||||
IsSandbox = subRoom.IsSandbox,
|
||||
MaxPlayers = subRoom.MaxPlayers,
|
||||
Accessibility = (int)subRoom.Accessibility,
|
||||
CurrentSave = new ExportSave { SubRoomDataSaveId = subRoom.SubRoomDataSaveId }
|
||||
};
|
||||
WriteJson(Path.Combine(subRoomDirectory, "subRoom.json"), exportSubRoom);
|
||||
exportSubRooms.Add(exportSubRoom);
|
||||
}
|
||||
|
||||
if (savesExported == 0)
|
||||
throw new InvalidDataException($"Room {roomId} has no save blobs on disk to export.");
|
||||
|
||||
bool imageExported = false;
|
||||
if (!string.IsNullOrWhiteSpace(room.ImageName))
|
||||
{
|
||||
string imageSourcePath = Path.Combine(imageSourceRoot, room.ImageName);
|
||||
if (File.Exists(imageSourcePath))
|
||||
{
|
||||
File.Copy(
|
||||
imageSourcePath,
|
||||
Path.Combine(outputRoot, Path.GetFileName(room.ImageName)),
|
||||
overwrite: true);
|
||||
imageExported = true;
|
||||
}
|
||||
}
|
||||
|
||||
var exportRoom = new ExportRoom
|
||||
{
|
||||
RoomId = room.RoomId,
|
||||
Name = room.Name,
|
||||
Description = room.Description,
|
||||
ImageName = room.ImageName,
|
||||
MaxPlayers = room.MaxPlayers,
|
||||
Accessibility = (int)room.Accessibility,
|
||||
PersistenceVersion = room.PersistenceVersion,
|
||||
SubRoomId = room.SubRooms?.FirstOrDefault()?.SubRoomId ?? 0,
|
||||
UnitySceneId = room.SubRooms?.FirstOrDefault()?.UnitySceneId,
|
||||
SubRooms = exportSubRooms
|
||||
};
|
||||
WriteJson(Path.Combine(outputRoot, "room.json"), exportRoom);
|
||||
|
||||
return new RoomExportResult(
|
||||
room.RoomId,
|
||||
outputRoot,
|
||||
exportSubRooms.Count,
|
||||
savesExported,
|
||||
imageExported,
|
||||
bakedAssetsExported,
|
||||
exportedBundleNames.Count);
|
||||
}
|
||||
|
||||
private static void WriteJson<T>(string path, T value)
|
||||
{
|
||||
File.WriteAllText(
|
||||
path,
|
||||
JsonSerializer.Serialize(value, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}));
|
||||
}
|
||||
|
||||
private static void MergeSubRoomMetadata(ExportSubRoom listed, ExportSubRoom source)
|
||||
{
|
||||
listed.Name = string.IsNullOrWhiteSpace(source.Name) ? listed.Name : source.Name;
|
||||
listed.UnitySceneId = string.IsNullOrWhiteSpace(source.UnitySceneId)
|
||||
? listed.UnitySceneId
|
||||
: source.UnitySceneId;
|
||||
listed.MaxPlayers = source.MaxPlayers > 0 ? source.MaxPlayers : listed.MaxPlayers;
|
||||
listed.Accessibility = source.Accessibility;
|
||||
listed.IsSandbox = source.IsSandbox;
|
||||
listed.CurrentSave = source.CurrentSave ?? listed.CurrentSave;
|
||||
}
|
||||
|
||||
private static bool CopyRoomImage(
|
||||
string exportRoot,
|
||||
string? requestedImageName,
|
||||
string destinationRoot,
|
||||
out string imageName)
|
||||
{
|
||||
string requestedFileName = Path.GetFileName(requestedImageName ?? string.Empty);
|
||||
imageName = requestedFileName;
|
||||
string? sourcePath = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requestedFileName))
|
||||
{
|
||||
sourcePath = Directory.EnumerateFiles(exportRoot, "*", SearchOption.TopDirectoryOnly)
|
||||
.FirstOrDefault(path => string.Equals(
|
||||
Path.GetFileName(path),
|
||||
requestedFileName,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
sourcePath ??= Directory.EnumerateFiles(exportRoot, "*", SearchOption.TopDirectoryOnly)
|
||||
.FirstOrDefault(path => IsImageExtension(Path.GetExtension(path)));
|
||||
|
||||
if (sourcePath == null)
|
||||
{
|
||||
imageName = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
imageName = Path.GetFileName(sourcePath);
|
||||
string destinationPath = Path.Combine(destinationRoot, imageName);
|
||||
return CopyIfDifferent(sourcePath, destinationPath) || File.Exists(destinationPath);
|
||||
}
|
||||
|
||||
private static bool IsImageExtension(string extension) =>
|
||||
extension.Equals(".png", StringComparison.OrdinalIgnoreCase) ||
|
||||
extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) ||
|
||||
extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) ||
|
||||
extension.Equals(".webp", StringComparison.OrdinalIgnoreCase) ||
|
||||
extension.Equals(".gif", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string? FindDirectory(string root, string name)
|
||||
{
|
||||
string direct = Path.Combine(root, name);
|
||||
if (Directory.Exists(direct))
|
||||
return direct;
|
||||
|
||||
return Directory.EnumerateDirectories(root, "*", SearchOption.TopDirectoryOnly)
|
||||
.FirstOrDefault(path => string.Equals(
|
||||
Path.GetFileName(path),
|
||||
name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool CopyIfDifferent(string sourcePath, string destinationPath)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
if (File.Exists(destinationPath))
|
||||
{
|
||||
var sourceInfo = new FileInfo(sourcePath);
|
||||
var destinationInfo = new FileInfo(destinationPath);
|
||||
if (sourceInfo.Length == destinationInfo.Length &&
|
||||
string.Equals(
|
||||
ComputeSha256Base64(sourcePath),
|
||||
ComputeSha256Base64(destinationPath),
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
File.Copy(sourcePath, destinationPath, overwrite: true);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? ParseUnityVersionFromMetadataName(string fileName)
|
||||
{
|
||||
|
||||
const string marker = "Unity_";
|
||||
int markerIndex = fileName.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (markerIndex < 0)
|
||||
return null;
|
||||
|
||||
int start = markerIndex + marker.Length;
|
||||
int end = fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
|
||||
? fileName.Length - 5
|
||||
: fileName.Length;
|
||||
if (start >= end)
|
||||
return null;
|
||||
|
||||
return fileName[start..end].Replace('_', '.');
|
||||
}
|
||||
|
||||
private static int NormalizePersistenceVersion(int sourceVersion)
|
||||
{
|
||||
if (sourceVersion <= 0)
|
||||
return CompatiblePersistenceVersion;
|
||||
|
||||
return Math.Min(sourceVersion, CompatiblePersistenceVersion);
|
||||
}
|
||||
|
||||
private static int NormalizeUgcSubVersion(int sourceVersion)
|
||||
{
|
||||
return CompatibleUgcSubVersion;
|
||||
}
|
||||
|
||||
private static DateTime NormalizeCreatedAt(DateTime createdAt)
|
||||
{
|
||||
if (createdAt == default)
|
||||
return DateTime.UtcNow;
|
||||
return createdAt.Kind == DateTimeKind.Utc
|
||||
? createdAt
|
||||
: createdAt.ToUniversalTime();
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(
|
||||
JsonElement element,
|
||||
string propertyName,
|
||||
out JsonElement value)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static T ReadJson<T>(string path)
|
||||
{
|
||||
string json = File.ReadAllText(path, Encoding.UTF8);
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOptions)
|
||||
?? throw new InvalidDataException($"Could not deserialize {path}.");
|
||||
}
|
||||
|
||||
private static string ComputeSha256Base64(string path)
|
||||
{
|
||||
using FileStream stream = File.OpenRead(path);
|
||||
return Convert.ToBase64String(SHA256.HashData(stream));
|
||||
}
|
||||
|
||||
private sealed class ExportRoom
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public int MaxPlayers { get; set; }
|
||||
public int Accessibility { get; set; }
|
||||
public int PersistenceVersion { get; set; }
|
||||
public long SubRoomId { get; set; }
|
||||
public string? UnitySceneId { get; set; }
|
||||
public List<ExportSubRoom>? SubRooms { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ExportSubRoom
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public long SubRoomId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? UnitySceneId { get; set; }
|
||||
public ExportSave? CurrentSave { get; set; }
|
||||
public bool IsSandbox { get; set; }
|
||||
public int MaxPlayers { get; set; }
|
||||
public int Accessibility { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ExportSave
|
||||
{
|
||||
public long SubRoomDataSaveId { get; set; }
|
||||
public long SubRoomId { get; set; }
|
||||
public string? UnityAssetId { get; set; }
|
||||
public string DataBlob { get; set; } = string.Empty;
|
||||
public string? DataBlobHash { get; set; }
|
||||
public List<string>? ReferencedUnityAssetIds { get; set; }
|
||||
public int PersistenceVersion { get; set; }
|
||||
public int OMVersion { get; set; }
|
||||
public int UgcSubVersion { get; set; }
|
||||
public int? SavedOnPlatform { get; set; }
|
||||
public int? SavedOnDeviceClass { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
public int ModerationState { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Mocha2023.Classes.DBs.DBClasses;
|
||||
|
||||
namespace Mocha2023.Classes.DBs
|
||||
{
|
||||
|
||||
public static class SteamAccessDB
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static Dictionary<ulong, SteamBlacklistEntry>? Entries;
|
||||
|
||||
private static string BlacklistPath =>
|
||||
Path.Combine(Program.dataDir, "SteamBlacklist.json");
|
||||
|
||||
public static bool IsBlacklisted(ulong steamId)
|
||||
{
|
||||
if (steamId == 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
EnsureLoadedLocked();
|
||||
return Entries!.ContainsKey(steamId);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetBlockedSteamId(
|
||||
PlayerDBClasses.FullPlayer? player,
|
||||
out ulong steamId)
|
||||
{
|
||||
steamId = 0;
|
||||
|
||||
if (player?.PlatformIds == null)
|
||||
return false;
|
||||
|
||||
ulong[] linkedSteamIds = player.PlatformIds
|
||||
.Where(identity =>
|
||||
identity.Platform == PlayerDBClasses.Platforms.Steam &&
|
||||
identity.PlatformId > 0)
|
||||
.Select(identity => identity.PlatformId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
EnsureLoadedLocked();
|
||||
|
||||
foreach (ulong linkedSteamId in linkedSteamIds)
|
||||
{
|
||||
if (Entries!.ContainsKey(linkedSteamId))
|
||||
{
|
||||
steamId = linkedSteamId;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<SteamBlacklistEntry> GetAll()
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
EnsureLoadedLocked();
|
||||
|
||||
return Entries!.Values
|
||||
.OrderByDescending(entry => entry.AddedAt)
|
||||
.ThenBy(entry => entry.SteamId)
|
||||
.Select(Clone)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static SteamBlacklistEntry AddOrUpdate(
|
||||
ulong steamId,
|
||||
string? reason,
|
||||
long addedByAccountId)
|
||||
{
|
||||
if (steamId == 0)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(steamId),
|
||||
"Steam ID must be greater than zero.");
|
||||
|
||||
string cleanReason = (reason ?? string.Empty).Trim();
|
||||
if (cleanReason.Length == 0)
|
||||
cleanReason = "Blacklisted by an administrator.";
|
||||
if (cleanReason.Length > 500)
|
||||
cleanReason = cleanReason[..500];
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
EnsureLoadedLocked();
|
||||
|
||||
var entry = new SteamBlacklistEntry
|
||||
{
|
||||
SteamId = steamId,
|
||||
Reason = cleanReason,
|
||||
AddedByAccountId = addedByAccountId,
|
||||
AddedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Entries![steamId] = entry;
|
||||
SaveLocked();
|
||||
return Clone(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Remove(ulong steamId)
|
||||
{
|
||||
if (steamId == 0)
|
||||
return false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
EnsureLoadedLocked();
|
||||
|
||||
if (!Entries!.Remove(steamId))
|
||||
return false;
|
||||
|
||||
SaveLocked();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureLoadedLocked()
|
||||
{
|
||||
if (Entries != null)
|
||||
return;
|
||||
|
||||
Entries = new Dictionary<ulong, SteamBlacklistEntry>();
|
||||
string path = BlacklistPath;
|
||||
|
||||
if (!File.Exists(path))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(path);
|
||||
var loaded = JsonSerializer.Deserialize<
|
||||
List<SteamBlacklistEntry>>(json, JsonOptions)
|
||||
?? new List<SteamBlacklistEntry>();
|
||||
|
||||
Entries = loaded
|
||||
.Where(entry => entry.SteamId > 0)
|
||||
.GroupBy(entry => entry.SteamId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => Clone(group
|
||||
.OrderByDescending(entry => entry.AddedAt)
|
||||
.First()));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[STEAM BLACKLIST LOAD FAILED] " +
|
||||
$"{exception.GetType().Name}: {exception.Message}");
|
||||
|
||||
Entries = new Dictionary<ulong, SteamBlacklistEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveLocked()
|
||||
{
|
||||
string path = BlacklistPath;
|
||||
string? directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
string temporaryPath = path + ".tmp";
|
||||
string json = JsonSerializer.Serialize(
|
||||
Entries!.Values
|
||||
.OrderBy(entry => entry.SteamId)
|
||||
.ToList(),
|
||||
JsonOptions);
|
||||
|
||||
File.WriteAllText(temporaryPath, json);
|
||||
File.Move(temporaryPath, path, overwrite: true);
|
||||
}
|
||||
|
||||
private static SteamBlacklistEntry Clone(
|
||||
SteamBlacklistEntry entry) =>
|
||||
new()
|
||||
{
|
||||
SteamId = entry.SteamId,
|
||||
Reason = entry.Reason,
|
||||
AddedByAccountId = entry.AddedByAccountId,
|
||||
AddedAt = entry.AddedAt
|
||||
};
|
||||
|
||||
public sealed class SteamBlacklistEntry
|
||||
{
|
||||
public ulong SteamId { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public long AddedByAccountId { get; set; }
|
||||
public DateTime AddedAt { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user