the files

This commit is contained in:
niko
2026-10-14 06:59:51 +02:00
parent f6175893fc
commit 2040419525
1855 changed files with 6735668 additions and 0 deletions
@@ -0,0 +1,159 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// LiteDB backup + restore for the admin "Database" tab.
/// • Snapshots every .db in Data/DBs three times a day (every 8h) into Data/Backups/backup_UTCstamp/
/// • Prunes snapshots older than 3 days.
/// • Restore is STAGED (a marker file) and applied at next startup BEFORE any DB is opened —
/// the only safe moment to overwrite the live files, which are otherwise held open by LiteDB.
/// The admin endpoint stages the restore then restarts the process (systemd Restart=always).
/// </summary>
public static class BackupService
{
private static readonly string DbDir = Path.Combine("Data", "DBs");
private static readonly string BackupRoot = Path.Combine("Data", "Backups");
private static readonly string RestoreMarker = Path.Combine(BackupRoot, ".restore");
private const int RetentionDays = 3;
private const int IntervalHours = 8; // 3x/day
private static Timer? _timer;
// ── startup restore (call FIRST in Main, before any DB access) ──────────────────────
public static void RestorePendingIfAny()
{
try
{
if (!File.Exists(RestoreMarker)) return;
string name = SafeName(File.ReadAllText(RestoreMarker).Trim());
string dir = Path.Combine(BackupRoot, name);
if (name.Length > 0 && Directory.Exists(dir))
{
Directory.CreateDirectory(DbDir);
// Drop stale write-ahead logs so a restored .db is not overlaid by an old WAL.
foreach (var log in Directory.GetFiles(DbDir, "*-log.db"))
try { File.Delete(log); } catch { }
int n = 0;
foreach (var f in Directory.GetFiles(dir, "*.db"))
{
File.Copy(f, Path.Combine(DbDir, Path.GetFileName(f)), overwrite: true);
n++;
}
Console.WriteLine($"[backup] RESTORED {n} files from '{name}' before startup");
}
File.Delete(RestoreMarker);
}
catch (Exception ex) { Console.WriteLine($"[backup] restore-on-start failed: {ex.Message}"); }
}
// ── schedule ────────────────────────────────────────────────────────────────────────
public static void Start()
{
try
{
Directory.CreateDirectory(BackupRoot);
Prune();
_timer = new Timer(_ =>
{
try { CreateBackup(); Prune(); }
catch (Exception ex) { Console.WriteLine($"[backup] scheduled run failed: {ex.Message}"); }
}, null, TimeSpan.FromHours(IntervalHours), TimeSpan.FromHours(IntervalHours));
Console.WriteLine($"[backup] scheduled every {IntervalHours}h, keeping {RetentionDays} days");
}
catch (Exception ex) { Console.WriteLine($"[backup] start failed: {ex.Message}"); }
}
public static void Stop() { _timer?.Dispose(); _timer = null; }
// ── create / prune / list ────────────────────────────────────────────────────────────
public static string CreateBackup()
{
DbMaintenance.CheckpointAll(quiet: true); // merge WAL into the .db files first
Directory.CreateDirectory(BackupRoot);
string stamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
string dir = Path.Combine(BackupRoot, "backup_" + stamp);
Directory.CreateDirectory(dir);
int n = 0;
if (Directory.Exists(DbDir))
foreach (var f in Directory.GetFiles(DbDir, "*.db"))
{
if (f.EndsWith("-log.db")) continue; // WAL already merged above
try { File.Copy(f, Path.Combine(dir, Path.GetFileName(f)), overwrite: true); n++; }
catch (Exception ex) { Console.WriteLine($"[backup] copy {Path.GetFileName(f)} failed: {ex.Message}"); }
}
Console.WriteLine($"[backup] created backup_{stamp} ({n} files)");
return "backup_" + stamp;
}
public static void Prune()
{
try
{
if (!Directory.Exists(BackupRoot)) return;
var cutoff = DateTime.UtcNow.AddDays(-RetentionDays);
foreach (var d in Directory.GetDirectories(BackupRoot))
{
var ts = ParseStamp(Path.GetFileName(d));
if (ts.HasValue && ts.Value < cutoff)
try { Directory.Delete(d, true); Console.WriteLine($"[backup] pruned {Path.GetFileName(d)}"); } catch { }
}
}
catch (Exception ex) { Console.WriteLine($"[backup] prune failed: {ex.Message}"); }
}
public class BackupInfo
{
public string name { get; set; } = "";
public string createdAt { get; set; } = "";
public long sizeBytes { get; set; }
public int fileCount { get; set; }
}
public static List<BackupInfo> List()
{
var outp = new List<BackupInfo>();
if (!Directory.Exists(BackupRoot)) return outp;
foreach (var d in Directory.GetDirectories(BackupRoot).OrderByDescending(x => x))
{
var files = Directory.GetFiles(d, "*.db");
var ts = ParseStamp(Path.GetFileName(d));
outp.Add(new BackupInfo
{
name = Path.GetFileName(d),
createdAt = (ts ?? DateTime.UtcNow).ToString("yyyy-MM-ddTHH:mm:ssZ"),
sizeBytes = files.Sum(f => { try { return new FileInfo(f).Length; } catch { return 0; } }),
fileCount = files.Length
});
}
return outp;
}
// Stage a restore for the next start. Returns false if the named backup doesn't exist.
public static bool StageRestore(string name)
{
name = SafeName(name);
if (name.Length == 0 || !Directory.Exists(Path.Combine(BackupRoot, name))) return false;
Directory.CreateDirectory(BackupRoot);
File.WriteAllText(RestoreMarker, name);
Console.WriteLine($"[backup] staged restore of '{name}' — applies on next start");
return true;
}
// ── helpers ──────────────────────────────────────────────────────────────────────────
private static string SafeName(string? raw) // no path traversal; a plain folder name only
=> Path.GetFileName((raw ?? "").Trim());
private static DateTime? ParseStamp(string folderName)
{
var m = Regex.Match(folderName, @"backup_(\d{8})_(\d{6})");
if (!m.Success) return null;
return DateTime.TryParseExact(m.Groups[1].Value + m.Groups[2].Value, "yyyyMMddHHmmss",
CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dt)
? dt : (DateTime?)null;
}
}
}
@@ -0,0 +1,163 @@
using LiteDB;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Device and IP bans.
///
/// An account ban (player_extra.ModerationBlockDetails) only stops that one account -
/// the player makes a new one and is straight back in. A device or IP ban is checked
/// at LOGIN, before a token is issued, so every account from that machine or address
/// is refused.
///
/// Lives in the existing Players.db so it is covered by the WAL checkpointing in
/// DbMaintenance rather than adding another file that never checkpoints.
/// </summary>
public static class BanDB
{
public const string Device = "device";
public const string Ip = "ip";
public class BanEntry
{
[BsonId]
public string Key { get; set; } = ""; // "device:<id>" or "ip:<encrypted>"
public string Type { get; set; } = ""; // Device | Ip
public string Value { get; set; } = ""; // device id, or AES-encrypted IP
public ulong PlayerId { get; set; } // account the ban originated from
public ulong BannedBy { get; set; }
public string? Reason { get; set; }
public long CreatedAt { get; set; }
/// <summary>Unix seconds when this lapses. 0 = permanent.</summary>
public long ExpiresAt { get; set; }
}
private static ILiteCollection<BanEntry> Col
{
get
{
var col = PlayerDB.PlayerDBFile.GetCollection<BanEntry>("bans");
col.EnsureIndex(x => x.Type);
col.EnsureIndex(x => x.PlayerId);
return col;
}
}
private static string KeyFor(string type, string value) => $"{type}:{value}";
private static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
/// <summary>
/// IPs are stored AES-encrypted, matching player_extra.IpAddresses. Utils.EncryptAes
/// uses a fixed IV, so the same address always produces the same ciphertext - which
/// is what makes a stored IP comparable at all.
/// </summary>
private static string NormalizeIp(string ip) => Utils.EncryptAes((ip ?? "").Trim());
// ---- writing -----------------------------------------------------------
/// <summary>Bans a device id. durationSeconds &lt;= 0 (or int.MaxValue) = permanent.</summary>
public static bool BanDevice(string deviceId, ulong playerId, ulong bannedBy, string? reason, int durationSeconds)
{
if (string.IsNullOrWhiteSpace(deviceId)) return false;
return Upsert(Device, deviceId.Trim(), playerId, bannedBy, reason, durationSeconds);
}
/// <summary>Bans an IP address (plaintext in; stored encrypted).</summary>
public static bool BanIp(string ip, ulong playerId, ulong bannedBy, string? reason, int durationSeconds)
{
if (string.IsNullOrWhiteSpace(ip)) return false;
return Upsert(Ip, NormalizeIp(ip), playerId, bannedBy, reason, durationSeconds);
}
private static bool Upsert(string type, string value, ulong playerId, ulong bannedBy, string? reason, int durationSeconds)
{
long expires = (durationSeconds <= 0 || durationSeconds == int.MaxValue)
? 0
: Now + durationSeconds;
// Drop any cached "not banned" decision so the 502 applies immediately.
if (type == Ip) BannedIpMiddleware.Forget(value == null ? null : Utils.DecryptAes(value));
Col.Upsert(new BanEntry
{
Key = KeyFor(type, value),
Type = type,
Value = value,
PlayerId = playerId,
BannedBy = bannedBy,
Reason = reason,
CreatedAt = Now,
ExpiresAt = expires
});
return true;
}
// ---- checking ----------------------------------------------------------
public static BanEntry? GetDeviceBan(string? deviceId)
=> string.IsNullOrWhiteSpace(deviceId) ? null : GetActive(Device, deviceId.Trim());
public static BanEntry? GetIpBan(string? ip)
=> string.IsNullOrWhiteSpace(ip) ? null : GetActive(Ip, NormalizeIp(ip));
public static bool IsDeviceBanned(string? deviceId) => GetDeviceBan(deviceId) != null;
public static bool IsIpBanned(string? ip) => GetIpBan(ip) != null;
/// <summary>Returns the entry only if it is still in force; expired entries are removed.</summary>
private static BanEntry? GetActive(string type, string value)
{
var entry = Col.FindById(KeyFor(type, value));
if (entry == null) return null;
if (entry.ExpiresAt > 0 && entry.ExpiresAt <= Now)
{
Col.Delete(entry.Key); // self-cleaning: a lapsed ban stops applying
return null;
}
return entry;
}
// ---- lifting -----------------------------------------------------------
public static bool UnbanDevice(string? deviceId)
=> !string.IsNullOrWhiteSpace(deviceId) && Col.Delete(KeyFor(Device, deviceId!.Trim()));
public static bool UnbanIp(string? ip)
{
if (string.IsNullOrWhiteSpace(ip)) return false;
BannedIpMiddleware.Forget(ip); // release immediately, don't wait for the cache
return Col.Delete(KeyFor(Ip, NormalizeIp(ip!)));
}
/// <summary>
/// Lifts every device/IP ban that originated from this account. Used by the admin
/// panel's Unban so lifting a ban actually lets the player back in, instead of
/// clearing the account block while their device stays blacklisted.
/// </summary>
public static int UnbanAllForPlayer(ulong playerId)
{
var entries = Col.Find(x => x.PlayerId == playerId).ToList();
int n = 0;
foreach (var e in entries)
if (Col.Delete(e.Key)) n++;
// An unban must let them straight back in, not after the cache expires.
if (n > 0) BannedIpMiddleware.ForgetAll();
return n;
}
// ---- listing -----------------------------------------------------------
public static List<BanEntry> ForPlayer(ulong playerId)
=> Col.Find(x => x.PlayerId == playerId).ToList();
public static List<BanEntry> All()
=> Col.FindAll().OrderByDescending(x => x.CreatedAt).ToList();
}
}
@@ -0,0 +1,57 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// In-game bug reports (the watch's "Report a bug" posts to /ado/bug - Azure DevOps on the real
/// service). Everything the client sent is kept verbatim (fields, plus any attached files under
/// Data/BugReports) and shown on the admin panel's Reports tab. The exact field names are not
/// pinned, so the raw dump is the source of truth and Title/Description are best-effort picks.
/// </summary>
public static class BugReportDB
{
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "BugReports.db")));
public class Report
{
[BsonId] public int Id { get; set; }
public ulong AccountId { get; set; }
public string Username { get; set; } = "";
public string Title { get; set; } = "";
public string Description { get; set; } = "";
public string Category { get; set; } = "";
public string Room { get; set; } = "";
public string Raw { get; set; } = "";
public List<string> Files { get; set; } = new();
public string Platform { get; set; } = "";
public DateTime At { get; set; } = DateTime.UtcNow;
public bool Resolved { get; set; }
public string? ResolvedBy { get; set; }
public string? Note { get; set; }
}
private static ILiteCollection<Report> Col()
{
var c = _db.Value.GetCollection<Report>("reports");
c.EnsureIndex(x => x.At);
return c;
}
public static Report Add(Report r) { Col().Insert(r); return r; }
public static List<Report> Recent(bool includeResolved, int take = 200) =>
Col().FindAll().Where(r => includeResolved || !r.Resolved).OrderByDescending(r => r.At).Take(take).ToList();
public static int OpenCount() => Col().Count(r => !r.Resolved);
public static bool Resolve(int id, string by, string? note, bool resolved = true)
{
var col = Col();
var r = col.FindById(id);
if (r == null) return false;
r.Resolved = resolved; r.ResolvedBy = by; r.Note = note;
col.Update(r);
return true;
}
}
}
@@ -0,0 +1,297 @@
using LiteDB;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes.db_class;
using static Campus_live_rewrite.api.server.Classes.db_class.ChatDBClasses;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Chat threads + messages (Data/DBs/Chat.db). One thread per exact member set for DMs and
/// groups (fetch-or-create, like the reference's GetChatBetweenPlayers), party threads as
/// type-1 threads, and per-member read / snooze / favorite state. Every write that another
/// member should see is pushed over the notifications hub as ChatMessageReceived so the
/// conversation (and the in-room bubble) updates without polling.
/// </summary>
public class ChatDB
{
public const int SystemSenderId = -5;
// The client's ChatThreadType: 0 Player (DM / group), 1 Club, 2 Party. Party threads were
// stored and served as 1 - a CLUB thread to the client, which is why the party's chat
// rendered as an ordinary conversation named after the other member.
public const int ThreadTypeUser = 0;
public const int ThreadTypeClub = 1;
public const int ThreadTypeParty = 2;
public const int MaxThreadMembers = 50;
public static LiteDatabase ChatDBFile = new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/Chat.db");
private static ILiteCollection<ChatThread> Threads => ChatDBFile.GetCollection<ChatThread>("threads");
private static ILiteCollection<ChatMessage> Messages => ChatDBFile.GetCollection<ChatMessage>("messages");
private static readonly object _idLock = new();
static ChatDB()
{
Threads.EnsureIndex(x => x.ChatThreadId, unique: true);
Threads.EnsureIndex(x => x.PlayerIds);
Messages.EnsureIndex(x => x.ChatThreadId);
Messages.EnsureIndex(x => x.ChatMessageId, unique: true);
// One-time migration: party threads created while the type was mis-numbered (1) become 2.
// No club threads exist in this store (clubs chat through their own service), so every
// type-1 thread here is a party.
try
{
int n = 0;
foreach (var t in Threads.Find(x => x.ChatThreadType == ThreadTypeClub).ToList())
{ t.ChatThreadType = ThreadTypeParty; Threads.Update(t); n++; }
if (n > 0) Console.WriteLine($"[chat] migrated {n} party thread(s) to ChatThreadType {ThreadTypeParty}");
}
catch (Exception ex) { Console.WriteLine($"[chat] party type migration failed: {ex.Message}"); }
}
private static long NextThreadId()
{
var last = Threads.Query().OrderByDescending(x => x.ChatThreadId).Limit(1).FirstOrDefault();
return (last?.ChatThreadId ?? 0) + 1;
}
private static long NextMessageId()
{
var last = Messages.Query().OrderByDescending(x => x.ChatMessageId).Limit(1).FirstOrDefault();
return (last?.ChatMessageId ?? 0) + 1;
}
public static string StartedChatContents(ulong by) =>
JsonConvert.SerializeObject(new MessageJson { Type = MessageContentType.Text, Version = 1, Data = $"Player <@U{by}> started a chat" });
// ---------------- threads ----------------
/// <summary>Create a thread. type 0 = DM/group (posts the "started a chat" notice), 1 = party.</summary>
public static ChatThread CreateThread(List<ulong> memberIds, ulong creatorid, string? name = null, int type = ThreadTypeUser)
{
ChatThread thread;
lock (_idLock)
{
thread = new ChatThread
{
ChatThreadId = NextThreadId(),
PlayerIds = memberIds.Distinct().ToList(),
ChatThreadName = name ?? "",
ChatThreadType = type,
CreatorPlayerId = creatorid,
CreatedAt = DateTime.UtcNow,
LastActivityAt = DateTime.UtcNow,
IsFavorited = false,
SnoozedUntil = null,
LastReadMessageId = 0,
Messages = new List<ChatMessage>()
};
Threads.Insert(thread);
}
if (type == ThreadTypeUser)
AddMessage(thread.ChatThreadId, SystemSenderId, StartedChatContents(creatorid));
return GetThread(thread.ChatThreadId)!;
}
/// <summary>
/// The existing type-0 thread whose members are EXACTLY this set, else null. Keeps one
/// conversation per pair/group: every "message X" used to mint a brand-new thread.
/// </summary>
public static ChatThread? FindThreadWithExactMembers(List<ulong> memberIds)
{
var want = memberIds.Distinct().OrderBy(x => x).ToList();
if (want.Count == 0) return null;
ulong probe = want[0];
var candidates = Threads.Find(x => x.PlayerIds.Contains(probe) && x.ChatThreadType == ThreadTypeUser).ToList();
var hit = candidates
.Where(t => t.PlayerIds != null && t.PlayerIds.Distinct().OrderBy(x => x).SequenceEqual(want))
.OrderByDescending(t => t.LastActivityAt ?? DateTime.MinValue).ThenByDescending(t => t.ChatThreadId)
.FirstOrDefault();
return hit == null ? null : GetThread(hit.ChatThreadId);
}
public static ChatThread GetOrCreateThreadWithMembers(List<ulong> memberIds, ulong creatorId)
=> FindThreadWithExactMembers(memberIds) ?? CreateThread(memberIds, creatorId);
/// <summary>The caller's party thread (type 1, most recent) or null.</summary>
public static ChatThread? GetPartyThreadFor(ulong playerId)
{
var t = Threads.Find(x => x.PlayerIds.Contains(playerId) && x.ChatThreadType == ThreadTypeParty)
.OrderByDescending(x => x.LastActivityAt ?? DateTime.MinValue).ThenByDescending(x => x.ChatThreadId).FirstOrDefault();
return t == null ? null : GetThread(t.ChatThreadId);
}
public static ChatThread? GetThread(long threadId)
{
var thread = Threads.FindOne(x => x.ChatThreadId == threadId);
if (thread == null) return null;
thread.Messages = Messages.Find(x => x.ChatThreadId == threadId).OrderBy(x => x.ChatMessageId).ToList();
thread.MemberStates ??= new();
thread.PlayerIds ??= new();
return thread;
}
/// <summary>Threads the player is on, most recently active first, each with only its latest message.</summary>
public static List<ChatThread> GetThreadsForPlayer(ulong playerId, int maxCount)
{
// Most recently active first; threads from before LastActivityAt existed sort by id.
var list = Threads.Find(x => x.PlayerIds.Contains(playerId))
.OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt ?? DateTime.MinValue)
.ThenByDescending(x => x.ChatThreadId)
.Take(maxCount).ToList();
foreach (var t in list)
{
t.Messages = Messages.Find(x => x.ChatThreadId == t.ChatThreadId).OrderByDescending(x => x.ChatMessageId).Take(1).ToList();
t.MemberStates ??= new();
t.PlayerIds ??= new();
}
return list;
}
public static bool IsMember(long threadId, ulong playerId)
{
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
return t?.PlayerIds != null && t.PlayerIds.Contains(playerId);
}
public static ChatThread? AddMember(long threadId, ulong playerId)
{
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
if (t == null) return null;
t.PlayerIds ??= new();
if (!t.PlayerIds.Contains(playerId)) { t.PlayerIds.Add(playerId); t.LastActivityAt = DateTime.UtcNow; Threads.Update(t); }
return GetThread(threadId);
}
public static ChatThread? RemoveMember(long threadId, ulong playerId)
{
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
if (t == null) return null;
t.PlayerIds ??= new();
if (t.PlayerIds.Remove(playerId)) { t.LastActivityAt = DateTime.UtcNow; Threads.Update(t); }
return GetThread(threadId);
}
public static ChatThread? SetName(long threadId, string name)
{
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
if (t == null) return null;
t.ChatThreadName = name ?? "";
Threads.Update(t);
return GetThread(threadId);
}
// ---------------- per-member state ----------------
public static ChatMemberState StateFor(ChatThread t, ulong playerId)
{
t.MemberStates ??= new();
if (t.MemberStates.TryGetValue(playerId.ToString(), out var s) && s != null) return s;
// No entry yet: fall back to the old shared thread-level values.
return new ChatMemberState { LastReadMessageId = t.LastReadMessageId, SnoozedUntil = t.SnoozedUntil, IsFavorited = t.IsFavorited };
}
public static void UpdateState(long threadId, ulong playerId, Action<ChatMemberState> change)
{
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
if (t == null) return;
t.MemberStates ??= new();
string key = playerId.ToString();
if (!t.MemberStates.TryGetValue(key, out var s) || s == null)
s = new ChatMemberState { LastReadMessageId = t.LastReadMessageId, SnoozedUntil = t.SnoozedUntil, IsFavorited = t.IsFavorited };
change(s);
t.MemberStates[key] = s;
Threads.Update(t);
}
/// <summary>Move the read pointer forward (never back, never past the latest message).</summary>
public static void MarkRead(long threadId, ulong playerId, long? upToMessageId = null)
{
var t = GetThread(threadId);
if (t == null) return;
long latest = t.Messages.Count > 0 ? t.Messages[^1].ChatMessageId : 0;
long target = Math.Min(upToMessageId ?? latest, latest);
UpdateState(threadId, playerId, s => { if (target > s.LastReadMessageId) s.LastReadMessageId = target; });
}
// ---------------- messages ----------------
public static ChatMessage? GetMessage(long messageId) => Messages.FindOne(x => x.ChatMessageId == messageId);
public static ChatMessage? AddMessage(long threadId, int senderId, string jsonContents)
{
var thread = Threads.FindOne(x => x.ChatThreadId == threadId);
if (thread == null) return null;
ChatMessage msg;
lock (_idLock)
{
msg = new ChatMessage
{
ChatMessageId = NextMessageId(),
ChatThreadId = threadId,
SenderPlayerId = senderId,
TimeSent = DateTime.UtcNow,
Contents = jsonContents,
ModerationState = 0
};
Messages.Insert(msg);
}
thread.LastActivityAt = msg.TimeSent;
// The sender has read their own message.
if (senderId > 0)
{
thread.MemberStates ??= new();
string key = ((ulong)senderId).ToString();
if (!thread.MemberStates.TryGetValue(key, out var s) || s == null) s = new ChatMemberState();
s.LastReadMessageId = msg.ChatMessageId;
thread.MemberStates[key] = s;
}
Threads.Update(thread);
return msg;
}
public static object MessageDto(ChatMessage m) => new
{
chatMessageId = m.ChatMessageId,
chatThreadId = m.ChatThreadId,
senderPlayerId = m.SenderPlayerId,
timeSent = m.TimeSent.ToUniversalTime(), // LiteDB hands back local time; the client wants a UTC instant
contents = m.Contents,
moderationState = m.ModerationState
};
/// <summary>Push a message to every member of its thread (ChatMessageReceived, or another frame id).</summary>
public static void Push(ChatThread thread, ChatMessage m, string frameId = "ChatMessageReceived")
{
var payload = JsonConvert.SerializeObject(new { Id = frameId, Msg = MessageDto(m) });
Notifications.FireAndForget(Notifications.SendToPlayers(thread.PlayerIds.ToList(), payload), $"chat push {frameId}");
}
/// <summary>Post a system (-5) notice into a thread and push it.</summary>
public static ChatMessage? SystemNotice(long threadId, string text)
{
var contents = JsonConvert.SerializeObject(new MessageJson { Type = MessageContentType.Text, Version = 1, Data = text });
var m = AddMessage(threadId, SystemSenderId, contents);
if (m == null) return null;
var t = GetThread(threadId);
if (t != null) Push(t, m);
return m;
}
// Kept for older callers.
public static ChatThread? RenameThread(long threadId, ulong senderId, string newThreadName)
{
var t = SetName(threadId, newThreadName);
if (t != null) SystemNotice(threadId, $"Player <@U{senderId}> renamed the chat");
return GetThread(threadId);
}
public static ChatThread? LeaveThread(long threadId, ulong senderId)
{
// Notice first (the leaver is still a member and gets the push that tells their
// client the thread is gone), then drop the membership.
SystemNotice(threadId, $"Player <@U{senderId}> left");
return RemoveMember(threadId, senderId);
}
}
}
@@ -0,0 +1,502 @@
using LiteDB;
// Club storage for the in-game Clubs feature. Ported to match the RecFlare reference
// (apps/clubs): clubs + membership rows, the { error, success, value } create envelope,
// and the ClubDetails payload the client renders a club screen from.
//
// Global namespace (like ImageMetadataDB) so controllers reference ClubDB.* unqualified.
public static class ClubDB
{
private static readonly string DbPath = Path.Combine("Data", "DBs", "Clubs.db");
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(DbPath));
private static LiteDatabase Db => _db.Value;
// Membership tiers (ClubMembershipType): -1 banned, 0 none, 1 pending request,
// 2 pending invite, 3 denied, 10 member, 20 moderator, 30 co-owner, 100 creator.
public const int TierNone = 0, TierMember = 10, TierModerator = 20, TierCoOwner = 30, TierCreator = 100;
public const string DefaultMainImage = "DefaultImgPurple";
public const int MaxNameLength = 40;
public const int MaxDescriptionLength = 512;
public class Club
{
public int ClubId { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public string Category { get; set; } = "Social";
public int Visibility { get; set; } = 1; // 0 Private, 1 Public
public int Joinability { get; set; } = 0; // 0 Open, 1 InviteOnly, 2 AskToJoin
public bool AllowJuniors { get; set; } = true;
public string MainImageName { get; set; } = DefaultMainImage;
public int ClubType { get; set; } = 0; // 0 regular (never 1 = subscription)
public int? ClubhouseRoomId { get; set; } = null;
public ulong CreatorAccountId { get; set; }
public bool IsRRO { get; set; } = false;
public int MinLevel { get; set; } = 0;
public int State { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public List<string> CustomTags { get; set; } = new();
public List<string> AdditionalImageNames { get; set; } = new();
}
public class ClubMember
{
public int ClubMemberId { get; set; }
public int ClubId { get; set; }
public ulong AccountId { get; set; }
public int MembershipType { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>A club noticeboard post (reference: ClubAnnouncement).</summary>
public class ClubAnnouncement
{
public int AnnouncementId { get; set; }
public int ClubId { get; set; }
public ulong AccountId { get; set; }
public string Title { get; set; } = "";
public string Body { get; set; } = "";
public string ImageName { get; set; } = "";
public string Meta { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ModifiedAt { get; set; }
}
/// <summary>The club a player spawns into (their "home club"); one row per account.</summary>
public class HomeClub
{
public int Id { get; set; }
public ulong AccountId { get; set; }
public int ClubId { get; set; }
}
private static ILiteCollection<HomeClub> Homes()
{
var c = Db.GetCollection<HomeClub>("home_clubs");
c.EnsureIndex(x => x.AccountId, true);
return c;
}
public static int GetHomeClubId(ulong accountId) => Homes().FindOne(h => h.AccountId == accountId)?.ClubId ?? 0;
public static void SetHomeClub(ulong accountId, int clubId)
{
var col = Homes();
var h = col.FindOne(x => x.AccountId == accountId);
if (h == null) col.Insert(new HomeClub { AccountId = accountId, ClubId = clubId });
else { h.ClubId = clubId; col.Update(h); }
}
public static void ClearHomeClub(ulong accountId) => Homes().DeleteMany(x => x.AccountId == accountId);
/// <summary>How far a player has read a club's noticeboard (the unread badge).</summary>
public class AnnouncementRead
{
public int Id { get; set; }
public int ClubId { get; set; }
public ulong AccountId { get; set; }
public int LastReadAnnouncementId { get; set; }
}
// ---- CREATOR (subscription) CLUBS ----
// Subscribing to a player joins that player's auto-created "creator club" (ClubType 1). They
// are not stored: the id is 5_000_000 + creator account id (ClubsController.CreatorClubId)
// and the club is synthesised from the account + its subscriber list whenever it's asked
// for, so /club/{id}, /details, /members and the noticeboard all work for it. Before this
// every one of those 404'd (the "click a player's subscribers" screen was blank).
public const long CreatorClubBase = 5_000_000L;
public static bool IsCreatorClub(long clubId) => clubId >= CreatorClubBase;
public static ulong CreatorOfClub(long clubId) => IsCreatorClub(clubId) ? (ulong)(clubId - CreatorClubBase) : 0;
public static Club? CreatorClub(ulong creatorAccountId)
{
var acc = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountById(creatorAccountId);
if (acc == null) return null;
var full = Campus_live_rewrite.api.dbs.PlayerDB.GetFullAccount(creatorAccountId);
return new Club
{
ClubId = (int)(CreatorClubBase + (long)creatorAccountId),
Name = acc.Username ?? creatorAccountId.ToString(),
Description = "",
Category = "CreatorAutoTag",
Visibility = 1,
Joinability = 0,
AllowJuniors = true,
MainImageName = "DefaultClubImage2k.jpg",
ClubType = 1,
ClubhouseRoomId = null,
CreatorAccountId = creatorAccountId,
IsRRO = false,
MinLevel = 0,
State = 0,
CreatedAt = full?.player?.created_at ?? DateTime.UtcNow,
};
}
private static ILiteCollection<Club> Clubs()
{
var c = Db.GetCollection<Club>("clubs");
c.EnsureIndex(x => x.ClubId, true);
c.EnsureIndex(x => x.CreatorAccountId);
return c;
}
private static ILiteCollection<ClubMember> Members()
{
var c = Db.GetCollection<ClubMember>("members");
c.EnsureIndex(x => x.ClubId);
c.EnsureIndex(x => x.AccountId);
return c;
}
private static int NextId<T>(ILiteCollection<T> col, System.Linq.Expressions.Expression<System.Func<T, int>> key)
{
var last = col.Query().OrderByDescending(key).Limit(1).FirstOrDefault();
return last == null ? 1 : (key.Compile()(last) + 1);
}
public static int CountByCreator(ulong accountId) =>
Clubs().Count(x => x.CreatorAccountId == accountId);
public static Club? GetById(int clubId) =>
IsCreatorClub(clubId) ? CreatorClub(CreatorOfClub(clubId)) : Clubs().FindById(clubId);
public static List<Club> GetByCreator(ulong accountId) =>
Clubs().Query().Where(x => x.CreatorAccountId == accountId && x.ClubType == 0)
.OrderByDescending(x => x.ClubId).ToList();
// Clubs the account belongs to (member tier or above), excluding subscription clubs.
public static List<Club> GetByMember(ulong accountId)
{
var ids = Members().Query()
.Where(m => m.AccountId == accountId && m.MembershipType >= TierMember)
.ToList().Select(m => m.ClubId).Distinct().ToList();
if (ids.Count == 0) return new();
return Clubs().Query().Where(x => ids.Contains(x.ClubId) && x.ClubType == 0)
.OrderByDescending(x => x.ClubId).ToList();
}
public static int MemberCount(int clubId) =>
IsCreatorClub(clubId)
? Campus_live_rewrite.api.dbs.PlayerDB.GetSubscriberIds(CreatorOfClub(clubId)).Count
: Members().Count(m => m.ClubId == clubId && m.MembershipType >= TierMember);
public static List<ClubMember> GetMembers(int clubId, int? membershipType, string? sortBy)
{
List<ClubMember> list;
if (IsCreatorClub(clubId))
{
// The creator owns their club; every subscriber is a plain member.
ulong creator = CreatorOfClub(clubId);
list = new List<ClubMember> { new() { ClubMemberId = 0, ClubId = clubId, AccountId = creator, MembershipType = TierCreator } };
int n = 1;
foreach (var sub in Campus_live_rewrite.api.dbs.PlayerDB.GetSubscriberIds(creator))
list.Add(new ClubMember { ClubMemberId = n++, ClubId = clubId, AccountId = sub, MembershipType = TierMember });
}
else list = Members().Query().Where(m => m.ClubId == clubId).ToList();
if (membershipType.HasValue) list = list.Where(m => m.MembershipType == membershipType.Value).ToList();
list = sortBy switch
{
"1" => list.OrderBy(m => m.AccountId).ToList(),
"2" => list.OrderBy(m => m.CreatedAt).ToList(),
_ => list.OrderByDescending(m => m.MembershipType).ThenBy(m => m.CreatedAt).ToList(),
};
return list;
}
// The caller's tier in a club (0 = none / signed out).
public static int MyMembership(int clubId, ulong accountId)
{
if (accountId == 0) return TierNone;
if (IsCreatorClub(clubId))
{
ulong creator = CreatorOfClub(clubId);
if (creator == accountId) return TierCreator;
return Campus_live_rewrite.api.dbs.PlayerDB.GetSubscribedToIds(accountId).Contains(creator) ? TierMember : TierNone;
}
var m = Members().FindOne(x => x.ClubId == clubId && x.AccountId == accountId);
return m?.MembershipType ?? TierNone;
}
// Create a club and seed the creator's membership row (tier 100). Returns the new club.
public static Club CreateClub(ulong creator, string name, string description, string category,
int visibility, int joinability, bool allowJuniors, int minLevel)
{
var clubs = Clubs();
var club = new Club
{
ClubId = NextId(clubs, x => x.ClubId),
Name = name,
Description = description ?? "",
Category = string.IsNullOrWhiteSpace(category) ? "Social" : category,
Visibility = visibility,
Joinability = joinability,
AllowJuniors = allowJuniors,
MainImageName = DefaultMainImage,
ClubType = 0,
CreatorAccountId = creator,
MinLevel = minLevel < 0 ? 0 : minLevel,
CreatedAt = DateTime.UtcNow,
CustomTags = new(),
};
clubs.Insert(club);
var members = Members();
members.Insert(new ClubMember
{
ClubMemberId = NextId(members, x => x.ClubMemberId),
ClubId = club.ClubId,
AccountId = creator,
MembershipType = TierCreator,
CreatedAt = DateTime.UtcNow,
});
return club;
}
// Set the club's main image (the club photo). Returns the updated club, or null.
public static Club? SetMainImage(int clubId, string imageName)
{
var col = Clubs();
var club = col.FindById(clubId);
if (club == null) return null;
club.MainImageName = imageName;
col.Update(club);
return club;
}
// Set one gallery image by position (packed: past-the-end appends). Returns updated club.
public static Club? SetAdditionalImage(int clubId, int index, string imageName)
{
var col = Clubs();
var club = col.FindById(clubId);
if (club == null) return null;
club.AdditionalImageNames ??= new();
if (index < 0 || index >= club.AdditionalImageNames.Count) club.AdditionalImageNames.Add(imageName);
else club.AdditionalImageNames[index] = imageName;
col.Update(club);
return club;
}
// Remove one gallery image and shift the rest up (no blank slot). Returns updated club.
public static Club? RemoveAdditionalImage(int clubId, int index)
{
var col = Clubs();
var club = col.FindById(clubId);
if (club == null) return null;
club.AdditionalImageNames ??= new();
if (index >= 0 && index < club.AdditionalImageNames.Count) club.AdditionalImageNames.RemoveAt(index);
col.Update(club);
return club;
}
// Edit a club's details (co-owner+). Every param is optional: null = leave unchanged.
// A non-null customTags REPLACES the tag set (capped at 3). Returns the updated club, or null.
public static Club? UpdateDetails(int clubId, string? name, string? description, string? category,
int? visibility, int? joinability, bool? allowJuniors, int? minLevel, List<string>? customTags)
{
var col = Clubs();
var club = col.FindById(clubId);
if (club == null) return null;
if (!string.IsNullOrWhiteSpace(name)) club.Name = name.Trim();
if (description != null) club.Description = description;
if (!string.IsNullOrWhiteSpace(category)) club.Category = category.Trim();
if (visibility.HasValue) club.Visibility = visibility.Value;
if (joinability.HasValue) club.Joinability = joinability.Value;
if (allowJuniors.HasValue) club.AllowJuniors = allowJuniors.Value;
if (minLevel.HasValue) club.MinLevel = minLevel.Value < 0 ? 0 : minLevel.Value;
if (customTags != null) club.CustomTags = customTags.Take(3).ToList();
col.Update(club);
return club;
}
// ---- ANNOUNCEMENTS (the club noticeboard) ----
private static ILiteCollection<ClubAnnouncement> Announcements()
{
var c = Db.GetCollection<ClubAnnouncement>("announcements");
c.EnsureIndex(x => x.AnnouncementId, true);
c.EnsureIndex(x => x.ClubId);
return c;
}
private static ILiteCollection<AnnouncementRead> Reads()
{
var c = Db.GetCollection<AnnouncementRead>("announcement_reads");
c.EnsureIndex(x => x.ClubId);
c.EnsureIndex(x => x.AccountId);
return c;
}
/// <summary>A club's noticeboard, newest first.</summary>
public static List<ClubAnnouncement> GetAnnouncements(int clubId) =>
Announcements().Query().Where(a => a.ClubId == clubId).ToList()
.OrderByDescending(a => a.AnnouncementId).ToList();
public static int LatestAnnouncementId(int clubId) =>
Announcements().Query().Where(a => a.ClubId == clubId).ToList()
.Select(a => a.AnnouncementId).DefaultIfEmpty(0).Max();
public static ClubAnnouncement PostAnnouncement(int clubId, ulong accountId, string? title, string? body, string? imageName, string? meta)
{
var col = Announcements();
var a = new ClubAnnouncement
{
AnnouncementId = NextId(col, x => x.AnnouncementId),
ClubId = clubId,
AccountId = accountId,
Title = (title ?? "").Trim(),
Body = (body ?? "").Trim(),
ImageName = (imageName ?? "").Trim(),
Meta = meta ?? "",
};
col.Insert(a);
// The poster has obviously read their own post.
MarkAnnouncementsRead(clubId, accountId, a.AnnouncementId);
return a;
}
public static ClubAnnouncement? EditAnnouncement(int announcementId, string? title, string? body, string? imageName, string? meta)
{
var col = Announcements();
var a = col.FindOne(x => x.AnnouncementId == announcementId);
if (a == null) return null;
if (title != null) a.Title = title.Trim();
if (body != null) a.Body = body;
if (imageName != null) a.ImageName = imageName.Trim();
if (meta != null) a.Meta = meta;
a.ModifiedAt = DateTime.UtcNow;
col.Update(a);
return a;
}
public static ClubAnnouncement? DeleteAnnouncement(int announcementId)
{
var col = Announcements();
var a = col.FindOne(x => x.AnnouncementId == announcementId);
if (a != null) col.DeleteMany(x => x.AnnouncementId == announcementId);
return a;
}
public static int LastReadAnnouncementId(int clubId, ulong accountId) =>
Reads().FindOne(r => r.ClubId == clubId && r.AccountId == accountId)?.LastReadAnnouncementId ?? 0;
public static void MarkAnnouncementsRead(int clubId, ulong accountId, int upToAnnouncementId)
{
if (accountId == 0) return;
var col = Reads();
var r = col.FindOne(x => x.ClubId == clubId && x.AccountId == accountId);
if (r == null) col.Insert(new AnnouncementRead { ClubId = clubId, AccountId = accountId, LastReadAnnouncementId = upToAnnouncementId });
else if (upToAnnouncementId > r.LastReadAnnouncementId) { r.LastReadAnnouncementId = upToAnnouncementId; col.Update(r); }
}
public static bool HasUnreadAnnouncements(int clubId, ulong accountId) =>
LatestAnnouncementId(clubId) > LastReadAnnouncementId(clubId, accountId);
public static void Checkpoint()
{
try { Db.Checkpoint(); } catch { /* best-effort */ }
}
// ---- browse ----------------------------------------------------------------------------
public const int TierBanned = -1, TierPendingRequest = 1, TierPendingInvite = 2, TierDenied = 3;
/// <summary>
/// The Clubs browse page (reference searchClubs): public, non-subscription clubs, optionally
/// one category (exact, case-insensitive) and a name/description substring; sort 1 = newest,
/// 2 = by name, anything else = most members first. Returns the page and the full match count.
/// </summary>
public static (List<Club> page, int total) Search(string? category, string? query, string? sort, int count)
{
var all = Clubs().Query().Where(x => x.Visibility == 1 && x.ClubType == 0).ToList();
string wanted = (category ?? "").Trim();
string term = (query ?? "").Trim();
var matched = all.Where(c =>
(wanted.Length == 0 || string.Equals(c.Category, wanted, StringComparison.OrdinalIgnoreCase)) &&
(term.Length == 0
|| (c.Name ?? "").Contains(term, StringComparison.OrdinalIgnoreCase)
|| (c.Description ?? "").Contains(term, StringComparison.OrdinalIgnoreCase))).ToList();
var counts = matched.ToDictionary(c => c.ClubId, c => MemberCount(c.ClubId));
matched = sort switch
{
"1" => matched.OrderByDescending(c => c.CreatedAt).ToList(),
"2" => matched.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(),
_ => matched.OrderByDescending(c => counts[c.ClubId]).ThenByDescending(c => c.CreatedAt).ToList(),
};
return (matched.Take(count).ToList(), matched.Count);
}
/// <summary>Clubs an account created that anyone may see (public ones), for their profile.</summary>
public static List<Club> PublicByCreator(ulong accountId) =>
Clubs().Query().Where(x => x.CreatorAccountId == accountId && x.ClubType == 0 && x.Visibility == 1)
.OrderByDescending(x => x.ClubId).ToList();
// ---- membership writes -----------------------------------------------------------------
private static void Upsert(int clubId, ulong accountId, int tier)
{
var members = Members();
var m = members.FindOne(x => x.ClubId == clubId && x.AccountId == accountId);
if (m == null)
{
members.Insert(new ClubMember
{
ClubMemberId = NextId(members, x => x.ClubMemberId),
ClubId = clubId, AccountId = accountId, MembershipType = tier, CreatedAt = DateTime.UtcNow,
});
}
else { m.MembershipType = tier; members.Update(m); }
}
/// <summary>
/// The caller asks to join (reference requesttojoin / join): an Open club takes them in as a
/// Member, an AskToJoin club records a pending request, an InviteOnly club refuses. A pending
/// invite is accepted by asking. Banned stays banned. Returns the resulting tier.
/// </summary>
public static int RequestJoin(int clubId, ulong accountId)
{
var club = Clubs().FindById(clubId);
if (club == null) return TierNone;
int have = MyMembership(clubId, accountId);
if (have == TierBanned || have >= TierMember) return have;
if (have == TierPendingInvite) { Upsert(clubId, accountId, TierMember); return TierMember; }
if (club.Joinability == 0) { Upsert(clubId, accountId, TierMember); return TierMember; }
if (club.Joinability == 2) { Upsert(clubId, accountId, TierPendingRequest); return TierPendingRequest; }
return have; // InviteOnly: nothing changes
}
/// <summary>Leave (or withdraw a request). The creator can't; a ban stays. Returns the new tier.</summary>
public static int Leave(int clubId, ulong accountId)
{
int have = MyMembership(clubId, accountId);
if (have == TierCreator || have == TierBanned) return have;
Members().DeleteMany(x => x.ClubId == clubId && x.AccountId == accountId);
return TierNone;
}
/// <summary>A co-owner adds / promotes / demotes someone (reference members/invite). Never the creator, never to Creator.</summary>
public static bool SetTier(int clubId, ulong accountId, int tier)
{
var club = Clubs().FindById(clubId);
if (club == null || club.CreatorAccountId == accountId) return false;
if (tier != TierMember && tier != TierModerator && tier != TierCoOwner && tier != TierBanned
&& tier != TierPendingInvite && tier != TierNone) return false;
if (tier == TierNone) { Members().DeleteMany(x => x.ClubId == clubId && x.AccountId == accountId); return true; }
Upsert(clubId, accountId, tier);
return true;
}
public static Club? SetClubhouse(int clubId, int? roomId)
{
var clubs = Clubs();
var c = clubs.FindById(clubId);
if (c == null) return null;
c.ClubhouseRoomId = roomId;
clubs.Update(c);
return c;
}
/// <summary>Delete a club with its memberships and announcements (creator only - the caller checks).</summary>
public static bool Delete(int clubId)
{
var clubs = Clubs();
if (clubs.FindById(clubId) == null) return false;
Members().DeleteMany(x => x.ClubId == clubId);
try { Db.GetCollection<ClubAnnouncement>("announcements").DeleteMany(x => x.ClubId == clubId); } catch { }
clubs.Delete(clubId);
return true;
}
}
@@ -0,0 +1,78 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// A player's own curated lists (the Lists service's `list` table in the reference): the
/// Play menu's "Saved for Later" row is `__SavedForLater_Rooms` (type 1, room ids), created by
/// the client itself through PUT /lists/curatedlists/{name}/items/{itemId}/createlistifneeded.
/// One list per (owner, type, name).
/// </summary>
public static class CuratedListDB
{
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "CuratedLists.db")));
public class PlayerList
{
[BsonId] public long ListId { get; set; }
public ulong CreatorAccountId { get; set; }
public string Name { get; set; } = "";
public int Type { get; set; } = 1;
public int Accessibility { get; set; } = 0;
public List<string> ItemIds { get; set; } = new();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
private static ILiteCollection<PlayerList> Col()
{
var c = _db.Value.GetCollection<PlayerList>("lists");
c.EnsureIndex(x => x.CreatorAccountId);
return c;
}
/// <summary>
/// A stable id for (owner, type, name) - the same list has the same id before and after it
/// is created, so a read that answers a not-yet-created reserved list (`__SavedForLater_Rooms`)
/// hands out the id the later write will use. Positive, 63-bit.
/// </summary>
public static long IdFor(ulong owner, int type, string name)
{
unchecked
{
ulong h = 1469598103934665603UL;
foreach (char ch in (name ?? "").ToLowerInvariant()) { h ^= ch; h *= 1099511628211UL; }
h ^= (ulong)type; h *= 1099511628211UL;
h ^= owner; h *= 1099511628211UL;
return (long)(h & 0x7FFF_FFFF_FFFF_FFFFUL) | 1L;
}
}
public static PlayerList? Get(ulong owner, int type, string name) =>
Col().FindById(IdFor(owner, type, name));
public static PlayerList? GetById(long listId) => Col().FindById(listId);
public static PlayerList AddItem(ulong owner, int type, string name, int accessibility, string itemId)
{
var col = Col();
long id = IdFor(owner, type, name);
var list = col.FindById(id) ?? new PlayerList
{
ListId = id, CreatorAccountId = owner, Name = name, Type = type, Accessibility = accessibility, CreatedAt = DateTime.UtcNow
};
if (!list.ItemIds.Contains(itemId)) list.ItemIds.Add(itemId);
col.Upsert(list);
return list;
}
public static PlayerList? RemoveItem(ulong owner, int type, string name, string itemId)
{
var col = Col();
var list = col.FindById(IdFor(owner, type, name));
if (list == null) return null;
list.ItemIds.RemoveAll(x => x == itemId);
col.Update(list);
return list;
}
}
}
@@ -0,0 +1,119 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
// LiteDB v5 writes through a write-ahead log (`<name>-log.db`) and only merges
// it back into the main data file on Checkpoint() - which happens automatically
// ONLY on LiteDatabase.Dispose(), or once the log passes CheckpointSize
// (default 1000 pages / ~8MB).
//
// Every LiteDatabase in this project is a `static` field that is never disposed,
// and `systemctl stop campus` kills the process without unwinding statics. So no
// checkpoint ever ran, and the logs sat just under the 1000-page auto-threshold
// (573-825 pages) and never tripped it either.
//
// The result, as of 2026-08-21: Heartbeats.db and Settings.db were 8192 bytes -
// a bare header page - while Heartbeats-log.db was 5.6MB and Settings-log.db
// 6.4MB. 100% of that data lived in the WAL and none of it in the data file.
//
// That matters beyond tidiness because install.sh deliberately preserves
// Data/DBs/* across deploys, so those logs were copied forward verbatim into
// every new build. Deploying a known-good server could not fix a data-level
// problem, because the data came along with it. That is why swapping in a
// working server still broke.
public static class DbMaintenance
{
// Checkpoint every store. Each is wrapped separately so one bad file can't
// stop the rest from flushing.
private static Timer? _autoCheckpoint;
// Checkpoint on a schedule, not just at shutdown.
//
// Writes land in <name>-log.db and are only merged into <name>.db by a
// checkpoint. Until then the data is recoverable ONLY by replaying that log.
// Measured 2026-08-23: a setting written and read back survived a restart, but
// Settings.db was still 8,192 bytes (untouched since 12 Aug) while
// Settings-log.db had grown to 7.37 MB - i.e. every setting the server has ever
// stored lived in the log alone.
//
// That works until the log is lost or truncated, at which point everything
// written since the last checkpoint disappears at once. Shutdown-only
// checkpointing means a hard kill (crash, SIGKILL, power loss) never merges.
//
// A periodic checkpoint bounds that exposure to `minutes`. It only flushes data
// already committed - it cannot change any response or alter stored values.
public static void StartAutoCheckpoint(int minutes = 5)
{
if (_autoCheckpoint != null) return;
var period = TimeSpan.FromMinutes(minutes);
_autoCheckpoint = new Timer(_ =>
{
try { CheckpointAll(quiet: true); }
catch (Exception ex) { Console.WriteLine($"[DbMaintenance] auto-checkpoint failed: {ex.Message}"); }
}, null, period, period);
Console.WriteLine($"[DbMaintenance] auto-checkpoint every {minutes} min");
}
public static void StopAutoCheckpoint()
{
_autoCheckpoint?.Dispose();
_autoCheckpoint = null;
}
public static void CheckpointAll(bool quiet = false)
{
Flush("Players", () => PlayerDB.PlayerDBFile.Checkpoint(), quiet);
Flush("Rooms", () => RoomDB.rooms_db.Checkpoint(), quiet);
Flush("Chat", () => ChatDB.ChatDBFile.Checkpoint(), quiet);
Flush("Events", () => EventDB.EventDBFile.Checkpoint(), quiet);
Flush("PlayersFriends", () => FriendsDB.PlayerFriendDBFile.Checkpoint(), quiet);
Flush("Heartbeats", HeartbeatDB.Checkpoint, quiet);
Flush("Settings", SettingsDB.Checkpoint, quiet);
Flush("Leaderboard", LeaderboardDB.Checkpoint, quiet);
Flush("Support", SupportDB.Checkpoint, quiet);
Flush("Clubs", ClubDB.Checkpoint, quiet);
Flush("Inventions", InventionDB.Checkpoint, quiet);
}
private static void Flush(string name, Action action, bool quiet = false)
{
try
{
action();
if (!quiet) Console.WriteLine($"[DbMaintenance] checkpointed {name}");
}
catch (Exception ex)
{
Console.WriteLine($"[DbMaintenance] FAILED to checkpoint {name}: {ex.Message}");
}
}
// Print the data-file / log-file split at startup. A main file stuck at 8192
// bytes with a multi-megabyte log means the checkpoint path is broken again.
public static void ReportWalState()
{
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "DBs");
if (!Directory.Exists(dir))
{
Console.WriteLine($"[DbMaintenance] no DB directory at {dir}");
return;
}
foreach (var db in Directory.GetFiles(dir, "*.db").OrderBy(f => f))
{
if (db.EndsWith("-log.db", StringComparison.OrdinalIgnoreCase)) continue;
var main = new FileInfo(db);
var log = new FileInfo(db[..^3] + "-log.db");
long logLen = log.Exists ? log.Length : 0;
string flag = (main.Length <= 8192 && logLen > 1024 * 1024)
? " <-- ALL DATA IS IN THE LOG, main file is empty"
: "";
Console.WriteLine(
$"[DbMaintenance] {main.Name,-24} data={main.Length,10:n0} log={logLen,12:n0}{flag}");
}
}
}
}
@@ -0,0 +1,129 @@
using LiteDB;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Creator earnings. When a player buys something another player MADE - an invention, a UGC
/// clothing item, a room key, room currency, a room consumable - the maker gets
/// ConfigCampus.CreatorEarningsPercent (30%) of what the buyer spent, in the same currency.
///
/// It is not paid on the spot: an earning PENDS for ConfigCampus.CreatorEarningsPendDays (14)
/// and is then paid on the first SUNDAY (00:00 UTC) on or after that. The Coach / system
/// accounts and the room importer never earn. Every payout is logged and pushes the balance,
/// so the number in the corner moves when it lands.
/// </summary>
public static class EarningsDB
{
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "Earnings.db")));
public class Entry
{
[BsonId] public int Id { get; set; }
public ulong SellerId { get; set; }
public ulong BuyerId { get; set; }
public string Kind { get; set; } = ""; // invention / ugc / roomkey / roomcurrency / roomconsumable
public string ItemId { get; set; } = "";
public string ItemName { get; set; } = "";
public int CurrencyType { get; set; } = 2;
public long Spent { get; set; }
public long Share { get; set; }
public DateTime BoughtAt { get; set; } = DateTime.UtcNow;
public DateTime AvailableAt { get; set; }
public DateTime PayoutAt { get; set; }
public DateTime? PaidAt { get; set; }
}
private static ILiteCollection<Entry> Col()
{
var c = _db.Value.GetCollection<Entry>("earnings");
c.EnsureIndex(x => x.SellerId);
c.EnsureIndex(x => x.PaidAt);
return c;
}
/// <summary>The first Sunday 00:00 UTC on or after <paramref name="t"/>.</summary>
public static DateTime NextSunday(DateTime t)
{
var day = new DateTime(t.Year, t.Month, t.Day, 0, 0, 0, DateTimeKind.Utc);
int add = ((int)DayOfWeek.Sunday - (int)day.DayOfWeek + 7) % 7;
if (add == 0 && day < t) add = 7;
return day.AddDays(add);
}
private static bool CanEarn(ulong seller)
{
if (seller == 0) return false;
var acc = PlayerDB.GetAccountById(seller);
if (acc == null) return false;
foreach (var n in ConfigCampus.NoEarningsUsernames)
if (string.Equals(acc.Username, n, StringComparison.OrdinalIgnoreCase)) return false;
return true;
}
/// <summary>Record a sale. Returns the entry, or null when the seller does not earn (buying your own, Coach, unknown).</summary>
public static Entry? Record(ulong seller, ulong buyer, string kind, string itemId, string itemName, int currencyType, long spent)
{
if (spent <= 0 || seller == buyer || !CanEarn(seller)) return null;
long share = (long)Math.Floor(spent * ConfigCampus.CreatorEarningsPercent / 100.0);
if (share <= 0) return null;
var now = DateTime.UtcNow;
var available = now.AddDays(ConfigCampus.CreatorEarningsPendDays);
var e = new Entry
{
SellerId = seller, BuyerId = buyer, Kind = kind, ItemId = itemId, ItemName = itemName,
CurrencyType = currencyType, Spent = spent, Share = share,
BoughtAt = now, AvailableAt = available, PayoutAt = NextSunday(available)
};
try { Col().Insert(e); }
catch (Exception ex) { Console.WriteLine($"[earnings] record failed: {ex.Message}"); return null; }
Console.WriteLine($"[earnings] {seller} earns {share} of {spent} (currency {currencyType}) from {buyer} buying {kind} '{itemName}' - pays out {e.PayoutAt:yyyy-MM-dd}");
return e;
}
/// <summary>Pay every earning whose Sunday has come. Called by the timer and on demand.</summary>
public static int PayDue()
{
int paid = 0;
try
{
var col = Col();
var now = DateTime.UtcNow;
var due = col.Find(x => x.PaidAt == null).Where(x => x.PayoutAt <= now).ToList();
foreach (var group in due.GroupBy(x => (x.SellerId, x.CurrencyType)))
{
long total = group.Sum(x => x.Share);
if (total <= 0) continue;
long balance = PlayerDB.AddBalance(group.Key.SellerId, group.Key.CurrencyType, total);
foreach (var e in group) { e.PaidAt = now; col.Update(e); paid++; }
GiftBoxes.PushBalance(group.Key.SellerId, group.Key.CurrencyType, balance, "creator earnings");
Console.WriteLine($"[earnings] paid {total} (currency {group.Key.CurrencyType}) to {group.Key.SellerId} for {group.Count()} sale(s) -> balance {balance}");
}
}
catch (Exception ex) { Console.WriteLine($"[earnings] payout failed: {ex.Message}"); }
return paid;
}
public static List<Entry> ForPlayer(ulong seller, int take = 200) =>
Col().Find(x => x.SellerId == seller).OrderByDescending(x => x.BoughtAt).Take(take).ToList();
public static List<Entry> Recent(int take = 200) =>
Col().FindAll().OrderByDescending(x => x.BoughtAt).Take(take).ToList();
public static (long pending, long paid, DateTime? nextPayout) Summary(ulong seller)
{
var all = Col().Find(x => x.SellerId == seller).ToList();
long pending = all.Where(x => x.PaidAt == null).Sum(x => x.Share);
long paidTotal = all.Where(x => x.PaidAt != null).Sum(x => x.Share);
DateTime? next = all.Where(x => x.PaidAt == null).Select(x => (DateTime?)x.PayoutAt).Min();
return (pending, paidTotal, next);
}
private static System.Threading.Timer? _timer;
/// <summary>Check for due payouts every 15 minutes (and once at boot).</summary>
public static void Start()
{
_timer ??= new System.Threading.Timer(_ => PayDue(), null, TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(15));
}
}
}
@@ -0,0 +1,82 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
// What each player has equipped, per equipment prefab.
//
// The client POSTs /api/equipment/v1/update every time a skin is equipped, sending
// the full entry for that prefab:
//
// [{"PrefabName":"[ShareCamera]","ModificationGuid":"48c97ace-...",
// "FriendlyName":"Camping Share Camera","Tooltip":"","Rarity":0,
// "ThumbnailImageName":null,"Favorited":true}]
//
// That route did not exist, so the POST 404'd and the choice lived only in the
// running client - at the next login everyone was back on the default skin.
//
// Stored as a collection inside Players.db rather than its own file, so it rides the
// existing checkpoint (DbMaintenance.CheckpointAll) instead of adding another
// write-ahead log that never gets merged.
public static class EquipmentDB
{
public class EquippedItem
{
public string PrefabName { get; set; } = "";
public string ModificationGuid { get; set; } = "";
public string? FriendlyName { get; set; }
public string? Tooltip { get; set; }
public int Rarity { get; set; }
public string? ThumbnailImageName { get; set; }
public bool Favorited { get; set; }
}
public class PlayerEquipment
{
[BsonId]
public ulong PlayerId { get; set; }
public List<EquippedItem> Equipped { get; set; } = new();
}
private static readonly ILiteCollection<PlayerEquipment> _col =
PlayerDB.PlayerDBFile.GetCollection<PlayerEquipment>("player_equipment");
public static List<EquippedItem> Get(ulong playerId) =>
_col.FindById(playerId)?.Equipped ?? new List<EquippedItem>();
/// <summary>
/// Records the state the client just sent, keyed by prefab AND modification guid.
/// Returns the player's full loadout afterwards.
///
/// Keying by prefab alone loses data. One request carries SEVERAL entries for the
/// same prefab - the newly equipped skin, plus the one it replaced so its flag can
/// be cleared:
///
/// [MakerPen] Professor Maker Pen (the new one)
/// [MakerPen] Host 2 Maker Pen (the old one, flag being cleared)
///
/// Replacing "all entries for [MakerPen]" therefore kept only whichever happened to
/// come LAST in the array - frequently the one being switched OFF - so the skin came
/// back unequipped. The client decides which entry is current; our job is to store
/// exactly what it told us, per item.
/// </summary>
public static List<EquippedItem> Update(ulong playerId, IEnumerable<EquippedItem>? items)
{
var row = _col.FindById(playerId) ?? new PlayerEquipment { PlayerId = playerId };
if (items == null) return row.Equipped;
foreach (var item in items)
{
if (item == null || string.IsNullOrWhiteSpace(item.PrefabName)) continue;
row.Equipped.RemoveAll(e =>
string.Equals(e.PrefabName, item.PrefabName, StringComparison.OrdinalIgnoreCase) &&
string.Equals(e.ModificationGuid, item.ModificationGuid, StringComparison.OrdinalIgnoreCase));
row.Equipped.Add(item);
}
_col.Upsert(row);
return row.Equipped;
}
}
}
@@ -0,0 +1,300 @@
using Discord;
using LiteDB;
using Campus_live_rewrite.api.server.Classes.db_class;
using static Campus_live_rewrite.api.server.Classes.db_class.EventDBClasses;
namespace Campus_live_rewrite.api.dbs
{
public class EventDB
{
public static LiteDatabase EventDBFile = new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/Events.db");
private static ILiteCollection<FullPlayerEvent> Events => EventDBFile.GetCollection<FullPlayerEvent>("events");
private static ulong GetNextId()
{
var last = Events.Query()
.OrderByDescending(x => x.PlayerEventId)
.FirstOrDefault();
return last == null ? 1UL : last.PlayerEventId + 1;
}
public static FullPlayerEvent? GetFullEventById(ulong eventId)
{
return Events.FindById(eventId);
}
/// <summary>Apply an edit to a stored event and persist it. Null when the event is unknown.</summary>
public static FullPlayerEvent? Mutate(ulong eventId, Action<FullPlayerEvent> edit)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
edit(ev);
Events.Update(ev);
return ev;
}
public static FullPlayerEvent? SetEventName(ulong eventId, string NewName)
{
var ev = Events.FindById(eventId);
if (ev == null)
return null;
ev.Name = NewName;
Events.Update(ev);
return ev;
}
public static FullPlayerEvent? SetEventImageName(ulong eventId, string NewImageName)
{
var ev = Events.FindById(eventId);
if (ev == null)
return null;
ev.ImageName = NewImageName;
Events.Update(ev);
return ev;
}
public static FullPlayerEvent? SetEventDescription(ulong eventId, string newDescription)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
ev.Description = newDescription;
Events.Update(ev);
return ev;
}
public static FullPlayerEvent? SetEventTime(ulong eventId, DateTime start, DateTime end)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
ev.StartTime = start;
ev.EndTime = end;
Events.Update(ev);
return ev;
}
public static FullPlayerEvent? SetEventAccessibility(ulong eventId, EventDBClasses.PlayerEventAccessibility accessibility)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
ev.Accessibility = accessibility;
Events.Update(ev);
return ev;
}
public static FullPlayerEvent? SetEventTags(ulong eventId, List<string> tags)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
ev.Tags = (tags ?? new List<string>())
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => new EventDBClasses.FullTag { Tag = t.Trim(), Type = EventDBClasses.TagType.Unknown1 })
.ToList();
Events.Update(ev);
return ev;
}
public static List<FullPlayerEvent> SearchEventByQuery(string? query)
{
if (string.IsNullOrWhiteSpace(query))
{
return Events.Query()
.OrderBy(e => e.StartTime)
.ToList();
}
query = query.Trim();
bool isTagSearch =
query.StartsWith("#") ||
query.StartsWith("%23");
if (isTagSearch)
{
string tag = query
.Replace("%23", "")
.TrimStart('#')
.ToLower();
return Events
.Find(Query.EQ("Tags.Tag", tag))
.OrderBy(e => e.StartTime)
.ToList();
}
string nameQuery = query.ToLower();
return Events.Query()
.Where(e =>
e.Name != null &&
e.Name.ToLower().Contains(nameQuery))
.OrderBy(e => e.StartTime)
.ToList();
}
/// <summary>
/// Events scheduled in a given room. /api/playerevents/v1/room/{RoomId} used to answer
/// a hardcoded "[]" (marked "todo db"), so a room never showed the events booked in it.
/// </summary>
public static List<FullPlayerEvent> GetEventsForRoom(ulong roomId)
{
return Events.Query()
.Where(e => e.RoomId == roomId)
.OrderBy(e => e.StartTime)
.ToList();
}
/// <summary>
/// Events happening right now - started, not yet ended. /api/playerevents/v1/searchlive
/// was another hardcoded "[]", so the "live now" list was always empty however many
/// events were running.
/// </summary>
public static List<FullPlayerEvent> GetLiveEvents()
{
var now = DateTime.UtcNow;
return Events.Query()
.ToList()
.Where(e => e.StartTime.ToUniversalTime() <= now && e.EndTime.ToUniversalTime() >= now)
.OrderBy(e => e.StartTime)
.ToList();
}
/// <summary>
/// Records a player's RSVP and keeps AttendeeCount in step with it. One response per
/// player per event - responding again replaces the previous answer.
/// </summary>
public static FullPlayerEvent? SetResponse(ulong eventId, ulong playerId, PlayerEventResponseType type)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
ev.Responses ??= new List<FullEventResponses>();
ev.Responses.RemoveAll(r => r.PlayerId == playerId);
if (type != PlayerEventResponseType.None)
{
ulong nextId = ev.Responses.Count == 0 ? 1UL : ev.Responses.Max(r => r.PlayerEventResponseId) + 1;
ev.Responses.Add(new FullEventResponses
{
PlayerEventResponseId = nextId,
PlayerEventId = eventId,
PlayerId = playerId,
CreatedAt = DateTime.UtcNow,
Type = type
});
}
// AttendeeCount was never maintained - it sat at 0 no matter how many people
// responded, so every event advertised itself as empty.
ev.AttendeeCount = ev.Responses.Count(r => r.Type == PlayerEventResponseType.Yes);
Events.Update(ev);
return ev;
}
/// <summary>
/// Whether a player may enter an event's room, and why not when they may not.
///
/// Rules:
/// - the CREATOR may join at any time, including before it starts (they have to be
/// able to set the room up).
/// - anyone INVITED may join at any time. An invite is a response row: the invite
/// flow writes Pending, and Yes / Interested / No all mean the player is already
/// part of the event, so any response row counts.
/// - everyone else may only join once it has actually started, and not after it has
/// ended.
///
/// Returns true when entry is allowed; `reason` explains a refusal for the log.
/// </summary>
public static bool CanPlayerJoin(FullPlayerEvent ev, ulong playerId, out string reason)
{
reason = "";
if (ev.CreatorPlayerId == playerId)
return true;
bool invited = ev.Responses?.Any(r => r.PlayerId == playerId) == true;
if (invited)
return true;
var now = DateTime.UtcNow;
// Doors open a few minutes early so people can be in the room when it starts.
if (now < ev.StartTime.ToUniversalTime().AddMinutes(-Campus_live_rewrite.api.server.ConfigCampus.EventEarlyJoinMinutes))
{
reason = $"event has not started (starts {ev.StartTime:u}, not invited)";
return false;
}
if (now > ev.EndTime.ToUniversalTime())
{
reason = $"event already ended ({ev.EndTime:u})";
return false;
}
return true;
}
/// <summary>Marks an event live / not live. Used by roominstance/{id}/inprogress.</summary>
public static FullPlayerEvent? SetBroadcastingInstance(ulong eventId, ulong roomInstanceId, bool inProgress)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
ev.BroadcastingRoomInstanceId = inProgress ? roomInstanceId : 0;
ev.State = inProgress ? 1 : 0;
Events.Update(ev);
return ev;
}
/// <summary>The event currently broadcasting to a given room instance, if any.</summary>
public static FullPlayerEvent? GetEventForRoomInstance(ulong roomInstanceId)
{
if (roomInstanceId == 0) return null;
return Events.Query()
.Where(e => e.BroadcastingRoomInstanceId == roomInstanceId)
.FirstOrDefault();
}
/// <summary>Removes an event outright. Used by the admin panel for moderation.</summary>
public static bool DeleteEvent(ulong eventId) => Events.Delete(eventId);
public static FullPlayerEvent CreateEvent(ulong creatorPlayerId, EventDBClasses.CreateEventRequest request)
{
var ev = new FullPlayerEvent
{
PlayerEventId = GetNextId(),
CreatorPlayerId = creatorPlayerId,
RoomId = request.RoomId,
SubRoomId = request.SubRoomId,
ClubId = request.ClubId,
Name = request.Name,
Description = request.Description,
ImageName = request.ImageName,
Tags = request.Tags?.Select(t => new EventDBClasses.FullTag
{
Tag = t,
Type = EventDBClasses.TagType.Unknown1
}).ToList(),
StartTime = request.StartTime,
EndTime = request.EndTime,
AttendeeCount = 0,
State = 0,
Accessibility = request.Accessibility,
IsMultiInstance = request.IsMultiInstance,
SupportMultiInstanceRoomChat = request.SupportMultiInstanceRoomChat,
DefaultBroadcastPermissions = (EventDBClasses.BroadcastPermissionsEnum)request.DefaultBroadcastPermissions,
CanRequestBroadcastPermissions = (EventDBClasses.BroadcastPermissionsEnum)request.CanRequestBroadcastPermissions,
Responses = new List<EventDBClasses.FullEventResponses>()
};
Events.Insert(ev);
return ev;
}
}
}
@@ -0,0 +1,774 @@
using System.Net.WebSockets;
using LiteDB;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes.db_class;
using Campus_live_rewrite.Controllers;
using static Campus_live_rewrite.api.WebsocketEvents;
using static Campus_live_rewrite.Controllers.AccountsController;
namespace Campus_live_rewrite.api.dbs
{
public class FriendsDB
{
public static LiteDatabase PlayerFriendDBFile = new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/PlayersFriends.db");
// ==========================
// GET ALL RELATIONSHIPS
// ==========================
public static List<RelationshipDetail> GetRelationships(ulong playerId)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
List<RelationshipDetail> list = new();
// Read the caller's OWN side of each pair (PlayerID == caller). The client's
// RelationshipResponse is projected from the caller's point of view: PlayerID is the
// OTHER player, RelationshipType/Favorited/Ignored/Muted are the CALLER's own values
// (RecFlare's toResponse does exactly this).
//
// This used to read the OTHER player's row instead (filter OtherPlayerID == caller),
// which returned that player's flags and, worse, the OTHER side's request direction -
// so a friend request you SENT showed up as one you RECEIVED (and the flags a favorite
// will set below would have landed on the wrong person's list).
foreach (var data in col.FindAll())
{
if (data.Relationship == null || data.Relationship.PlayerID != playerId)
continue;
list.Add(new RelationshipDetail
{
Id = data.Relationship.Id,
PlayerID = data.Relationship.OtherPlayerID, // the OTHER player (client's shape)
OtherPlayerID = playerId, // the caller
RelationshipType = data.Relationship.RelationshipType,
Muted = data.Relationship.Muted,
Ignored = data.Relationship.Ignored,
Favorited = data.Relationship.Favorited,
VoiceVolume = data.Relationship.VoiceVolume
});
}
return list;
}
// ==========================
// BLOCK ("ignore" on the wire). Unlike favorite / mute this lands on BOTH sides of the
// pair: the blocker's row gets Local, the blocked player's row gets Remote (Mutual when
// both have blocked each other), so each of them reads Ignored = true and their clients
// hide and mute the other. Only the blocker's own part is theirs to lift: the blocked
// player's "unblock" cannot clear a Remote, so a block stays until the player who set it
// takes it off. Blocking also ends a friendship / pending request between the two.
// Both players get a live RelationshipChanged frame (id 1) - which is what the client
// repaints its card / friends list from - plus a website refresh.
// ==========================
public static RelationshipDetail SetBlock(ulong caller, ulong target, bool value)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var mine = GetOrCreateRow(col, caller, target);
var theirs = GetOrCreateRow(col, target, caller);
static ReciprocalStatus With(ReciprocalStatus s, ReciprocalStatus part, bool on)
{
bool local = s == ReciprocalStatus.Local || s == ReciprocalStatus.Mutual;
bool remote = s == ReciprocalStatus.Remote || s == ReciprocalStatus.Mutual;
if (part == ReciprocalStatus.Local) local = on; else remote = on;
return local && remote ? ReciprocalStatus.Mutual : local ? ReciprocalStatus.Local : remote ? ReciprocalStatus.Remote : ReciprocalStatus.None;
}
bool wasBlocking = mine.Relationship.Ignored == ReciprocalStatus.Local || mine.Relationship.Ignored == ReciprocalStatus.Mutual;
mine.Relationship.Ignored = With(mine.Relationship.Ignored, ReciprocalStatus.Local, value);
theirs.Relationship.Ignored = With(theirs.Relationship.Ignored, ReciprocalStatus.Remote, value);
bool unfriended = false;
if (value && (mine.Relationship.RelationshipType != RelationshipType.None || theirs.Relationship.RelationshipType != RelationshipType.None))
{
mine.Relationship.RelationshipType = RelationshipType.None;
theirs.Relationship.RelationshipType = RelationshipType.None;
unfriended = true;
}
col.Update(mine);
col.Update(theirs);
if (unfriended)
{
try { RemoveFriendInviteMessage(caller, target); RemoveFriendInviteMessage(target, caller); } catch { }
try { SitePrefsDB.Forget(caller, target); SitePrefsDB.Forget(target, caller); } catch { } // nickname / home pin go with the friendship
}
string what = value ? (wasBlocking ? "re-blocked" : "blocked") : (wasBlocking ? "unblocked" : "asked to unblock (not theirs to lift)");
Console.WriteLine($"[block] {caller} {what} {target}: mine={mine.Relationship.Ignored} theirs={theirs.Relationship.Ignored}{(unfriended ? " (friendship / request ended)" : "")}");
// Live to both sides: the frame the client's RelationshipChanged handler repaints
// from, in each player's own point of view.
Notifications.FireAndForget(Notifications.SendToPlayer(caller, JsonConvert.SerializeObject(new Reponse { Id = "1", Msg = RelationshipView(mine.Relationship) })), "block changed (self)");
Notifications.FireAndForget(Notifications.SendToPlayer(target, JsonConvert.SerializeObject(new Reponse { Id = "1", Msg = RelationshipView(theirs.Relationship) })), "block changed (other)");
Notifications.FireAndForget(Notifications.SendHubInvocation(caller, "RelationshipsInvalid"), "block invalidate (self)");
Notifications.FireAndForget(Notifications.SendHubInvocation(target, "RelationshipsInvalid"), "block invalidate (other)");
SiteNotifications.Notify(caller);
SiteNotifications.Notify(target);
// Each now sees the other as offline (PresenceIsBlocked); refresh what they see.
Notifications.FireAndForget(Notifications.PushPresenceTo(caller, target), "block presence refresh (self)");
Notifications.FireAndForget(Notifications.PushPresenceTo(target, caller), "block presence refresh (other)");
return new RelationshipDetail
{
Id = mine.Relationship.Id,
PlayerID = target,
OtherPlayerID = caller,
RelationshipType = mine.Relationship.RelationshipType,
Muted = mine.Relationship.Muted,
Ignored = mine.Relationship.Ignored,
Favorited = mine.Relationship.Favorited,
VoiceVolume = mine.Relationship.VoiceVolume
};
}
/// <summary>The caller's voice volume for one player (their own side of the row).</summary>
public static void SetVoiceVolume(ulong caller, ulong target, int volume)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var row = GetOrCreateRow(col, caller, target);
row.Relationship.VoiceVolume = volume;
col.Update(row);
}
/// <summary>True when either of the two has blocked the other.</summary>
public static bool IsBlockedEitherWay(ulong a, ulong b)
{
if (a == 0 || b == 0 || a == b) return false;
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var row = col.FindOne(x => x.PlayerID == a && x.Relationship.OtherPlayerID == b);
if (row?.Relationship != null && row.Relationship.Ignored != ReciprocalStatus.None) return true;
var other = col.FindOne(x => x.PlayerID == b && x.Relationship.OtherPlayerID == a);
return other?.Relationship != null && other.Relationship.Ignored != ReciprocalStatus.None;
}
/// <summary>True when <paramref name="blocker"/> has blocked <paramref name="other"/> (their own Local part).</summary>
public static bool HasBlocked(ulong blocker, ulong other)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var row = col.FindOne(x => x.PlayerID == blocker && x.Relationship.OtherPlayerID == other);
return row?.Relationship != null && (row.Relationship.Ignored == ReciprocalStatus.Local || row.Relationship.Ignored == ReciprocalStatus.Mutual);
}
private static YourFriendData GetOrCreateRow(ILiteCollection<YourFriendData> col, ulong owner, ulong other)
{
var row = col.FindOne(x => x.PlayerID == owner && x.Relationship.OtherPlayerID == other);
if (row != null) return row;
row = new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = owner,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = owner,
OtherPlayerID = other,
RelationshipType = RelationshipType.None,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
col.Insert(row);
return row;
}
// ==========================
// SET A PER-SIDE FLAG (favorite / ignore / mute) on the CALLER's own side.
// Mirrors RecFlare's setRelationshipFlag: the write lands on the caller's side of the
// pair, and flagging someone you have no relationship with creates a bare (None) row to
// hold the flag - so you can favorite/ignore/mute a player you were never friends with.
// Value maps to ReciprocalStatus.Local (set, serializes as 1) or None (clear, 0), which
// is the 0/1 the client reads for each flag. Returns the caller-POV relationship.
// ==========================
public static RelationshipDetail SetRelationshipFlag(ulong caller, ulong target, FriendInfo flag, bool value)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var row = col.FindOne(x => x.PlayerID == caller && x.Relationship.OtherPlayerID == target);
if (row == null)
{
row = new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = caller,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = caller,
OtherPlayerID = target,
RelationshipType = RelationshipType.None,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
col.Insert(row);
}
var status = value ? ReciprocalStatus.Local : ReciprocalStatus.None;
switch (flag)
{
case FriendInfo.Favorited: row.Relationship.Favorited = status; break;
case FriendInfo.Muted: row.Relationship.Muted = status; break;
case FriendInfo.Ignored: row.Relationship.Ignored = status; break;
}
col.Update(row);
// Realtime: the caller's game re-reads its relationships (favorite star, mute/ignore
// state) and their website tabs refresh - the same channels a friend request uses.
// A favorite also changes what the caller may SEE of the target ("Appear Online To:
// Favorites"), so re-send that one presence to them with the new relationship applied.
Notifications.FireAndForget(Notifications.SendHubInvocation(caller, "RelationshipsInvalid"), "relationship-flag invalidate");
Notifications.FireAndForget(Notifications.SendToPlayer(caller, JsonConvert.SerializeObject(new Reponse { Id = "1", Msg = RelationshipView(row.Relationship) })), "relationship-flag changed");
SiteNotifications.Notify(caller);
if (flag == FriendInfo.Favorited)
Notifications.FireAndForget(Notifications.PushPresenceTo(caller, target), "favorite presence refresh");
// Return in the caller's POV shape (PlayerID = the other player), the same projection
// GetRelationships uses, so the caller can apply it directly.
return new RelationshipDetail
{
Id = row.Relationship.Id,
PlayerID = target,
OtherPlayerID = caller,
RelationshipType = row.Relationship.RelationshipType,
Muted = row.Relationship.Muted,
Ignored = row.Relationship.Ignored,
Favorited = row.Relationship.Favorited,
VoiceVolume = row.Relationship.VoiceVolume
};
}
// ==========================
// COUNT ONLINE FRIENDS - the caller's accepted friends that currently hold a live
// notification websocket. Replaces the hardcoded int.MaxValue/0 stub.
// ==========================
public static int CountOnlineFriends(ulong playerId)
{
return GetRelationships(playerId)
.Where(r => r.RelationshipType == RelationshipType.Friend)
.Select(r => r.PlayerID)
.Distinct()
.Count(fid => CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(fid));
}
// ==========================
// SEND FRIEND REQUEST
// ==========================
public static RelationshipDetail SendFriendRequest(ulong fromPlayer, ulong toPlayer)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
// Blocked either way: nothing is sent and nobody is told; the caller just gets their
// current (None) relationship back.
if (IsBlockedEitherWay(fromPlayer, toPlayer))
{
Console.WriteLine($"[friends] {fromPlayer} -> {toPlayer}: friend request refused, one has blocked the other");
var blockedRow = GetOrCreateRow(col, fromPlayer, toPlayer).Relationship;
return new RelationshipDetail
{
Id = blockedRow.Id, PlayerID = toPlayer, OtherPlayerID = fromPlayer,
RelationshipType = blockedRow.RelationshipType, Muted = blockedRow.Muted, Ignored = blockedRow.Ignored,
Favorited = blockedRow.Favorited, VoiceVolume = blockedRow.VoiceVolume
};
}
// ---- A -> B ----
var relA = col.FindOne(x =>
x.PlayerID == fromPlayer &&
x.Relationship.OtherPlayerID == toPlayer
);
if (relA == null)
{
relA = new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = fromPlayer,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = fromPlayer,
OtherPlayerID = toPlayer,
RelationshipType = RelationshipType.None,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
col.Insert(relA);
}
// ---- B -> A ----
var relB = col.FindOne(x =>
x.PlayerID == toPlayer &&
x.Relationship.OtherPlayerID == fromPlayer
);
if (relB == null)
{
relB = new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = toPlayer,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = toPlayer,
OtherPlayerID = fromPlayer,
RelationshipType = RelationshipType.None,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
col.Insert(relB);
}
// Update types
relA.Relationship.RelationshipType = RelationshipType.FriendRequestSent;
relB.Relationship.RelationshipType = RelationshipType.FriendRequestReceived;
col.Update(relA);
col.Update(relB);
//todo: send ws event to anounce the player that it got a new friend request
// Websocket pushes. fromPlayer is always the SENDER, toPlayer the RECIPIENT (both
// callers pass that order).
// - sender: their side is now "Sent" (relationship changed -> shows Pending).
// - recipient: the incoming request, as BOTH a relationship change (so the friends
// UI updates live) AND a message notification "<sender> sent you a friend request".
// The message used to be sent to the SENDER, so the recipient's game never got the
// heads-up and the request only showed on a later fetch.
Notifications.SendToPlayer(
fromPlayer,
JsonConvert.SerializeObject(CreateResponseFriendRequestSent(toPlayer))
);
Notifications.SendToPlayer(
toPlayer,
JsonConvert.SerializeObject(CreateResponseFriendRequestReceived(fromPlayer, toPlayer))
);
// A stored FriendInvite message for the recipient: the in-game notifications list is
// /api/messages/v2/get, so a request that only ever went out as a live frame vanished
// from it on the next open - "friend requests don't show unless you search for the
// player". One per sender: re-sending replaces rather than stacks. Removed again when
// the request is accepted, declined or cancelled (RemoveFriendInviteMessage).
RemoveFriendInviteMessage(toPlayer, fromPlayer);
var inviteMsg = PlayerDB.AddMessage(toPlayer, fromPlayer, WebsocketEvents.MessageType.FriendInvite, "");
if (inviteMsg != null)
Notifications.FireAndForget(Notifications.SendToPlayer(toPlayer, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(inviteMsg))), "friend-request message");
// (The extra "FriendInvite" message frame that used to go out here as well showed up
// as a SECOND notification for the same request - one alert per request now.)
// Fire the client's OWN hub handlers (proven by the 2023 server) so the request shows
// in-game live: FriendRequestReceived raises the alert on the recipient, and
// RelationshipsInvalid makes BOTH sides re-fetch /api/relationships so the incoming
// request and the sender's "pending" state appear without a relog.
Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "FriendRequestReceived", (long)fromPlayer), "friend-request alert");
Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "RelationshipChanged", RelationshipView(relB.Relationship)), "friend-request changed (recipient)");
Notifications.FireAndForget(Notifications.SendHubInvocation(fromPlayer, "RelationshipChanged", RelationshipView(relA.Relationship)), "friend-request changed (sender)");
Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "RelationshipsInvalid"), "friend-request invalidate (recipient)");
Notifications.FireAndForget(Notifications.SendHubInvocation(fromPlayer, "RelationshipsInvalid"), "friend-request invalidate (sender)");
return relA.Relationship;
}
/// <summary>
/// Befriend with no pending-request step (the in-game handshake). A pending request either
/// way, or an existing friendship, is simply accepted / re-confirmed; otherwise the pair is
/// minted as a request from <paramref name="me"/> and accepted in the same call, so both
/// sides end up Friend with the usual "relationship changed" pushes.
/// </summary>
public static RelationshipDetail? AddFriendDirect(ulong me, ulong other)
{
if (me == 0 || other == 0 || me == other) return null;
var accepted = AcceptFriendRequest(other, me);
if (accepted != null) return accepted;
SendFriendRequest(me, other);
return AcceptFriendRequest(me, other);
}
public static RelationshipDetail? AcceptFriendRequest(ulong fromPlayer, ulong toPlayer)
{
if (fromPlayer == 0 || toPlayer == 0 || fromPlayer == toPlayer) return null;
// The request is being answered: its notification goes, whichever side held it.
RemoveFriendInviteMessage(fromPlayer, toPlayer);
RemoveFriendInviteMessage(toPlayer, fromPlayer);
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var relA = col.FindOne(x => x.PlayerID == fromPlayer && x.Relationship.OtherPlayerID == toPlayer);
var relB = col.FindOne(x => x.PlayerID == toPlayer && x.Relationship.OtherPlayerID == fromPlayer);
static bool IsRequest(YourFriendData? r) =>
r?.Relationship != null &&
(r.Relationship.RelationshipType == RelationshipType.FriendRequestSent ||
r.Relationship.RelationshipType == RelationshipType.FriendRequestReceived);
static bool IsFriend(YourFriendData? r) =>
r?.Relationship != null && r.Relationship.RelationshipType == RelationshipType.Friend;
// There must be a real, pending request on at least one side (or an already-formed
// friendship we're just re-confirming). This blocks "accepting" a request nobody sent -
// otherwise the row-healing below would let anyone force-friend a stranger.
if (!IsRequest(relA) && !IsRequest(relB) && !IsFriend(relA) && !IsFriend(relB))
return null;
// Heal a half-formed pair: if one side's row is missing (e.g. a partial write or an
// orphan cleanup removed it), recreate it so acceptance ALWAYS ends in a mutual
// friendship rather than silently doing nothing. Mirrors SendFriendRequest's row shape.
YourFriendData MakeRow(ulong owner, ulong other) => new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = owner,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = owner,
OtherPlayerID = other,
RelationshipType = RelationshipType.None,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
if (relA == null) { relA = MakeRow(fromPlayer, toPlayer); col.Insert(relA); }
if (relB == null) { relB = MakeRow(toPlayer, fromPlayer); col.Insert(relB); }
// Work out who ORIGINALLY SENT the request (had FriendRequestSent) vs who ACCEPTED it
// (had FriendRequestReceived), BEFORE overwriting both to Friend. The two callers pass
// fromPlayer/toPlayer in opposite orders (website: accepter,sender - game: sender,accepter),
// so we can't trust the arg order - the row types are the source of truth. Without this
// the "X accepted your request" notice went to whoever happened to be fromPlayer, so the
// ACCEPTER got told the SENDER accepted (backwards).
ulong sender, accepter;
if (relA.Relationship.RelationshipType == RelationshipType.FriendRequestSent) { sender = fromPlayer; accepter = toPlayer; }
else if (relB.Relationship.RelationshipType == RelationshipType.FriendRequestSent) { sender = toPlayer; accepter = fromPlayer; }
else { sender = fromPlayer; accepter = toPlayer; } // no pending side (re-confirm) - harmless default
// Set both sides to Friend
relA.Relationship.RelationshipType = RelationshipType.Friend;
relB.Relationship.RelationshipType = RelationshipType.Friend;
col.Update(relA);
col.Update(relB);
// Relationship-changed -> BOTH clients update their friends list live (order-agnostic).
Notifications.SendToPlayer(fromPlayer,
JsonConvert.SerializeObject(CreateResponseFriendAccepted(toPlayer, fromPlayer)));
Notifications.SendToPlayer(toPlayer,
JsonConvert.SerializeObject(CreateResponseFriendAccepted(fromPlayer, toPlayer)));
// "<accepter> accepted your friend request" -> ONLY the original sender, naming the
// accepter. Also persisted as a saved message so it shows in the sender's website inbox,
// survives, and (via the AddMessage -> SiteNotifications.Notify hook) updates the web live.
Notifications.SendToPlayer(sender,
JsonConvert.SerializeObject(CreateResponseFriendAcceptedMsg(accepter)));
PlayerDB.AddMessage(sender, accepter, WebsocketEvents.MessageType.FriendRequestAccepted, null);
// Refresh BOTH friends lists live (the client's own RelationshipsInvalid handler).
Notifications.FireAndForget(Notifications.SendHubInvocation(fromPlayer, "RelationshipsInvalid"), "accept invalidate");
Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "RelationshipsInvalid"), "accept invalidate");
// return the accepted version
return relA.Relationship;
}
private static WebsocketEvents.Reponse CreateResponseFriendAcceptedMsg(ulong fromPlayer) => new WebsocketEvents.Reponse
{
Id = "2",
Msg = new
{
Id = (ulong)new Random().Next(),
FromPlayerId = fromPlayer,
SentTime = DateTime.UtcNow,
Type = WebsocketEvents.MessageType.FriendRequestAccepted,
Data = "",
RoomId = (ulong?)null,
PlayerEventId = (ulong?)null,
}
};
public static void DeleteFriends(ulong playerId)
{
if (playerId == 0) return;
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
col.DeleteMany(x => x.PlayerID == playerId);
}
// The RelationshipChanged (1) payload is EXACTLY the five fields the client's
// RelationshipResponse has (reference RelationshipChangedPayload: PlayerID,
// RelationshipType, Muted, Ignored, Favorited) - the same strict parser that dropped the
// whole /relationships/v2/get array over the extra Id/OtherPlayerID/VoiceVolume keys.
// These frames used to carry the raw RelationshipDetail (with its ~1.79-trillion Id), so
// the live "you have a friend request" frame never parsed and requests sent from the
// website only showed up on the next relationships fetch. PlayerID = the OTHER player.
private static object RelWire(ulong otherPlayer, RelationshipType type, bool favorited = false, bool muted = false, ReciprocalStatus ignored = ReciprocalStatus.None) => new
{
PlayerID = otherPlayer,
RelationshipType = (int)type,
Muted = muted ? 1 : 0,
Ignored = IgnoredWire(ignored),
Favorited = favorited ? 1 : 0
};
public static Reponse CreateResponseFriendAccepted(ulong playerid, ulong fromplayerid)
=> new Reponse { Id = "1", Msg = RelWire(playerid, RelationshipType.Friend) };
public static Reponse CreateResponseFriendRemoved(ulong playerid, ulong fromplayerid)
=> new Reponse { Id = "1", Msg = RelWire(playerid, RelationshipType.None) };
// Sent to the RECIPIENT. Client convention (see GetRelationships): PlayerID = the OTHER
// player (the sender), OtherPlayerID = the caller (the recipient). OtherPlayerID used to be
// set to the sender too, which is wrong and can stop the client rendering the request.
private static WebsocketEvents.Reponse CreateResponseFriendRequestReceived(ulong fromPlayer, ulong toPlayer) => new WebsocketEvents.Reponse
{
Id = "1",
Msg = RelWire(fromPlayer, RelationshipType.FriendRequestReceived)
};
private static WebsocketEvents.Reponse CreateResponseFriendRequestMsg(ulong fromPlayer) => new WebsocketEvents.Reponse
{
Id = "2",
Msg = new
{
Id = (ulong)new Random().Next(),
FromPlayerId = fromPlayer,
SentTime = DateTime.UtcNow,
Type = WebsocketEvents.MessageType.FriendInvite,
Data = "",
RoomId = (ulong?)null,
PlayerEventId = (ulong?)null,
}
};
// The relationship as the client's RelationshipChanged handler expects it (the same shape
// /relationships/v2/get lists): PlayerID = the OTHER player from the receiver's view.
private static object RelationshipView(RelationshipDetail r) =>
RelWire(r.OtherPlayerID, r.RelationshipType, r.Favorited != ReciprocalStatus.None, r.Muted != ReciprocalStatus.None, r.Ignored);
/// <summary>
/// What the wire `Ignored` means: "I have blocked this player" - the caller's OWN part of
/// the pair (Local / Mutual). A Remote (the other player blocked me) is kept for the
/// server-side gates but is never shown: the blocked player is not told, and reading it
/// as true made their client draw the block as if THEY had blocked the blocker.
/// </summary>
public static bool IsOwnBlock(ReciprocalStatus s) => s == ReciprocalStatus.Local || s == ReciprocalStatus.Mutual;
/// <summary>
/// The wire `Ignored` is the client's ReciprocalStatus, from the receiver's point of view:
/// 0 None - nobody blocked anybody
/// 1 Local - I blocked them (their card shows Blocked + my Unblock button)
/// 2 Remote - they blocked me (their card shows Blocked, no Unblock - not mine to lift)
/// 3 Mutual - both (Unblock lifts only MY part; theirs stays -> 2)
/// Sending a flat 0/1 was the bug: a 1 on both rows drew the block as the blocked
/// player's own (they got an Unblock button), and a 0 hid it from them entirely.
/// </summary>
public static int IgnoredWire(ReciprocalStatus s) => (int)s;
/// <summary>Drop the stored "X sent you a friend request" notification (and tell the game).</summary>
public static void RemoveFriendInviteMessage(ulong toPlayer, ulong fromPlayer)
{
try
{
var full = PlayerDB.GetFullAccount(toPlayer);
var stale = full?.player_Extra?.Messages?
.Where(m => m.Type == WebsocketEvents.MessageType.FriendInvite && m.FromPlayerId == fromPlayer)
.Select(m => m.Id).ToList();
if (stale == null || stale.Count == 0) return;
foreach (var id in stale)
{
PlayerDB.DeleteMessage(toPlayer, id);
Notifications.FireAndForget(Notifications.SendToPlayer(toPlayer, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageDeleted(id))), "friend-request message gone");
}
SiteNotifications.Notify(toPlayer);
}
catch (Exception ex) { Console.WriteLine($"[friends] invite message cleanup {fromPlayer}->{toPlayer}: {ex.Message}"); }
}
public static RelationshipDetail RemoveFriend(ulong playerA, ulong playerB)
{
// website extras (nickname / home pin) don't outlive the friendship
try { SitePrefsDB.Forget(playerA, playerB); SitePrefsDB.Forget(playerB, playerA); } catch { }
// Decline / cancel / unfriend: any pending "sent you a friend request" notification goes.
RemoveFriendInviteMessage(playerA, playerB);
RemoveFriendInviteMessage(playerB, playerA);
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
// A -> B
var relA = col.FindOne(x => x.PlayerID == playerA &&
x.Relationship.OtherPlayerID == playerB);
// B -> A
var relB = col.FindOne(x => x.PlayerID == playerB &&
x.Relationship.OtherPlayerID == playerA);
// If they were never connected, return empty relationship
if (relA == null && relB == null)
{
return new RelationshipDetail
{
PlayerID = playerA,
OtherPlayerID = playerB,
RelationshipType = RelationshipType.None
};
}
// Cancelling a PENDING request (or declining one) - tell the other side the request is
// gone, so their notification disappears instead of lingering until they act on it.
bool wasPending = (relA?.Relationship.RelationshipType == RelationshipType.FriendRequestSent
|| relA?.Relationship.RelationshipType == RelationshipType.FriendRequestReceived
|| relB?.Relationship.RelationshipType == RelationshipType.FriendRequestSent
|| relB?.Relationship.RelationshipType == RelationshipType.FriendRequestReceived);
// Delete A->B
if (relA != null)
col.Delete(relA.Id);
// Delete B->A
if (relB != null)
col.Delete(relB.Id);
if (wasPending)
{
Notifications.FireAndForget(Notifications.SendHubInvocation(playerB, "FriendRequestCancelled", (long)playerA), "friend-request cancelled");
Notifications.FireAndForget(Notifications.SendHubInvocation(playerB, "RelationshipChanged", RelationshipView(new RelationshipDetail { PlayerID = playerB, OtherPlayerID = playerA, RelationshipType = RelationshipType.None, VoiceVolume = 100 })), "friend-request cancelled (changed)");
Notifications.FireAndForget(Notifications.SendHubInvocation(playerA, "RelationshipChanged", RelationshipView(new RelationshipDetail { PlayerID = playerA, OtherPlayerID = playerB, RelationshipType = RelationshipType.None, VoiceVolume = 100 })), "friend-request cancelled (self)");
Console.WriteLine($"[friends] {playerA} cancelled/declined the pending request with {playerB}");
}
// Send WS update to player A
Notifications.SendToPlayer(
playerA,
JsonConvert.SerializeObject(
CreateResponseFriendRemoved(playerA, playerB)
)
);
// Send WS update to player B
Notifications.SendToPlayer(
playerB,
JsonConvert.SerializeObject(
CreateResponseFriendRemoved(playerB, playerA)
)
);
// Refresh BOTH friends lists live (unfriend / decline / cancel).
Notifications.FireAndForget(Notifications.SendHubInvocation(playerA, "RelationshipsInvalid"), "remove invalidate");
Notifications.FireAndForget(Notifications.SendHubInvocation(playerB, "RelationshipsInvalid"), "remove invalidate");
// Return final relationship state
return new RelationshipDetail
{
PlayerID = playerA,
OtherPlayerID = playerB,
RelationshipType = RelationshipType.None,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
};
}
public static RelationshipDetail AddFriend(ulong playerA, ulong playerB)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
// A -> B
var relA = col.FindOne(x => x.PlayerID == playerA &&
x.Relationship.OtherPlayerID == playerB);
if (relA == null)
{
relA = new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = playerA,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = playerA,
OtherPlayerID = playerB,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
col.Insert(relA);
}
// B -> A
var relB = col.FindOne(x => x.PlayerID == playerB &&
x.Relationship.OtherPlayerID == playerA);
if (relB == null)
{
relB = new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = playerB,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = playerB,
OtherPlayerID = playerA,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
col.Insert(relB);
}
// Set both sides to Friend
relA.Relationship.RelationshipType = RelationshipType.Friend;
relB.Relationship.RelationshipType = RelationshipType.Friend;
col.Update(relA);
col.Update(relB);
// WebSocket updates
Notifications.SendToPlayer(
playerA,
JsonConvert.SerializeObject(CreateResponseFriendAccepted(playerA, playerB))
);
Notifications.SendToPlayer(
playerB,
JsonConvert.SerializeObject(CreateResponseFriendAccepted(playerB, playerA))
);
return relA.Relationship;
}
// {"type":1,"target":"Notification","arguments":["{\"id\":\"2\",\"msg\":{\"id\":16212007930,\"fromPlayerId\":1764227894,\"sentTime\":\"2025-06-23T17:20:53.72092Z\",\"type\":4,\"data\":\"\",\"roomId\":null,\"playerEventId\":null}}"]}
private static WebsocketEvents.Reponse CreateResponseFriendRequestSent(ulong toPlayer) => new WebsocketEvents.Reponse
{
Id = "50",
Msg = ""
};
}
}
@@ -0,0 +1,848 @@
using System.Text.Json.Serialization;
using LiteDB;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.dbs
{
public class HeartbeatDB
{
private static readonly LiteDatabase _db = new($"{Environment.CurrentDirectory}/Data/DBs/Heartbeats.db");
// Merge this store's write-ahead log back into the data file.
// See DbMaintenance for why this must be called explicitly.
public static void Checkpoint() => _db.Checkpoint();
private static readonly ILiteCollection<PlayerHeartbeatData> _col =
_db.GetCollection<PlayerHeartbeatData>("players_heartbeat");
public static void Setup()
{
_col.EnsureIndex(x => x.playerid, unique: true);
// Rows written before the key change carry a sequential iD (1, 2, 3...)
// rather than the player's id, so the next insert for a player whose id
// matches an old sequential value collides on _id and throws - which is
// the failure the key change exists to remove.
//
// Heartbeats are transient (GetPlayerHeartbeat falls back to
// CreateDefaultHeartbeat when a player has no row), so dropping stale
// rows costs nothing and is safer than leaving the collision armed.
try
{
var stale = _col.FindAll().Where(x => x.iD != x.playerid).ToList();
if (stale.Count > 0)
{
foreach (var row in stale) _col.Delete(row.iD);
Console.WriteLine($"[HeartbeatDB] migrated: dropped {stale.Count} row(s) keyed by row-count instead of playerid");
}
}
catch (Exception ex)
{
Console.WriteLine($"[HeartbeatDB] key migration skipped: {ex.Message}");
}
// Correct EVERY stored appVersion to the current client build. The old hardcoded
// "20250731.01" is NEWER than the 20250718.01 client, so a player whose row still
// carried it showed to everyone as "ON NEWER VERSION" and couldn't be joined. The
// per-heartbeat fix only touches a row when that player moves; this catches the
// offline/stationary rows at startup so nobody is left stale.
try
{
string want = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion;
var wrong = _col.FindAll().Where(x => x.Heartbeat != null && x.Heartbeat.appVersion != want).ToList();
foreach (var row in wrong) { row.Heartbeat.appVersion = want; _col.Update(row); }
if (wrong.Count > 0)
Console.WriteLine($"[HeartbeatDB] appVersion migration: corrected {wrong.Count} row(s) to {want}");
}
catch (Exception ex)
{
Console.WriteLine($"[HeartbeatDB] appVersion migration skipped: {ex.Message}");
}
}
public class PlayerHeartbeatData
{
// Keyed by playerid. This used to be `(ulong)_col.LongCount() + 1` at every
// insert, which is not a usable primary key: delete any row and the count
// drops, so the next insert reuses an existing id and LiteDB throws
// "Cannot insert duplicate key in unique index '_id'". Two concurrent
// inserts read the same count and collide identically. A throw there aborts
// the write, so the heartbeat row silently stops being updated.
// playerid is already unique (see the index in Setup), so it IS the key.
[BsonId]
public ulong iD { get; set; }
public ulong playerid { get; set; }
public Heartbeat Heartbeat { get; set; }
}
private static Heartbeat CreateDefaultHeartbeat(ulong playerid, Platforms platform, DeviceClasses deviceClasses) =>
new()
{
playerId = (long)playerid,
appVersion = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion,
// 0 = Unknown. Was -1, which is NOT a valid DeviceClasses value - the client's
// strict presence parser threw on the out-of-range enum and dropped the WHOLE
// presence array, so a friends/player list built against it (offline friends all
// get this default) came back EMPTY. Same class of bug as the statusVisibility=4
// fix below; the reference emits `deviceClass ?? 0`.
deviceClass = 0,
isOnline = false,
platform = 0,
roomInstance = null,
statusVisibility = 0, // 0 = Everyone (matches the reference); was 4 (out of range)
vrMovementMode = 1, // reference default (`?? 1`); 0 was also fine but match it
};
// Where we last DELIBERATELY put each player, held in memory alongside the DB row.
//
// The heartbeat answer is authoritative to the client: answer "nowhere" and it
// decides it was disconnected and walks itself home to its dorm. So a room
// instance that goes missing by ACCIDENT - a row that never got written, a lost
// LiteDB update, a restart, a concurrent write - must not be reported as "nowhere".
//
// Only UpdatePlayerHeartbeat writes this, so an entry existing means "we put them
// there on purpose and nothing has deliberately moved them since". A real logout or
// ban clears the entry (it goes through UpdatePlayerHeartbeat with a null instance),
// so intentional "nowhere" still answers nowhere.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, RoomInstance> _placedIn = new();
// Last instance id we answered per player, purely so a CHANGE can be logged. A
// change the player did not ask for is the drop, and this is the line that names it.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, long> _lastAnswered = new();
// ---- Short-term memory that survives a mass drop -------------------------------------
//
// An instance only "exists" while some ONLINE heartbeat row points at it. That is fine
// until the Photon master client (the first player into the instance) leaves: every other
// client in that Photon room runs its master-switch recovery - a full logout + re-login,
// then "join the player I was with" - and the drop itself has just wiped all of their
// rows. So each recovering player asks about a friend whose row is now empty, gets
// "target offline / not in a room", and lands in their dorm. Seen live 2026-09-11 22:12:
// 6 leaves ^RecCenter -> 11 and 64 both reset within 6 s -> all three in dorms.
//
// These two maps remember, for a few minutes, where each player was LAST placed and
// which instances were live, independent of the rows. They are consulted only when the
// live answer is empty, so nothing changes for a player who is simply somewhere else.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<long, (RoomInstance inst, DateTime seen)> _recentInstances = new();
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, (RoomInstance inst, DateTime seen)> _lastPlacement = new();
/// <summary>How long after a drop a player's last instance is still offered for regrouping.</summary>
public static readonly TimeSpan RegroupWindow = TimeSpan.FromMinutes(2);
/// <summary>How long an instance is answerable by id after its last occupant's row is cleared.</summary>
public static readonly TimeSpan InstanceMemory = TimeSpan.FromMinutes(15);
// The instance each player was in BEFORE their latest move, so "were these two just
// together?" can be answered after one of them has already moved on (the party leader
// has left the room by the time the member asks to follow).
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, (long instanceId, DateTime left)> _prevPlacement = new();
private static void Remember(ulong playerid, RoomInstance inst)
{
if (inst == null || inst.roomInstanceId == 0) return;
var now = DateTime.UtcNow;
if (_lastPlacement.TryGetValue(playerid, out var last) && last.inst.roomInstanceId != inst.roomInstanceId)
_prevPlacement[playerid] = (last.inst.roomInstanceId, now);
_recentInstances[inst.roomInstanceId] = (inst, now);
_lastPlacement[playerid] = (inst, now);
}
/// <summary>
/// Were <paramref name="a"/> and <paramref name="b"/> standing in the same instance within
/// the regroup window? True when a's current/last instance equals b's current OR the one b
/// just left. This is what stands in for "same party" on a follow: the client keeps its
/// party on the Photon side and never posts the member list, so being together a moment
/// ago is the best evidence the server has.
/// </summary>
public static bool WereTogetherRecently(ulong a, ulong b)
{
var cutoff = DateTime.UtcNow - RegroupWindow;
long aInst = _lastPlacement.TryGetValue(a, out var la) && la.seen >= cutoff ? la.inst.roomInstanceId : 0;
if (aInst == 0) return false;
if (_lastPlacement.TryGetValue(b, out var lb) && lb.inst.roomInstanceId == aInst) return true;
if (_prevPlacement.TryGetValue(b, out var pb) && pb.instanceId == aInst && pb.left >= cutoff) return true;
return false;
}
/// <summary>The instance this player was in within RegroupWindow, or null.</summary>
public static RoomInstance? RecentInstanceFor(ulong playerid)
{
if (!_lastPlacement.TryGetValue(playerid, out var p)) return null;
return DateTime.UtcNow - p.seen <= RegroupWindow ? p.inst : null;
}
/// <summary>The most recently live instance of a room (public or private) within InstanceMemory, or null.</summary>
public static RoomInstance? RecentInstanceOfRoomAny(ulong roomId)
{
var cutoff = DateTime.UtcNow - InstanceMemory;
return _recentInstances.Values
.Where(v => v.seen >= cutoff && v.inst.roomId == (long)roomId && !string.IsNullOrEmpty(v.inst.photonRoomIdYeee))
.OrderByDescending(v => v.seen)
.Select(v => v.inst)
.FirstOrDefault();
}
/// <summary>The most recently live PUBLIC instance of a room within RegroupWindow, or null.</summary>
public static RoomInstance? RecentInstanceOfRoom(ulong roomId)
{
var cutoff = DateTime.UtcNow - RegroupWindow;
return _recentInstances.Values
.Where(v => v.seen >= cutoff && v.inst.roomId == (long)roomId && !v.inst.isPrivate
&& !string.IsNullOrEmpty(v.inst.photonRoomIdYeee))
.OrderByDescending(v => v.seen)
.Select(v => v.inst)
.FirstOrDefault();
}
/// <summary>
/// Whether this player has a stored heartbeat at all. Distinct from "has no room
/// instance" - a player sitting in a menu has a row with a null instance.
/// </summary>
public static bool HasHeartbeatRow(ulong playerid) =>
_col.FindOne(x => x.playerid == playerid) != null;
/// <summary>
/// Read a player's presence WITHOUT the repair-writes/logging that GetPlayerHeartbeat does -
/// for read-only surfaces like the admin players list that must not mutate state or spam the
/// console when they enumerate everyone. Returns whether they're online and their current
/// room instance (null if nowhere), honoring the in-memory placement fallback.
/// </summary>
public static (bool online, RoomInstance? room) PeekPresence(ulong playerid)
{
// Online = a LIVE notification WebSocket, not the stored isOnline flag. The flag goes
// stale on a hard disconnect (shows "online" forever) and is unaffected by - but also
// doesn't help with - "appear offline". The WebSocket is ground truth: it's opened on
// connect, removed on disconnect, and is independent of the player's status visibility,
// so the admin sees who is ACTUALLY connected even if they're appearing offline to
// everyone else. If they're not connected, they're offline here regardless of the flag.
bool online = CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(playerid);
var hb = _col.FindOne(x => x.playerid == playerid)?.Heartbeat;
RoomInstance? room = hb?.roomInstance;
if (room == null && _placedIn.TryGetValue(playerid, out var placed))
room = placed;
// Only surface a room while actually online, so a stale room from a dropped session
// doesn't read as "still in that room".
return online ? (true, room) : (false, null);
}
/// <summary>
/// Presence of ANOTHER player as it should appear in someone else's friends/player list.
/// Unlike GetPlayerHeartbeat (which is the player's OWN authoritative heartbeat and
/// deliberately refuses to answer "nowhere"), this is what OTHERS see, so it is gated on a
/// LIVE notification WebSocket - the same ground truth PeekPresence uses for the admin panel:
/// * not actually connected -> isOnline=false, roomInstance=null (fixes "shows online in
/// a room they aren't in" after a crash/close, where the stored row keeps isOnline=true
/// and the last room forever, and _placedIn still holds the old instance);
/// * connected but "appear offline" (statusVisibility 3 = No One) -> also reported offline.
/// Side-effect-free: never writes the DB or restores _placedIn, so enumerating a whole
/// friends list doesn't churn repair-writes or spam the console. Enum fields stay valid
/// (appVersion/deviceClass/etc.) so the client's strict presence parser still accepts it.
/// </summary>
public static Heartbeat GetPresenceForList(ulong playerid, bool viewerIsSelf, bool viewerIsFriend, bool viewerIsFavorite, ulong viewerId = 0)
{
// An invite is consent to be seen: while a live invite from this player to the viewer
// exists, the viewer sees them online and in their (private) instance no matter what
// "Appear Online To" says. Without this the invitee's client refused the invite with
// "Cannot accept invite. Target player is offline." whenever the inviter wasn't
// visible to them (non-friend + Friends-only status, or Favorites-only).
if (!viewerIsSelf && viewerId != 0 && viewerId != playerid
&& Campus_live_rewrite.api.server.RoomInvites.HasInviteFromTo(playerid, viewerId))
{
viewerIsFriend = true;
viewerIsFavorite = true;
}
var stored = _col.FindOne(x => x.playerid == playerid)?.Heartbeat;
var hb = stored ?? CreateDefaultHeartbeat(playerid, Platforms.Steam, DeviceClasses.Screen);
hb.serverTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (string.IsNullOrEmpty(hb.lastOnline)) hb.lastOnline = DateTime.UtcNow.ToString("o");
hb.appVersion = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion;
// Defensive clamp: a legacy row with an out-of-range deviceClass (-1) or vrMovementMode
// makes the client's strict presence parser throw and drop the WHOLE friends-list array
// (see the deviceClass=-1 trap). Never serve one from this read.
if (hb.deviceClass < 0) hb.deviceClass = 0; // 0 = Unknown
if (hb.vrMovementMode < 0) hb.vrMovementMode = 1;
// statusVisibility is 0..3 on the client; the old default row carried 4, and one such
// entry in a friends-list page made the client drop the WHOLE page ("some friends are
// missing and their profile won't open"). Clamp on the way out too.
if (hb.statusVisibility < 0 || hb.statusVisibility > 3) hb.statusVisibility = 0;
bool live = CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(playerid);
// "Appear Online To" (statusVisibility): 0 Everyone, 1 Friends, 2 Favorites, 3 No One.
// The player always sees their OWN real presence; others see it only if the setting
// allows THEM. Changing this in-game (PUT player/statusvisibility) therefore reflects to
// friends within one presence poll. Unknown/legacy values are treated as Everyone.
int vis = hb.statusVisibility;
bool hiddenByStatus = !viewerIsSelf && (
vis == 3
|| (vis == 1 && !viewerIsFriend)
|| (vis == 2 && !viewerIsFavorite));
// A block hides presence both ways: neither of them sees the other online or where.
if (!viewerIsSelf && viewerId != 0 && !hiddenByStatus && FriendsDB.IsBlockedEitherWay(viewerId, playerid))
hiddenByStatus = true;
// online-in-no-room: a player whose presence says online but names no instance (just
// kicked, mid-recovery) NREs the 2025 client's friend card (NotInSameInstanceHide
// dereferences the instance when online) - the card renders blank and the profile
// won't open. To everyone else that player reads as offline until they land somewhere.
if (!viewerIsSelf && hb.isOnline && hb.roomInstance == null) hb.isOnline = false;
if (!live || hiddenByStatus)
{
hb.isOnline = false;
hb.roomInstance = null;
}
// "Full" on the friends list / room card: computed from live presence, never stored.
if (hb.roomInstance != null) hb.roomInstance.isFull = InstanceIsFull(hb.roomInstance);
return hb;
}
public static Heartbeat GetPlayerHeartbeat(ulong playerid)
{
var data = _col.FindOne(x => x.playerid == playerid);
var hb = data?.Heartbeat;
// own-heartbeat clamp: the same out-of-range guard GetPresenceForList applies
if (hb != null)
{
if (hb.statusVisibility < 0 || hb.statusVisibility > 3) hb.statusVisibility = 0;
if (hb.deviceClass < 0) hb.deviceClass = 0;
if (hb.vrMovementMode < 0) hb.vrMovementMode = 1;
}
if (hb == null)
{
hb = CreateDefaultHeartbeat(playerid, Platforms.Steam, DeviceClasses.Screen);
// No row at all. If we know where we put them, say that rather than
// "nowhere", and write the row back so matchmaking agrees with us.
if (_placedIn.TryGetValue(playerid, out var placed))
{
hb.roomInstance = placed;
hb.isOnline = true;
Console.WriteLine($"[heartbeat] {playerid}: no row - restored instance " +
$"{placed.roomInstanceId} ({placed.name}) instead of answering 'nowhere'");
try { UpdatePlayerHeartbeat(playerid, placed); } catch { }
}
}
else if (hb.roomInstance == null && _placedIn.TryGetValue(playerid, out var placed))
{
// Row exists but has lost its room instance while we still believe the
// player is somewhere. Nothing deliberate nulls it without clearing
// _placedIn, so this is drift - repair it rather than sending them home.
hb.roomInstance = placed;
hb.isOnline = true;
Console.WriteLine($"[heartbeat] {playerid}: row lost its instance - restored " +
$"{placed.roomInstanceId} ({placed.name}) instead of answering 'nowhere'");
try { UpdatePlayerHeartbeat(playerid, placed); } catch { }
}
// serverTime was DECLARED and never assigned anywhere, so every presence
// heartbeat this server has ever sent carried serverTime = 0 - i.e. 1970. Any
// client-side check that compares our clock to its own sees a ~56 year skew.
// Unix SECONDS, matching the JWT iat/exp the client already parses from our
// tokens and the rest of this codebase (BanDB, moderation timestamps).
hb.serverTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
// Also never set: null reads as "never seen", which is wrong for a player who
// is heartbeating at us right now.
hb.lastOnline = DateTime.UtcNow.ToString("o");
long now = hb.roomInstance?.roomInstanceId ?? 0;
long prev = _lastAnswered.TryGetValue(playerid, out var p) ? p : 0;
if (now != prev)
{
Console.WriteLine($"[heartbeat] {playerid}: answer changed {prev} -> {now} " +
$"({hb.roomInstance?.name ?? "nowhere"})");
_lastAnswered[playerid] = now;
}
return hb;
}
public static string GetPlayerHeartbeatRoomName(ulong playerid)
{
var data = _col.FindOne(x => x.playerid == playerid);
return data?.Heartbeat?.roomInstance?.name ?? "nowhere!";
}
public static bool CreatePlayerHeartbeat(ulong playerid, out Heartbeat heartbeat, Platforms platform, DeviceClasses deviceClasses)
{
if (_col.Exists(x => x.playerid == playerid))
{
heartbeat = null!;
return false;
}
heartbeat = CreateDefaultHeartbeat(playerid, platform, deviceClasses);
_col.Insert(new PlayerHeartbeatData
{
iD = playerid, // keyed by playerid - see note on PlayerHeartbeatData.iD
playerid = playerid,
Heartbeat = heartbeat
});
return true;
}
public static void UpdateVrMovementMode(ulong playerid, int mode)
{
var data = _col.FindOne(x => x.playerid == playerid);
if (data?.Heartbeat == null) return;
data.Heartbeat.vrMovementMode = mode;
_col.Update(data);
}
public static void UpdateStatusVisibility(ulong playerid, int visibility)
{
var data = _col.FindOne(x => x.playerid == playerid);
if (data != null)
{
data.Heartbeat.statusVisibility = visibility;
_col.Update(data);
}
else
{
// Upsert: a player who changes their status before any heartbeat row exists used
// to have the change silently dropped. Create the row so it persists (and so the
// change is honored by GetPresenceForList right away).
var hb = CreateDefaultHeartbeat(playerid, Platforms.Steam, DeviceClasses.Screen);
hb.statusVisibility = visibility;
_col.Insert(new PlayerHeartbeatData { iD = playerid, playerid = playerid, Heartbeat = hb });
}
Console.WriteLine($"[presence] {playerid} statusVisibility -> {visibility}");
}
public static string GetPlayerCurrentRoomName(ulong playerid)
{
var data = _col.FindOne(x => x.playerid == playerid);
return data?.Heartbeat?.roomInstance?.name ?? "not in a room.";
}
/// <summary>
/// Finds a live room instance by its id, whoever is in it.
///
/// connection-info is asked about a SPECIFIC roomInstanceId and used to answer it
/// from the caller's own heartbeat instead. That was survivable only while every
/// public instance shared the Photon room name "room" - any mismatch still landed
/// the player in the same place. With per-instance names a mismatch sends them to
/// the wrong Photon room and the load dies with "disconnect during load".
/// </summary>
/// <summary>
/// The live instances of a room with who is standing in each - the owner's "Instances"
/// tab (reference match GET /room/{id}/instances). Grouped by Photon room, which IS the
/// instance as far as the client is concerned.
/// </summary>
public static List<(RoomInstance inst, List<ulong> players)> LiveInstancesOfRoom(ulong roomId)
{
var rows = _col.FindAll()
.Where(x => x.Heartbeat != null && x.Heartbeat.isOnline && x.Heartbeat.roomInstance != null
&& x.Heartbeat.roomInstance.roomId == (long)roomId
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid))
.ToList();
return rows
.GroupBy(x => string.IsNullOrEmpty(x.Heartbeat!.roomInstance!.photonRoomIdYeee) ? x.Heartbeat.roomInstance.roomInstanceId.ToString() : x.Heartbeat.roomInstance.photonRoomIdYeee!)
.Select(g => (g.First().Heartbeat!.roomInstance!, g.Select(x => x.playerid).Distinct().ToList()))
.OrderBy(t => t.Item1.roomInstanceId)
.ToList();
}
/// <summary>
/// Change a flag on a live instance. Presence keeps a COPY of the instance on every
/// player's row, so a change (private, in progress) has to land on each of them or the
/// next heartbeat serves the old value back.
/// </summary>
public static int UpdateInstanceEverywhere(long roomInstanceId, Action<RoomInstance> edit)
{
int n = 0;
foreach (var row in _col.FindAll().Where(x => x.Heartbeat?.roomInstance != null && x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId).ToList())
{
edit(row.Heartbeat!.roomInstance!);
_col.Update(row);
n++;
}
if (_recentInstances.TryGetValue(roomInstanceId, out var r)) edit(r.inst);
return n;
}
/// <summary>
/// One-off repair of stored presence rows that carry values the client's strict parser
/// refuses (statusVisibility 4 from the old default, deviceClass -1). Returns how many.
/// </summary>
public static int FixOutOfRangePresence()
{
int n = 0;
foreach (var row in _col.FindAll().ToList())
{
var hb = row.Heartbeat;
if (hb == null) continue;
bool changed = false;
if (hb.statusVisibility < 0 || hb.statusVisibility > 3) { hb.statusVisibility = 0; changed = true; }
if (hb.deviceClass < 0) { hb.deviceClass = 0; changed = true; }
if (hb.vrMovementMode < 0) { hb.vrMovementMode = 1; changed = true; }
if (changed) { _col.Update(row); n++; }
}
return n;
}
public static RoomInstance? FindInstanceById(long roomInstanceId)
{
if (roomInstanceId == 0) return null;
var live = _col.FindAll()
.Where(x => x.Heartbeat?.roomInstance != null
&& x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId)
.Select(x => x.Heartbeat!.roomInstance!)
.FirstOrDefault();
if (live != null) return live;
// Nobody's row points at it right now - but if it was live minutes ago, a client that
// still holds the id (a recovering one) can be sent back to the same Photon room.
if (_recentInstances.TryGetValue(roomInstanceId, out var r) && DateTime.UtcNow - r.seen <= InstanceMemory)
return r.inst;
return null;
}
/// <summary>
/// A LIVE instance of a given room, whoever is in it - the same (name, isPrivate) the admin
/// panel shows. Used by the Discord-presence room lookup so it reflects the instance the
/// player is actually in (works for dorms, which the static room API doesn't describe the
/// same way). Returns null if nobody is in an instance of that room right now.
/// </summary>
/// <summary>
/// A player's OWN current room + their status-visibility, straight from their heartbeat row
/// (no WebSocket gating - the caller IS the player, so they're online by definition). Used
/// by the Discord-presence self endpoint: statusVisibility decides whether the room may be
/// shown publicly at all, roomInstance is where they are. Honors the placement fallback.
/// </summary>
public static (RoomInstance? room, int statusVisibility) GetSelfPresence(ulong playerid)
{
var hb = _col.FindOne(x => x.playerid == playerid)?.Heartbeat;
RoomInstance? room = hb?.roomInstance;
if (room == null && _placedIn.TryGetValue(playerid, out var placed)) room = placed;
return (room, hb?.statusVisibility ?? 0);
}
public static RoomInstance? FindInstanceByRoomId(long roomId)
{
if (roomId == 0) return null;
return _col.FindAll()
.Where(x => x.Heartbeat?.roomInstance != null
&& x.Heartbeat.roomInstance.roomId == roomId)
.Select(x => x.Heartbeat!.roomInstance!)
.FirstOrDefault();
}
/// <summary>
/// Finds a public instance of <paramref name="roomId"/> that someone is already in
/// and that still has space, so a second player joining the same room LANDS WITH
/// THEM instead of in a private world of their own.
///
/// This is required by the unique Photon room names in Sessions.CreateRoom. While
/// every public instance shared the constant name "room", players met by accident -
/// and also met everyone in every other room, which was the bug. Now that names are
/// per-instance, matchmaking has to deliberately reuse an instance for people to
/// meet at all.
///
/// Returns null when nobody is in the room, or every instance is full - the caller
/// then creates a fresh one.
/// </summary>
public static RoomInstance? FindJoinableInstance(ulong roomId, ulong excludePlayerId = 0)
{
var occupants = _col.FindAll()
.Where(x => x.playerid != excludePlayerId
&& x.Heartbeat != null
&& x.Heartbeat.isOnline
&& x.Heartbeat.roomInstance != null
&& x.Heartbeat.roomInstance.roomId == (long)roomId
&& !x.Heartbeat.roomInstance.isPrivate
&& !string.IsNullOrEmpty(x.Heartbeat.roomInstance.photonRoomIdYeee)
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid)
// an instance they were kicked out of is not offered to them again
&& !server.InstanceBlocks.IsBlocked(excludePlayerId, x.Heartbeat.roomInstance))
.Select(x => x.Heartbeat.roomInstance!)
.ToList();
if (occupants.Count == 0)
{
// Nobody recorded in the room - but if an instance of it was live moments ago, this
// is most likely a player recovering from the mass drop above. Put them back in the
// same Photon room so the group re-forms instead of each opening a fresh instance.
var recent = RecentInstanceOfRoom(roomId);
if (recent != null && server.InstanceBlocks.IsBlocked(excludePlayerId, recent)) recent = null;
if (recent != null)
Console.WriteLine($"[matchmake] regrouping into recent instance {recent.roomInstanceId} of room {roomId} (photon {recent.photonRoomIdYeee})");
return recent;
}
// Group by the Photon room - that IS the instance as far as the client is
// concerned - and pick the fullest one with space, so players gather together
// rather than scattering one-per-instance.
var best = occupants
.GroupBy(r => r.photonRoomIdYeee)
.Select(g => new { Instance = g.First(), Count = g.Count() })
.Where(g => g.Count < EffectiveCapacity(g.Instance))
.OrderByDescending(g => g.Count)
.FirstOrDefault();
return best?.Instance;
}
/// <summary>
/// Live player count per ROOM id, in one pass over presence - what Room.CCU carries.
/// Rooms with nobody in them are simply absent from the map.
/// </summary>
private static Dictionary<long, int> _ccuCache = new();
private static DateTime _ccuCacheAt = DateTime.MinValue;
public static Dictionary<long, int> LivePlayerCountsByRoom()
{
// Cached for a few seconds: this is called once per room response (including the hot
// list) and a full presence scan per request would be the expensive part of it.
if ((DateTime.UtcNow - _ccuCacheAt).TotalSeconds < 5) return _ccuCache;
var counts = new Dictionary<long, int>();
foreach (var x in _col.FindAll())
{
var inst = x?.Heartbeat?.roomInstance;
if (x?.Heartbeat == null || !x.Heartbeat.isOnline || inst == null) continue;
counts[inst.roomId] = counts.TryGetValue(inst.roomId, out var n) ? n + 1 : 1;
}
_ccuCache = counts; _ccuCacheAt = DateTime.UtcNow;
return counts;
}
/// <summary>
/// The cap that actually applies to an instance: the subroom's CURRENT MaxPlayers (what
/// the room settings show), falling back to the value stamped when the instance was
/// opened, and finally 4 like the reference. Read live so an owner raising the cap in
/// room settings frees the running instance instead of it staying "full" at the old
/// number. Cached briefly - this runs on every heartbeat.
/// </summary>
private static readonly Dictionary<(long, long), (int cap, DateTime at)> _capCache = new();
public static int EffectiveCapacity(RoomInstance? inst)
{
if (inst == null) return 0;
int fromSub = 0;
var key = (inst.roomId, inst.subRoomId);
lock (_capCache)
{
if (_capCache.TryGetValue(key, out var c) && (DateTime.UtcNow - c.at).TotalSeconds < 20) fromSub = c.cap;
else
{
try { fromSub = RoomDB.GetSubRoom((ulong)inst.roomId, (ulong)inst.subRoomId)?.MaxPlayers ?? 0; } catch { fromSub = 0; }
_capCache[key] = (fromSub, DateTime.UtcNow);
if (_capCache.Count > 5000) _capCache.Clear();
}
}
if (fromSub > 0) return fromSub;
return inst.maxCapacity > 0 ? inst.maxCapacity : 4;
}
/// <summary>Whether the instance is at its player cap (the subroom's MaxPlayers).</summary>
public static bool InstanceIsFull(RoomInstance? inst)
{
if (inst == null) return false;
int cap = EffectiveCapacity(inst);
inst.maxCapacity = cap;
return cap > 0 && CountInInstance(inst.roomInstanceId) >= cap;
}
/// <summary>How many online players' presence points at this instance.</summary>
// live-connection gate: a presence row stays "online" after a crash / closed game until
// the player logs in again, so ghosts piled up in an instance and it read as full while
// half the slots were empty. Only a player holding a live notification socket counts.
/// <summary>Players in any instance of this room right now (live websocket only).</summary>
public static int CountInRoom(ulong roomId) =>
_col.FindAll().Count(x => x.Heartbeat != null && x.Heartbeat.isOnline
&& x.Heartbeat.roomInstance != null
&& x.Heartbeat.roomInstance.roomId == (long)roomId
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid));
public static int CountInInstance(long roomInstanceId) =>
_col.FindAll().Count(x => x.Heartbeat != null && x.Heartbeat.isOnline
&& x.Heartbeat.roomInstance != null
&& x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid));
public static void ClearPlayersHeartbeats()
{
foreach (var data in _col.FindAll())
{
UpdatePlayerHeartbeat(data.playerid, null, false);
}
}
public static void ClearPlayerHeartbeat(ulong playerId) =>
UpdatePlayerHeartbeat(playerId, null, false);
/// <summary>
/// When this player was last connected, from the STORED row (GetPlayerHeartbeat rewrites
/// lastOnline to "now" on every read, so it can't be used for this). Stamped by
/// StampLastSeen on every placement and on notification-socket close, so for an offline
/// player it is the moment they disconnected. Null when never seen.
/// </summary>
public static string? LastSeen(ulong playerid) => _col.FindOne(x => x.playerid == playerid)?.Heartbeat?.lastOnline;
public static void StampLastSeen(ulong playerid)
{
try
{
var data = _col.FindOne(x => x.playerid == playerid);
if (data?.Heartbeat == null) return;
data.Heartbeat.lastOnline = DateTime.UtcNow.ToString("o");
_col.Update(data);
}
catch { }
}
public static void UpdatePlayerHeartbeat(
ulong playerid,
RoomInstance? roomInstance,
bool online = true,
Platforms platform = Platforms.All,
DeviceClasses deviceClasses = DeviceClasses.Unknown)
{
// Record the DELIBERATE placement before touching the DB. Everything that
// moves a player goes through here, so this is the one place that can tell
// "we put them nowhere" apart from "they fell off the record by accident".
// See _placedIn on GetPlayerHeartbeat.
if (online && roomInstance != null) { _placedIn[playerid] = roomInstance; Remember(playerid, roomInstance); }
else _placedIn.TryRemove(playerid, out _);
var data = _col.FindOne(x => x.playerid == playerid);
// Capture the prior state so we only push a realtime presence update to friends when it
// ACTUALLY changed (online<->offline, or a different room instance) - the periodic
// heartbeat doesn't come through here, but matchmake/join/dorm/ban all do, some with the
// same instance repeatedly.
long oldInstance = data?.Heartbeat?.roomInstance?.roomInstanceId ?? 0;
bool wasOnline = data?.Heartbeat?.isOnline ?? false;
if (data != null)
{
var hb = data.Heartbeat;
hb.roomInstance = online ? roomInstance : null;
hb.isOnline = online;
hb.lastOnline = DateTime.UtcNow.ToString("o"); // see LastSeen
// Correct any row still carrying the old hardcoded build, so existing players stop
// showing as a version mismatch on their next heartbeat (not just fresh rows).
hb.appVersion = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion;
_col.Update(data);
}
else
{
// Create a new heartbeat and assign the current roomInstance
var hb = CreateDefaultHeartbeat(playerid, platform, deviceClasses);
hb.roomInstance = roomInstance;
hb.isOnline = online;
_col.Insert(new PlayerHeartbeatData
{
iD = playerid, // keyed by playerid - see note on PlayerHeartbeatData.iD
playerid = playerid,
Heartbeat = hb
});
}
long newInstance = (online ? roomInstance?.roomInstanceId : null) ?? 0;
if (wasOnline != online || oldInstance != newInstance)
{
Campus_live_rewrite.api.server.Notifications.FireAndForget(
Campus_live_rewrite.api.server.Notifications.PushPresenceToFriends(playerid),
$"presence-change {playerid}");
}
}
public class Heartbeat
{
public long serverTime { get; set; }
public long playerId { get; set; }
public int statusVisibility { get; set; }
public int platform { get; set; }
public int deviceClass { get; set; }
public int vrMovementMode { get; set; }
public RoomInstance roomInstance { get; set; } // Can be null
public string lastOnline { get; set; } // ISO 8601 string
public bool isOnline { get; set; }
public string appVersion { get; set; }
}
public class FullRoomInstance
{
public RoomInstance? roomInstance { get; set; }
public string? correlationId { get; set; }
}
public class RoomInstance
{
public long roomInstanceId { get; set; }
public long roomId { get; set; }
public long subRoomId { get; set; }
public string? location { get; set; }
public int roomInstanceType { get; set; }
public string? name { get; set; }
// The event/club this instance belongs to (0 = none). The client reads eventId to
// show the "This Event" tab on the room panel when you're in an event instance
// (mirrors the reference RoomInstanceDto's eventId/clubId).
public long eventId { get; set; }
public long clubId { get; set; }
public int maxCapacity { get; set; }
public bool isFull { get; set; }
public bool isPrivate { get; set; }
public bool isInProgress { get; set; }
[JsonIgnore]
public string? photonRoomIdYeee { get; set; } // im crack
public string? voiceServerId { get; set; }
public string? voiceAuthId { get; set; }
public int matchmakingPolicy { get; set; }
// Whether the Photon Voice stream is encrypted. RecFlare sets this to false on every
// room instance, and it MUST be present: absent, the client falls back to its own
// default (encrypt), and then peers encrypt their voice with no shared key exchange
// set up - they connect fine but hear each other as silence, which is exactly the
// "mic works, nobody can hear you" symptom. false = plain voice, everyone hears everyone.
public bool EncryptVoiceChat { get; set; } = false;
}
public enum MatchmakingErrorCode
{
Success,
NoSuchGame,
PlayerNotOnline,
InsufficientSpace,
EventNotStarted,
EventAlreadyFinished,
EventCreatorNotReady,
BlockedFromRoom,
ProfileLocked,
NoBirthday,
MarkedForDelete,
JuniorNotAllowed,
Banned,
AlreadyInBestInstance,
InsufficientRelationship,
UpdateRequired = 16,
AlreadyInTargetInstance,
RegistrationRequired,
UGCNotAllowed,
NoSuchRoom,
RoomCreatorNotReady,
RoomIsNotActive,
RoomBlockedByCreator,
RoomBlockingCreator,
RoomIsPrivate,
RoomInstanceIsPrivate,
DeviceClassNotSupported = 30,
DeviceClassNotSupportedByRoomOwner,
VRMovementModeNotSupportedByRoomOwner,
EventIsPrivate = 35,
RoomInviteExpired = 40,
NoAvailableRegion = 45,
NotorietyTooPoor = 50,
BannedFromRoom = 55
}
}
}
@@ -0,0 +1,714 @@
using LiteDB;
using Newtonsoft.Json;
using System.Collections.Concurrent;
using System.Text.Json.Serialization;
public static class ImageMetadataDB
{
private static readonly string DbPath = Path.Combine("Data", "DBs", "Images.db");
public class FullSavedImage
{
public int Id { get; set; }
public ImageAccessibility Accessibility { get; set; }
public bool AccessibilityLocked { get; set; } = false;
public int CheerCount { get; set; } = 0;
public int CommentCount { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string? Description { get; set; }
public string ImageName { get; set; }
public ulong PlayerEventId { get; set; }
public ulong PlayerId { get; set; }
public int RoomId { get; set; }
//public string Description { get; set; } = "";
public SavedImageTypeEnum Type { get; set; }
public List<ulong> TaggedPlayerIds { get; set; } = new();
// Website-only extra state. The game reads Accessibility (Private/Public) and never sees
// this - a friends-only photo is stored Accessibility=Private (safe in-game), FriendsOnly=true,
// and the WEBSITE shows it to the owner's friends.
public bool FriendsOnly { get; set; } = false;
}
// NOTE: the client's v6 record keys the id as SavedImageId and the type as SavedImageType
// (wire-shapes: api-images-v6). `Id`/`Type` are dropped silently by Utf8Json, which left every
// v6 lookup with SavedImageId 0.
public class ImageV6
{
public long SavedImageId { get; set; }
public SavedImageTypeEnum SavedImageType { get; set; }
public ulong ClubId { get; set; }
public ImageAccessibility Accessibility { get; set; }
public bool AccessibilityLocked { get; set; } = false;
public int CheerCount { get; set; } = 0;
public int CommentCount { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string? Description { get; set; }
public int Id { get; set; }
public string ImageName { get; set; }
public ulong PlayerEventId { get; set; }
public ulong PlayerId { get; set; }
public int RoomId { get; set; }
public List<ulong> TaggedPlayerIds { get; set; } = new();
public SavedImageTypeEnum Type { get; set; }
}
public class ImagesPlayer
{
public ImageAccessibility Accessibility { get; set; }
public bool AccessibilityLocked { get; set; } = false;
public int CheerCount { get; set; } = 0;
public int CommentCount { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string? Description { get; set; }
public string ImageName { get; set; }
public ulong PlayerEventId { get; set; }
public ulong PlayerId { get; set; }
public int RoomId { get; set; }
public int SavedImageId { get; set; }
public SavedImageTypeEnum SavedImageType { get; set; }
public List<ulong> TaggedPlayerIds { get; set; } = new();
public bool FriendsOnly { get; set; } = false;
}
public class ImageMetadata
{
public List<ulong> playerIds { get; set; } = new();
public SavedImageTypeEnum savedImageType { get; set; }
public int roomId { get; set; }
public ImageAccessibility accessibility { get; set; }
public string? description { get; set; }
}
public enum SavedImageTypeEnum
{
None, // 0
ShareCamera, // 1
OutfitThumbnail, // 2
RoomThumbnail, // 3
ProfileThumbnail, // 4
InventionThumbnail, // 5
PlayerEventThumbnail, // 6
RoomLoadScreen // 7
}
public enum ImageAccessibility
{
Private = 0,
Public = 1,
}
public class Cheer
{
public ObjectId Id { get; set; }
public int SavedImageId { get; set; }
public ulong PlayerId { get; set; }
}
public class CheerRequest
{
public int SavedImageId { get; set; }
public bool Cheer { get; set; }
}
/// <summary>A website comment on a photo. CommentCount on the image is kept in step.</summary>
public class ImageComment
{
public int Id { get; set; }
public int SavedImageId { get; set; }
public ulong PlayerId { get; set; }
public string Text { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public static FullSavedImage? GetByName(string name) =>
string.IsNullOrWhiteSpace(name) ? null : GetDb().GetCollection<FullSavedImage>("images").FindOne(x => x.ImageName == name);
public static ImageComment? GetComment(int commentId) =>
GetDb().GetCollection<ImageComment>("comments").FindById(commentId);
public static List<ImageComment> GetComments(int savedImageId) =>
GetDb().GetCollection<ImageComment>("comments").Find(x => x.SavedImageId == savedImageId).OrderBy(x => x.CreatedAt).ToList();
public static ImageComment? AddComment(int savedImageId, ulong playerId, string text)
{
var db = GetDb();
if (db.GetCollection<FullSavedImage>("images").FindById(savedImageId) == null) return null;
var c = new ImageComment { SavedImageId = savedImageId, PlayerId = playerId, Text = text, CreatedAt = DateTime.UtcNow };
db.GetCollection<ImageComment>("comments").Insert(c);
RecountComments(savedImageId);
return c;
}
/// <summary>Delete a comment: its author, or the photo's owner, may.</summary>
public static bool DeleteComment(int commentId, ulong byPlayer)
{
var db = GetDb();
var col = db.GetCollection<ImageComment>("comments");
var c = col.FindById(commentId);
if (c == null) return false;
var img = db.GetCollection<FullSavedImage>("images").FindById(c.SavedImageId);
if (c.PlayerId != byPlayer && (img == null || img.PlayerId != byPlayer)) return false;
col.Delete(commentId);
RecountComments(c.SavedImageId);
return true;
}
private static void RecountComments(int savedImageId)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
var img = col.FindById(savedImageId);
if (img == null) return;
img.CommentCount = db.GetCollection<ImageComment>("comments").Count(x => x.SavedImageId == savedImageId);
col.Update(img);
}
// PERF: Keep a thread-safe singleton LiteDatabase instance to avoid reopening files constantly.
private static readonly Lazy<LiteDatabase> _dbInstance = new(() => new LiteDatabase(DbPath));
public static LiteDatabase GetDb() => _dbInstance.Value;
public static FullSavedImage InsertImage(FullSavedImage image)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
col.EnsureIndex(x => x.Id, true);
// If the image doesn't already have an ID, assign the next sequential ID.
if (image.Id == 0)
{
// Get the highest existing ID and increment by 1.
// PERF: This uses descending order to get the latest ID efficiently.
var last = col.Query()
.OrderByDescending(x => x.Id)
.Limit(1)
.FirstOrDefault();
image.Id = (last?.Id ?? 0) + 1;
}
col.Insert(image);
return image;
}
public static List<FullSavedImage> GetRoomImages(
ulong playerId,
int roomId,
int sort = 0,
int filter = 0,
int take = 50,
int skip = 0)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
// Base query: only images in this room
var query = col.Query()
.Where(x => x.RoomId == roomId);
// Apply filter conditions
switch (filter)
{
case 2: // Only show player's private images
query = query.Where(x => x.PlayerId == playerId);
break;
case 3: // Only images that have been cheered
query = query.Where(x => x.CheerCount > 0);
break;
// filter == 1 will be handled in sorting
}
// Apply sorting
if (sort == 0 && filter == 1)
{
// Special case: newest first when sort=0 & filter=1
query = query.OrderByDescending(x => x.CreatedAt);
}
else
{
query = sort switch
{
1 => query.OrderByDescending(x => x.CreatedAt), // Newest first
2 => query.OrderByDescending(x => x.CheerCount), // Most cheered
3 => query.OrderByDescending(x => x.CommentCount),// Most commented
_ => query.OrderBy(x => x.CreatedAt), // Default: oldest first
};
}
// Apply pagination
return query
.Skip(skip)
.Limit(take)
.ToList();
}
public static List<FullSavedImage> GetGlobalImages()
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
return col.Query()
.Where(x => x.Accessibility == ImageAccessibility.Public)
.OrderByDescending(x => x.CheerCount)
.ToList();
}
public const int SlideshowLimit = 10;
public const int SlideshowMaxLimit = 100;
/// <summary>
/// The global slideshow feed: the most recent PUBLIC ShareCamera photos across all rooms,
/// newest first. Ported from RecFlare's getSlideshowImages. Public-only (Accessibility ==
/// Public) because this endpoint is unauthenticated - it must never surface a private photo.
/// The caller joins Username + RoomName on top of these rows.
/// </summary>
public static List<FullSavedImage> GetSlideshowImages(int limit)
{
if (limit <= 0) limit = SlideshowLimit;
if (limit > SlideshowMaxLimit) limit = SlideshowMaxLimit;
var db = GetDb();
return db.GetCollection<FullSavedImage>("images")
.Query()
.Where(x => x.Accessibility == ImageAccessibility.Public
&& x.Type == SavedImageTypeEnum.ShareCamera)
.OrderByDescending(x => x.Id)
.Limit(limit)
.ToList();
}
/// <summary>
/// The website "Recent Photos" feed: the newest PUBLIC camera photos across all rooms.
/// Like GetSlideshowImages but ALSO includes Type == None, because this server's camera
/// photos are saved as None (only ShareCamera would match nothing - the home feed was empty).
/// Still excludes thumbnails (Outfit/Room/Profile/Invention/PlayerEvent/RoomLoadScreen) so
/// profile pictures and room thumbnails never appear as "photos". Public-only (unauthenticated).
/// </summary>
// Public camera photos taken in one room, newest first - the website room page. Filters
// in the query (not after a Take) so a room full of thumbnails can't crowd real photos
// out of the window; None is included because this server saves camera photos as None.
public static List<FullSavedImage> GetRoomPublicPhotos(int roomId, int limit)
{
if (limit <= 0) limit = 24;
var db = GetDb();
return db.GetCollection<FullSavedImage>("images")
.Query()
.Where(x => x.RoomId == roomId
&& x.Accessibility == ImageAccessibility.Public
&& (x.Type == SavedImageTypeEnum.None || x.Type == SavedImageTypeEnum.ShareCamera))
.OrderByDescending(x => x.Id)
.Limit(limit)
.ToList();
}
public static List<FullSavedImage> GetRecentPublicPhotos(int limit)
{
if (limit <= 0) limit = SlideshowLimit;
if (limit > SlideshowMaxLimit) limit = SlideshowMaxLimit;
// Only the LAST 24 HOURS - "Recent Photos" should be genuinely recent, not a months-deep
// archive. Cutoff computed here (not inline) so LiteDB compares against a fixed value.
// CreatedAt is stored UTC (DateTime.UtcNow default), so compare in UTC.
var cutoff = DateTime.UtcNow.AddHours(-24);
var db = GetDb();
return db.GetCollection<FullSavedImage>("images")
.Query()
.Where(x => x.Accessibility == ImageAccessibility.Public
&& (x.Type == SavedImageTypeEnum.None || x.Type == SavedImageTypeEnum.ShareCamera)
&& x.CreatedAt >= cutoff)
.OrderByDescending(x => x.Id)
.Limit(limit)
.ToList();
}
/// <summary>Every saved photo, newest first - for the admin gallery (no accessibility filter).</summary>
public static List<FullSavedImage> GetAllImages()
{
var db = GetDb();
return db.GetCollection<FullSavedImage>("images")
.Query()
.OrderByDescending(x => x.Id)
.ToList();
}
/// <summary>
/// Deletes one photo: its metadata row, its cheers, and the image file on disk. Returns
/// the deleted row (for logging), or null if there was no such photo.
/// </summary>
public static FullSavedImage? DeleteImage(int id)
{
var db = GetDb();
var imageCol = db.GetCollection<FullSavedImage>("images");
var img = imageCol.FindById(id);
if (img == null) return null;
imageCol.Delete(id);
db.GetCollection<Cheer>("cheers").DeleteMany(c => c.SavedImageId == id);
db.GetCollection<ImageComment>("comments").DeleteMany(c => c.SavedImageId == id);
// Remove the file from wherever /img serves polaroids from. Best-effort: the metadata
// is already gone, so a stray file just 404s and is invisible.
if (!string.IsNullOrWhiteSpace(img.ImageName))
{
foreach (var dir in new[] { "polaroid_images", "player_images" })
{
try
{
var path = Path.Combine(Environment.CurrentDirectory, "Data", "Images", dir, Path.GetFileName(img.ImageName));
if (File.Exists(path)) File.Delete(path);
}
catch (Exception ex) { Console.WriteLine($"[Delete] could not remove file {img.ImageName}: {ex.Message}"); }
}
}
Console.WriteLine($"[Delete] removed photo {id} '{img.ImageName}' taken by {img.PlayerId}");
return img;
}
/// <summary>
/// A player's photos. `sort`: 0 newest first (default), 1 most cheered (the client's
/// "sort by cheers" toggle, same value the reference uses), 2 oldest first. Was unsorted -
/// LiteDB's natural order is insertion order, i.e. oldest first, and the toggle did nothing.
/// </summary>
public static List<ImagesPlayer> GetPlayerImages(ulong playeridthatrequesting, ulong playerid, int sort = 0, int skip = 0, int take = 0)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
var query = col.Query().Where(x => x.PlayerId == playerid);
if (playeridthatrequesting != playerid)
{
query = query.Where(x => x.Accessibility == ImageAccessibility.Public);
}
IEnumerable<FullSavedImage> rows = query.ToEnumerable();
rows = sort switch
{
1 => rows.OrderByDescending(x => x.CheerCount).ThenByDescending(x => x.Id),
2 => rows.OrderBy(x => x.Id),
_ => rows.OrderByDescending(x => x.Id),
};
if (skip > 0) rows = rows.Skip(skip);
if (take > 0) rows = rows.Take(take);
return rows.Select(MapToImagesPlayer).ToList();
}
/// <summary>
/// A player's photo FEED - the images they took PLUS the ones they are tagged in, newest
/// first. This is what the photo tab on a profile shows (RecFlare getPlayerFeed +
/// /api/images/v3/feed/player). Public-only unless the requester is the player themselves.
/// </summary>
public static List<ImagesPlayer> GetPlayerFeed(ulong requester, ulong playerid, int skip = 0, int take = 100, bool viewerIsFriend = false, int sort = 0)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
IEnumerable<FullSavedImage> feed = col.FindAll()
.Where(x => x.PlayerId == playerid
|| (x.TaggedPlayerIds != null && x.TaggedPlayerIds.Contains(playerid)));
// Non-owner sees public photos, plus friends-only ones when they're a friend
// (friends-only is stored Accessibility=Private + FriendsOnly=true).
if (requester != playerid)
feed = feed.Where(x => x.Accessibility == ImageAccessibility.Public
|| (x.FriendsOnly && viewerIsFriend));
feed = sort switch
{
1 => feed.OrderByDescending(x => x.CheerCount).ThenByDescending(x => x.Id),
2 => feed.OrderBy(x => x.Id),
_ => feed.OrderByDescending(x => x.Id),
};
return feed.Skip(skip < 0 ? 0 : skip)
.Take(take <= 0 ? 100 : take)
.Select(MapToImagesPlayer)
.ToList();
}
private static ImagesPlayer MapToImagesPlayer(FullSavedImage x)
{
return new ImagesPlayer
{
Accessibility = x.Accessibility,
AccessibilityLocked = x.AccessibilityLocked,
CheerCount = x.CheerCount,
CommentCount = x.CommentCount,
CreatedAt = x.CreatedAt,
Description = x.Description,
ImageName = x.ImageName,
PlayerEventId = x.PlayerEventId,
PlayerId = x.PlayerId,
RoomId = x.RoomId,
SavedImageId = x.Id,
SavedImageType = x.Type,
TaggedPlayerIds = x.TaggedPlayerIds ?? new(),
FriendsOnly = x.FriendsOnly
};
}
/// <summary>One image row by id (null if none). For the website detail/edit endpoints.</summary>
public static FullSavedImage? GetById(int id)
=> GetDb().GetCollection<FullSavedImage>("images").FindById(id);
/// <summary>Owner-only visibility change: set Accessibility (what the game reads) + FriendsOnly
/// (website). Returns false if the photo doesn't exist.</summary>
public static bool SetVisibility(int id, ImageAccessibility accessibility, bool friendsOnly)
{
var col = GetDb().GetCollection<FullSavedImage>("images");
var img = col.FindById(id);
if (img == null) return false;
img.Accessibility = accessibility;
img.FriendsOnly = friendsOnly;
col.Update(img);
return true;
}
/// <summary>Owner-only caption change.</summary>
public static bool SetDescription(int id, string description)
{
var col = GetDb().GetCollection<FullSavedImage>("images");
var img = col.FindById(id);
if (img == null) return false;
img.Description = description;
col.Update(img);
return true;
}
/// <summary>Remove one tagged player from a photo. Returns the updated tag list, or null if
/// the photo doesn't exist.</summary>
public static List<ulong>? RemoveTag(int id, ulong taggedPlayerId)
{
var col = GetDb().GetCollection<FullSavedImage>("images");
var img = col.FindById(id);
if (img == null) return null;
img.TaggedPlayerIds ??= new();
img.TaggedPlayerIds.RemoveAll(x => x == taggedPlayerId);
col.Update(img);
return img.TaggedPlayerIds;
}
public static void UpdateImageCheerCount(int savedImageId, int cheerCount)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
var img = col.FindById(savedImageId);
if (img != null)
{
img.CheerCount = cheerCount;
col.Update(img);
}
}
public static bool ToggleCheer(int savedImageId, ulong playerId, bool cheer)
{
var db = GetDb();
var col = db.GetCollection<Cheer>("cheers");
var existing = col.FindOne(x => x.SavedImageId == savedImageId && x.PlayerId == playerId);
if (cheer && existing == null)
{
col.Insert(new Cheer { SavedImageId = savedImageId, PlayerId = playerId });
}
else if (!cheer && existing != null)
{
col.Delete(existing.Id);
}
return col.Exists(x => x.SavedImageId == savedImageId && x.PlayerId == playerId);
}
public static int GetCheerCount(int savedImageId)
{
var db = GetDb();
return db.GetCollection<Cheer>("cheers").Count(x => x.SavedImageId == savedImageId);
}
public static bool HasUserCheered(int savedImageId, ulong playerId)
{
var db = GetDb();
return db.GetCollection<Cheer>("cheers")
.Exists(x => x.SavedImageId == savedImageId && x.PlayerId == playerId);
}
private static readonly string OldDir = Path.Combine("Data", "images_metadata");
/// <summary>A v6 record for an image that does not exist - every id 0, so the client's
/// normalising hook is satisfied and nothing renders. Never answer [] here.</summary>
public static ImageV6 EmptyImageV6(string name) => new ImageV6
{
SavedImageId = 0, ImageName = name ?? "", PlayerId = 0, RoomId = 0,
PlayerEventId = 0, ClubId = 0, Description = null,
Accessibility = ImageAccessibility.Public, AccessibilityLocked = false,
SavedImageType = SavedImageTypeEnum.None, CreatedAt = DateTime.UtcNow,
CheerCount = 0, CommentCount = 0
};
public static ImageV6? GetImageV6ByName(string name) => ToV6(
GetDb().GetCollection<FullSavedImage>("images").FindOne(x => x.ImageName == name));
public static ImageV6? GetImageV6ById(long savedImageId) => ToV6(
GetDb().GetCollection<FullSavedImage>("images").FindOne(x => x.Id == (int)savedImageId));
private static ImageV6? ToV6(FullSavedImage? r) => r == null ? null : new ImageV6
{
SavedImageId = r.Id,
Id = r.Id,
ImageName = r.ImageName,
PlayerId = r.PlayerId,
RoomId = r.RoomId,
PlayerEventId = r.PlayerEventId,
ClubId = 0,
Description = r.Description,
Accessibility = r.Accessibility,
AccessibilityLocked = r.AccessibilityLocked,
SavedImageType = r.Type,
Type = r.Type,
CreatedAt = r.CreatedAt,
CheerCount = r.CheerCount,
CommentCount = r.CommentCount,
TaggedPlayerIds = r.TaggedPlayerIds ?? new()
};
public static ImageV6 GetImageByName(string name)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
var record = col.FindOne(x => x.ImageName == name);
if (record == null) return null;
return new ImageV6
{
Accessibility = record.Accessibility,
AccessibilityLocked = record.AccessibilityLocked,
CheerCount = record.CheerCount,
CommentCount = record.CommentCount,
CreatedAt = record.CreatedAt,
Description = record.Description,
Id = record.Id,
ImageName = record.ImageName,
PlayerEventId = record.PlayerEventId,
PlayerId = record.PlayerId,
RoomId = record.RoomId,
TaggedPlayerIds = record.TaggedPlayerIds,
Type = record.Type,
};
}
public static void DeleteImagesFromPlayerId(ulong playerId)
{
var db = GetDb();
var imageCol = db.GetCollection<FullSavedImage>("images");
var cheerCol = db.GetCollection<Cheer>("cheers");
var imagesToDelete = imageCol.Query().Where(x => x.PlayerId == playerId).ToList();
if (imagesToDelete.Count == 0)
return;
foreach (var img in imagesToDelete)
{
imageCol.Delete(img.Id);
cheerCol.DeleteMany(c => c.SavedImageId == img.Id);
}
Console.WriteLine($"[Delete] Removed {imagesToDelete.Count} images and their cheers for PlayerId {playerId}");
}
public static List<FullSavedImage> GetImagesByIds(List<int> ids)
{
if (ids == null || ids.Count == 0)
return new List<FullSavedImage>();
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
// Efficient LiteDB WHERE query using Contains()
return col.Query()
.Where(x => ids.Contains(x.Id))
.ToList();
}
public static void ImportOldJson()
{
if (!Directory.Exists(OldDir))
{
Console.WriteLine($"[Import] Old directory not found: {OldDir}");
return;
}
var db = GetDb();
var imageCol = db.GetCollection<FullSavedImage>("images");
var cheerCol = db.GetCollection<Cheer>("cheers");
imageCol.EnsureIndex(x => x.Id, true);
// PERF: Parallelize JSON import for multiple files.
var files = Directory.GetFiles(OldDir, "*.json");
var cheerFiles = files.Where(f => f.EndsWith("_cheers.json")).ToArray();
var imageFiles = files.Except(cheerFiles).ToArray();
Parallel.ForEach(imageFiles, file =>
{
string fileName = Path.GetFileName(file);
try
{
string json = File.ReadAllText(file);
var images = JsonConvert.DeserializeObject<List<FullSavedImage>>(json);
if (images == null) return;
foreach (var img in images)
{
if (!imageCol.Exists(x => x.Id == img.Id))
{
imageCol.Insert(img);
Console.WriteLine($"[Import] Imported image {img.Id} from {fileName}");
}
else
{
Console.WriteLine($"[Import] Skipped duplicate image {img.Id}");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[Import] Failed to import {fileName}: {ex.Message}");
}
});
Parallel.ForEach(cheerFiles, file =>
{
try
{
string fileName = Path.GetFileName(file);
int savedImageId = int.Parse(fileName.Replace("_cheers.json", ""));
string json = File.ReadAllText(file);
var cheerList = JsonConvert.DeserializeObject<List<ulong>>(json);
if (cheerList == null) return;
foreach (var pid in cheerList)
{
if (!cheerCol.Exists(x => x.SavedImageId == savedImageId && x.PlayerId == pid))
{
cheerCol.Insert(new Cheer { SavedImageId = savedImageId, PlayerId = pid });
}
}
Console.WriteLine($"[Import] Imported {cheerList.Count} cheers for image {savedImageId}");
}
catch (Exception ex)
{
Console.WriteLine($"[Import] Failed to import cheers from {file}: {ex.Message}");
}
});
}
}
@@ -0,0 +1,89 @@
using LiteDB;
// Invention persistence for the Maker Pen "save selection as invention" flow.
// Minimal but real: stores the metadata the client's v6/v9 save posts (the circuit blob
// itself is uploaded separately and referenced by name), assigns an id + replication id,
// and lists a creator's own inventions. Global namespace like ImageMetadataDB/ClubDB.
public static class InventionDB
{
private static readonly string DbPath = Path.Combine("Data", "DBs", "Inventions.db");
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(DbPath));
public static LiteDatabase Db => _db.Value;
public static ILiteCollection<Invention> Collection() => Col();
public class Invention
{
public int InventionId { get; set; }
public string ReplicationId { get; set; } = "";
public ulong CreatorPlayerId { get; set; }
public string Name { get; set; } = "Untitled";
public string Description { get; set; } = "No description yet";
public string ImageName { get; set; } = "";
public string BlobName { get; set; } = ""; // inventionDataFilename (the .inv blob)
public int CurrentVersionNumber { get; set; } = 1;
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 int Accessibility { get; set; } = 0; // 0 private until published
public bool IsPublished { get; set; } = false;
public int CreationRoomId { get; set; }
public int Price { get; set; }
public int UgcVersion { get; set; }
public bool HasBetaContent { get; set; }
public string? LongDescription { get; set; }
public string? DisplayMetadataJson { get; set; }
public List<int> ReferencedInventions { get; set; } = new();
public List<string> ReferencedUnityAssetIds { get; set; } = new();
public List<string> Tags { get; set; } = new();
public bool IsFeatured { get; set; } = false; // admin-curated (InventionStore.SetFeatured)
public int NumDownloads { get; set; } = 0; // purchases / pickups
public int CheerCount { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
public DateTime? FirstPublishedAt { get; set; } = null;
}
private static ILiteCollection<Invention> Col()
{
var c = Db.GetCollection<Invention>("inventions");
c.EnsureIndex(x => x.InventionId, true);
c.EnsureIndex(x => x.CreatorPlayerId);
return c;
}
public static Invention Create(Invention inv)
{
var col = Col();
if (inv.InventionId == 0)
{
var last = col.Query().OrderByDescending(x => x.InventionId).Limit(1).FirstOrDefault();
inv.InventionId = (last?.InventionId ?? 0) + 1;
}
if (string.IsNullOrEmpty(inv.ReplicationId)) inv.ReplicationId = Guid.NewGuid().ToString("N");
inv.CreatedAt = inv.ModifiedAt = DateTime.UtcNow;
col.Insert(inv);
return inv;
}
public static Invention? GetById(int id) => Col().FindById(id);
public static List<Invention> GetByCreator(ulong creator) =>
Col().Query().Where(x => x.CreatorPlayerId == creator).OrderByDescending(x => x.InventionId).ToList();
public static Invention? Publish(int id, ulong creator, bool published)
{
var col = Col();
var inv = col.FindById(id);
if (inv == null || inv.CreatorPlayerId != creator) return null;
inv.IsPublished = published;
inv.Accessibility = published ? 1 : 0;
if (published && inv.FirstPublishedAt == null) inv.FirstPublishedAt = DateTime.UtcNow;
inv.ModifiedAt = DateTime.UtcNow;
col.Update(inv);
return inv;
}
public static void Checkpoint() { try { Db.Checkpoint(); } catch { } }
}
@@ -0,0 +1,198 @@
using LiteDB;
// The invention browse / store side of InventionDB: search, feeds, ownership (bought
// inventions), prices, cheers, deletes. Kept next to InventionDB (same LiteDB file) so one
// checkpoint covers both. Everything the client browses is PUBLISHED inventions only; a
// creator always sees their own.
public static class InventionStore
{
private static ILiteCollection<InventionDB.Invention> Col() => InventionDB.Collection();
public class Owned
{
[BsonId] public string Key { get; set; } = ""; // "{player}|{invention}"
public ulong PlayerId { get; set; }
public int InventionId { get; set; }
public int PricePaid { get; set; }
public DateTime At { get; set; } = DateTime.UtcNow;
}
public class Cheer
{
[BsonId] public string Key { get; set; } = ""; // "{player}|{invention}"
public ulong PlayerId { get; set; }
public int InventionId { get; set; }
}
private static ILiteCollection<Owned> OwnedCol()
{
var c = InventionDB.Db.GetCollection<Owned>("owned");
c.EnsureIndex(x => x.PlayerId);
c.EnsureIndex(x => x.InventionId);
return c;
}
private static ILiteCollection<Cheer> CheerCol()
{
var c = InventionDB.Db.GetCollection<Cheer>("cheers");
c.EnsureIndex(x => x.InventionId);
return c;
}
// ---- reads --------------------------------------------------------------------------
public static List<InventionDB.Invention> Published() =>
Col().Query().Where(x => x.IsPublished).ToList();
public static List<InventionDB.Invention> GetByIds(IEnumerable<int> ids)
{
var set = new HashSet<int>(ids);
if (set.Count == 0) return new();
var col = Col();
return set.Select(id => col.FindById(id)).Where(i => i != null).Select(i => i!).ToList();
}
public static List<InventionDB.Invention> ByRoom(long roomId) =>
Col().Query().Where(x => x.CreationRoomId == (int)roomId && x.IsPublished)
.ToList().OrderByDescending(x => x.CreatedAt).ToList();
/// <summary>
/// The store's search. `value` is the text the player typed; "#something" is a tag or a
/// creator's @name (the store's creator filter posts it that way). No text = every published
/// invention, newest first. Terms match the name, the description and the creator's name.
/// </summary>
public static List<InventionDB.Invention> Search(string? value, int skip, int take)
{
var all = Published();
string q = (value ?? "").Trim();
IEnumerable<InventionDB.Invention> hits = all;
if (q.StartsWith("#") && q.Length > 1)
{
string tag = q[1..].Trim();
ulong creator = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountIdByUsernameCI(tag);
hits = all.Where(i =>
(creator != 0 && i.CreatorPlayerId == creator)
|| (i.Tags ?? new()).Any(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase)));
}
else if (q.Length > 0)
{
var terms = q.ToLowerInvariant().Split(new[] { ' ', '+', ',' }, StringSplitOptions.RemoveEmptyEntries);
var names = new Dictionary<ulong, string>();
string CreatorName(ulong id)
{
if (!names.TryGetValue(id, out var n))
{
n = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountById(id)?.Username ?? "";
names[id] = n;
}
return n;
}
hits = all.Where(i =>
{
string hay = ((i.Name ?? "") + " " + (i.Description ?? "") + " " + string.Join(" ", i.Tags ?? new()) + " " + CreatorName(i.CreatorPlayerId)).ToLowerInvariant();
return terms.All(t => hay.Contains(t));
});
}
return hits.OrderByDescending(i => i.CreatedAt).ThenByDescending(i => i.InventionId)
.Skip(Math.Max(0, skip)).Take(take <= 0 ? 100 : take).ToList();
}
/// <summary>Featured / top today: the admin-featured ones first, then by downloads and cheers.</summary>
public static List<InventionDB.Invention> Top(int skip, int take) =>
Published().OrderByDescending(i => i.IsFeatured).ThenByDescending(i => i.NumDownloads + i.CheerCount * 3)
.ThenByDescending(i => i.CreatedAt).Skip(Math.Max(0, skip)).Take(take <= 0 ? 50 : take).ToList();
// ---- ownership ------------------------------------------------------------------------
public static HashSet<int> OwnedIds(ulong player) =>
OwnedCol().Find(x => x.PlayerId == player).Select(x => x.InventionId).ToHashSet();
public static bool Owns(ulong player, int inventionId) =>
OwnedCol().FindById($"{player}|{inventionId}") != null;
public static bool AddOwned(ulong player, int inventionId, int pricePaid)
{
var col = OwnedCol();
string key = $"{player}|{inventionId}";
if (col.FindById(key) != null) return false;
col.Insert(new Owned { Key = key, PlayerId = player, InventionId = inventionId, PricePaid = pricePaid });
var c = Col();
var inv = c.FindById(inventionId);
if (inv != null) { inv.NumDownloads++; c.Update(inv); }
return true;
}
/// <summary>Created + bought, newest first (the maker pen's Inventions tab).</summary>
public static List<InventionDB.Invention> Mine(ulong player)
{
var mine = InventionDB.GetByCreator(player);
var bought = GetByIds(OwnedIds(player));
var byId = new Dictionary<int, InventionDB.Invention>();
foreach (var i in mine.Concat(bought)) byId[i.InventionId] = i;
return byId.Values.OrderByDescending(i => i.CreatedAt).ThenByDescending(i => i.InventionId).ToList();
}
public static bool OwnsAll(ulong player, IEnumerable<int> ids)
{
var owned = OwnedIds(player);
var col = Col();
foreach (int id in ids)
{
var inv = col.FindById(id);
if (inv == null) return false;
if (inv.CreatorPlayerId == player || owned.Contains(id)) continue;
return false;
}
return true;
}
// ---- cheers ---------------------------------------------------------------------------
public static bool IsCheering(ulong player, int inventionId) => CheerCol().FindById($"{player}|{inventionId}") != null;
public static InventionDB.Invention? SetCheer(ulong player, int inventionId, bool cheer)
{
var col = Col();
var inv = col.FindById(inventionId);
if (inv == null) return null;
var cc = CheerCol();
string key = $"{player}|{inventionId}";
bool has = cc.FindById(key) != null;
if (cheer && !has) { cc.Insert(new Cheer { Key = key, PlayerId = player, InventionId = inventionId }); inv.CheerCount++; }
else if (!cheer && has) { cc.Delete(key); inv.CheerCount = Math.Max(0, inv.CheerCount - 1); }
col.Update(inv);
return inv;
}
// ---- creator writes -------------------------------------------------------------------
/// <summary>Edit the creator's own invention; null when it is not theirs (or does not exist).</summary>
public static InventionDB.Invention? Edit(int id, ulong creator, Action<InventionDB.Invention> edit, bool staffOverride = false)
{
var col = Col();
var inv = col.FindById(id);
if (inv == null || (inv.CreatorPlayerId != creator && !staffOverride)) return null;
edit(inv);
inv.ModifiedAt = DateTime.UtcNow;
col.Update(inv);
return inv;
}
public static bool Delete(int id, ulong creator, bool staffOverride = false)
{
var col = Col();
var inv = col.FindById(id);
if (inv == null || (inv.CreatorPlayerId != creator && !staffOverride)) return false;
col.Delete(id);
return true;
}
public static void SetFeatured(int id, bool featured)
{
var col = Col();
var inv = col.FindById(id);
if (inv == null) return;
inv.IsFeatured = featured;
col.Update(inv);
}
}
@@ -0,0 +1,164 @@
using LiteDB;
using static Campus_live_rewrite.api.server.Classes.db_class.LeaderboardDBClasses;
namespace Campus_live_rewrite.api.dbs
{
public class LeaderboardDB
{
private static readonly LiteDatabase DB = new LiteDatabase(
Environment.CurrentDirectory + "/Data/DBs/Leaderboard.db"
);
// Merge this store's write-ahead log back into the data file.
// See DbMaintenance for why this must be called explicitly.
public static void Checkpoint() => DB.Checkpoint();
private static ILiteCollection<Entry> Entries =>
DB.GetCollection<Entry>("leaderboard");
static LeaderboardDB()
{
// Short index names to avoid LiteDB 32-char name limit
Entries.EnsureIndex("pid", x => x.PlayerId);
Entries.EnsureIndex("sc", x => x.StatChannel);
Entries.EnsureIndex("rid", x => x.RoomId);
// Score index for sorting
Entries.EnsureIndex("score", x => x.Score);
}
// ------------------------------------------
// Add or Update Stat
// ------------------------------------------
// `expected` is the client's CurrentStatValue. The reference honours it as an optimistic
// compare-and-set (only overwrite if the row still holds that value), but that silently
// DROPPED real updates here: a returning player's client sends CurrentStatValue 0 (fresh
// session, hasn't re-read the board) while the server still holds their old score, so the
// compare failed and their new score got STUCK. We take the value UNCONDITIONALLY instead
// (the reference's CurrentStatValue=null path) so a score always records; `expected` is only
// logged when it disagreed. The endpoint answers 0 either way.
public static Entry? GetEntry(ulong playerId, int statChannel, ulong roomId) =>
Entries.FindOne(x => x.PlayerId == (int)playerId && x.StatChannel == statChannel && x.RoomId == (long)roomId);
public static RequestResult SetStat(ulong playerId, int statChannel, ulong roomId, int newValue, int? expected = null)
{
try
{
var entry = GetEntry(playerId, statChannel, roomId);
var now = DateTime.UtcNow;
if (entry == null)
{
entry = new Entry
{
PlayerId = (int)playerId,
StatChannel = statChannel,
RoomId = (long)roomId,
Score = newValue,
UpdatedAt = now,
DayGainDate = now.Date,
DayGain = Math.Abs((long)newValue)
};
Entries.Insert(entry);
}
else
{
if (expected.HasValue && entry.Score != expected.Value)
Console.WriteLine($"[leaderboard] {playerId} sc{statChannel} room{roomId}: CurrentStatValue {expected} != stored {entry.Score} - writing {newValue} anyway (would have stuck otherwise)");
long gain = Math.Abs((long)newValue - entry.Score);
if (entry.DayGainDate != now.Date) { entry.DayGainDate = now.Date; entry.DayGain = 0; }
entry.DayGain += gain;
entry.UpdatedAt = now;
entry.Score = newValue;
Entries.Update(entry);
}
// Rank is computed at READ time now (see OrderedBoard), so there is nothing to
// recompute on write - and doing it descending-only here was the bug.
return RequestResult.Success;
}
catch
{
return RequestResult.InvalidStat;
}
}
// ------------------------------------------
// The board's entries in RANK ORDER, computed for THIS read.
//
// Order is decided by the caller's SortAscending, exactly as RecFlare's leaderboard
// does it: descending (highest first) by default, ascending (lowest first) for a
// lowest-wins stat like a race time, with ties broken on the LOWER player id either
// way so the order is stable. Rank (1-based) is assigned by position here, at READ
// time - so the SAME stored board reads correctly in either direction.
//
// This replaced a Rank baked DESCENDING-ONLY into every row at write time: every
// ascending stat (times, fewest-deaths) was ranked backwards - the WORST score shown
// as #1 - and no read could get the right order because the direction was already
// frozen the wrong way.
private static List<Entry> OrderedBoard(int statChannel, ulong roomId, bool sortAscending)
{
var list = Entries.Find(x => x.StatChannel == statChannel && x.RoomId == (long)roomId).ToList();
var ordered = (sortAscending
? list.OrderBy(x => x.Score)
: list.OrderByDescending(x => x.Score))
.ThenBy(x => x.PlayerId) // ties: lower id first, either direction
.ToList();
// Ranks are 0-BASED (RecFlare/live-service convention): the client ADDS ONE before it
// draws, so Rank 0 renders as "#1". Assigning i+1 here made every board show everyone
// one place too low (the winner as "#2").
for (int i = 0; i < ordered.Count; i++) ordered[i].Rank = i;
return ordered;
}
// ------------------------------------------
// Get Player Rank
// ------------------------------------------
public static Entry? GetPlayerRank(ulong playerId, int statChannel, ulong roomId, bool sortAscending = false)
{
return OrderedBoard(statChannel, roomId, sortAscending)
.FirstOrDefault(x => x.PlayerId == (int)playerId);
}
// ------------------------------------------
// Nearby Scores Window - windowSize entries either side of the caller.
// ------------------------------------------
public static List<Entry> GetNearbyScores(
ulong playerId,
int statChannel,
ulong roomId,
int windowSize,
bool sortAscending = false)
{
var board = OrderedBoard(statChannel, roomId, sortAscending);
int myIndex = board.FindIndex(x => x.PlayerId == (int)playerId);
if (myIndex < 0)
return board.Take(windowSize * 2 + 1).ToList(); // caller not on the board yet: top slice
int start = Math.Max(myIndex - windowSize, 0);
int end = Math.Min(myIndex + windowSize, board.Count - 1);
return board.GetRange(start, end - start + 1);
}
// ------------------------------------------
// Range Get - GetRanks API. RankStart/RankEnd are 0-based, inclusive (the client
// sends RankStart=0, RankEnd=9 for the top ten).
// ------------------------------------------
public static List<Entry> GetRanks(
int statChannel,
ulong roomId,
int rankStart,
int rankEnd,
bool sortAscending = false)
{
var board = OrderedBoard(statChannel, roomId, sortAscending);
int start = Math.Max(rankStart, 0);
int count = Math.Max(rankEnd - start + 1, 0);
return board.Skip(start).Take(count).ToList();
}
}
}
@@ -0,0 +1,95 @@
using LiteDB;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server.Classes;
namespace Campus_live_rewrite.api.dbs
{
public static class LinkDB
{
private static readonly string _dbPath = "Data/DBs/Links.db";
private static readonly string _collection = "actionlinks";
public static ActionLink Insert(ActionLink link)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<ActionLink>(_collection);
col.Insert(link);
return link;
}
public static ActionLink? Get(string linkIdOrCreatorCode)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<ActionLink>(_collection);
ActionLink? link = col.FindById(linkIdOrCreatorCode);
// If not found by ID, try as a creator code (case-insensitive)
if (link == null)
{
string code = linkIdOrCreatorCode.Trim().ToLowerInvariant();
var player = PlayerDB.GetInfluencerByCreatorCode(code);
if (player?.player == null) return null; // unknown link AND unknown creator code
return new ActionLink // {"creatorPlayerId":4213153,"data":"{\"Type\":6}","isValid":true}
{
creatorPlayerId = player.player.Id,
data = JsonConvert.SerializeObject(new { Type = (int)LinkType.Influencer }),
isValid = true
};
}
if (link == null)
return null;
// Automatically invalidate expired links
if (link.ExpiresAt < DateTime.UtcNow && link.isValid)
{
link.isValid = false;
col.Update(link);
}
return link;
}
public static bool Consume(string linkId, bool alwayValid = false)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<ActionLink>(_collection);
var link = col.FindById(linkId);
if (link == null || !link.isValid || link.ExpiresAt < DateTime.UtcNow)
return false;
if (!alwayValid)
{
link.isValid = false;
}
col.Update(link);
return true;
}
public static IEnumerable<ActionLink> GetAll()
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<ActionLink>(_collection);
return col.FindAll().ToList();
}
public static void DeleteLinksFromPlayerId(ulong playerId)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<ActionLink>(_collection);
try
{
col.DeleteMany(x => x.creatorPlayerId == playerId);
Console.WriteLine("deleted links from playerid " + playerId);
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}
}
}
@@ -0,0 +1,58 @@
using LiteDB;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Append-only record of moderation actions (warnings, kicks, room bans and unbans): who did
/// what to whom, where and why. The reference server keeps `warning` / `report` tables for
/// this; before now a warning here left no trace once its (0-second) block was overwritten.
/// Read by the admin panel's player view; never by the client.
/// </summary>
public static class ModerationLogDB
{
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "ModerationLog.db")));
public class Entry
{
[BsonId] public int Id { get; set; }
public string Kind { get; set; } = ""; // warning / modkick / kick / roomban / roomunban
public ulong TargetId { get; set; }
public ulong ByAccountId { get; set; }
public ulong RoomId { get; set; }
public long GameSessionId { get; set; }
public long DurationSeconds { get; set; }
public int Category { get; set; }
public string Message { get; set; } = "";
public string Note { get; set; } = "";
public DateTime At { get; set; } = DateTime.UtcNow;
}
private static ILiteCollection<Entry> Col()
{
var c = _db.Value.GetCollection<Entry>("actions");
c.EnsureIndex(x => x.TargetId);
return c;
}
public static void Record(string kind, ulong target, ulong by, ulong roomId, long gameSessionId, long durationSeconds,
Moderation_Detail.ReportCategory category, string? message, string? note = null)
{
try
{
Col().Insert(new Entry
{
Kind = kind, TargetId = target, ByAccountId = by, RoomId = roomId, GameSessionId = gameSessionId,
DurationSeconds = durationSeconds, Category = (int)category, Message = message ?? "", Note = note ?? ""
});
}
catch (Exception ex) { Console.WriteLine($"[moderation] log write failed: {ex.Message}"); }
}
public static List<Entry> ForPlayer(ulong target, int take = 50) =>
Col().Find(x => x.TargetId == target).OrderByDescending(x => x.At).Take(take).ToList();
public static List<Entry> Recent(int take = 100) =>
Col().FindAll().OrderByDescending(x => x.At).Take(take).ToList();
}
}
@@ -0,0 +1,3753 @@
using System;
using System.Numerics;
using System.Security.Principal;
using LiteDB;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using Campus_live_rewrite.Controllers;
using SixLabors.ImageSharp;
using static ImageMetadataDB;
namespace Campus_live_rewrite.api.dbs
{
public class PlayerDB
{
public static LiteDatabase PlayerDBFile = new LiteDatabase(Environment.CurrentDirectory + $"/Data/DBs/Players.db");
private static readonly Random random = new Random();
private static readonly object randLock = new object();
public static bool CreateAccount(Platforms platform, ulong platformId, string deviceId, ulong discord_id, out account_data account)
{
account = new account_data();
Console.WriteLine($"[CreateAccount] === START === platform={platform} platformId={platformId} deviceId='{deviceId}' discord_id={discord_id}");
var col = PlayerDBFile.GetCollection<player_data>("players");
var maxId = col.FindAll().OrderByDescending(x => x.id).FirstOrDefault()?.id ?? 0;
ulong accid = maxId + 1;
account.accountId = accid;
string name = Utils.GetRandomName();
Console.WriteLine($"[CreateAccount] Assigning accid={accid} (maxId was {maxId}), generated name='{name}'");
account = new account_data
{
createdAt = DateTime.UtcNow,
identityFlags = 0,
isJunior = null,
personalPronouns = 0,
platforms = 0,
displayName = name,
username = name,
profileImage = "DefaultPFP.png",
accountId = accid
};
col.Insert(new player_data
{
playerid = account.accountId,
id = accid,
platform = platform,
//platformId = platformId,
CreatedInGame = true,
deviceId = new List<string>
{
deviceId
},
authtoken = Utils.GetRandomString(400),
password = "",
player = new player_info
{
Id = accid,
Username = account.username,
DisplayName = account.displayName,
XP = 0,
Level = 1,
created_at = account.createdAt,
bannerImage = Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage,
profileImage = account.profileImage,
IsJunior = false,
AvoidJuniors = true,
PlayerReputation = new mPlayerReputation
{
Noteriety = 0,
CheerCredit = 20,
CheerGeneral = 1,
CheerHelpful = 1,
CheerGreatHost = 1,
CheerSportsman = 1,
CheerCreative = 1,
SubscriberCount = 0,
SubscribedCount = 0,
SelectedCheer = CheerCategoryEnum.None
},
PlatformIds = new List<mPlatformID>
{
new mPlatformID
{
Platform = platform,
PlatformId = platformId
}
}
},
player_Extra = new player_extra
{
DormRoomID = 1,
IpAddresses = new List<string>(),
DiscordId = discord_id,
Bio = "",
avatar = "{\"FaceFeatures\": \"\",\"HairColor\": \"\",\"OutfitSelections\": \"\",\"SkinColor\": \"\"}",
SavedOutfits = new List<avatar_data_saved>(),
//ModerationBlockDetails = new Moderation_Detail.ModerationBlockDetails()
CheeredRooms = new List<ulong>(),
FavoritedRooms = new List<ulong>(),
sub_count = 0,
SubbedTo = new List<ulong>(),
IsRecentRoomHistoryVisible = true,
Influencer = new Influencer
{
creatorCode = "",
isInfluencer = false,
},
CustomAvatarItems = new Campus_live_rewrite.api.server.Classes.db_class.CustomAvatarItems(),
},
dev_Flag = DevFlag.none
});
//Settings.SaveSettings_db(Settings.CreateDefaultSettings(), accid);
Console.WriteLine($"[CreateAccount] Row inserted. accid={accid} platform={platform} discord_id={discord_id} display='{name}' img='{account.profileImage}'");
HeartbeatDB.CreatePlayerHeartbeat(accid, out _, platform, DeviceClasses.Unknown);
Console.WriteLine($"[CreateAccount] Heartbeat created for accid={accid}");
CreateDormForPlayerId(accid);
Console.WriteLine($"[CreateAccount] Dorm created for accid={accid}. === DONE ===");
return true;
}
// ================================================================================
// WEBSITE ACCOUNT CREATION (Campus accounts are now made on the website, not in game)
//
// The game used to auto-create an account on first launch (AuthController
// grant_type=create_account). That path is disabled - accounts are made here, on the
// site, with a username + password the player chooses, and the game only ever LOGS IN
// to them. The account carries NO platform binding at creation: a browser has no Steam
// id. The Steam id is stamped on the account the first time the player signs in from
// the game (AccountLock.TryEnforceAndBind), which is what "locks the account to your
// Steam" means.
// ================================================================================
/// <summary>Case-insensitive username -> account id, or 0 if no such account.</summary>
public static ulong GetAccountIdByUsernameCI(string username)
{
if (string.IsNullOrWhiteSpace(username)) return 0;
string want = username.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
var p = col.FindAll().FirstOrDefault(x =>
x?.player?.Username != null &&
string.Equals(x.player.Username, want, StringComparison.OrdinalIgnoreCase));
return p?.playerid ?? 0;
}
/// <summary>Case-insensitive: is this exact username already in use?</summary>
public static bool IsUsernameTaken(string username)
{
if (string.IsNullOrWhiteSpace(username)) return true;
string want = username.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll().Any(p =>
p?.player?.Username != null &&
string.Equals(p.player.Username, want, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Create an account from the website. No device id, no platform binding - those are
/// stamped on the first in-game login. The account is marked onboarding-complete so the
/// game drops the player straight in (no first-run "make a room" / username / password
/// flow) since they already chose all of that here.
/// </summary>
public static bool CreateWebAccount(string username, string displayName, string password,
string? email, out account_data account, out string error,
bool isJunior = false, string? birthday = null)
{
account = new account_data();
error = "";
username = (username ?? "").Trim();
displayName = string.IsNullOrWhiteSpace(displayName) ? username : displayName.Trim();
if (IsUsernameTaken(username))
{
error = "That username is already taken.";
return false;
}
var col = PlayerDBFile.GetCollection<player_data>("players");
var maxId = col.FindAll().OrderByDescending(x => x.id).FirstOrDefault()?.id ?? 0;
ulong accid = maxId + 1;
account = new account_data
{
createdAt = DateTime.UtcNow,
identityFlags = 0,
isJunior = isJunior,
personalPronouns = 0,
platforms = 0,
displayName = displayName,
username = username,
profileImage = "DefaultPFP.png",
accountId = accid
};
col.Insert(new player_data
{
playerid = accid,
id = accid,
platform = Platforms.All,
CreatedInGame = false,
deviceId = new List<string>(), // bound on first in-game login
authtoken = Utils.GetRandomString(400),
password = ClientSecurity.HashPassword(password),
player = new player_info
{
Id = accid,
Username = username,
DisplayName = displayName,
Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
Birthday = string.IsNullOrWhiteSpace(birthday) ? null : birthday.Trim(),
XP = 0,
Level = 1,
created_at = account.createdAt,
bannerImage = Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage,
profileImage = account.profileImage,
IsJunior = isJunior,
// Juniors are placed among juniors (don't avoid them); adults avoid juniors by default.
AvoidJuniors = !isJunior,
PlayerReputation = new mPlayerReputation
{
Noteriety = 0,
CheerCredit = 20,
CheerGeneral = 1,
CheerHelpful = 1,
CheerGreatHost = 1,
CheerSportsman = 1,
CheerCreative = 1,
SubscriberCount = 0,
SubscribedCount = 0,
SelectedCheer = CheerCategoryEnum.None
},
PlatformIds = new List<mPlatformID>() // NO steam id yet
},
player_Extra = new player_extra
{
DormRoomID = 1,
IpAddresses = new List<string>(),
DiscordId = 0,
Bio = "",
avatar = "{\"FaceFeatures\": \"\",\"HairColor\": \"\",\"OutfitSelections\": \"\",\"SkinColor\": \"\"}",
SavedOutfits = new List<avatar_data_saved>(),
CheeredRooms = new List<ulong>(),
FavoritedRooms = new List<ulong>(),
sub_count = 0,
SubbedTo = new List<ulong>(),
IsRecentRoomHistoryVisible = true,
Influencer = new Influencer { creatorCode = "", isInfluencer = false },
CustomAvatarItems = new Campus_live_rewrite.api.server.Classes.db_class.CustomAvatarItems(),
},
dev_Flag = DevFlag.none
});
HeartbeatDB.CreatePlayerHeartbeat(accid, out _, Platforms.All, DeviceClasses.Unknown);
CreateDormForPlayerId(accid);
// Mark first-run onboarding + tutorial complete so the game goes straight in - the
// player already chose username/password on the website (mirrors the Kyle seed in
// Program.cs and SettingsDB.CreateDefaultSettings).
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasStarted", "true", accid);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasFinished", "true", accid);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasChosenUsername", "true", accid);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasCreatedPassword","true", accid);
SettingsDB.SetPlayerSetting("TUTORIAL_COMPLETE_MASK", "4294967295", accid);
Console.WriteLine($"[web-signup] created account {accid} @{username} (\"{displayName}\")"
+ (email != null ? " with email" : ""));
return true;
}
/// <summary>
/// The account's bound platform id for a platform (0 if none). Ignores the placeholder
/// 0 entry that some legacy rows carry.
/// </summary>
public static ulong GetAccountPlatformId(ulong accid, Platforms platform)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var p = col.FindOne(x => x.playerid == accid);
var ids = p?.player?.PlatformIds;
if (ids == null) return 0;
var match = ids.FirstOrDefault(pid => pid.Platform == platform && pid.PlatformId != 0);
return match?.PlatformId ?? 0;
}
/// <summary>Append a platform id to the account if it is not already present.</summary>
public static void AddPlatformId(ulong accid, Platforms platform, ulong platformId)
{
if (platformId == 0) return;
var col = PlayerDBFile.GetCollection<player_data>("players");
var p = col.FindOne(x => x.playerid == accid);
if (p?.player == null) return;
p.player.PlatformIds ??= new List<mPlatformID>();
if (p.player.PlatformIds.Any(pid => pid.Platform == platform && pid.PlatformId == platformId))
return;
p.player.PlatformIds.Add(new mPlatformID { Platform = platform, PlatformId = platformId });
col.Update(p);
Console.WriteLine($"[account-lock] bound {platform} id {platformId} to account {accid}");
}
/// <summary>Every account carrying this platform id (used by the one-account-per-Steam lock).</summary>
public static List<ulong> AccountsWithPlatformId(Platforms platform, ulong platformId)
{
if (platformId == 0) return new List<ulong>();
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll()
.Where(x => x.player?.PlatformIds != null &&
x.player.PlatformIds.Any(pid => pid.Platform == platform && pid.PlatformId == platformId))
.Select(x => x.playerid)
.ToList();
}
// ---- website signup rate limit -----------------------------------------------------
// One successful account creation per IP per ConfigCampus.SignupRateLimitDays (default 7).
// Persisted so a service restart does not reset the window. A tiny row per signup - the
// volume is human-scale, not request-scale.
public class WebSignupRecord
{
public int Id { get; set; }
public string? Ip { get; set; }
public ulong AccountId { get; set; }
public DateTime CreatedAt { get; set; }
}
/// <summary>The limiter's verdict: which layer refused, and when it frees up.</summary>
public enum SignupLimit { None, Ip, Subnet, Global }
/// <summary>
/// May this IP register right now? Checks, in order, the global per-hour cap, the per-IP
/// day/week caps and the /24 subnet cap (see ConfigCampus.SignupsPer*). On refusal says
/// which layer refused and the earliest time it frees up, so the caller can tell the
/// player something true. Blank/loopback/allowlisted IPs skip the per-address layers.
/// </summary>
public static SignupLimit CheckSignupLimit(string? ip, out DateTime nextAllowedUtc)
{
var now = DateTime.UtcNow;
nextAllowedUtc = now;
// Admin-allowlisted IPs test signup flows without tripping the caps (see
// ConfigCampus.AdminRateLimitEnabled).
if (AdminProtectionMiddleware.IsLimitExempt(ip)) return SignupLimit.None;
var col = PlayerDBFile.GetCollection<WebSignupRecord>("web_signups");
col.EnsureIndex(r => r.Ip);
col.EnsureIndex(r => r.CreatedAt);
// Global cap. This is a MASS-CREATION circuit breaker, not a growth cap, and for a
// long time it behaved as the latter: at 15/hour across every address on the internet
// it was the ONLY limit that ever fired. In one 12-hour window it refused 399 signups
// - 120 of them headsets retrying create_account, 34 distinct devices - while letting
// 19 through, and not one refusal came from the per-IP or per-subnet caps. Every real
// player who arrived after the 15th that hour was told "try again later"; on Quest
// that is a hard login failure the client then retries every ~8 s forever.
//
// So volume alone no longer trips it. Mass creation is CONCENTRATED - one actor behind
// a handful of networks - because the per-IP (1/day) and per-/24 (4/day) caps already
// bound what any single network can produce. Organic growth is DIFFUSE: a hundred
// strangers on a hundred networks. The breaker now fires only when an hour is both
// over the ceiling AND coming from few enough networks to be one actor.
if (ConfigCampus.SignupsPerHour > 0)
{
var hourAgo = now.AddHours(-1);
var lastHour = col.Find(r => r.CreatedAt >= hourAgo).OrderBy(r => r.CreatedAt).ToList();
if (lastHour.Count >= ConfigCampus.SignupsPerHour)
{
// Distinct networks behind the hour. An unattributable IP counts as its own
// network rather than being pooled with the other unattributable ones, so a
// flood cannot look diffuse just by stripping the address.
int networks = lastHour
.Select(r => SubnetKey(r.Ip) ?? ("ip:" + (r.Ip ?? "")))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
if (networks < ConfigCampus.SignupBurstDistinctNetworks)
{
nextAllowedUtc = lastHour[lastHour.Count - ConfigCampus.SignupsPerHour].CreatedAt.AddHours(1);
Console.WriteLine($"[signup-guard] GLOBAL cap tripped: {lastHour.Count} signups in the last hour " +
$"from only {networks} distinct network(s) - mass creation, not growth");
return SignupLimit.Global;
}
Console.WriteLine($"[signup-guard] {lastHour.Count} signups in the last hour across {networks} " +
$"distinct networks - over the ceiling but diffuse, allowed");
}
}
if (string.IsNullOrWhiteSpace(ip)) return SignupLimit.None;
ip = ip.Trim();
if (ip == "127.0.0.1" || ip == "::1" || ip == "localhost") return SignupLimit.None;
if (ConfigCampus.SignupExemptIps != null && ConfigCampus.SignupExemptIps.Contains(ip)) return SignupLimit.None;
var weekAgo = now.AddDays(-7);
var dayAgo = now.AddDays(-1);
var mine = col.Find(r => r.Ip == ip && r.CreatedAt >= weekAgo).OrderBy(r => r.CreatedAt).ToList();
if (ConfigCampus.SignupsPerIpPerWeek > 0 && mine.Count >= ConfigCampus.SignupsPerIpPerWeek)
{
nextAllowedUtc = mine[mine.Count - ConfigCampus.SignupsPerIpPerWeek].CreatedAt.AddDays(7);
return SignupLimit.Ip;
}
var mineToday = mine.Where(r => r.CreatedAt >= dayAgo).ToList();
if (ConfigCampus.SignupsPerIpPerDay > 0 && mineToday.Count >= ConfigCampus.SignupsPerIpPerDay)
{
nextAllowedUtc = mineToday[mineToday.Count - ConfigCampus.SignupsPerIpPerDay].CreatedAt.AddDays(1);
return SignupLimit.Ip;
}
// Network block - /24 for IPv4, /64 for IPv6. Same window as the per-IP day cap.
string? block = SubnetKey(ip);
if (block != null && ConfigCampus.SignupsPerSubnetPerDay > 0)
{
// Compare on the computed KEY, not a text prefix: one IPv6 address can be written
// several ways ("2001:db8::1" / "2001:db8:0:0::1"), so matching the stored string
// missed hosts that a real /64 comparison catches.
var inBlock = col.Find(r => r.CreatedAt >= dayAgo)
.Where(r => string.Equals(SubnetKey(r.Ip), block, StringComparison.OrdinalIgnoreCase))
.OrderBy(r => r.CreatedAt).ToList();
if (inBlock.Count >= ConfigCampus.SignupsPerSubnetPerDay)
{
nextAllowedUtc = inBlock[inBlock.Count - ConfigCampus.SignupsPerSubnetPerDay].CreatedAt.AddDays(1);
return SignupLimit.Subnet;
}
}
return SignupLimit.None;
}
/// <summary>
/// The network an address belongs to: the /24 for IPv4, the /64 for IPv6, null when the
/// string is not an address at all.
///
/// IPv6 used to return null, so an IPv6 client had NO subnet cap - the per-/24 rule simply
/// did not apply to it and the global hourly ceiling was the only thing covering that
/// hole, which is part of why the ceiling had to be set so low. A /64 is what a
/// residential customer is handed, so it is the IPv6 equivalent of the /24 here.
/// </summary>
private static string? SubnetKey(string? ip)
{
if (string.IsNullOrWhiteSpace(ip)) return null;
if (!System.Net.IPAddress.TryParse(ip.Trim(), out var addr)) return null;
var b = addr.GetAddressBytes();
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
return "v4:" + b[0] + "." + b[1] + "." + b[2];
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
{
var sb = new System.Text.StringBuilder("v6:");
for (int i = 0; i < 8; i++) sb.Append(b[i].ToString("x2")); // the /64 prefix
return sb.ToString();
}
return null;
}
public static void RecordSignup(string? ip, ulong accountId)
{
var col = PlayerDBFile.GetCollection<WebSignupRecord>("web_signups");
col.Insert(new WebSignupRecord
{
Ip = string.IsNullOrWhiteSpace(ip) ? "" : ip.Trim(),
AccountId = accountId,
CreatedAt = DateTime.UtcNow
});
}
public static bool ImportPlayers(string importPath)
{
Console.WriteLine($"[IMPORT] Starting player import");
Console.WriteLine($"[IMPORT] Import path: {importPath}");
try
{
if (!File.Exists(importPath))
{
Console.WriteLine($"[ERROR] Import file not found: {importPath}");
return false;
}
Console.WriteLine("[IMPORT] Reading import file...");
var json = File.ReadAllText(importPath);
Console.WriteLine($"[IMPORT] File size: {json.Length} bytes");
Console.WriteLine("[IMPORT] Deserializing player data...");
var importedPlayers = JsonConvert.DeserializeObject<List<player_data>>(json);
if (importedPlayers == null)
{
Console.WriteLine("[ERROR] Deserialization returned null (invalid JSON?)");
return false;
}
if (importedPlayers.Count == 0)
{
Console.WriteLine("[WARN] Import file contained 0 players");
return false;
}
Console.WriteLine($"[IMPORT] Players found in file: {importedPlayers.Count}");
var col = PlayerDBFile.GetCollection<player_data>("players");
int inserted = 0;
int skipped = 0;
foreach (var player in importedPlayers)
{
if (player == null)
{
Console.WriteLine("[WARN] Encountered null player entry, skipping");
skipped++;
continue;
}
Console.WriteLine($"[IMPORT] Processing player ID: {player.id}");
try
{
// Avoid duplicate IDs
if (col.Exists(x => x.id == player.id))
{
Console.WriteLine($"[SKIP] Duplicate player ID {player.id}");
skipped++;
continue;
}
player.player_Extra.DormRoomID = RoomDB.clone_dorm_room(player.playerid);
col.Insert(player);
var name = player.player.Username ?? "<unknown>";
var displayName = player.player.DisplayName ?? "<unknown>";
var displayEmoji = player.player.DisplayEmoji ?? "";
Console.WriteLine($"[OK] Inserted player ({player.id}) @{name} {displayName} {displayEmoji}");
inserted++;
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to insert player ID {player.id}: {ex.Message}");
skipped++;
}
}
Console.WriteLine("[IMPORT] Import completed");
Console.WriteLine($"[IMPORT] Inserted: {inserted}");
Console.WriteLine($"[IMPORT] Skipped: {skipped}");
Console.WriteLine($"[IMPORT] Total processed: {importedPlayers.Count}");
return inserted > 0;
}
catch (JsonException jex)
{
Console.WriteLine($"[ERROR] JSON parsing error: {jex.Message}");
return false;
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Import failed with exception: {ex}");
return false;
}
}
public static bool ExportPlayers(string exportPath)
{
try
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var allPlayers = col.FindAll().ToList();
var json = JsonConvert.SerializeObject(allPlayers, Formatting.Indented);
File.WriteAllText(exportPath, json);
Console.WriteLine($"Exported {allPlayers.Count} players to {exportPath}");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Export failed: {ex.Message}");
return false;
}
}
public static bool CreateDormForPlayerId(ulong playerid, bool log = false)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
// Was: no null check, so an unknown player id threw here instead of
// reporting failure to the caller.
if (player == null)
{
Console.WriteLine($"[CreateDormForPlayerId] no player row for id {playerid}");
return false;
}
if (player.player_Extra != null)
{
ulong roomid = RoomDB.clone_dorm_room(playerid);
player.player_Extra.DormRoomID = roomid;
col.Update(player);
if (log)
{
Console.WriteLine($"Room id is {roomid} and successfully updaeted player");
}
return true;
}
return false;
}
public static void EnsureDormsForAllPlayers()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
// Get all players
var players = col.FindAll();
foreach (var player in players)
{
if (player?.player == null || player.player_Extra == null)
continue;
// Missing or invalid dorm
if (player.player_Extra.DormRoomID == null || player.player_Extra.DormRoomID == 1)
{
ulong roomId = RoomDB.clone_dorm_room(player.player.Id);
player.player_Extra.DormRoomID = roomId;
col.Update(player);
Console.WriteLine($"Created dorm {roomId} for player {player.player.Id}");
}
}
}
public static void EnsureDormsDontBeStupid(bool log = false)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var players = col.FindAll();
int fixCount = 0;
foreach (var player in players)
{
if (player?.player == null || player.player_Extra == null)
continue;
var room = RoomDB.GetRoom((ulong)player.player_Extra.DormRoomID);
// Check if the room is missing or simply isn't flagged as a Dorm
if (room == null || !room.IsDorm)
{
if (log)
{
string reason = (room == null) ? "Room Missing" : "Room not a Dorm";
Console.WriteLine($"[DormFix] Player: {player.player.Username} ({player.player.Id}) | Reason: {reason}");
}
CreateDormForPlayerId(player.player.Id);
fixCount++;
}
}
if (log && fixCount > 0)
{
Console.WriteLine($"[DormFix] Finished. Fixed {fixCount} player dorm records.");
}
}
public static ulong GetDormId(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (playerid == 1)
{
return 1;
}
// Was: dereferenced player.player_Extra with no null check, so an unknown
// player id threw NullReferenceException here.
if (player == null)
{
Console.WriteLine($"[GetDormId] no player row for id {playerid} - no dorm");
return 0;
}
if (player.player_Extra != null)
{
return (ulong)player.player_Extra.DormRoomID;
}
// Was: `return 1`. That is player 1's dorm, so any player whose row had no
// player_Extra was silently handed SOMEONE ELSE'S dorm room. 0 means "no
// dorm yet" and makes the caller create a real one.
Console.WriteLine($"[GetDormId] player {playerid} has no player_Extra - no dorm assigned yet");
return 0;
}
public static bool GetLogins(Platforms platform, ulong platformId, out List<AccountLogin> accounts)
{
accounts = new List<AccountLogin>();
var col = PlayerDBFile.GetCollection<player_data>("players");
// Pull all players (or consider filtering by another simple field if possible)
var allPlayers = col.FindAll().ToList();
// Filter in memory
var players = allPlayers
.Where(player => player.player.PlatformIds != null &&
player.player.PlatformIds.Any(pid => pid.Platform == platform &&
pid.PlatformId == platformId))
.ToList();
if (players.Count == 0)
return false;
foreach (var player in players)
{
var pid = player.player.PlatformIds.First(p => p.Platform == platform &&
p.PlatformId == platformId);
accounts.Add(new AccountLogin
{
accountId = player.playerid,
createdAt = player.player.created_at,
lastLoginTime = player.player.last_login_time,
platform = (Platforms)pid.Platform,
platformId = pid.PlatformId.ToString()
});
}
accounts = accounts
.OrderByDescending(a => a.lastLoginTime)
.ToList();
return true;
}
// A synthesized stand-in for an account id that has no DB row. Mirrors RecFlare's
// defaultAccount(): a "Player{id}" placeholder so the client can render a card instead
// of choking on a 404 / missing entry. The account_data_web getters supply the rest of
// the coalesced defaults (profileImage -> DefaultProfileImage.jpg, banner/emoji -> "").
private static account_data_web DefaultAccountWeb(ulong id)
{
// The login placeholder (see PlatformIdentity.LoginPlaceholderAccountId): the headset's
// cached-login picker asks for this id to label the "type your password" entry. Answer
// with a neutral identity, not "Player0" - that read as a real account failing to log in.
if (id == Campus_live_rewrite.api.server.PlatformIdentity.LoginPlaceholderAccountId)
return new account_data_web
{
accountId = id,
createdAt = DateTime.UtcNow,
username = Campus_live_rewrite.api.server.PlatformIdentity.LoginPlaceholderUsername,
displayName = Campus_live_rewrite.api.server.PlatformIdentity.LoginPlaceholderDisplayName,
profileImage = "DefaultPFP.png",
isJunior = false,
identityFlags = 0,
personalPronouns = 0,
platforms = 1 // Oculus - the platform the placeholder is served on
};
return new account_data_web
{
accountId = id,
createdAt = DateTime.UtcNow,
username = $"Player{id}",
displayName = $"Player{id}",
isJunior = false,
identityFlags = 0,
personalPronouns = 0,
platforms = 0
};
}
// A valid, non-negative `platforms` value for an account DTO. The account's primary
// platform int (Steam=0, Oculus=1, ...), or 0 if none is known. NEVER returns -1: the
// old code seeded a Platforms.All (-1) accumulator and OR'd onto it, so it was always
// -1, and -1 is not a real platform - the client's strict account parser rejects it and
// then NO player card / profile renders (own or others'). Same class as statusVisibility=4
// and deviceClass=-1.
public static long PrimaryPlatformValue(player_data? player)
{
var firstReal = player?.player?.PlatformIds?
.Select(p => (int)p.Platform)
.FirstOrDefault(v => v >= 0) ?? -1;
if (firstReal >= 0) return firstReal;
if (player != null && (int)player.platform >= 0) return (int)player.platform;
return 0;
}
public static string GetAccountsBulk(List<ulong> playerIds)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
col.EnsureIndex(x => x.id);
// ONE entry per requested id, in the requested set. A missing row is SYNTHESIZED
// (a Player{id} default), never dropped or 404'd - otherwise a profile / friends
// list / player list that references a deleted or never-existed id (e.g. the purged
// orphan ids 2,3,5) renders that card BLANK. This is exactly why single-account
// reads were 404ing (AccountById -> here -> empty array -> 404). Matches RecFlare's
// bulk, which guarantees one entry per requested id via defaultAccount().
var requested = playerIds?.Distinct().ToList() ?? new List<ulong>();
if (requested.Count == 0) return "[]";
var found = col.Find(x => requested.Contains(x.playerid))
.Where(p => p?.player != null)
.GroupBy(p => p.playerid)
.ToDictionary(g => g.Key, g => g.First());
var accounts = requested
.Select(id =>
{
if (!found.TryGetValue(id, out var player))
return DefaultAccountWeb(id);
// `platforms` MUST be a valid, non-negative number. It used to start at
// Platforms.All (-1) and OR onto it, so it was ALWAYS -1 - and -1 is not a
// real platform. The RecFlare account DTO emits a plain small number (0 by
// default; the reference never combines PlatformIds into it). A -1 here is
// the same out-of-range trap as statusVisibility=4 / deviceClass=-1: the
// client's strict account parser chokes on it and NO player card / profile
// renders (own or others'). Emit the account's primary platform int (Steam=0,
// Oculus=1, ...), defaulting to 0, never -1.
long platformsValue = 0;
var firstReal = player.player.PlatformIds?
.Select(p => (int)p.Platform)
.FirstOrDefault(v => v >= 0) ?? -1;
if (firstReal >= 0) platformsValue = firstReal;
else if ((int)player.platform >= 0) platformsValue = (int)player.platform;
return new account_data_web
{
accountId = player.playerid,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
displayEmoji = player.player.DisplayEmoji,
username = player.player.Username,
identityFlags = player.player.identityFlags,
isJunior = player.player.IsJunior,
personalPronouns = player.player.pronounFlags,
platforms = platformsValue,
profileImage = player.player.profileImage,
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage
};
})
.OrderBy(a => a.accountId)
.ToList();
return JsonConvert.SerializeObject(accounts);
}
public static string GetAccountsBulkAdmin(List<ulong> playerIds)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
col.EnsureIndex(x => x.id);
var players = col.Find(x => playerIds.Contains(x.playerid)).ToList();
if (players.Count == 0)
{
return "[]";
}
var accounts = players
.Select(player =>
{
return new
{
accountId = player.playerid,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
displayEmoji = player.player.DisplayEmoji,
username = player.player.Username,
identityFlags = player.player.identityFlags,
isJunior = player.player.IsJunior,
personalPronouns = player.player.pronounFlags,
platforms = PrimaryPlatformValue(player),
profileImage = player.player.profileImage,
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage,
bio = player.player_Extra.Bio,
mbd = JsonConvert.DeserializeObject(PlayerDB.Get_moderation_block(player.playerid)),
PlatformIDs = player.player.PlatformIds.Select(p => new
{
p.Platform,
PlatformId = p.PlatformId.ToString(),
// Username = Steam.GetAccountInfoAsync(p.PlatformId.ToString()).Result?.PersonaName ?? ""
}),
level = player.player.Level,
xp = player.player.XP
};
})
.OrderBy(a => a.accountId)
.ToList();
return JsonConvert.SerializeObject(accounts);
}
public static player_data? getfullaccount_token(ulong playerid, ulong platformid, int platform)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return null;
var ids = player.player.PlatformIds;
if (ids == null || ids.Count == 0)
return null;
bool match = ids.Any(p => p.PlatformId == platformid);
return match ? player : null;
}
/*public static player_data? GetFullAccount(ulong playerid, ulong plaformid, int plaform, string device_id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
if (player.platformId == plaformid && player.deviceId.Contains(device_id))
return player;
return null;
}
*/
public static player_data? GetFullAccount(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
return player;
}
// ---- Currency balances ------------------------------------------------
// Stored per player in player_extra.Balances (CurrencyType -> amount).
public static long GetBalance(ulong playerId, int currencyType)
{
var acc = GetFullAccount(playerId);
if (acc?.player_Extra?.Balances == null) return 0;
return acc.player_Extra.Balances.TryGetValue(currencyType, out var v) ? v : 0;
}
public static Dictionary<int, long> GetAllBalances(ulong playerId)
{
var acc = GetFullAccount(playerId);
return acc?.player_Extra?.Balances ?? new Dictionary<int, long>();
}
/// <summary>Adds (or subtracts) an amount and returns the NEW balance. Clamped at 0.</summary>
public static long AddBalance(ulong playerId, int currencyType, long delta)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player == null) return 0;
player.player_Extra ??= new player_extra();
player.player_Extra.Balances ??= new Dictionary<int, long>();
long current = player.player_Extra.Balances.TryGetValue(currencyType, out var v) ? v : 0;
long updated = current + delta;
if (updated < 0) updated = 0;
player.player_Extra.Balances[currencyType] = updated;
col.Update(player);
return updated;
}
public static player_data? GetFullAccountByName(string username)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Username == username);
if (player == null)
{
return null;
}
return player;
}
public static player_info? GetAccountById(ulong id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindById(id);
return player?.player;
}
public static account_data_me? GetAccountMeSafe(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
// See PrimaryPlatformValue: a valid, non-negative platform number, NEVER -1 (which
// is what the old Platforms.All accumulator always produced and which breaks the
// client's account parser - the owner's OWN /account/me card wouldn't render).
account_data_me account = new account_data_me
{
accountId = player.playerid,
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
email = player.player.Email,
displayEmoji = player.player.DisplayEmoji,
username = player.player.Username,
identityFlags = player.player.identityFlags,
isJunior = player.player.IsJunior,
// Juniors are treated as juniors client-side (chat/social restrictions).
TreatAsJunior = player.player.IsJunior,
personalPronouns = player.player.pronounFlags,
platforms = PrimaryPlatformValue(player),
profileImage = player.player.profileImage,
};
// The client requires a present, valid birthday. Use the real one entered at signup
// when we have it; otherwise keep the fixed placeholder the DTO defaults to.
if (!string.IsNullOrWhiteSpace(player.player.Birthday))
{
account.birthday = player.player.Birthday;
account.hasBirthday = true;
}
else if (string.Equals(SettingsDB.GetPlayerSettingValue("Recroom.AccountCreation.HasFinished", player.playerid), "false", StringComparison.OrdinalIgnoreCase))
{
// An in-game account still in its creation flow with no birthday on record: tell
// the client so its title-screen account-creation flow (birthday -> avatar ->
// username -> password) has a page it cannot skip. Website accounts entered a
// birthday at signup and never hit this.
account.hasBirthday = false;
}
/*
account_data_me account = new account_data_me
{
accountId = player.playerid,
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
displayEmoji = player.player.DisplayEmoji,
username = player.player.Username,
identityFlags = player.player.identityFlags,
isJunior = player.player.IsJunior,
personalPronouns = player.player.pronounFlags,
platforms = (long)combinedPlatforms,
profileImage = player.player.profileImage,
};
*/
return account;
}
public static string Get_moderation_block(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return JsonConvert.SerializeObject(new Moderation_Detail.ModerationBlockDetails());
}
var block = player.player_Extra.ModerationBlockDetails;
// Only an ACTIVE penalty is a moderation block. A stored record with Duration <= 0 (or
// no set-time) is NOT active - e.g. a logged report with no penalty applied
// (IsBan=false, Duration=0). The client counts ANY non-empty moderationBlockDetails as a
// recent moderation incident and then blocks club/room creation ("Creation Restricted -
// too many moderation incidents"), so a non-active record MUST read back as empty.
if (block == null || block.Duration <= 0 || block.ModerationSetUnixTime <= 0)
return JsonConvert.SerializeObject(new Moderation_Detail.ModerationBlockDetails());
// Permanent ban.
if (block.Duration == int.MaxValue)
return JsonConvert.SerializeObject(block);
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int elapsed = (int)(now - block.ModerationSetUnixTime);
int remaining = block.Duration - elapsed;
if (remaining <= 0)
{
// Timeout has run out - clear it and report no block.
player.player_Extra.ModerationBlockDetails = null;
col.Update(player);
return JsonConvert.SerializeObject(new Moderation_Detail.ModerationBlockDetails());
}
// Still serving time: report the block with the time that's left.
block.Duration = remaining;
return JsonConvert.SerializeObject(block);
}
public static string Set_moderation_block(
ulong playerid,
Moderation_Detail.ReportCategory category,
int duration,
long gameSessionId,
bool? isHostKick,
string? message,
ulong? playerIdReporter,
bool? isBan,
ulong bannedByPlayerId,
bool addAppealMessage = true
)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return "{}";
if (addAppealMessage && isBan == true)
{
message += "\nTo appeal this ban, Make a ticket in https://discord.gg/xH8MMwK5Bk.";
}
var newBlock = new Moderation_Detail.ModerationBlockDetails
{
ReportCategory = category,
Duration = duration,
GameSessionId = gameSessionId,
IsHostKick = isHostKick,
Message = message,
PlayerIdReporter = playerIdReporter,
IsBan = isBan,
ModerationSetUnixTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
BannedByPlayerId = bannedByPlayerId
};
player.player_Extra.ModerationBlockDetails = newBlock;
col.Update(player);
// Every moderation action is logged with WHO did it to WHOM and why. Without this
// there is no way to tell a real moderator action from a bug calling this path -
// and a 1-second isBan looks identical to "the game keeps closing itself" from the
// player's side, because the block clears before anyone can inspect it.
Console.WriteLine(
$"[moderation] target={playerid} by={bannedByPlayerId} " +
$"isBan={isBan} isHostKick={isHostKick} duration={duration}s " +
$"category={category} reporter={playerIdReporter?.ToString() ?? "-"} " +
$"msg=\"{(message ?? "").Replace("\n", " ")}\"");
// A ban has to actually REMOVE them from the room.
//
// The client drops to its dorm and then resyncs against the presence heartbeat
// - and the heartbeat still said "you are in room X", so it put the banned
// player straight back into the room they were just banned from. Clearing the
// room instance means the resync has nowhere to send them.
if (isBan == true)
{
try
{
HeartbeatDB.UpdatePlayerHeartbeat(playerid, null, online: false);
Console.WriteLine($"[ban] cleared room instance for banned player {playerid}");
}
catch (Exception ex)
{
Console.WriteLine($"[ban] could not clear heartbeat for {playerid}: {ex.Message}");
}
}
try
{
Notifications.SendToPlayer(playerid, JsonConvert.SerializeObject(WebsocketEvents.CreateBan(newBlock)));
}
catch { }
return JsonConvert.SerializeObject(newBlock);
}
public static bool HasActiveModerationBlock(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return false;
var block = player.player_Extra.ModerationBlockDetails;
if (block == null || block.Duration <= 0 || block.ModerationSetUnixTime <= 0)
return false;
if (block.Duration == int.MaxValue) // Permanent ban
return true;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int elapsed = (int)(now - block.ModerationSetUnixTime);
int remaining = block.Duration - elapsed;
if (remaining <= 0)
{
return false;
}
return true;
}
public static bool CheckDevFlag(ulong id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.id == id);
if (player != null && (player.dev_Flag & DevFlag.dev) == DevFlag.dev)
{
return true;
}
return false;
}
public static bool CheckModFlag(ulong id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.id == id);
if (player != null && (player.dev_Flag & DevFlag.mod) == DevFlag.mod)
{
return true;
}
return false;
}
public static void set_player_avatar(string avatar, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
// Was: guarded `player_Setting1.player != null` but then wrote to
// player_Setting1.player_Extra - the wrong field. A row with a null
// player_Extra threw NullReferenceException while saving the avatar.
if (player_Setting1?.player_Extra != null)
{
player_Setting1.player_Extra.avatar = avatar;
col.Update(player_Setting1);
return;
}
Console.WriteLine($"[set_player_avatar] no player_Extra for {playerid} - avatar not saved");
}
public static (bool Cheered, bool Favorited) GetInteraction(ulong playerid, ulong roomid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return (false, false);
bool cheered = player.player_Extra.CheeredRooms?.Contains(roomid) ?? false;
bool favorited = player.player_Extra.FavoritedRooms?.Contains(roomid) ?? false;
return (cheered, favorited);
}
public static bool ToggleCheer(ulong playerid, ulong roomid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null) return false;
if (player.player_Extra.CheeredRooms == null)
player.player_Extra.CheeredRooms = new List<ulong>();
bool nowCheered;
if (player.player_Extra.CheeredRooms.Contains(roomid))
{
player.player_Extra.CheeredRooms.Remove(roomid);
nowCheered = false;
RoomDB.UpdateRoomCheerCount(roomid, -1);
}
else
{
player.player_Extra.CheeredRooms.Add(roomid);
nowCheered = true;
RoomDB.UpdateRoomCheerCount(roomid, +1);
}
col.Update(player);
return nowCheered;
}
public static bool ToggleFavorite(ulong playerid, ulong roomid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null) return false;
if (player.player_Extra.FavoritedRooms == null)
player.player_Extra.FavoritedRooms = new List<ulong>();
bool nowFavorited;
if (player.player_Extra.FavoritedRooms.Contains(roomid))
{
player.player_Extra.FavoritedRooms.Remove(roomid);
nowFavorited = false;
RoomDB.UpdateRoomFavoriteCount(roomid, -1);
}
else
{
player.player_Extra.FavoritedRooms.Add(roomid);
nowFavorited = true;
RoomDB.UpdateRoomFavoriteCount(roomid, +1);
}
col.Update(player);
return nowFavorited;
}
public static List<account_data> Search(string text)
{
// GET /Accounts/account/search with no `name` query parameter passes null
// here, and the first thing the loop does is text.StartsWith("@") - an
// unhandled NullReferenceException that Kestrel turns into a 500.
// Verified live: probing the route with no argument returned HTTP 500.
// An empty search matches nothing, so return an empty list.
if (string.IsNullOrWhiteSpace(text))
return new List<account_data>();
// Match on USERNAME first (the reference server prefix-matches username, and the
// People search is by @handle), and also on DISPLAY NAME so searching the shown name
// works too. Case-insensitive Contains, which is a superset of the reference's prefix
// match. A leading "@" is stripped so "@eli" and "eli" behave the same.
//
// The previous version only checked username when the query STARTED WITH "@", and
// otherwise matched display name ONLY - so searching a plain "eli" (which is a
// USERNAME) never checked usernames and returned []. It also left `found=true` when a
// row had a null DisplayName, which quietly matched every such account.
string q = text.Trim().TrimStart('@').ToLowerInvariant();
if (q.Length == 0)
return new List<account_data>();
var col = PlayerDBFile.GetCollection<player_data>("players");
List<account_data> list = new List<account_data>();
foreach (player_data room in col.FindAll())
{
var p = room?.player;
if (p == null)
continue;
bool matchUsername = !string.IsNullOrEmpty(p.Username)
&& p.Username.ToLowerInvariant().Contains(q);
bool matchDisplay = !string.IsNullOrEmpty(p.DisplayName)
&& p.DisplayName.ToLowerInvariant().Contains(q);
if (!matchUsername && !matchDisplay)
continue;
var room_data = GetAccountBulk(room.playerid);
if (room_data is null)
continue;
list.Add(room_data);
}
return list;
}
public static account_data GetAccountBulk(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
return new account_data
{
accountId = player.playerid,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
username = player.player.Username,
identityFlags = 0,
isJunior = player.player.IsJunior,
personalPronouns = 0,
platforms = PrimaryPlatformValue(player),
profileImage = player.player.profileImage,
};
}
public static bool SetPlayerUsername(string username, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
// Case-INSENSITIVE: "kyle" next to "Kyle" is the whole point of a spoofed @name.
var existingplayer = col.FindAll().FirstOrDefault(x => x.player?.Username != null
&& string.Equals(x.player.Username, username, StringComparison.OrdinalIgnoreCase) && x.playerid != playerid);
if (existingplayer != null)
{
return false;
}
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.Username = username;
col.Update(player_Setting1);
RoomDB.RenameDormForPlayer(playerid);
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
return true;
}
}
return false;
}
/// <summary>
/// Every platform id linked to this account for a platform, in the order stored.
/// An account may hold several - a player with two Steam accounts signs in on
/// either and lands here.
/// </summary>
public static List<ulong> GetPlatformIds(ulong playerid, Platforms platform)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var row = col.FindOne(x => x.playerid == playerid);
return row?.player?.PlatformIds?
.Where(p => p.Platform == platform)
.Select(p => p.PlatformId)
.ToList()
?? new List<ulong>();
}
/// <summary>First platform id for this platform, or 0. Convenience for display.</summary>
public static ulong GetPlatformId(ulong playerid, Platforms platform) =>
GetPlatformIds(playerid, platform).FirstOrDefault();
/// <summary>
/// Every account carrying this platform id, ignoring <paramref name="ignorePlayerId"/>.
///
/// Informational only - the admin panel reports shared ids rather than refusing
/// them. Sharing IS allowed by design, but it is worth knowing about: GetLogins
/// resolves a sign-in by (platform, platformId), so when several accounts hold the
/// same id, that lookup returns them all and the caller picks.
/// </summary>
public static List<ulong> FindPlayersByPlatformId(Platforms platform, ulong platformId, ulong ignorePlayerId = 0)
{
if (platformId == 0) return new List<ulong>();
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll()
.Where(x => x.playerid != ignorePlayerId &&
x.player?.PlatformIds != null &&
x.player.PlatformIds.Any(p => p.Platform == platform && p.PlatformId == platformId))
.Select(x => x.playerid)
.ToList();
}
/// <summary>
/// Replaces every link for this platform with <paramref name="platformIds"/>.
/// An empty list unlinks the platform entirely. Duplicates within the list are
/// collapsed; the same id living on OTHER accounts is left alone - that is
/// deliberate, several accounts may share one Steam id.
/// </summary>
public static bool SetPlatformIds(ulong playerid, Platforms platform, IEnumerable<ulong> platformIds)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var row = col.FindOne(x => x.playerid == playerid);
if (row?.player == null) return false;
row.player.PlatformIds ??= new List<mPlatformID>();
row.player.PlatformIds.RemoveAll(p => p.Platform == platform);
foreach (var id in platformIds.Where(i => i != 0).Distinct())
row.player.PlatformIds.Add(new mPlatformID { Platform = platform, PlatformId = id });
col.Update(row);
return true;
}
/// <summary>Single-id form of <see cref="SetPlatformIds"/>; 0 unlinks.</summary>
public static bool SetPlatformId(ulong playerid, Platforms platform, ulong platformId) =>
SetPlatformIds(playerid, platform, platformId == 0 ? Array.Empty<ulong>() : new[] { platformId });
/// <summary>Sets (or with 0, clears) the linked Discord account.</summary>
public static bool SetDiscordId(ulong playerid, ulong discordId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var row = col.FindOne(x => x.playerid == playerid);
if (row?.player == null) return false;
row.player_Extra ??= new player_extra();
row.player_Extra.DiscordId = discordId;
col.Update(row);
return true;
}
/// <summary>
/// Finds whoever already has this Discord id linked, ignoring
/// <paramref name="ignorePlayerId"/>. Returns 0 when nobody does.
/// </summary>
public static ulong FindPlayerByDiscordId(ulong discordId, ulong ignorePlayerId = 0)
{
if (discordId == 0) return 0;
var col = PlayerDBFile.GetCollection<player_data>("players");
var owner = col.FindAll().FirstOrDefault(x =>
x.playerid != ignorePlayerId &&
x.player_Extra != null &&
x.player_Extra.DiscordId == discordId);
return owner?.playerid ?? 0;
}
/// <summary>
/// Link a Discord user to a Campus account (website Settings > Discord tab + the /link bot
/// command). Sets BOTH the id and the display tag. This is ONLY an association for the bot
/// and the site - it grants NO dev/admin/moderation privilege of any kind. Pass discordId 0
/// to unlink.
/// </summary>
public static bool SetDiscordLink(ulong playerid, ulong discordId, string? discordUsername)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.DiscordId = discordId;
record.player_Extra.DiscordUsername = discordId == 0 ? null : discordUsername;
col.Update(record);
Console.WriteLine($"[discord-link] account {playerid} {(discordId == 0 ? "UNLINKED" : $"linked to discord {discordId} ({discordUsername})")}");
return true;
}
/// <summary>Whether a moderator has locked this player's @name / display name (admin panel).</summary>
public static bool IsNameLocked(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return record?.player_Extra?.NameLocked == true;
}
/// <summary>Lock or unlock a player's names. Admin edits are never gated by this.</summary>
public static bool SetNameLock(ulong playerid, bool locked)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.NameLocked = locked;
col.Update(record);
Console.WriteLine($"[names] account {playerid} (@{record.player.Username}) name lock {(locked ? "ON" : "off")}");
return true;
}
public static bool SetPlayerDev(DevFlag dev_Flag, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == playerid);
if (playerRecord != null)
{
if (playerRecord.player != null)
{
playerRecord.dev_Flag = dev_Flag;
col.Update(playerRecord);
return true;
}
}
return false;
}
public static bool SetAllDev()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
foreach (var playerRecord in col.FindAll())
{
if (playerRecord.player != null)
{
playerRecord.dev_Flag = DevFlag.dev;
col.Update(playerRecord);
return true;
}
}
return false;
}
public static account_login<account_data> GetAccountPassword(string username, string password, bool isAdminPanel)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return new account_login<account_data>
{
success = false,
error = "Your username or password is empty, please enter a username and password.",
};
}
var player = col.FindOne(x => x.player.Username == username);
if (player == null)
{
return new account_login<account_data>
{
success = false,
error = "The account you are trying to login to does not exist.",
};
}
if (string.IsNullOrEmpty(player.password))
{
return new account_login<account_data>
{
success = false,
error = "The password or username you entered don't match the account you are trying to login to."
};
}
if (string.IsNullOrEmpty(password))
{
return new account_login<account_data>
{
success = false,
error = "The password or username you entered don't match the account you are trying to login to.",
};
}
if (isAdminPanel)
{
var devCheckResult = PlayerDB.CheckDevFlag(player.playerid);
if (!devCheckResult)
{
return new account_login<account_data>
{
success = false,
error = "You are not a developer",
};
}
}
bool isValid = ClientSecurity.VerifyPassword(password, player.password);
if (!isValid)
{
string oldHash = ClientSecurity.GetHashString(password);
if (string.Equals(player.password, oldHash, StringComparison.OrdinalIgnoreCase))
{
player.password = ClientSecurity.HashPassword(password);
col.Update(player);
}
else
{
return new account_login<account_data>
{
success = false,
error = "The password or username you entered don't match the account you are trying to login to."
};
}
}
return new account_login<account_data>
{
success = true,
error = "",
accid = player.playerid,
};
}
public static void SetPlayerPassword(string pass, ulong playerid, bool log)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == playerid);
if (playerRecord != null && playerRecord.player != null)
{
playerRecord.password = ClientSecurity.HashPassword(pass);
col.Update(playerRecord);
if (log)
{
Console.WriteLine($"[Password Change] PlayerID: {playerid} has changed their password.");
}
}
}
public static bool SetPlayerBio(string bio, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player_Extra.Bio = bio;
col.Update(player_Setting1);
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
return true;
}
}
return false;
}
public static async Task<bool> SetPlayerLevel(int level, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.Level = level;
try
{
await Notifications.SendToAll(JsonConvert.SerializeObject(WebsocketEvents.createResponse_levelupdate(playerid, level, Progress.CumulativeXpForLevel(level, 0))));
}
catch
{
}
col.Update(player_Setting1);
return true;
}
}
return false;
}
public static player_data? GetPlayerByDiscordId(ulong discordId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player_Extra.DiscordId == discordId);
return player;
}
public static void SetDisplayName(string username, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.DisplayName = username;
col.Update(player_Setting1);
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
return;
}
}
}
public static bool SetRoleFlag(ulong playerid, DevFlag flag, bool enable)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
if (enable) record.dev_Flag |= flag;
else record.dev_Flag &= ~flag;
col.Update(record);
return true;
}
/// <summary>
/// Sets XP and/or Level directly (admin panel). Pass null to leave one unchanged.
/// Returns the values actually stored, or null if the player does not exist.
/// </summary>
public static (int XP, int Level)? SetProgression(ulong playerid, int? xp, int? level)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return null;
if (xp.HasValue) record.player.XP = xp.Value < 0 ? 0 : xp.Value;
if (level.HasValue) record.player.Level = level.Value < 0 ? 0 : level.Value;
col.Update(record);
return (record.player.XP, record.player.Level);
}
/// <summary>
/// Grants XP to a player and applies any level-ups it now pays for, using the real
/// progression curve (Progress.LEVEL_REQUIRED_XP). XP is progress INTO the current level;
/// a grant that fills the level carries the remainder forward. Returns the resulting
/// (Level, XP) and how many levels were gained, or null if the player has no row.
/// This is what a game reward calls - it was the missing piece: nothing granted XP, so
/// players never levelled up. Mirrors RecFlare's addXp.
/// </summary>
public static (int Level, int XP, int LevelsGained)? AddXp(ulong playerid, int amount)
{
if (amount <= 0)
{
var cur = Progress.GetPlayerLevel(playerid);
return (cur.Level, cur.XP, 0);
}
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return null;
int startLevel = record.player.Level < 1 ? 1 : record.player.Level;
int newXp = (record.player.XP < 0 ? 0 : record.player.XP) + amount;
var (level, xp) = Progress.ApplyLevelUps(startLevel, newXp);
int levelsGained = level - startLevel;
record.player.Level = level;
record.player.XP = xp;
col.Update(record);
// Push the new level/XP so the crown + progress bar update in real time (no relog) -
// AddXp used to be silent, so a level earned in-game only showed after a relog. A level
// CHANGE broadcasts (everyone's view of this player's crown updates); an XP-only gain
// pings just the owner so their bar advances. Same PlayerProgressionLevelUpdate event
// the admin level set + the progression GET use.
try
{
// Report CUMULATIVE XP (client derives the crown level from XP - see
// Progress.CumulativeXpForLevel), not the raw into-level XP.
int reportXp = Progress.CumulativeXpForLevel(level, xp);
string msg = JsonConvert.SerializeObject(WebsocketEvents.createResponse_levelupdate(playerid, level, reportXp));
if (levelsGained > 0) Notifications.FireAndForget(Notifications.SendToAll(msg), "levelup broadcast");
else Notifications.FireAndForget(Notifications.SendToPlayer(playerid, msg), "xp update");
}
catch { }
// Every level crossed pays its box (reference: grantLevelUpGifts). After the level is
// stored, so a failed roll costs a prize and not the level.
if (levelsGained > 0)
{
try { Campus_live_rewrite.api.server.LevelRewards.GrantLevelUps(playerid, startLevel, level); }
catch (Exception ex) { Console.WriteLine($"[levelrewards] {playerid}: {ex.Message}"); }
}
return (level, xp, levelsGained);
}
/// <summary>
/// Claims a once-per-day reward. Returns false if it was already claimed today,
/// in which case nothing is written.
/// </summary>
public static bool TryClaimDailyReward(ulong playerid, string rewardKey)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.DailyRewardClaims ??= new Dictionary<string, string>();
string today = DateTime.UtcNow.ToString("yyyy-MM-dd");
if (record.player_Extra.DailyRewardClaims.TryGetValue(rewardKey, out var last) && last == today)
return false;
record.player_Extra.DailyRewardClaims[rewardKey] = today;
col.Update(record);
return true;
}
// ---- Gift boxes -------------------------------------------------------
// ---- Avatar item ownership ------------------------------------------------
//
// The 2023 server treats AvatarItems.json as a CATALOG - the list of every item
// that exists - and keeps what each player owns on the player record, seeded from
// DefaultAvatarItems.json. The 2025 server had no ownership list at all and simply
// served the whole catalog from /api/avatar/v4/items, so everybody owned
// everything. These helpers restore the 2023 model.
/// <summary>
/// Canonical key for comparing two descriptors. Trailing empty colour slots are
/// dropped, so "guid,,," and "guid," compare equal, while "guid,colour,,"
/// stays distinct from "guid,,," - every colour of an item shares a base guid, so
/// keying on the guid alone would merge Knight Cape (Red) into Knight Cape (Green).
/// </summary>
public static string NormalizeAvatarItemDesc(string? desc)
{
if (string.IsNullOrWhiteSpace(desc)) return "";
var parts = desc.Trim().Split(',').Select(p => p.Trim()).ToList();
while (parts.Count > 1 && string.IsNullOrEmpty(parts[^1])) parts.RemoveAt(parts.Count - 1);
// Compare on the EXPANDED form of every id.
//
// Rec Room writes ids two ways - a full UUID, or a 22-char base64 "short guid" -
// and avatar_items.json used to mix both. The catalog has since been rewritten to
// full UUIDs throughout (see Data/APIS/items/README-avatar-items.md), but stored
// ownership rows, DefaultAvatarItems.json and the storefronts still carry whichever
// spelling they were written with. Expanding here means "guid,8XajS2adOEOqzqnLy6-zcQ"
// and "guid,4ba376f1-9d66-4338-aace-a9cbcbafb371" are the same item, so nobody
// loses an item they own just because the catalog changed spelling.
for (int i = 0; i < parts.Count; i++)
{
var expanded = Campus_live_rewrite.api.server.Classes.ItemGuids.NormalizeGuid(parts[i]);
if (expanded != null) parts[i] = expanded;
}
return string.Join(",", parts);
}
/// <summary>
/// The starter wardrobe every non-developer account begins with. Loaded once.
/// </summary>
private static List<string>? _defaultAvatarItems;
private static readonly object _defaultAvatarItemsLock = new();
public static List<string> DefaultAvatarItemDescs()
{
if (_defaultAvatarItems != null) return _defaultAvatarItems;
lock (_defaultAvatarItemsLock)
{
if (_defaultAvatarItems != null) return _defaultAvatarItems;
var list = new List<string>();
try
{
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "DefaultAvatarItems.json");
if (File.Exists(path))
{
// The file is a flat array of descriptor strings.
var arr = Newtonsoft.Json.JsonConvert.DeserializeObject<List<string>>(File.ReadAllText(path));
foreach (var d in arr ?? new List<string>())
if (!string.IsNullOrWhiteSpace(d)) list.Add(d.Trim());
}
else
{
Console.WriteLine($"[items] DefaultAvatarItems.json not found at {path} - new accounts will start empty");
}
}
catch (Exception ex)
{
Console.WriteLine($"[items] could not read DefaultAvatarItems.json: {ex.Message}");
}
_defaultAvatarItems = list;
Console.WriteLine($"[items] loaded {list.Count} default starter avatar item(s)");
return _defaultAvatarItems;
}
}
/// <summary>
/// Everything this account owns, as canonical keys. Grants the starter set on
/// first read so existing accounts need no migration.
/// </summary>
public static HashSet<string> OwnedAvatarItemDescs(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
record.player_Extra ??= new player_extra();
if (record.player_Extra.AvatarItems == null)
{
record.player_Extra.AvatarItems = new List<string>(DefaultAvatarItemDescs());
col.Update(record);
Console.WriteLine($"[items] granted {record.player_Extra.AvatarItems.Count} starter item(s) to {playerid}");
}
var set = new HashSet<string>(
record.player_Extra.AvatarItems.Select(NormalizeAvatarItemDesc).Where(d => d.Length > 0),
StringComparer.OrdinalIgnoreCase);
// Unioned on READ rather than written into the wardrobe row, so every existing account
// gets it with no migration and it cannot drift out of sync with the catalog.
set.UnionWith(Campus_live_rewrite.api.server.HairGrants.FreeHairKeys());
return set;
}
/// <summary>Adds an item to a player's wardrobe. Returns true if it was new.</summary>
/// <summary>The normalised keys of every avatar item the player owns (defaults included).</summary>
public static HashSet<string> GetOwnedAvatarItemKeys(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var items = record?.player_Extra?.AvatarItems ?? DefaultAvatarItemDescs();
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var d in items) { var k = NormalizeAvatarItemDesc(d); if (k.Length > 0) set.Add(k); }
set.UnionWith(Campus_live_rewrite.api.server.HairGrants.FreeHairKeys());
return set;
}
// Consumable descriptors come in short-guid and UUID spellings; own them by the UUID form.
public static string NormalizeConsumableDesc(string? desc)
{
string s = (desc ?? "").Trim();
if (s.Length == 0) return "";
return (Campus_live_rewrite.api.server.Classes.ItemGuids.NormalizeGuid(s) ?? s).ToLowerInvariant();
}
/// <summary>The player's consumable stock (desc key -> count), empty when they own none.</summary>
public static Dictionary<string, int> OwnedConsumables(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return new Dictionary<string, int>(record?.player_Extra?.Consumables ?? new Dictionary<string, int>(), StringComparer.OrdinalIgnoreCase);
}
public static void GrantConsumable(ulong playerid, string? desc, int count)
{
string ck = NormalizeConsumableDesc(desc);
if (ck.Length == 0 || count <= 0) return;
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return;
record.player_Extra ??= new player_extra();
record.player_Extra.Consumables ??= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
record.player_Extra.Consumables[ck] = (record.player_Extra.Consumables.TryGetValue(ck, out var have) ? have : 0) + count;
col.Update(record);
}
public static bool GrantAvatarItem(ulong playerid, string? desc)
{
string key = NormalizeAvatarItemDesc(desc);
if (key.Length == 0) return false;
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.AvatarItems ??= new List<string>(DefaultAvatarItemDescs());
if (record.player_Extra.AvatarItems.Any(d => NormalizeAvatarItemDesc(d).Equals(key, StringComparison.OrdinalIgnoreCase)))
return false;
record.player_Extra.AvatarItems.Add(desc!.Trim());
col.Update(record);
Console.WriteLine($"[items] granted '{desc!.Trim()}' to {playerid}");
return true;
}
/// <summary>Developers get the whole catalog - the dev locker.</summary>
public static bool HasDevLocker(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return record != null && (record.dev_Flag & DevFlag.dev) == DevFlag.dev;
}
/// <summary>
/// Marks CONTENTLESS pending gift boxes as opened, so they stop lingering forever. An empty
/// box (no gift drop, no avatar item, no consumable, no equipment) is filtered out of the
/// gifts list the client is sent - it can never be opened there and would sit in the DB
/// counting toward the gift badge as a "random gift" that won't clear. Marking it opened
/// retires it. Returns how many were retired.
/// </summary>
public static int MarkEmptyGiftBoxesOpened(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var boxes = record?.player_Extra?.PendingGiftBoxes;
if (boxes == null || boxes.Count == 0) return 0;
int cleaned = 0;
foreach (var g in boxes)
{
if (g.Opened) continue;
// Currency / XP boxes count as content (a token box has no item and is real).
if (!Campus_live_rewrite.api.server.GiftBoxes.HasContent(g)) { g.Opened = true; cleaned++; }
}
if (cleaned > 0) col.Update(record);
return cleaned;
}
public static bool AddPendingGiftBox(ulong playerid, int giftContext, string? message, ulong fromAccountId = 0)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.PendingGiftBoxes ??= new List<PendingGiftBox>();
record.player_Extra.PendingGiftBoxes.Add(new PendingGiftBox
{
GiftContext = giftContext,
Message = message,
FromAccountId = fromAccountId
});
col.Update(record);
return true;
}
/// <summary>Stores a fully-populated gift box (store purchases carry item data).</summary>
/// <summary>Backfill a numeric Id onto any pending gift box that still has NumericId 0 (older
/// reward/select boxes). Without it the gifts endpoint emits a guid Id the client rejects.</summary>
// ---- gift package ids: ONE sequence for the whole server ------------------------------
//
// A gift box id has to be unique across PLAYERS, not just within one player's list. The
// client networks a spawned box into the room by its package id, and "Open All" walks the
// boxes it can see: with per-player ids (reward boxes 1001+ per player, purchases from a
// counter that restarted at 5000 on every deploy) two players' boxes in the same room
// shared ids. Player.log, opening gifts in the Rec Center after another player had: the
// client posts consume for 5003 and 5002 (the other player's, already opened - "no matching
// unopened box"), throws "Gift is missing from the list", and only then reaches its own
// 5004 - which the other player ALSO had as 5004. The reference's received_gift table is
// AUTOINCREMENT for the same reason.
//
// Seeded once above every id already stored, so nothing pending collides with a new box.
private sealed class Counter { public int Id { get; set; } public int Value { get; set; } }
private static readonly object _giftIdLock = new();
public static int NextGiftPackageId()
{
lock (_giftIdLock)
{
var counters = PlayerDBFile.GetCollection<Counter>("counters");
var row = counters.FindById(1);
if (row == null)
{
int max = 100_000;
try
{
foreach (var p in PlayerDBFile.GetCollection<player_data>("players").FindAll())
foreach (var b in p.player_Extra?.PendingGiftBoxes ?? new List<PendingGiftBox>())
if (b.NumericId > max) max = b.NumericId;
}
catch { }
row = new Counter { Id = 1, Value = max };
Console.WriteLine($"[gifts] gift package id sequence seeded at {max}");
}
row.Value++;
counters.Upsert(row);
return row.Value;
}
}
/// <summary>True when a box with this id is (or was) some OTHER player's.</summary>
public static bool GiftBoxBelongsToSomeoneElse(ulong playerid, int numericId)
{
try
{
foreach (var p in PlayerDBFile.GetCollection<player_data>("players").Find(x => x.playerid != playerid))
if ((p.player_Extra?.PendingGiftBoxes ?? new List<PendingGiftBox>()).Any(b => b.NumericId == numericId))
return true;
}
catch { }
return false;
}
public static void EnsureGiftBoxNumericIds(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var boxes = record?.player_Extra?.PendingGiftBoxes;
if (boxes == null || boxes.Count == 0) return;
bool changed = false;
foreach (var b in boxes)
if (b.NumericId == 0) { b.NumericId = NextGiftPackageId(); changed = true; }
if (changed) col.Update(record);
}
// The guid is compared on its UUID form: Equipments.json spells every skin as a full UUID,
// but 112 of the 187 skin rows in the Watch store spell the SAME skins as 22-char short
// guids. Compared as text they never matched, so a bought skin was stored under a key
// that /equipment/v2/getUnlocked could never find - "Can't load item" on the box, and the
// skin never turned up in the locker.
public static string EquipmentSkinKey(string? prefab, string? guid)
{
string g = Campus_live_rewrite.api.server.Classes.ItemGuids.NormalizeGuid(guid) ?? (guid ?? "").Trim().ToLowerInvariant();
return $"{(prefab ?? "").Trim().ToLowerInvariant()}|{g}";
}
public static HashSet<string> OwnedEquipmentSkins(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return new HashSet<string>(record?.player_Extra?.EquipmentSkins ?? new List<string>(), StringComparer.OrdinalIgnoreCase);
}
public static bool OwnsCustomItem(ulong playerid, string caid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return record?.player_Extra?.CustomItems?.Any(c => string.Equals(c, caid, StringComparison.OrdinalIgnoreCase)) == true;
}
public static HashSet<string> OwnedCustomItems(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return new HashSet<string>(record?.player_Extra?.CustomItems ?? new List<string>(), StringComparer.OrdinalIgnoreCase);
}
/// <summary>Put a UGC item in the player's wardrobe. Returns true if it was new.</summary>
public static bool GrantCustomItem(ulong playerid, string? caid)
{
if (string.IsNullOrWhiteSpace(caid)) return false;
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.CustomItems ??= new List<string>();
if (record.player_Extra.CustomItems.Any(c => string.Equals(c, caid, StringComparison.OrdinalIgnoreCase))) return false;
record.player_Extra.CustomItems.Add(caid);
col.Update(record);
Console.WriteLine($"[ugc] {playerid} now owns custom item {caid}");
return true;
}
/// <summary>Put a skin in the player's locker. Returns true if it was new.</summary>
public static bool GrantEquipmentSkin(ulong playerid, string? prefab, string? guid)
{
if (string.IsNullOrWhiteSpace(prefab) || string.IsNullOrWhiteSpace(guid)) return false;
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.EquipmentSkins ??= new List<string>();
string key = EquipmentSkinKey(prefab, guid);
if (record.player_Extra.EquipmentSkins.Any(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase))) return false;
record.player_Extra.EquipmentSkins.Add(key);
col.Update(record);
Console.WriteLine($"[skins] {playerid} now owns skin {prefab} {guid}");
return true;
}
public static bool AddPendingGiftBoxFull(ulong playerid, PendingGiftBox box)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.PendingGiftBoxes ??= new List<PendingGiftBox>();
// Every gift box MUST have a numeric Id. The gifts endpoint emits the guid Id when
// NumericId is 0, but the client's gift model expects a NUMBER there and rejects the whole
// gifts array as "Malformed Response" if it sees a guid - which silently killed every
// reward gift box (and made the reward watch open then instantly close). Purchases already
// set NumericId; reward/select boxes didn't, so assign one here - from the server-wide
// sequence (NextGiftPackageId), never a per-player count.
if (box.NumericId == 0) box.NumericId = NextGiftPackageId();
record.player_Extra.PendingGiftBoxes.Add(box);
// A purchased or gifted item has to enter the wardrobe too, now that owning it
// is no longer implied by the catalog being served to everyone. Done here, in
// the same write, so a bought item can never be left unowned.
if (!string.IsNullOrWhiteSpace(box.AvatarItemDesc))
{
string key = NormalizeAvatarItemDesc(box.AvatarItemDesc);
record.player_Extra.AvatarItems ??= new List<string>(DefaultAvatarItemDescs());
if (!record.player_Extra.AvatarItems.Any(d =>
NormalizeAvatarItemDesc(d).Equals(key, StringComparison.OrdinalIgnoreCase)))
{
record.player_Extra.AvatarItems.Add(box.AvatarItemDesc!.Trim());
Console.WriteLine($"[items] {playerid} now owns '{box.AvatarItemDesc!.Trim()}' ({box.FriendlyName})");
}
}
if (!string.IsNullOrWhiteSpace(box.CustomAvatarItemId))
{
record.player_Extra.CustomItems ??= new List<string>();
if (!record.player_Extra.CustomItems.Any(c => string.Equals(c, box.CustomAvatarItemId, StringComparison.OrdinalIgnoreCase)))
{
record.player_Extra.CustomItems.Add(box.CustomAvatarItemId!);
Console.WriteLine($"[ugc] {playerid} now owns custom item {box.CustomAvatarItemId} ({box.FriendlyName})");
}
}
// A consumable: counted into the player's own stock (was: everyone had every
// consumable with an infinite count, so nothing was ever really "got").
if (!string.IsNullOrWhiteSpace(box.ConsumableItemDesc))
{
record.player_Extra.Consumables ??= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
string ck = NormalizeConsumableDesc(box.ConsumableItemDesc);
int add = box.ConsumableCount > 0 ? box.ConsumableCount : 1;
record.player_Extra.Consumables[ck] = (record.player_Extra.Consumables.TryGetValue(ck, out var have) ? have : 0) + add;
Console.WriteLine($"[consumables] {playerid} +{add} {box.FriendlyName} -> {record.player_Extra.Consumables[ck]}");
}
// An equipment SKIN enters the locker the same way an avatar item enters the wardrobe -
// in this write, so a bought skin can never be left unowned (it used to be: skins were
// served to everyone, so nothing ever recorded a purchase).
if (!string.IsNullOrWhiteSpace(box.EquipmentPrefabName) && !string.IsNullOrWhiteSpace(box.EquipmentModificationGuid))
{
record.player_Extra.EquipmentSkins ??= new List<string>();
string skey = EquipmentSkinKey(box.EquipmentPrefabName, box.EquipmentModificationGuid);
if (!record.player_Extra.EquipmentSkins.Any(k => string.Equals(k, skey, StringComparison.OrdinalIgnoreCase)))
{
record.player_Extra.EquipmentSkins.Add(skey);
Console.WriteLine($"[skins] {playerid} now owns skin {box.EquipmentPrefabName} {box.EquipmentModificationGuid} ({box.FriendlyName})");
}
}
col.Update(record);
return true;
}
public static List<PendingGiftBox> GetPendingGiftBoxes(ulong playerid)
{
var acc = GetFullAccount(playerid);
var all = acc?.player_Extra?.PendingGiftBoxes;
if (all == null) return new List<PendingGiftBox>();
return all.Where(g => !g.Opened).ToList();
}
// ================= Daily objectives / weekly challenges (quests) =================
// Per-player progress only - the objective/challenge CATALOG (descriptions, targets,
// rewards) is client-side. See APIController objectives + challenge handlers.
public static (List<ObjectiveProgress> objectives, List<ObjectiveGroupProgress> groups)
GetObjectivesProgress(ulong playerid)
{
var ex = GetFullAccount(playerid)?.player_Extra;
return (ex?.Objectives ?? new List<ObjectiveProgress>(),
ex?.ObjectiveGroups ?? new List<ObjectiveGroupProgress>());
}
/// <summary>
/// Upsert one objective's progress on the (Group, Index) key. Returns true ONLY on the call
/// that first completes it while its reward is unclaimed - the once-only reward latch - so
/// the caller grants the reward exactly once. Hands back the parent group's state.
/// </summary>
public static bool UpdateObjective(ulong playerid, long group, int index,
double progress, double visualProgress, bool isCompleted,
out ObjectiveGroupProgress groupState)
{
groupState = new ObjectiveGroupProgress { Group = group };
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.Objectives ??= new List<ObjectiveProgress>();
record.player_Extra.ObjectiveGroups ??= new List<ObjectiveGroupProgress>();
var o = record.player_Extra.Objectives.FirstOrDefault(x => x.Group == group && x.Index == index);
if (o == null)
{
o = new ObjectiveProgress { Group = group, Index = index };
record.player_Extra.Objectives.Add(o);
}
o.Progress = progress;
o.VisualProgress = visualProgress;
bool justCompleted = isCompleted && !o.HasClaimedReward;
if (isCompleted) o.IsCompleted = true;
if (justCompleted) o.HasClaimedReward = true;
var g = record.player_Extra.ObjectiveGroups.FirstOrDefault(x => x.Group == group);
if (g == null)
{
g = new ObjectiveGroupProgress { Group = group };
record.player_Extra.ObjectiveGroups.Add(g);
}
groupState = g;
col.Update(record);
return justCompleted;
}
public static ObjectiveGroupProgress ClearObjectiveGroup(ulong playerid, long group)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var g = new ObjectiveGroupProgress { Group = group, IsCompleted = true, ClearedAt = DateTime.UtcNow.ToString("o") };
if (record == null) return g;
record.player_Extra ??= new player_extra();
record.player_Extra.ObjectiveGroups ??= new List<ObjectiveGroupProgress>();
var existing = record.player_Extra.ObjectiveGroups.FirstOrDefault(x => x.Group == group);
if (existing == null) { record.player_Extra.ObjectiveGroups.Add(g); }
else { existing.IsCompleted = true; existing.ClearedAt = g.ClearedAt; g = existing; }
col.Update(record);
return g;
}
public static HashSet<int> GetCompletedChallengeIds(ulong playerid, int mapId)
{
var set = new HashSet<int>();
var st = GetFullAccount(playerid)?.player_Extra?.ChallengeStatuses;
if (st != null)
foreach (var s in st)
if (s.ChallengeMapId == mapId && s.Complete) set.Add(s.ChallengeId);
return set;
}
/// <summary>Latch one challenge's completion within a rotation (false-&gt;true only). Returns the stored state.</summary>
public static bool RecordChallengeProgress(ulong playerid, int mapId, int challengeId, bool complete)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.ChallengeStatuses ??= new List<ChallengeStatus>();
var s = record.player_Extra.ChallengeStatuses
.FirstOrDefault(x => x.ChallengeMapId == mapId && x.ChallengeId == challengeId);
if (s == null)
{
s = new ChallengeStatus { ChallengeMapId = mapId, ChallengeId = challengeId };
record.player_Extra.ChallengeStatuses.Add(s);
}
s.Complete = s.Complete || complete;
s.UpdatedAt = DateTime.UtcNow.ToString("o");
col.Update(record);
return s.Complete;
}
/// <summary>At-most-once gift claim for a challenge map. Returns true only on the FIRST claim.</summary>
public static bool ClaimChallengeGift(ulong playerid, int mapId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.ClaimedChallengeMaps ??= new List<int>();
if (record.player_Extra.ClaimedChallengeMaps.Contains(mapId)) return false;
record.player_Extra.ClaimedChallengeMaps.Add(mapId);
col.Update(record);
return true;
}
/// <summary>
/// Opens (consumes) a gift box for a player. Returns the box that was opened, or null
/// when the player has no such unopened box.
///
/// The item was already granted at PURCHASE time (see AddPendingGiftBoxFull) - opening
/// is only the cosmetic reveal, so this just marks the box opened so it stops showing
/// as pending. Without this the client's POST /api/avatar/v2/gifts/consume 404'd, the
/// box was never marked opened, and GetPendingGiftBoxes handed it straight back - so a
/// box could be "opened" over and over and never went away.
///
/// The client sends back whatever id we emitted in the gifts list, which is NumericId
/// for purchases and the guid Id otherwise (see the Gifts endpoint), so match on both.
/// </summary>
/// <summary>Flip a box's PayOnOpen off. Returns true only for the call that flipped it.</summary>
public static bool ClearGiftBoxPayOnOpen(ulong playerid, PendingGiftBox box)
{
lock (_giftPayLock)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var stored = record?.player_Extra?.PendingGiftBoxes?.FirstOrDefault(g =>
(box.NumericId != 0 && g.NumericId == box.NumericId) || (box.NumericId == 0 && g.Id == box.Id));
if (stored == null || (!stored.PayOnOpen && !stored.XpOnOpen)) return false;
stored.PayOnOpen = false; stored.XpOnOpen = false;
box.PayOnOpen = false; box.XpOnOpen = false;
col.Update(record);
return true;
}
}
private static readonly object _giftPayLock = new();
/// <summary>Persist PayOnOpen=true on an already-stored box.</summary>
public static void SetGiftBoxPayOnOpen(ulong playerid, PendingGiftBox box)
{
lock (_giftPayLock)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var stored = record?.player_Extra?.PendingGiftBoxes?.FirstOrDefault(g =>
(box.NumericId != 0 && g.NumericId == box.NumericId) || (box.NumericId == 0 && g.Id == box.Id));
if (stored == null) return;
stored.PayOnOpen = true;
col.Update(record);
}
}
public static PendingGiftBox? ConsumeGiftBox(ulong playerid, string? id, int numericId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var boxes = record?.player_Extra?.PendingGiftBoxes;
if (boxes == null) return null;
var box = boxes.FirstOrDefault(g => !g.Opened &&
((numericId != 0 && g.NumericId == numericId) ||
(!string.IsNullOrEmpty(id) && g.Id == id)));
if (box == null) return null;
box.Opened = true;
col.Update(record);
Console.WriteLine($"[gifts] {playerid} opened box {(numericId != 0 ? numericId.ToString() : id)} ({box.FriendlyName})");
return box;
}
public static bool SetPlayerSteamId(ulong steamId, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return false;
record.platform = Platforms.Steam;
record.player.PlatformIds ??= new List<mPlatformID>();
// Replace any existing Steam entry, add if missing.
var existing = record.player.PlatformIds.FirstOrDefault(p => p != null && p.Platform == Platforms.Steam);
if (existing != null) existing.PlatformId = steamId;
else record.player.PlatformIds.Add(new mPlatformID { Platform = Platforms.Steam, PlatformId = steamId });
col.Update(record);
return true;
}
/// <summary>The account using this email (case-insensitive), or 0.</summary>
public static ulong GetAccountIdByEmail(string email)
{
if (string.IsNullOrWhiteSpace(email)) return 0;
var col = PlayerDBFile.GetCollection<player_data>("players");
var hit = col.FindAll().FirstOrDefault(x => x.player?.Email != null &&
string.Equals(x.player.Email.Trim(), email.Trim(), StringComparison.OrdinalIgnoreCase));
return hit?.playerid ?? 0;
}
public static bool SetPlayerEmail(string? email, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return false;
record.player.Email = email;
col.Update(record);
return true;
}
public static bool SetLastLoginTime(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (player != null)
{
player.player.last_login_time = DateTime.UtcNow;
col.Update(player);
return true;
}
return false;
}
public static bool ResetCheerCreditIfNewDay(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (player?.player?.PlayerReputation == null)
return false;
DateTime lastLoginDate = player.player.last_login_time.Date;
DateTime today = DateTime.UtcNow.Date;
if (lastLoginDate < today)
{
player.player.PlayerReputation.CheerCredit = 20;
player.player.last_login_time = DateTime.UtcNow;
col.Update(player);
return true;
}
return false;
}
public static bool AddIpAddress(ulong playerid, string ip)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (player != null)
{
if (player.player_Extra.IpAddresses == null)
player.player_Extra.IpAddresses = new List<string>();
string encryptedIp = Utils.EncryptAes(ip);
if (!player.player_Extra.IpAddresses.Contains(encryptedIp))
player.player_Extra.IpAddresses.Add(encryptedIp);
col.Update(player);
return true;
}
return false;
}
public static List<string> GetDecryptedIpAddresses(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
List<string> result = new();
if (player?.player_Extra?.IpAddresses != null)
{
foreach (string encryptedIp in player.player_Extra.IpAddresses)
{
try
{
result.Add(Utils.DecryptAes(encryptedIp));
}
catch
{
}
}
}
return result;
}
public static void SetPlayerEmoji(string emoji, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.DisplayEmoji = emoji;
col.Update(player_Setting1);
return;
}
}
}
public static void SetPlayerPronounFlags(int pronounflag, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.pronounFlags = pronounflag;
col.Update(player_Setting1);
}
else
{
}
}
else
{
}
}
public static void AddSubscription(ulong fromPlayerId, ulong toPlayerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == fromPlayerId);
if (playerRecord == null || playerRecord.player_Extra == null)
throw new InvalidOperationException($"player_Extra for player {fromPlayerId} not found.");
// check if already subscribed
if (!playerRecord.player_Extra.SubbedTo.Contains(toPlayerId))
{
playerRecord.player_Extra.SubbedTo.Add(toPlayerId);
col.Update(playerRecord);
IncrementSubscriberCount(toPlayerId);
}
}
public static void IncrementSubscriberCount(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player != null && player.player_Extra != null)
{
player.player_Extra.sub_count++;
col.Update(player);
}
}
public static void RemoveSubscription(ulong fromPlayerId, ulong toPlayerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == fromPlayerId);
if (playerRecord == null || playerRecord.player_Extra == null)
return;
if (playerRecord.player_Extra.SubbedTo.Remove(toPlayerId))
{
col.Update(playerRecord);
DecrementSubscriberCount(toPlayerId);
}
}
public static void DecrementSubscriberCount(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player != null && player.player_Extra != null && player.player_Extra.sub_count > 0)
{
player.player_Extra.sub_count--;
col.Update(player);
}
}
/// <summary>Everyone subscribed to <paramref name="playerId"/> (the creator's subscribers).</summary>
public static List<ulong> GetSubscriberIds(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll()
.Where(x => x.player_Extra?.SubbedTo != null && x.player_Extra.SubbedTo.Contains(playerId))
.Select(x => x.playerid)
.ToList();
}
public static List<ulong> GetSubscribedToIds(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == playerId);
if (playerRecord != null && playerRecord.player_Extra != null)
{
return playerRecord.player_Extra.SubbedTo;
}
return new List<ulong>();
}
public static void SetPlayerIdentityFlags(int IdentityFlag, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.identityFlags = IdentityFlag;
col.Update(player_Setting1);
}
else
{
}
}
else
{
}
}
public static void SetPlayerImageName(string imageName, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player == null)
return;
player.player.profileImage = imageName;
col.Update(player);
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
}
public static void SetPlayerBanner(string imageName, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player == null)
return;
player.player.bannerImage = imageName;
col.Update(player);
}
public static int GetAccountCountByPlatformId(ulong platformId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.Count(x =>
x.player.PlatformIds != null &&
x.player.PlatformIds
.Select(pid => pid.PlatformId)
.Any(id => id == platformId)
);
}
public static int GetAccountCountByDiscordId(ulong discordId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.Count(x =>
x.player_Extra != null &&
x.player_Extra.DiscordId == discordId
);
}
public static List<ulong> GetAccountIdsByDiscordId(ulong discordId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
return col
.Find(x =>
x.player_Extra != null &&
x.player_Extra.DiscordId == discordId
)
.Select(x => (ulong)x.playerid)
.ToList();
}
public static ulong GetPlayerCount()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var players = col.FindAll().ToList();
if (players.Count == 0)
return 0;
ulong minID = players.Min(p => p.playerid);
ulong maxID = players.Max(p => p.playerid);
ulong expectedCount = (ulong)(maxID - minID + 1);
return expectedCount;
}
public static List<object> Find_all_player_filtered(string? id, string? displayName, string? minLevel, string? jrState, string? platformId, string? deviceId, bool onlyBannedPlayers)
{
var col = PlayerDBFile.GetCollection<player_data>("players"); List<object> list = new List<object>();
foreach (player_data player in col.FindAll())
{
if (id != null && player.playerid.ToString() != id)
continue;
if (displayName != null && !player.player.DisplayName.Contains(displayName, StringComparison.OrdinalIgnoreCase))
continue;
if (minLevel != null && int.TryParse(minLevel, out int minLvl))
{
if (player.player.Level < minLvl)
continue;
}
if (jrState != null && jrState != "N/A")
{
bool expectedJunior = jrState == "true";
if (player.player.IsJunior != expectedJunior)
continue;
}
if (!string.IsNullOrEmpty(platformId) && (player.player.PlatformIds == null || !player.player.PlatformIds.Any(pid => pid.PlatformId.ToString() == platformId)))
{
continue;
}
if (onlyBannedPlayers)
{
var block = player.player_Extra.ModerationBlockDetails;
if (block == null || block.IsBan != true || !HasActiveModerationBlock(player.playerid))
continue;
}
if (deviceId != null && !string.Equals(player.deviceId.ToString(), deviceId, StringComparison.OrdinalIgnoreCase))
continue;
var room_data = GetAccountBulk_full(player.playerid);
if (room_data != null)
list.Add(room_data);
}
return list;
}
public static object? GetAccountBulk_full(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
List<object> list = new List<object>();
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
// The admin row template reads `player.PlatformID` (singular) — surface the first
// non-zero linked platform id so accounts created via /create-account show up as
// "Steam 76561…" instead of "Steam undefined".
var firstPlatformId = player?.player?.PlatformIds?
.FirstOrDefault(p => p != null && p.PlatformId != 0)?.PlatformId.ToString();
// PlatformID above is the first non-zero id of ANY platform, so on a Quest
// account it is an Oculus id even though the admin table labels the column
// "Steam". The Steam allowlist must never be fed that, so surface the real
// Steam id separately - empty when the account has none.
var steamId = player?.player?.PlatformIds?
.FirstOrDefault(p => p != null && p.Platform == Platforms.Steam && p.PlatformId != 0)
?.PlatformId.ToString() ?? "";
return new
{
AccountId = player?.playerid,
Level = player?.player?.Level,
xp = player?.player?.XP,
DisplayName = player?.player?.DisplayName,
DeviceId = player?.deviceId,
Username = player?.player?.Username,
IsJunior = player?.player?.IsJunior,
ProfileImage = player?.player?.profileImage,
LastLoginTime = player?.player?.last_login_time,
CreatedAt = player?.player?.created_at,
PlatformID = firstPlatformId ?? "",
SteamID = steamId,
PlatformIDs = player?.player?.PlatformIds,
// Linked Discord (website Settings > Discord tab). Empty/0 when not linked.
DiscordUsername = player?.player_Extra?.DiscordUsername ?? "",
DiscordId = (player?.player_Extra?.DiscordId ?? 0) == 0 ? "" : (player!.player_Extra!.DiscordId).ToString(),
dormroom_id = player?.player_Extra?.DormRoomID,
DevFlagRaw = (int)(player?.dev_Flag ?? DevFlag.none),
IsDev = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.dev) == DevFlag.dev,
IsMod = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.mod) == DevFlag.mod,
IsScreenshare = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.screenshare) == DevFlag.screenshare,
IsSupport = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.support) == DevFlag.support,
IsInfluencer = player?.player_Extra?.Influencer?.isInfluencer == true,
};
}
public static void DeletePlayer(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
col.Delete(playerid);
Console.WriteLine("Deleted player id " + playerid);
}
else
{
Console.WriteLine($"Player with ID {playerid} not found.");
}
}
public static int DeletePlayersWithNullLastLogin()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var minDate = DateTime.MinValue;
var playersToDelete = col
.Find(x => x.player.last_login_time == minDate)
.Select(x => x.playerid)
.ToList();
int deleted = 0;
foreach (var id in playersToDelete)
{
if (DeletePlayerFull(id))
deleted++;
}
Console.WriteLine($"Deleted {deleted} players with default LastLogin.");
return deleted;
}
public static bool DeletePlayerFull(ulong playerid)
{
try
{
DeletePlayer(playerid);
RoomDB.DeleteAllRoomsFromPlayerId(playerid);
HeartbeatDB.ClearPlayerHeartbeat(playerid);
SettingsDB.DeleteSettings(playerid);
LinkDB.DeleteLinksFromPlayerId(playerid);
ImageMetadataDB.DeleteImagesFromPlayerId(playerid);
return true;
}
catch
{
return false;
}
}
public static string SetRecentRooms(ulong playerid, ulong room_id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (player != null)
{
if (player.player.recent_rooms == null)
{
player.player.recent_rooms = new List<ulong>();
}
player.player.recent_rooms.Remove(room_id);
player.player.recent_rooms.Insert(0, room_id);
col.Update(player);
}
return "";
}
public static string GetRecentRooms(ulong playerId, int skip = 0, int take = 20)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerId);
if (player == null || player.player.recent_rooms == null || !player.player.recent_rooms.Any())
{
return "[]";
}
var paginatedRoomIds = player.player.recent_rooms
.Skip(Math.Max(0, skip))
.Take(Math.Max(1, take))
.ToList();
var visitedRooms = new List<RoomDBClasses.RoomRoot>();
foreach (ulong roomId in paginatedRoomIds)
{
var room = RoomDB.GetRoom(roomId);
if (room != null)
{
visitedRooms.Add(room);
}
}
// Strip blobs the client cannot download - this listing is fetched moments
// before every join and the client can join straight from a cached entry.
Campus_live_rewrite.api.server.RoomBlobFilter.Strip(visitedRooms);
return JsonConvert.SerializeObject(visitedRooms, Formatting.None);
}
public static string ClearRecentRoomsForAllPlayers()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var allPlayers = col.FindAll();
foreach (var player in allPlayers)
{
if (player.player.recent_rooms != null && player.player.recent_rooms.Count > 0)
{
player.player.recent_rooms = new List<ulong>();
col.Update(player);
}
}
return "";
}
public static string GetFavoritedRooms(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
// Was: `player == null || player.player_Extra.FavoritedRooms == null` -
// if player_Extra itself was null the second clause threw NRE before the
// check could help.
if (player?.player_Extra?.FavoritedRooms == null)
{
return "[]";
}
var favRooms = new List<RoomDBClasses.RoomRoot>();
foreach (ulong roomId in player.player_Extra.FavoritedRooms)
{
var room = RoomDB.GetRoom(roomId);
if (room != null)
{
favRooms.Add(room);
}
}
return JsonConvert.SerializeObject(favRooms);
}
/// <summary>
/// Records a device this account has logged in from. An account may accumulate
/// SEVERAL - a player is allowed to play on more than one machine, and each new
/// one is remembered the first time they sign in on it.
///
/// The restriction is on CREATION, not login: see CanCreateAccountOnDevice. A
/// device that already belongs to an account cannot be used to register another,
/// which is what stops alt farms, while a real player moving to a second device
/// just signs in and has it added here.
///
/// (This used to overwrite the list with a single id - one account, one device.
/// That made a legitimate second machine look like a device move on every login,
/// and it meant the creation check below had nothing reliable to test against.)
///
/// Blank ids are ignored rather than stored, so a login that sends no device id
/// cannot wipe the binding (and cannot create an unbannable empty-id account).
///
/// Device bans still work: AccountsOnDevice matches by CONTAINMENT, so banning a
/// device finds every account that has ever used it. Note the trade-off that comes
/// with allowing several devices - banning one does not stop a player who has
/// another registered.
/// </summary>
public static void SetPlayerDeviceId(string DeviceId, ulong playerid)
{
if (string.IsNullOrWhiteSpace(DeviceId)) return;
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player == null) return;
string id = DeviceId.Trim();
var current = player.deviceId ?? new List<string>();
// Already known for this account - nothing to write.
if (current.Contains(id)) return;
current.Add(id);
player.deviceId = current;
col.Update(player);
Console.WriteLine($"[device] account {playerid} added device '{id}' "
+ $"({current.Count} total)");
}
/// <summary>Drop device bindings (the account lock replacing an idle device). The flat
/// anti-alt deviceId list is left alone: that device stays "used" for alt purposes.</summary>
public static void UnbindDevices(ulong playerid, IEnumerable<string> deviceIds)
{
var ids = new HashSet<string>(deviceIds, StringComparer.Ordinal);
if (ids.Count == 0) return;
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.DeviceBindings == null) return;
int before = player.DeviceBindings.Count;
player.DeviceBindings.RemoveAll(b => b.DeviceId != null && ids.Contains(b.DeviceId));
if (player.DeviceBindings.Count != before) col.Update(player);
}
/// <summary>The account's device bindings (device id + the platform it signed in on).
/// Empty for legacy rows until the next login re-binds them.</summary>
public static List<mDeviceBind> GetDeviceBindings(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
return player?.DeviceBindings ?? new List<mDeviceBind>();
}
/// <summary>
/// Record that this account signed in from this device on this platform. Writes BOTH the
/// flat deviceId list (the anti-alt source of truth) and the platform-aware DeviceBindings
/// list (used by the account lock's one-PC-one-Quest cap). Idempotent per (device,platform).
/// </summary>
public static void BindDevice(string? deviceId, Platforms platform, ulong playerid)
{
if (string.IsNullOrWhiteSpace(deviceId)) return;
string id = deviceId.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player == null) return;
bool changed = false;
var flat = player.deviceId ?? new List<string>();
if (!flat.Contains(id)) { flat.Add(id); player.deviceId = flat; changed = true; }
var binds = player.DeviceBindings ?? new List<mDeviceBind>();
var mine = binds.FirstOrDefault(b => b.DeviceId == id && b.Platform == platform);
if (mine == null)
{
binds.Add(new mDeviceBind { DeviceId = id, Platform = platform, LastSeen = DateTime.UtcNow });
player.DeviceBindings = binds;
changed = true;
}
else
{
// Touch: the account lock lets a NEW device of the same class take over when this
// one has been idle (AccountLock), so every login has to stamp it.
mine.LastSeen = DateTime.UtcNow;
player.DeviceBindings = binds;
changed = true;
}
if (changed)
{
col.Update(player);
Console.WriteLine($"[device] account {playerid} bound device '{id}' on {platform} "
+ $"({binds.Select(b => b.DeviceId).Distinct().Count()} device(s))");
}
}
/// <summary>
/// Clear an account's device bindings so it can be signed in from fresh hardware - the
/// operator's "reset" when a player legitimately replaces their PC or headset. Clears both
/// the flat deviceId list and the platform-aware DeviceBindings.
/// </summary>
public static bool ClearDeviceBindings(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null) return false;
player.deviceId = new List<string>();
player.DeviceBindings = new List<mDeviceBind>();
col.Update(player);
Console.WriteLine($"[device] account {playerid} device bindings CLEARED");
return true;
}
/// <summary>
/// May a NEW account be created from this device?
///
/// False once any existing account has used it. This is the whole anti-alt rule:
/// one account per device at creation time, while logins from further devices are
/// unrestricted (SetPlayerDeviceId).
///
/// A blank device id returns TRUE - it carries no information, and refusing on it
/// would lock out every caller that does not send one. Callers that care must
/// require a non-empty id themselves before asking.
/// </summary>
public static bool CanCreateAccountOnDevice(string? deviceId, out ulong existingAccountId)
{
existingAccountId = 0;
if (string.IsNullOrWhiteSpace(deviceId)) return true;
var owners = AccountsOnDevice(deviceId).Where(o => !AccountLock.IsExempt(o)).ToList();
if (owners.Count == 0) return true;
// A device may hold one game-made account and one website account (see
// ConfigCampus.MaxAccountsPerDevice). Creating in-game takes the game-made slot: refused
// once the device is full, or once it already has a game-made account.
existingAccountId = owners[0];
if (owners.Count >= ConfigCampus.MaxAccountsPerDevice) return false;
var gameMade = owners.FirstOrDefault(o => OriginOf(o) == AccountOrigin.InGame);
if (gameMade != 0) { existingAccountId = gameMade; return false; }
return true;
}
public enum AccountOrigin { Unknown, InGame, Website }
/// <summary>
/// Where an account was made. Rows created since the flag exists say so explicitly; older
/// rows are Unknown, and an Unknown account fits EITHER of a device's two slots (it only
/// counts toward the cap), so nobody who existed before the rule is locked out by it.
/// </summary>
public static AccountOrigin OriginOf(ulong accountId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var p = col.FindOne(x => x.playerid == accountId);
if (p?.CreatedInGame == null) return AccountOrigin.Unknown;
return p.CreatedInGame.Value ? AccountOrigin.InGame : AccountOrigin.Website;
}
/// <summary>The account's single device id, or null if it has never logged in.</summary>
public static string? GetDeviceId(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
var list = player?.deviceId;
if (list == null || list.Count == 0) return null;
return string.IsNullOrWhiteSpace(list[0]) ? null : list[0];
}
/// <summary>
/// Cached-login picker entries for the accounts bound to this device on this platform
/// (see ConfigCampus.QuestDeviceCachedLogin). requirePassword=false: device ownership is the
/// credential, the cached_login grant re-checks the same binding before issuing a token.
/// </summary>
public static List<AccountLogin> GetDeviceLogins(string? deviceId, Platforms platform, ulong platformId)
{
var result = new List<AccountLogin>();
if (string.IsNullOrWhiteSpace(deviceId)) return result;
string id = deviceId.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
foreach (var p in col.FindAll())
{
if (p?.player == null || p.deviceId == null || !p.deviceId.Contains(id)) continue;
// Only a binding made from THIS platform class counts (a PC login on some shared
// device id must not turn into a headset cached login).
if (p.DeviceBindings == null || !p.DeviceBindings.Any(b => b.DeviceId == id && b.Platform == platform)) continue;
result.Add(new AccountLogin
{
accountId = p.playerid,
createdAt = p.player.created_at,
lastLoginTime = p.player.last_login_time,
platform = platform,
platformId = platformId.ToString(),
requirePassword = false
});
}
return result.OrderByDescending(a => a.lastLoginTime).ToList();
}
/// <summary>Is this account bound to this device on this platform?</summary>
public static bool IsDeviceBoundTo(ulong accountId, string? deviceId, Platforms platform)
{
if (string.IsNullOrWhiteSpace(deviceId)) return false;
string id = deviceId.Trim();
var binds = GetDeviceBindings(accountId);
return binds.Any(b => b.DeviceId == id && b.Platform == platform);
}
/// <summary>
/// Every account bound to this device id. Surfaced by the admin panel so a device
/// ban shows what else it takes out (alt accounts).
/// </summary>
public static List<ulong> AccountsOnDevice(string? deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId)) return new List<ulong>();
string id = deviceId.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll()
.Where(x => x.deviceId != null && x.deviceId.Contains(id))
.Select(x => x.playerid)
.ToList();
}
/// <summary>Clears an account-level ban. Device/IP bans are lifted separately (BanDB).</summary>
public static bool ClearModerationBlock(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player_Extra == null) return false;
player.player_Extra.ModerationBlockDetails = null;
col.Update(player);
return true;
}
public static void SetPlayerDiscordId(ulong DiscordId, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
if (player.player != null)
{
player.player_Extra.DiscordId = DiscordId;
col.Update(player);
return;
}
}
}
public static string GetPlayerNameById(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
return player?.player.Username ?? "Unknown";
}
public static ulong GetPlayerIdByName(string name)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Username == name);
return player?.player.Id ?? 0;
}
public static string GetAvatarSaved(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
// Was: only `player` was null-checked, then player_Extra.SavedOutfits was
// dereferenced - NRE when player_Extra is null. And SerializeObject(null)
// returns the literal "null", not "[]", which the client cannot parse as a
// list. Both paths now yield a real empty array.
var saved = player?.player_Extra?.SavedOutfits;
if (saved == null || saved.Count == 0)
{
return "[]";
}
// The newer client's Saved Outfits page (SavedOutfitsWatchUIFlow) deserializes each
// outfit into the StoredOutfit envelope and THROWS "Expected LegacyData but was null"
// (DCJDHBOIBBC.MissingLegacyData) on any entry without a `LegacyData` object - which
// then blanks the whole page. Serializing the STRUCTURED avatar_data_saved (which has
// no LegacyData field) is exactly that failure. So project each outfit into the shape
// the client expects:
// - Preferred: round-trip the RAW document the client posted (SaveOutfit stores it in
// .Avatar); it already carries LegacyData/Selections/DataVersion verbatim.
// - Fallback (older saves / server-made presets with no raw body): synthesize the
// LegacyData envelope from the flat fields so LegacyData is never null.
var arr = new Newtonsoft.Json.Linq.JArray();
foreach (var o in saved)
{
Newtonsoft.Json.Linq.JObject? raw = null;
if (!string.IsNullOrWhiteSpace(o.Avatar))
{
try { raw = Newtonsoft.Json.Linq.JObject.Parse(o.Avatar); } catch { raw = null; }
}
if (raw != null && raw["LegacyData"] != null &&
raw["LegacyData"]!.Type != Newtonsoft.Json.Linq.JTokenType.Null)
{
// Slot/Name are the authority for slot overwrite - keep them in sync with the
// structured fields in case the stored raw body predated them.
raw["Slot"] = (long)o.Slot;
if (o.Name != null) raw["Name"] = o.Name;
arr.Add(raw);
continue;
}
arr.Add(new Newtonsoft.Json.Linq.JObject
{
["LegacyData"] = new Newtonsoft.Json.Linq.JObject
{
["SelectionsV1"] = o.OutfitSelections,
["SelectionsV2"] = o.OutfitSelectionsV2,
["FaceFeatures"] = o.FaceFeatures,
["SkinColor"] = o.SkinColor,
["HairColor"] = o.HairColor
},
["Selections"] = new Newtonsoft.Json.Linq.JArray(),
["DataVersion"] = 9,
["CustomizationSettings"] = (string?)null,
["ThumbnailFileName"] = o.ImageName ?? o.PreviewImageName,
["Name"] = o.Name,
["Accessibility"] = 0,
["Slot"] = (long)o.Slot
});
}
return arr.ToString(Newtonsoft.Json.Formatting.None);
}
public static string SetAvatarSaved(ulong playerid, List<avatar_data_saved> avatars)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
// Was: `player == null` only - player_Extra was dereferenced unchecked.
if (player?.player_Extra == null)
return "[]";
if (player.player_Extra.SavedOutfits == null)
player.player_Extra.SavedOutfits = new List<avatar_data_saved>();
var incomingSlots = avatars.Select(a => a.Slot).ToHashSet();
player.player_Extra.SavedOutfits.RemoveAll(a => incomingSlots.Contains(a.Slot));
player.player_Extra.SavedOutfits.AddRange(avatars);
col.Update(player);
return JsonConvert.SerializeObject(player.player_Extra.SavedOutfits);
}
public static void SetPlayerCheer(CheerCategoryEnum cheer = CheerCategoryEnum.None, ulong playerid = 0)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player.PlayerReputation != null)
{
player_Setting1.player.PlayerReputation.SelectedCheer = cheer;
col.Update(player_Setting1);
return;
}
}
}
public static CheerCategoryEnum? GetPlayerCheer(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player.PlayerReputation != null)
{
return player_Setting1.player.PlayerReputation.SelectedCheer;
}
}
return null;
}
public static List<ulong> GetInfluencerIds()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var result = col.Query().Where(x => x.player_Extra.Influencer.isInfluencer == true).Select(x => x.playerid).ToList();
return result;
}
public static player_data? GetInfluencerByCreatorCode(string creatorcode)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player_Extra.Influencer.creatorCode == creatorcode && x.player_Extra.Influencer.isInfluencer == true);
return player;
}
public static bool IsInfluencer(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
return player?.player_Extra?.Influencer?.isInfluencer == true;
}
public static void SetIsInfluencer(ulong playerid, bool IsInfluencer)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return;
if (player.player_Extra == null)
player.player_Extra = new player_extra();
if (player.player_Extra.Influencer == null)
player.player_Extra.Influencer = new Influencer();
player.player_Extra.Influencer.isInfluencer = IsInfluencer;
col.Update(player);
}
public static void SetCreatorCode(ulong playerid, string creatorcode)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return;
if (player.player_Extra == null)
player.player_Extra = new player_extra();
if (player.player_Extra.Influencer == null)
player.player_Extra.Influencer = new Influencer();
player.player_Extra.Influencer.creatorCode = creatorcode;
col.Update(player);
}
public static void SupportInfluencer(ulong requestplayerid, ulong SupportId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == requestplayerid);
if (player != null)
{
if (player.player_Extra.Influencer == null)
{
player.player_Extra.Influencer = new Influencer();
}
player.player_Extra.Influencer.SupportingInfluencer = SupportId;
col.Update(player);
return;
}
}
public static ulong? GetMyInfluencer(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
if (player.player_Extra.Influencer != null)
{
return player.player_Extra.Influencer.SupportingInfluencer;
}
}
return 0;
}
// message funcs
public static List<MessageData> GetMessages(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player == null)
return new List<MessageData>();
if (player.player_Extra.Messages == null)
player.player_Extra.Messages = new List<MessageData>();
return player.player_Extra.Messages;
}
public static MessageData? AddMessage(ulong toPlayerId, ulong fromPlayerId, WebsocketEvents.MessageType type, string? data, ulong? roomId = null, ulong? playerEventId = null)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == toPlayerId);
if (player == null)
return null;
if (player.player_Extra.Messages == null)
player.player_Extra.Messages = new List<MessageData>();
var msg = new MessageData()
{
Id = GenerateRandomMessageId(),
FromPlayerId = fromPlayerId,
SentTime = DateTime.UtcNow,
Type = type,
Data = data,
RoomId = roomId,
PlayerEventId = playerEventId
};
player.player_Extra.Messages.Add(msg);
col.Update(player);
// Nudge the recipient's open website tabs to re-fetch the inbox (cheers, coach
// messages, ...). No-op if they have no browser tab open.
Campus_live_rewrite.api.server.SiteNotifications.Notify(toPlayerId);
return msg;
}
public static void DeleteMessage(ulong playerId, ulong messageId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player == null)
return;
if (player.player_Extra.Messages == null || player.player_Extra.Messages.Count == 0)
return;
var message = player.player_Extra.Messages.FirstOrDefault(m => m.Id == messageId);
if (message == null)
return;
player.player_Extra.Messages.Remove(message);
// Save changes
col.Update(player);
}
public static ulong GenerateRandomMessageId()
{
lock (randLock)
{
byte[] buffer = new byte[8];
random.NextBytes(buffer);
return BitConverter.ToUInt64(buffer, 0);
}
}
// message funcs
public static int SetCheerCredit(int cheerCredit, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.PlayerReputation.CheerCredit = cheerCredit;
col.Update(player_Setting1);
return cheerCredit;
}
}
return 0;
}
public static void Setup()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
col.EnsureIndex(x => x.playerid, true);
col.EnsureIndex(x => x.platform);
col.EnsureIndex(x => x.player.PlatformIds);
col.EnsureIndex(x => x.player.Username);
col.EnsureIndex(x => x.deviceId);
col.EnsureIndex(x => x.authtoken);
col.EnsureIndex(x => x.id);
col.EnsureIndex(x => x.player_Extra.Messages);
col.EnsureIndex("infl", "playerExtra.Influencer.isInfluencer");
}
public static string? GetPlayerData(ulong playerId, ulong roomId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player?.player_Extra?.MyRoomData?.PlayerData == null)
return null;
var entry = player.player_Extra.MyRoomData.PlayerData.FirstOrDefault(p => p.RoomId == roomId);
return entry?.Data;
}
public static bool SetPlayerData(ulong playerId, ulong roomId, string data)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player == null)
return false;
if (player.player_Extra == null)
player.player_Extra = new player_extra();
if (player.player_Extra.MyRoomData == null)
player.player_Extra.MyRoomData = new MyRoomData();
if (player.player_Extra.MyRoomData.PlayerData == null)
player.player_Extra.MyRoomData.PlayerData = new List<PlayerData>();
var existingEntry = player.player_Extra.MyRoomData.PlayerData.FirstOrDefault(p => p.RoomId == roomId);
if (existingEntry != null)
{
existingEntry.Data = data;
}
else
{
player.player_Extra.MyRoomData.PlayerData.Add(new PlayerData
{
RoomId = roomId,
Data = data
});
}
return col.Update(player);
}
public static PlayerPhotoTaggingSetting GetPlayerPhotoTaggingSetting(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
if (player.player != null)
{
return player.player_Extra.PlayerPhotoTaggingSetting;
}
}
return PlayerPhotoTaggingSetting.Anyone;
}
public static bool SetPlayerPhotoTaggingSetting(ulong playerid, PlayerPhotoTaggingSetting playerPhotoTaggingSetting)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
if (player.player != null)
{
player.player_Extra.PlayerPhotoTaggingSetting = playerPhotoTaggingSetting;
col.Update(player);
return true;
}
}
return false;
}
}
}
@@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Player reports - the ones raised from the in-game report UI.
///
/// The client files them with POST /api/PlayerReporting/v3/create (form-encoded; the reporter
/// is the bearer token, never a body field). That route did not exist here at all, so every
/// report a player made 404'd and was lost - which is why reporting "did nothing" and why the
/// admin panel had nothing to show. Reports now land in this store and the admin panel's
/// Reports section reads and acts on them.
///
/// A report is filed OPEN. A moderator resolves it, and resolving is the only thing that
/// changes its state - the ban / kick an action performs goes through the normal ban path so
/// there is one ban mechanism, not two.
/// </summary>
public static class ReportsDB
{
private static readonly LiteDatabase _db =
new(System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "DBs", "Reports.db"));
/// <summary>Merge this store's write-ahead log back into the data file (see DbMaintenance).</summary>
public static void Checkpoint() => _db.Checkpoint();
public class Report
{
[BsonId]
public long Id { get; set; }
public ulong ReporterPlayerId { get; set; }
public ulong ReportedPlayerId { get; set; }
/// <summary>Stored verbatim, unmapped - Moderation_Detail.ReportCategory names it for display.</summary>
public int ReportCategory { get; set; }
public string? Details { get; set; }
public double? HeightReporter { get; set; }
public double? HeightReported { get; set; }
/// <summary>Null when the report was raised outside a room (the client omits it).</summary>
public ulong? RoomId { get; set; }
public string? RoomInstanceType { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// ---- website reports -------------------------------------------------------
// Filed from the site's Report buttons (POST /api/site/report). Source is "website";
// Subject says what was reported (player / photo / comment), SubjectId which one,
// SubjectLink is the site page a moderator can open, Reason the label the reporter
// picked. In-game reports leave these null and keep using ReportCategory.
public string? Source { get; set; }
public string? Subject { get; set; }
public long SubjectId { get; set; }
public string? SubjectLink { get; set; }
public string? Reason { get; set; }
// ---- moderation state -------------------------------------------------------
public bool Resolved { get; set; }
public string? Action { get; set; } // what was done: dismissed / warned / banned / ...
public ulong ResolvedByPlayerId { get; set; }
public DateTime? ResolvedAt { get; set; }
public string? ModeratorNote { get; set; }
}
private static ILiteCollection<Report> Col
{
get
{
var c = _db.GetCollection<Report>("reports");
c.EnsureIndex(x => x.ReportedPlayerId);
c.EnsureIndex(x => x.CreatedAt);
c.EnsureIndex(x => x.Resolved);
return c;
}
}
public static Report Create(Report r)
{
r.CreatedAt = DateTime.UtcNow;
r.Resolved = false;
Col.Insert(r);
Console.WriteLine($"[report] #{r.Id}: {r.ReporterPlayerId} reported {r.ReportedPlayerId} " +
$"(category {r.ReportCategory}{(r.RoomId != null ? $", room {r.RoomId}" : "")})");
return r;
}
public static Report? Get(long id) => Col.FindById(id);
/// <summary>Newest first. openOnly leaves resolved reports out of the queue.</summary>
public static List<Report> List(bool openOnly, int skip, int take, ulong reportedPlayerId = 0)
{
var q = Col.FindAll().AsEnumerable();
if (openOnly) q = q.Where(x => !x.Resolved);
if (reportedPlayerId != 0) q = q.Where(x => x.ReportedPlayerId == reportedPlayerId);
return q.OrderByDescending(x => x.CreatedAt).Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, 500)).ToList();
}
public static int CountOpen() => Col.Count(x => !x.Resolved);
/// <summary>An OPEN report by this reporter about the same thing already exists.</summary>
public static bool HasOpenDuplicate(ulong reporter, string subject, long subjectId, ulong reported) =>
Col.Exists(x => !x.Resolved && x.ReporterPlayerId == reporter && x.Subject == subject
&& x.SubjectId == subjectId && x.ReportedPlayerId == reported);
/// <summary>How many reports this player has had filed against them, ever.</summary>
public static int CountAgainst(ulong playerId) => Col.Count(x => x.ReportedPlayerId == playerId);
public static bool Resolve(long id, ulong byPlayerId, string action, string? note)
{
var r = Col.FindById(id);
if (r == null) return false;
r.Resolved = true;
r.Action = action;
r.ResolvedByPlayerId = byPlayerId;
r.ResolvedAt = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(note)) r.ModeratorNote = note;
Col.Update(r);
Console.WriteLine($"[report] #{id} resolved by {byPlayerId}: {action}");
return true;
}
/// <summary>
/// Resolve every OPEN report against one player at once. Used when an action is taken on a
/// player rather than on a single report - banning someone should clear their queue, not
/// leave nine duplicates of the thing you just acted on.
/// </summary>
public static int ResolveAllAgainst(ulong reportedPlayerId, ulong byPlayerId, string action, string? note)
{
int n = 0;
foreach (var r in Col.Find(x => x.ReportedPlayerId == reportedPlayerId && !x.Resolved).ToList())
if (Resolve(r.Id, byPlayerId, action, note)) n++;
return n;
}
}
}
@@ -0,0 +1,109 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
// Comments pinned in a room's scene - the little notes players leave stuck to a spot in
// a room. Ported from RecFlare's `roomcomments` service, which the 2025 client talks to
// on the `/RoomComments` base URL (see WebController's service map). There were no
// handlers for it, so reading a room's notes and leaving one both 404'd.
//
// Stored as a collection INSIDE Rooms.db so it rides RoomDB's existing checkpoint
// (DbMaintenance) instead of adding another write-ahead log that never merges.
public static class RoomCommentsDB
{
public const int MaxCommentLength = 1000;
public const int DefaultCount = 100;
public const int MaxCount = 500;
// Matches RecFlare's RoomCommentEntry field-for-field (PascalCase, the client's shape).
public class RoomCommentEntry
{
[BsonId]
public int CommentId { get; set; } // autoincrement; also the read cursor (minId)
public long RoomId { get; set; }
public long SubRoomId { get; set; }
public ulong AccountId { get; set; } // who wrote it
public string CreatedAt { get; set; } = ""; // ISO-8601 UTC
public string Message { get; set; } = "";
public int Style { get; set; }
public bool Unread { get; set; } = true; // always true - read state, not per-viewer
public double PositionX { get; set; }
public double PositionY { get; set; }
public double PositionZ { get; set; }
}
private static readonly ILiteCollection<RoomCommentEntry> _col =
RoomDB.rooms_db.GetCollection<RoomCommentEntry>("room_comments");
static RoomCommentsDB()
{
_col.EnsureIndex(x => x.RoomId);
}
/// <summary>
/// A room's comments, NEWEST FIRST. minId is an EXCLUSIVE cursor (-1 = give me
/// everything), so a client holding up to id N polls with minId=N and gets only what
/// was written since. count is clamped 1..500. An unknown room simply has none.
/// </summary>
public static List<RoomCommentEntry> Get(long roomId, int? count, int? minId, int? subRoomId)
{
int take = count.HasValue ? Math.Clamp(count.Value, 1, MaxCount) : DefaultCount;
int cursor = minId ?? -1;
var q = _col.Query().Where(x => x.RoomId == roomId);
// subRoomId omitted -> the whole room; given -> narrow to that subroom.
if (subRoomId.HasValue)
{
int sr = subRoomId.Value;
q = q.Where(x => x.SubRoomId == sr);
}
return q.ToEnumerable()
.Where(x => x.CommentId > cursor) // exclusive
.OrderByDescending(x => x.CommentId) // newest first
.Take(take)
.ToList();
}
/// <summary>
/// Pins a comment in a subroom's scene. Author is the caller (never trusted from the
/// body). Message is truncated to MaxCommentLength; a blank message or missing subroom
/// is the caller's responsibility to reject (returns null on a blank message here as a
/// safety net). Returns the stored comment so the client can draw the bubble it placed.
/// </summary>
public static RoomCommentEntry? Create(long roomId, ulong accountId, long subRoomId,
string message, int style, double x, double y, double z)
{
message = (message ?? "").Trim();
if (message.Length > MaxCommentLength) message = message.Substring(0, MaxCommentLength);
if (message.Length == 0) return null;
int nextId = (_col.Query().OrderByDescending(c => c.CommentId).FirstOrDefault()?.CommentId ?? 0) + 1;
var entry = new RoomCommentEntry
{
CommentId = nextId,
RoomId = roomId,
SubRoomId = subRoomId,
AccountId = accountId,
CreatedAt = DateTime.UtcNow.ToString("o"),
Message = message,
Style = style,
Unread = true,
PositionX = x,
PositionY = y,
PositionZ = z
};
_col.Insert(entry);
Console.WriteLine($"[roomcomments] {accountId} pinned a comment in room {roomId}/{subRoomId} (id {nextId})");
return entry;
}
/// <summary>Deletes one comment - for admin moderation.</summary>
public static bool Delete(int commentId) => _col.Delete(commentId);
/// <summary>Every comment across all rooms, newest first - for the admin panel.</summary>
public static List<RoomCommentEntry> GetAll()
=> _col.Query().OrderByDescending(x => x.CommentId).ToList();
}
}
@@ -0,0 +1,2599 @@
using System.Diagnostics.Metrics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Discord;
using LiteDB;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Security.Cryptography;
using Campus_live_rewrite.api.server.Classes.db_class;
using static Campus_live_rewrite.api.server.Classes.db_class.RoomDBClasses;
using System.Linq;
namespace Campus_live_rewrite.api.dbs
{
public class RoomDB
{
public static LiteDatabase rooms_db = new LiteDatabase(Environment.CurrentDirectory + $"/Data/DBs/Rooms.db");
private static readonly object _roomLock = new();
public static readonly ILiteCollection<RoomRoot> col = rooms_db.GetCollection<RoomRoot>("Main_rooms");
public static readonly ILiteCollection<SubRoom> subroom_col = rooms_db.GetCollection<SubRoom>("Main_subrooms");
public static async Task ImportRooms(string path, ulong? ownerOverride = null)
{
string jsonData = await File.ReadAllTextAsync(path);
var hotRooms = JsonConvert.DeserializeObject<Resultslist<RoomDBClasses.RoomRoot>>(jsonData);
if (hotRooms?.Results == null)
return;
foreach (var room in hotRooms.Results)
{
// Re-own every imported room to the given account (the @Importer account), so all
// uploaded rooms land under one owner regardless of who created them originally.
if (ownerOverride.HasValue) room.CreatorAccountId = ownerOverride.Value;
AddRoom(room, isImportingFromJson: true);
}
}
// Rec Room exports carry JSON nulls for value-type fields (e.g. "PublishedAt": null); the
// server's RoomRoot types those as non-nullable DateTime, so a plain ToObject throws and the
// whole room import dies before any blob is placed. Ignoring nulls leaves such fields at
// their default instead of throwing.
private static readonly Newtonsoft.Json.JsonSerializer _lenientRoomSerializer =
Newtonsoft.Json.JsonSerializer.Create(new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
public class RoomExportResult
{
public int Rooms, Blobs, Images;
public readonly List<string> Names = new();
public readonly List<string> Errors = new();
}
/// <summary>
/// Import Rec Room's OFFICIAL room-export format (the one the game's export tool produces):
///
/// {Room}/RoomDetails.json # a SINGLE RoomRoot object (NOT {Results:[...]})
/// {Room}/super_room_data(.original).binpb # bytes for RoomRoot.DataBlob (a .meta)
/// {Room}/RoomImage.jpg # bytes for RoomRoot.ImageName
/// {Room}/SubRoom_{SubRoomId}_{Name}/persisted_room_data(.original).binpb
/// # bytes for that subroom's CurrentSave.DataBlob (a .room)
///
/// The OLD ImportRooms() only understood the server's own {Results:[...]} dump AND expected the
/// .room/.meta blobs to already exist under their content-hash names. A real export has neither:
/// RoomDetails.json is a bare object, and the blob bytes live in the .binpb files under DIFFERENT
/// names. So a dragged-in export imported 0 rooms (or a room with no build = bare scene).
///
/// The blob mapping is verified by hash: RoomRoot.DataBlob's recorded DataBlobHash matches
/// super_room_data.binpb, and each CurrentSave.DataBlobHash matches that subroom's
/// persisted_room_data.ORIGINAL.binpb (the pre-migration copy is the canonical blob the manifest
/// records; the plain .binpb is a migrated variant). We pick, per blob, the .binpb whose SHA-256
/// equals the recorded hash and write it under the DataBlob name into RoomBlobs, so the client's
/// integrity check passes and the room actually loads its build.
/// </summary>
public static async Task<RoomExportResult> ImportRoomExport(string dir, ulong? ownerOverride = null)
{
var res = new RoomExportResult();
string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
string imgDir = Path.Combine(Environment.CurrentDirectory, "Data", "Images");
Directory.CreateDirectory(blobDir);
Directory.CreateDirectory(imgDir);
var manifests = Directory.EnumerateFiles(dir, "RoomDetails.json", SearchOption.AllDirectories).ToList();
if (manifests.Count == 0) { res.Errors.Add("no RoomDetails.json found"); return res; }
foreach (var manifest in manifests)
{
try
{
string baseDir = Path.GetDirectoryName(manifest)!;
var tok = JToken.Parse(await File.ReadAllTextAsync(manifest));
var roomTokens =
tok is JObject o && o["Results"] is JArray ra ? ra.Cast<JToken>().ToList() :
tok is JArray arr ? arr.Cast<JToken>().ToList() :
new List<JToken> { tok };
foreach (var rt in roomTokens)
{
var room = rt.ToObject<RoomRoot>(_lenientRoomSerializer);
if (room == null) { res.Errors.Add($"{Path.GetFileName(baseDir)}: could not parse RoomDetails.json"); continue; }
// Downgrade the room's declared build version to the client's level so the
// client doesn't reject it with "playing on an older version of Rec Room than
// this room was saved with." Working base rooms declare no minimum; the subroom
// CurrentSave versions are zeroed separately in AddRoom. See ConfigCampus.
if (Campus_live_rewrite.api.server.ConfigCampus.ClampImportedRoomVersionToClient)
{
room.MinUgcSubVersion = null;
room.UgcSubVersion = null;
room.PersistenceVersion = 0;
}
// DataBlobHash is NOT modelled on RoomRoot/CurrentSave (commented out), so read
// the recorded hashes straight off the raw JSON to hash-match the .binpb files.
string? roomHash = (string?)rt["DataBlobHash"];
var subHashById = new Dictionary<ulong, string?>();
if (rt["SubRooms"] is JArray sra)
foreach (var st in sra)
if (ulong.TryParse((string?)st["SubRoomId"], out var sid))
subHashById[sid] = (string?)st["CurrentSave"]?["DataBlobHash"];
// Room-level blob (super room data) -> RoomBlobs/<DataBlob>.
if (!string.IsNullOrWhiteSpace(room.DataBlob))
{
var src = PickBlobByHash(baseDir, new[] { "super_room_data.original.binpb", "super_room_data.binpb" }, roomHash);
if (src != null) { File.Copy(src, Path.Combine(blobDir, room.DataBlob), true); res.Blobs++; }
else res.Errors.Add($"{room.Name}: super_room_data.binpb not found for DataBlob {room.DataBlob}");
}
// Each subroom's build blob -> RoomBlobs/<CurrentSave.DataBlob>.
foreach (var sub in room.SubRooms ?? new List<SubRoom>())
{
var cs = sub.CurrentSave;
if (cs == null || string.IsNullOrWhiteSpace(cs.DataBlob)) continue;
string? subDir = FindSubRoomDir(baseDir, sub.SubRoomId);
if (subDir == null) { res.Errors.Add($"{room.Name}/{sub.Name}: SubRoom_{sub.SubRoomId}_* folder missing"); continue; }
subHashById.TryGetValue(sub.SubRoomId, out var subHash);
var src = PickBlobByHash(subDir, new[] { "persisted_room_data.original.binpb", "persisted_room_data.binpb" }, subHash);
if (src != null) { File.Copy(src, Path.Combine(blobDir, cs.DataBlob), true); res.Blobs++; }
else res.Errors.Add($"{room.Name}/{sub.Name}: persisted_room_data.binpb not found for DataBlob {cs.DataBlob}");
}
// Room thumbnail -> Images/<ImageName>. Skip when the room has no custom image
// (ImageName is the shared DefaultRoomImage.jpg) so we don't clobber the global
// fallback every other room falls back to.
if (!string.IsNullOrWhiteSpace(room.ImageName) &&
!room.ImageName.StartsWith("Default", StringComparison.OrdinalIgnoreCase))
{
var img = Directory.EnumerateFiles(baseDir, "RoomImage.*", SearchOption.TopDirectoryOnly).FirstOrDefault();
if (img != null) { try { File.Copy(img, Path.Combine(imgDir, room.ImageName), true); res.Images++; } catch { } }
}
if (ownerOverride.HasValue) room.CreatorAccountId = ownerOverride.Value;
var id = AddRoom(room, isImportingFromJson: true);
if (id != 0) { res.Rooms++; res.Names.Add(room.Name); }
else res.Errors.Add($"{room.Name}: not added (a room with that name may already exist)");
}
}
catch (Exception ex) { res.Errors.Add($"{Path.GetFileName(Path.GetDirectoryName(manifest))}: {ex.Message}"); }
}
return res;
}
/// <summary>
/// Clamp the declared build version of every @Importer-owned room down to the client's level
/// (no minimum), so already-imported rooms stop tripping the client's "playing on an older
/// version of Rec Room" gate. Idempotent; only touches imported rooms (base rooms are owned
/// by other accounts and already declare no minimum). Returns how many rooms were changed.
/// </summary>
public static int ClampImportedRoomsToClient(ulong importerId)
{
if (!Campus_live_rewrite.api.server.ConfigCampus.ClampImportedRoomVersionToClient || importerId == 0) return 0;
int n = 0;
lock (_roomLock)
{
foreach (var room in col.Find(r => r.CreatorAccountId == importerId).ToList())
{
bool ch = false;
if (room.MinUgcSubVersion != null) { room.MinUgcSubVersion = null; ch = true; }
if (room.UgcSubVersion != null) { room.UgcSubVersion = null; ch = true; }
if (room.PersistenceVersion != null && room.PersistenceVersion != 0) { room.PersistenceVersion = 0; ch = true; }
if (ch) { col.Update(room); n++; }
}
}
return n;
}
/// <summary>
/// Wrap a loose .room blob (a single subroom's build data, with no RoomDetails.json) in a
/// minimal, joinable @Importer-owned room so it can actually be played. The blob must already
/// be present in Data/CDN/RoomBlobs under `roomFile`. The room-level blob points at the shared
/// default_room.META (empty room variables). Scene id is taken from the blob when present, else
/// the standard MakerPen sandbox scene. Returns the new RoomId, or 0 if a room with that name
/// already exists / creation failed.
/// </summary>
public static ulong CreateRoomFromBlob(string roomFile, string roomName, ulong ownerId, string? sceneId = null, int persistenceVersion = 0)
{
const string SandboxScene = "a75f7547-79eb-47c6-8986-6767abcb4f92";
string scene = string.IsNullOrWhiteSpace(sceneId) ? SandboxScene : sceneId!;
var room = new RoomRoot
{
Name = roomName,
Description = "Imported from a loose .room blob.",
CreatorAccountId = ownerId,
Accessibility = RoomAccessibility.Public,
State = RoomState.Active,
PublishState = 0,
CreatedAt = DateTime.UtcNow,
PublishedAt = DateTime.UtcNow,
DataBlob = "default_room.META", // shared empty room-level (super) blob
ImageName = "DefaultRoomImage.jpg",
MaxPlayers = 20,
MaxPlayerCalculationMode = 0,
AgeRating = 0,
MinLevel = 0,
CloningAllowed = false,
SupportsScreens = true,
SupportsWalkVR = true,
SupportsTeleportVR = true,
SupportsVRLow = true,
SupportsQuest2 = true,
SupportsMobile = true,
SupportsJuniors = false,
IsRRO = false,
UgcVersion = 1,
UgcSubVersion = null, // no minimum -> passes the client version gate
MinUgcSubVersion = null,
PersistenceVersion = persistenceVersion, // the blob's real save-format version
LoadScreens = new List<LoadScreen> { new LoadScreen { ImageName = "", Title = roomName, Subtitle = "" } },
Stats = new Stats(),
Tags = new List<TagClass>(),
Roles = new List<RoleClass>(),
PromoImages = new List<string>(),
PromoExternalContent = new List<string>(),
RestrictedCircuitsAllowListNames = new List<string>(),
SubRooms = new List<SubRoom>
{
new SubRoom
{
Name = roomName,
UnitySceneId = scene,
IsSandbox = true,
MaxPlayers = 20,
Accessibility = RoomAccessibility.Public,
ShouldAutoStageSaves = true,
AllSaves = new List<CurrentSave>(),
CurrentSave = new CurrentSave
{
DataBlob = roomFile,
Description = "Imported from a loose .room blob.",
CreatedAt = DateTime.UtcNow,
SavedByAccountId = ownerId,
PersistenceVersion = 0,
OMVersion = 0,
UgcSubVersion = 0,
ReferencedUnityAssetIds = new List<string>()
}
}
}
};
return AddRoom(room, isImportingFromJson: true);
}
/// <summary>True if any room (room-level DataBlob) or subroom (CurrentSave/AllSaves DataBlob) already points at this blob.</summary>
public static bool AnyRoomUsesBlob(string blobName)
{
try
{
if (col.Exists(r => r.DataBlob == blobName)) return true;
foreach (var s in subroom_col.FindAll())
{
if (s.CurrentSave != null && string.Equals(s.CurrentSave.DataBlob, blobName, StringComparison.OrdinalIgnoreCase)) return true;
if (s.AllSaves != null && s.AllSaves.Any(x => string.Equals(x.DataBlob, blobName, StringComparison.OrdinalIgnoreCase))) return true;
}
}
catch { }
return false;
}
/// <summary>
/// Remove @Importer rooms that were wrapped around a loose .room blob whose save version is
/// newer than the client can load - they only freeze the game and drop the player to the dorm.
/// The blob file stays hosted. Only touches loose-blob rooms (our marker), never real imports.
/// </summary>
public static int RemoveTooNewLooseRooms(ulong importerId)
{
if (importerId == 0) return 0;
int max = Campus_live_rewrite.api.server.ConfigCampus.ClientMaxRoomPersistenceVersion;
string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
int removed = 0;
lock (_roomLock)
{
foreach (var room in col.Find(r => r.CreatorAccountId == importerId).ToList())
{
var subs = subroom_col.Find(s => s.RoomId == room.RoomId).ToList();
bool loose = subs.Count == 1 && subs[0].CurrentSave != null &&
(subs[0].CurrentSave.Description ?? "").Contains("loose .room", StringComparison.OrdinalIgnoreCase);
if (!loose) continue;
int ver = BlobPersistenceVersion(Path.Combine(blobDir, subs[0].CurrentSave!.DataBlob ?? ""));
if (ver > max)
{
subroom_col.DeleteMany(x => x.RoomId == room.RoomId);
col.DeleteMany(x => x.RoomId == room.RoomId);
removed++;
Console.WriteLine($"[room-version] removed too-new loose room '{room.Name}' (id {room.RoomId}, save v{ver} > client max {max})");
}
}
}
return removed;
}
/// <summary>The .room blob's persistence version = field 1 (a varint) at the very start. 0 if unreadable.</summary>
public static int BlobPersistenceVersion(string path)
{
try
{
using var fs = File.OpenRead(path);
int first = fs.ReadByte();
if (first != 0x08) return 0; // expect field 1, wiretype 0 (varint)
int result = 0, shift = 0, b;
while ((b = fs.ReadByte()) >= 0)
{
result |= (b & 0x7f) << shift; shift += 7;
if ((b & 0x80) == 0) break;
if (shift > 28) break;
}
return result;
}
catch { return 0; }
}
public enum LooseWrapStatus { Created, TooNew, AlreadyUsed, NameExists, Error }
/// <summary>
/// Host a single loose .room blob in RoomBlobs and, when it's old enough for the client and not
/// already linked to a room, wrap it in a joinable @Importer room. Reusable by the admin upload
/// AND the staging scan. Returns what happened.
/// </summary>
public static (LooseWrapStatus status, ulong roomId, int version, string roomName, string? error) WrapLooseRoom(string onDiskPath, ulong ownerId)
{
string bn = Path.GetFileName(onDiskPath);
try
{
string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
Directory.CreateDirectory(blobDir);
string dest = Path.Combine(blobDir, bn);
if (!File.Exists(dest) || new FileInfo(dest).Length != new FileInfo(onDiskPath).Length)
File.Copy(onDiskPath, dest, true);
if (AnyRoomUsesBlob(bn)) return (LooseWrapStatus.AlreadyUsed, 0, 0, bn, null);
int ver = BlobPersistenceVersion(dest);
if (ver > Campus_live_rewrite.api.server.ConfigCampus.ClientMaxRoomPersistenceVersion)
return (LooseWrapStatus.TooNew, 0, ver, bn, null);
string roomName = Path.GetFileNameWithoutExtension(bn);
string? scene = SceneIdFromBlob(dest);
ulong id = CreateRoomFromBlob(bn, roomName, ownerId, scene, ver);
return id != 0 ? (LooseWrapStatus.Created, id, ver, roomName, null)
: (LooseWrapStatus.NameExists, 0, ver, roomName, null);
}
catch (Exception ex) { return (LooseWrapStatus.Error, 0, 0, bn, ex.Message); }
}
/// <summary>Best-effort: pull the first GUID (the subroom's UnitySceneId) out of a .room blob's header.</summary>
public static string? SceneIdFromBlob(string path)
{
try
{
byte[] head = new byte[Math.Min(4096, (int)new FileInfo(path).Length)];
using (var fs = File.OpenRead(path)) fs.Read(head, 0, head.Length);
string ascii = System.Text.Encoding.ASCII.GetString(head);
var m = System.Text.RegularExpressions.Regex.Match(ascii, "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
return m.Success ? m.Value : null;
}
catch { return null; }
}
/// <summary>True if the directory looks like a Rec Room room export (has RoomDetails.json + a room blob).</summary>
public static bool LooksLikeRoomExport(string dir)
{
try
{
return Directory.EnumerateFiles(dir, "RoomDetails.json", SearchOption.AllDirectories).Any()
&& Directory.EnumerateFiles(dir, "*room_data*.binpb", SearchOption.AllDirectories).Any();
}
catch { return false; }
}
// Pick, among candidate filenames in dir, the one whose SHA-256 matches expectedHashB64.
// Falls back to the first existing candidate (candidates are listed best-first) when no hash
// is recorded or none match - better to serve a plausible blob than nothing.
private static string? PickBlobByHash(string dir, string[] candidateNames, string? expectedHashB64)
{
var existing = candidateNames.Select(n => Path.Combine(dir, n)).Where(File.Exists).ToList();
if (existing.Count == 0) return null;
if (!string.IsNullOrWhiteSpace(expectedHashB64))
{
foreach (var p in existing)
{
try
{
using var fs = File.OpenRead(p);
var hash = Convert.ToBase64String(SHA256.Create().ComputeHash(fs));
if (string.Equals(hash, expectedHashB64, StringComparison.Ordinal)) return p;
}
catch { }
}
}
return existing[0];
}
private static string? FindSubRoomDir(string baseDir, ulong subRoomId)
{
try
{
string prefix = $"SubRoom_{subRoomId}_";
return Directory.EnumerateDirectories(baseDir, "SubRoom_*", SearchOption.AllDirectories)
.FirstOrDefault(d => Path.GetFileName(d).StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}
catch { return null; }
}
public static void ExportRooms()
{
var allRooms = col.FindAll().ToList();
var allSubrooms = subroom_col.FindAll().ToList();
// Option 1: Export rooms with their subrooms attached
foreach (var room in allRooms)
{
room.SubRooms = allSubrooms.Where(s => s.RoomId == room.RoomId).ToList();
}
// Build final result
var exportData = new Resultslist<RoomDBClasses.RoomRoot>
{
Results = allRooms, // No mapping, just raw rooms
TotalResults = allRooms.Count
};
string outputPath = Path.Combine(Environment.CurrentDirectory, "Data/exported_rooms.json");
string json = JsonConvert.SerializeObject(exportData, Formatting.Indented);
File.WriteAllText(outputPath, json);
Console.WriteLine($"Exported {allRooms.Count} rooms with subrooms → {outputPath}");
}
public static void FixDuplicateDorms()
{
// Get all dorm rooms
var dormRooms = col.Find(x => x.IsDorm).ToList();
// Group dorms by creator
var groupedDorms = dormRooms
.GroupBy(r => r.CreatorAccountId)
.Where(g => g.Count() > 1);
foreach (var group in groupedDorms)
{
// Load player data to get DormRoomId
var player = PlayerDB.GetFullAccount(group.FirstOrDefault().CreatorAccountId);
if (player == null)
continue;
// Try to keep the dorm that matches player.DormRoomId
var dormToKeep = group.FirstOrDefault(r => r.RoomId == player.player_Extra.DormRoomID);
// Fallback: keep oldest dorm if DormRoomId is invalid/missing
if (dormToKeep == null)
{
dormToKeep = group
.OrderBy(r => r.CreatedAt)
.First();
}
// Delete all other dorms
var roomsToDelete = group
.Where(r => r.RoomId != dormToKeep.RoomId)
.ToList();
foreach (var room in roomsToDelete)
{
subroom_col.DeleteMany(x => x.RoomId == room.RoomId);
col.DeleteMany(x => x.RoomId == room.RoomId);
Console.WriteLine(
$"Deleted duplicate dorm room: {room.Name} (ID: {room.RoomId}) for creator {room.CreatorAccountId}"
);
}
Console.WriteLine(
$"Kept dorm room: {dormToKeep.Name} (ID: {dormToKeep.RoomId}) for creator {group.Key}"
);
}
}
public static string get_subroom_from_id(ulong room_id, ulong subroom_id)
{
try
{
var subroom = subroom_col.FindOne(x => x.RoomId == room_id && x.SubRoomId == subroom_id);
return subroom?.Name ?? "Unknown Room";
}
catch (Exception ex)
{
Console.WriteLine($"Error in get_subroom_from_id: {ex.Message}");
return "Unknown Room";
}
}
public static ulong AddRoom(RoomDBClasses.RoomRoot room, bool isImportingFromJson = false)
{
try
{
if (room == null)
throw new ArgumentNullException(nameof(room));
lock (_roomLock)
{
// Skip duplicate check if DormRoom
if (room.Name != "DormRoom")
{
var existingRoom = col.FindOne(x => x.Name == room.Name);
if (existingRoom != null)
return 0;
}
var highest = col.Query().OrderByDescending(x => x.RoomId).Limit(1).FirstOrDefault();
room.RoomId = highest != null ? highest.RoomId + 1 : 1;
if (room.RoomId == 0)
{
throw new Exception($"Imported room '{room.Name}' has RoomId = 0");
}
//room.DataBlobHash = null;
if (string.IsNullOrEmpty(room.RankedEntityId))
room.RankedEntityId = room.RoomId.ToString();
if (room.UgcVersion == null)
{
room.UgcVersion = 1;
}
if (room.PersistenceVersion == null)
{
room.PersistenceVersion = 0;
}
if (room.NeedsSnapshotId == false)
{
room.CurrentSnapshotId = null;
}
// Ensure required fields exist
if (room.LoadScreens == null) throw new Exception("room.LoadScreens is null!");
if (room.Stats == null) throw new Exception("room.Stats is null!");
// --- Insert the room directly, no mapping ---
col.Insert(room);
// --- Insert subrooms directly, no mapping ---
if (room.SubRooms != null && room.SubRooms.Count > 0)
{
// ----------------------------
// Get next SubRoomId safely
// ----------------------------
var lastSubroom = subroom_col.Query()
.OrderByDescending(x => x.SubRoomId)
.Limit(1)
.FirstOrDefault();
ulong nextSubId = lastSubroom?.SubRoomId + 1 ?? 1;
// ----------------------------
// Get next SubRoomDataSaveId globally
// ----------------------------
ulong nextSaveId = 1;
var allExistingSubrooms = subroom_col.FindAll().ToList();
var maxSaveId = allExistingSubrooms
.SelectMany(x =>
{
var saves = new List<RoomDBClasses.CurrentSave>();
if (x.CurrentSave != null)
saves.Add(x.CurrentSave);
if (x.AllSaves != null)
saves.AddRange(x.AllSaves);
return saves;
})
.Select(x => x.SubRoomDataSaveId)
.DefaultIfEmpty(0UL)
.Max();
nextSaveId = maxSaveId + 1;
// ----------------------------
// Insert SubRooms
// ----------------------------
foreach (var sub in room.SubRooms)
{
try
{
ulong subId = nextSubId++;
if (isImportingFromJson && subId == 0)
throw new Exception($"Imported subroom '{sub.Name}' has SubRoomId = 0");
sub.SubRoomId = subId;
sub.RoomId = room.RoomId;
// Fix CurrentSave
if (sub.CurrentSave != null)
{
sub.CurrentSave.SubRoomDataSaveId = nextSaveId++;
sub.CurrentSave.SubRoomId = sub.SubRoomId;
sub.CurrentSave.UgcSubVersion = 0;
sub.CurrentSave.PersistenceVersion = 0;
}
// ...and AllSaves the same way.
//
// Only CurrentSave was re-keyed. When a room is CLONED the
// subroom objects come straight off the source room, so every
// AllSaves entry kept the SOURCE subroom's SubRoomId and save
// id. GetRoomSaveById looks a save up by SubRoomDataSaveId, so
// the clone's saves pointed at another room's ids - the save
// either could not be found or resolved to the wrong subroom.
//
// Harmless on this server today because no template room has a
// save to copy, but it breaks the moment one does.
if (sub.AllSaves != null)
{
foreach (var save in sub.AllSaves)
{
save.SubRoomDataSaveId = nextSaveId++;
save.SubRoomId = sub.SubRoomId;
}
}
subroom_col.Insert(sub);
Console.WriteLine($"Added subroom: {sub.Name} ({sub.SubRoomId})");
}
catch (Exception ex)
{
Console.WriteLine($"Error adding subroom '{sub.Name}' for room '{room.Name}': {ex.Message}");
}
}
}
Console.WriteLine($"added room: {room.Name} ({room.RoomId})");
return room.RoomId;
}
}
catch (Exception ex)
{
Console.WriteLine($"Error adding room '{room.Name}': {ex.Message}");
return 0;
}
}
public static RoomDBClasses.RoomRoot? GetRoom(ulong room_id)
{
// Find the main room
var room = col.FindOne(x => x.RoomId == room_id);
if (room == null) return null;
// Fetch subrooms and attach them directly
var subrooms = subroom_col.Find(x => x.RoomId == room_id).ToList();
// Attach subrooms directly, no new object mapping
room.SubRooms = subrooms;
// Return the database object as-is
return room;
}
public static bool DoesPlayerDormExist(ulong playerId)
{
var playercol = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
var player = playercol.FindOne(x => x.player.Id == playerId);
if (player?.player_Extra == null)
return false;
ulong dormId = (ulong)player.player_Extra.DormRoomID;
if (dormId == 0 || dormId == 1)
return false;
var dormRoom = RoomDB.GetRoom(dormId);
return dormRoom != null;
}
// The dorm template the client asks for by name. Rec Room added multi-dorm
// ("My Dorms" -> Create new Dorm) after this server was written, so no room
// called CustomDormRoomTemplate was ever seeded - the lookup 404'd and the
// client reported "Failed to find ^CustomDormRoomTemplate from server".
// Room 1 IS the dorm template (Name "DormRoom", IsDorm, one "Home" subroom) and
// is already what clone_dorm_room copies, so point the new name at it.
private const ulong DormTemplateRoomId = 1;
public static RoomDBClasses.RoomRoot? GetRoomByName(string roomName)
{
if (!string.IsNullOrEmpty(roomName))
{
// The client prefixes template/base room lookups with '^' ("^Park",
// "^CustomDormRoomTemplate") but the rooms are stored unprefixed, so an
// exact match never hit. Strip it before comparing.
roomName = roomName.TrimStart('^');
if (roomName.Equals("CustomDormRoomTemplate", StringComparison.OrdinalIgnoreCase)
|| roomName.Equals("DormRoomTemplate", StringComparison.OrdinalIgnoreCase))
{
var dormTemplate = GetRoom(DormTemplateRoomId);
if (dormTemplate != null)
{
dormTemplate.DataBlob ??= "";
return dormTemplate;
}
}
foreach (RoomDBClasses.RoomRoot subroom in col.FindAll())
{
if (roomName.Equals(subroom.Name, StringComparison.OrdinalIgnoreCase))
{
var room = GetRoom(subroom.RoomId);
if (room.DataBlob is null)
{
room.DataBlob = "";
}
return room;
}
}
}
return null;
}
// "<Player>'s DormRoom" instead of the literal "DormRoom" every dorm used to
// get. With every dorm sharing one name the client had nothing distinguishing
// to show and fell back to just the accessibility tag - a bare "[PRIVATE]".
public static string DormRoomName(ulong player_id)
{
string owner = PlayerDB.GetPlayerNameById(player_id);
return string.IsNullOrWhiteSpace(owner) || owner == "Unknown"
? "DormRoom"
: $"{owner}'s DormRoom";
}
/// <summary>
/// Keep the player's dorm ROOM named after their current @name ("kyle's DormRoom"). Called
/// after a username change; the room's stored Name is what the room list, the in-game
/// "go to dorm" tile and the website would otherwise keep showing under the old handle.
/// The live dorm INSTANCE name (`@kyle's Dorm`) is rebuilt from the username on every
/// dorm matchmake, so it follows on the next visit by itself.
/// </summary>
public static void RenameDormForPlayer(ulong player_id)
{
try
{
ulong dormId = PlayerDB.GetDormId(player_id);
if (dormId == 0) return;
var dorm = col.FindOne(x => x.RoomId == dormId);
if (dorm == null || !dorm.IsDorm) return;
string wanted = DormRoomName(player_id);
if (dorm.Name == wanted) return;
dorm.Name = wanted;
col.Update(dorm);
Console.WriteLine($"[dorm] renamed dorm {dormId} of {player_id} -> \"{wanted}\"");
}
catch (Exception ex) { Console.WriteLine($"[dorm] rename for {player_id} failed: {ex.Message}"); }
}
/// <summary>Startup pass: bring every existing dorm's name in line with its owner's current @name.</summary>
public static void RenameAllDormsToOwners()
{
int fixedCount = 0;
foreach (var dorm in col.Find(x => x.IsDorm).ToList())
{
if (dorm.CreatorAccountId == 0) continue;
string wanted = DormRoomName(dorm.CreatorAccountId);
if (wanted == "DormRoom" || dorm.Name == wanted) continue;
dorm.Name = wanted; col.Update(dorm); fixedCount++;
}
if (fixedCount > 0) Console.WriteLine($"[dorm] startup: renamed {fixedCount} dorm(s) to their owners' current @names");
}
public static ulong clone_dorm_room(ulong player_id)
{
RoomDBClasses.RoomRoot room_data = GetRoom(1);
var deep = JsonConvert.SerializeObject(room_data);
var room_data2 = JsonConvert.DeserializeObject<RoomDBClasses.RoomRoot>(deep);
if (room_data2 == null)
{
throw new Exception("Base dorm room not found.");
}
if (room_data2.SubRooms == null || room_data2.SubRooms.Count == 0)
{
throw new Exception("Base dorm room has no subrooms.");
}
if (room_data2.Stats == null)
{
throw new Exception("Base dorm room stats are null.");
}
room_data2.CreatorAccountId = player_id;
room_data2.CloningAllowed = false;
// Was: the literal "DormRoom" for every player, so the client had nothing to
// show but the accessibility tag - hence a bare "[PRIVATE]". Name it after
// the owner so it reads "<Player>'s DormRoom [Private]" instead.
room_data2.Name = DormRoomName(player_id);
room_data2.CreatedAt = DateTime.Now;
room_data2.Accessibility = RoomDBClasses.RoomAccessibility.Private;
room_data2.IsRRO = false;
room_data2.IsDorm = true;
room_data2.Stats.CheerCount = 0;
room_data2.Stats.FavoriteCount = 0;
room_data2.Stats.VisitorCount = 0;
room_data2.Stats.VisitCount = 0;
room_data2.Roles = new List<RoomDBClasses.RoleClass>
{
new RoomDBClasses.RoleClass
{
AccountId = (int)player_id,
InvitedRole = 0,
Role = RoomDBClasses.Role_data.Creator
}
};
ulong id = AddRoom(room_data2);
return id;
}
public static void GiveCoOwnerToAllRooms(ulong playerId)
{
var allRooms = col.FindAll().ToList();
foreach (var room in allRooms)
{
if (room.CreatorAccountId == playerId)
continue;
if (room.Roles == null)
room.Roles = new List<RoleClass>();
var existingRole = room.Roles.FirstOrDefault(r => (ulong)r.AccountId == playerId);
if (existingRole != null)
{
if (existingRole.Role != RoomDBClasses.Role_data.Creator)
{
existingRole.Role = RoomDBClasses.Role_data.CoOwner;
}
}
else
{
room.Roles.Add(new RoleClass
{
AccountId = (int)playerId,
InvitedRole = 0,
Role = RoomDBClasses.Role_data.CoOwner
});
}
col.Update(room);
}
Console.WriteLine($"Updated all rooms to grant Co-Owner to player {playerId}.");
}
public static RoomDBClasses.RoomRoot? IncreaseRoomVisit(ulong roomid)
{
var room = col.FindOne(x => x.RoomId == roomid);
if (room != null)
{
if (room.Stats == null)
room.Stats = new Stats();
if (room.Stats.VisitCount == 0)
room.Stats.VisitCount = 1;
else
room.Stats.VisitCount++;
col.Update(room);
return room;
}
return null;
}
/// <summary>
/// The hot-rooms candidate set: every non-dorm room, WITHOUT subrooms. The list is
/// filtered, sorted and paged by the caller, which then attaches subrooms to just the
/// page it returns (AttachSubRooms) - loading them for all ~2,500 candidates to serve 30
/// was what made this endpoint take over a minute.
/// </summary>
public static List<RoomDBClasses.RoomRoot> GetHotRooms()
{
return col.Query()
.Where(r => !r.IsDorm)
.ToList();
}
/// <summary>
/// Populate SubRooms on the given rooms. One indexed lookup per room (RoomId is indexed),
/// so call it on the rooms you are actually returning, not on the whole table.
/// </summary>
public static void AttachSubRooms(IEnumerable<RoomDBClasses.RoomRoot> rooms)
{
foreach (var room in rooms)
room.SubRooms = subroom_col.Find(s => s.RoomId == room.RoomId).ToList();
}
/// <summary>
/// Every non-dorm room with subrooms attached. Subrooms are read in ONE pass and grouped in
/// memory rather than one query per room. Admin/maintenance use - the client-facing lists
/// should page first and use AttachSubRooms on the page.
/// </summary>
public static List<RoomDBClasses.RoomRoot> GetAllRooms()
{
var mainRooms = col.Query()
.Where(r => !r.IsDorm)
.ToList();
var byRoom = subroom_col.FindAll()
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var room in mainRooms)
room.SubRooms = byRoom.TryGetValue(room.RoomId, out var subs) ? subs : new List<SubRoom>();
return mainRooms;
}
public static void UpdateRoomCheerCount(ulong roomid, int delta)
{
var room = col.FindOne(x => x.RoomId == roomid);
if (room != null)
{
room.Stats.CheerCount = Math.Max(0, room.Stats.CheerCount + delta);
col.Update(room);
}
}
public static void UpdateRoomFavoriteCount(ulong roomid, int delta)
{
var room = col.FindOne(x => x.RoomId == roomid);
if (room != null)
{
room.Stats.FavoriteCount = Math.Max(0, room.Stats.FavoriteCount + delta);
col.Update(room);
}
}
/// <summary>
/// Attaches each room's LIVE subrooms from the subroom collection.
///
/// Rooms carry an embedded SubRooms array, but it is a STALE SNAPSHOT taken when
/// the room was created/cloned - saves are written to the subroom collection, not
/// back into it. GetRoom() has always overwritten it with live data; the list
/// queries did not, so a listing reported the original subroom with
/// CurrentSave = null even for rooms that had been saved many times.
///
/// That is what made the client treat saved rooms as "never been saved": it hid
/// them from Create -> My Rooms, and prompted "Do you want to save?" on exit
/// because as far as the listing was concerned no save existed.
/// </summary>
// ---- Room access control ----------------------------------------------
//
// RoomAccessibility: Private = 0, Public = 1, Unlisted = 2, Dev_only = 3,
// Dev_Unlisted = 4. Private is the DEFAULT for a freshly created room, which is
// why almost every player room is private.
//
// There is no separate invite system on this server - room ROLES are the invite.
// Give someone Host/Moderator/CoOwner/Creator on a room and they can see and join
// it; without a role a private room is invisible and unjoinable to them.
/// <summary>The player's role on this room, or null if they have none.</summary>
public static RoomDBClasses.Role_data? PlayerRoleOn(RoomDBClasses.RoomRoot? room, ulong playerId)
{
if (room == null) return null;
if (room.CreatorAccountId == playerId) return RoomDBClasses.Role_data.Creator;
var role = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == playerId);
return role?.Role;
}
/// <summary>
/// True when the player holds a role that counts as being part of the room -
/// owner, co-owner, moderator or host. Explicitly false for Banned and None.
/// </summary>
public static bool PlayerIsMemberOf(RoomDBClasses.RoomRoot? room, ulong playerId)
{
var role = PlayerRoleOn(room, playerId);
if (role == null) return false;
switch (role.Value)
{
case RoomDBClasses.Role_data.Creator:
case RoomDBClasses.Role_data.CoOwner:
case RoomDBClasses.Role_data.TemporaryCoOwner:
case RoomDBClasses.Role_data.Contributor:
case RoomDBClasses.Role_data.Moderator:
case RoomDBClasses.Role_data.Host:
return true;
default:
return false; // None / Banned
}
}
/// <summary>Banned from this specific room (an elapsed timed ban is lifted on the way).</summary>
public static bool PlayerIsBannedFrom(RoomDBClasses.RoomRoot? room, ulong playerId)
=> ActiveRoomBan(room, playerId) != null;
/// <summary>
/// The ban currently keeping this player out of the room, or null. A ban is the Banned ROLE
/// on the room (which is what every join gate reads) plus a RoomBans record carrying who /
/// when / why / until. A timed ban whose BanEndTime has passed is lifted here - the role
/// removed and the record marked Elapsed - so a "15 minutes" restriction really ends.
/// </summary>
public static RoomDBClasses.RoomBanDetails? ActiveRoomBan(RoomDBClasses.RoomRoot? room, ulong playerId)
{
if (room == null) return null;
var role = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == playerId);
if (role == null || role.Role != RoomDBClasses.Role_data.Banned) return null;
var ban = room.RoomBans?.FirstOrDefault(b => b.AccountId == (long)playerId && b.Status == 0);
if (ban != null && ban.BanEndTime.HasValue && ban.BanEndTime.Value <= DateTime.UtcNow)
{
// Elapsed: lift it on the stored document (not on whatever copy we were handed).
var stored = col.FindOne(x => x.RoomId == room.RoomId);
if (stored != null)
{
stored.Roles?.RemoveAll(r => (ulong)r.AccountId == playerId && r.Role == RoomDBClasses.Role_data.Banned);
foreach (var b in stored.RoomBans ?? new()) if (b.AccountId == (long)playerId && b.Status == 0) b.Status = 1;
if (stored.RoomBanDetails?.AccountId == (long)playerId) stored.RoomBanDetails = null;
col.Update(stored);
}
room.Roles!.Remove(role);
ban.Status = 1;
Console.WriteLine($"[roomban] room {room.RoomId}: ban on {playerId} elapsed, lifted");
return null;
}
// A Banned role with no record (older data): still a ban, with what we know.
return ban ?? new RoomDBClasses.RoomBanDetails { AccountId = (long)playerId, BanStartTime = DateTime.UtcNow, Reason = "", Status = 0 };
}
/// <summary>
/// Ban a player from a room: the Banned role (what the join gates refuse on) plus a record.
/// duration null = until someone unbans them. The room's creator can never be banned.
/// Any earlier active ban on the same player is closed as Lifted first.
/// </summary>
public static RoomDBClasses.RoomBanDetails? BanFromRoom(ulong roomId, ulong playerId, ulong by, string? reason, TimeSpan? duration)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null || room.CreatorAccountId == playerId) return null;
room.Roles ??= new List<RoomDBClasses.RoleClass>();
var existing = room.Roles.FirstOrDefault(r => (ulong)r.AccountId == playerId);
if (existing != null) { existing.Role = RoomDBClasses.Role_data.Banned; existing.InvitedRole = 0; existing.LastChangedByAccountId = (long)by; }
else room.Roles.Add(new RoomDBClasses.RoleClass { AccountId = (long)playerId, Role = RoomDBClasses.Role_data.Banned, InvitedRole = 0, LastChangedByAccountId = (long)by });
room.RoomBans ??= new List<RoomDBClasses.RoomBanDetails>();
foreach (var old in room.RoomBans) if (old.AccountId == (long)playerId && old.Status == 0) old.Status = 2;
var ban = new RoomDBClasses.RoomBanDetails
{
AccountId = (long)playerId,
BannedByAccountId = (long)by,
BanStartTime = DateTime.UtcNow,
BanEndTime = duration.HasValue ? DateTime.UtcNow + duration.Value : null,
Reason = reason ?? "",
Status = 0
};
room.RoomBans.Add(ban);
room.RoomBanDetails = ban;
col.Update(room);
Console.WriteLine($"[roomban] room {roomId} '{room.Name}': {by} banned {playerId} " +
$"{(duration.HasValue ? $"for {duration.Value.TotalMinutes:0} min" : "until unbanned")}" +
$"{(string.IsNullOrEmpty(reason) ? "" : $" - {reason}")}");
return ban;
}
/// <summary>Lift a room ban. Returns the closed record, or null if they were not banned.</summary>
public static RoomDBClasses.RoomBanDetails? UnbanFromRoom(ulong roomId, ulong playerId, ulong by)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return null;
var role = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == playerId && r.Role == RoomDBClasses.Role_data.Banned);
var ban = room.RoomBans?.FirstOrDefault(b => b.AccountId == (long)playerId && b.Status == 0);
if (role == null && ban == null) return null;
if (role != null) room.Roles!.Remove(role);
if (ban != null) { ban.Status = 2; ban.UnbannedByAccountId = (long)by; }
if (room.RoomBanDetails?.AccountId == (long)playerId) room.RoomBanDetails = null;
col.Update(room);
Console.WriteLine($"[roomban] room {roomId}: {by} unbanned {playerId}");
return ban ?? new RoomDBClasses.RoomBanDetails { AccountId = (long)playerId, BannedByAccountId = 0, BanStartTime = DateTime.UtcNow, Status = 2, UnbannedByAccountId = (long)by, Reason = "" };
}
/// <summary>
/// One-off repair: room kicks used to be stored as room bans ("Kicked by X" / "Vote
/// kicked ..."), so every kick was a permanent ban from the map. Lift those; a real ban
/// (issued from the ban route, any other reason) is left alone. Returns how many.
/// </summary>
public static int LiftKickBans()
{
int lifted = 0;
foreach (var room in col.FindAll().Where(r => r.RoomBans != null && r.RoomBans.Count > 0).ToList())
{
bool changed = false;
foreach (var b in room.RoomBans!.Where(b => b.Status == 0).ToList())
{
string reason = b.Reason ?? "";
if (!reason.StartsWith("Kicked by ", StringComparison.OrdinalIgnoreCase) && !reason.StartsWith("Vote kicked", StringComparison.OrdinalIgnoreCase)) continue;
b.Status = 2;
room.Roles?.RemoveAll(r => r.AccountId == b.AccountId && r.Role == RoomDBClasses.Role_data.Banned);
if (room.RoomBanDetails?.AccountId == b.AccountId) room.RoomBanDetails = null;
Console.WriteLine($"[roomban] room {room.RoomId} '{room.Name}': lifted ban on {b.AccountId} - it was a kick ({reason})");
changed = true; lifted++;
}
if (changed) col.Update(room);
}
return lifted;
}
/// <summary>The room's active bans, newest first.</summary>
public static List<RoomDBClasses.RoomBanDetails> ActiveRoomBans(RoomDBClasses.RoomRoot room)
{
var list = new List<RoomDBClasses.RoomBanDetails>();
foreach (var r in (room.Roles ?? new()).Where(r => r.Role == RoomDBClasses.Role_data.Banned).ToList())
{
var b = ActiveRoomBan(room, (ulong)r.AccountId);
if (b != null) list.Add(b);
}
return list.OrderByDescending(b => b.BanStartTime).ToList();
}
/// <summary>Creator, co-owner, moderator or host on the room, or a staff account.</summary>
public static bool CanModerateRoom(RoomDBClasses.RoomRoot? room, ulong accountId)
{
if (room == null) return false;
if (room.CreatorAccountId == accountId) return true;
if (PlayerDB.CheckDevFlag(accountId) || PlayerDB.CheckModFlag(accountId)) return true;
var role = PlayerRoleOn(room, accountId);
return role is RoomDBClasses.Role_data.CoOwner or RoomDBClasses.Role_data.TemporaryCoOwner
or RoomDBClasses.Role_data.Moderator or RoomDBClasses.Role_data.Host;
}
/// <summary>
/// Is the room published?
///
/// PublishState is the client's own field (RoomRoot.PublishState) and the save
/// request carries AutoPublish. Neither was ever read by this server - every room
/// in the DB sits at PublishState = 0, including the stock RRO rooms.
///
/// So "published" is treated as: PublishState says so, OR the room is Public /
/// Unlisted. That second clause matters - without it every existing room (Rec
/// Center, Park, all 54 of account 1's) would read as unpublished and vanish from
/// everyone's listings the moment this shipped.
/// </summary>
public static bool RoomHasTag(RoomDBClasses.RoomRoot room, string tag) =>
room.Tags != null && room.Tags.Any(t => string.Equals(t.Tag, tag, StringComparison.OrdinalIgnoreCase));
// Rooms 2.0 keep their REAL visibility on the "Home" subroom; the room-level Accessibility
// is a stale default (Private) for them. Search / hot / CanPlayerSeeRoom all read the
// ROOM-level field, so a published 2.0 room was invisible everywhere. This returns the
// room's effective accessibility - its Home subroom's (or the first subroom's) - which the
// room-level field is kept mirrored to (ModifySubroom + the startup SyncAllRoomAccessibility).
public static RoomDBClasses.RoomAccessibility PrimaryAccessibility(RoomDBClasses.RoomRoot room)
{
var subs = room.SubRooms;
if (subs == null || subs.Count == 0) return room.Accessibility;
var home = subs.FirstOrDefault(s => string.Equals(s.Name, "Home", StringComparison.OrdinalIgnoreCase)) ?? subs[0];
return home.Accessibility;
}
// The room-level Accessibility a room SHOULD carry: its Home subroom's, which is what the
// owner actually set in-game. Rooms are NOT auto-published, so the accessibility the owner
// chose (Public / Unlisted / Private) IS the room's published state - see IsRoomPublished.
public static RoomDBClasses.RoomAccessibility EffectiveRoomAccessibility(RoomDBClasses.RoomRoot room)
=> PrimaryAccessibility(room);
// Idempotent migration: give every non-RRO room its effective accessibility, so existing
// PUBLISHED Rooms 2.0 become Public/searchable and unpublished ones match their subroom.
// RRO rooms already carry a correct room-level Accessibility, so leave them.
/// <summary>
/// Idempotent: player-made rooms cloned before the clone fix carry the template's
/// IsDeveloperOwned=true and RankedEntityId (the client hides developer-owned rooms from a
/// player's created list). Rec Room Originals and base templates are left alone.
/// </summary>
public static int FixPlayerRoomFlags()
{
int n = 0;
foreach (var room in col.FindAll().ToList())
{
if (room == null || room.IsRRO || room.IsDorm) continue;
if (RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal")) continue;
if (room.CreatorAccountId <= 1) continue; // system / owner-seeded rooms
bool changed = false;
if (room.IsDeveloperOwned) { room.IsDeveloperOwned = false; changed = true; }
if (room.RankedEntityId != room.RoomId.ToString()) { room.RankedEntityId = room.RoomId.ToString(); changed = true; }
if (changed) { col.Update(room); n++; }
}
if (n > 0) Console.WriteLine($"[rooms] cleared template flags (IsDeveloperOwned / RankedEntityId) on {n} player room(s)");
return n;
}
public static int SyncAllRoomAccessibility()
{
int n = 0;
// Subrooms live in their own collection and are NOT on the rows FindAll hands back, so
// EffectiveRoomAccessibility (the Home subroom's setting) used to see no subrooms and
// answer the room's own field - this sync never changed anything. One pass over the
// subroom collection, grouped by room, instead of a query per room.
var subsByRoom = subroom_col.FindAll().GroupBy(s => s.RoomId).ToDictionary(g => g.Key, g => g.ToList());
foreach (var room in col.FindAll().ToList())
{
if (room == null || room.IsRRO) continue;
if (subsByRoom.TryGetValue(room.RoomId, out var subs)) room.SubRooms = subs;
if (room.SubRooms == null || room.SubRooms.Count == 0) continue;
var eff = EffectiveRoomAccessibility(room);
if (room.Accessibility != eff)
{
room.Accessibility = eff;
col.Update(room); n++; // the embedded subroom copy is refreshed from the subroom collection too
Console.WriteLine($"[rooms] {room.RoomId} '{room.Name}': room-level accessibility -> {eff} (from its Home subroom)");
}
}
return n;
}
public static bool IsRoomPublished(RoomDBClasses.RoomRoot? room)
{
if (room == null) return false;
// Legacy Rec Room Originals (Rec Center, Park, the base rooms) are always listed.
if (room.IsRRO || RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal"))
return true;
// For a user room (Rooms 2.0 / beta) the ACCESSIBILITY the owner set IS the published
// state: Public or Unlisted = visible to non-members; Private = a draft, members-only.
// Rooms are NOT auto-published on save, so a freshly created (Private) room stays in the
// owner's list only until they deliberately set it Public/Unlisted.
var acc = EffectiveRoomAccessibility(room);
return acc == RoomDBClasses.RoomAccessibility.Public
|| acc == RoomDBClasses.RoomAccessibility.Unlisted;
}
/// <summary>
/// Does the room appear in LISTS for people who hold no role on it (another player's
/// profile, search, hot)? Only a PUBLIC room does - Unlisted is exactly that (reachable by
/// invite or link, never listed) and Private is a draft. Rec Room Originals are always listed.
/// </summary>
/// <summary>A Rec Room Original - the stock rooms, not a player's creation.</summary>
public static bool IsRecRoomOriginal(RoomDBClasses.RoomRoot? room) =>
room != null && (room.IsRRO || RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal"));
public static bool IsRoomListed(RoomDBClasses.RoomRoot? room)
{
if (room == null) return false;
if (room.IsRRO || RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal"))
return true;
return EffectiveRoomAccessibility(room) == RoomDBClasses.RoomAccessibility.Public;
}
/// <summary>
/// May this player SEE the room (listings, search, someone's portfolio)?
///
/// Published rooms are visible to everyone, private or not - a published private
/// room advertises itself, it just does not let strangers into your session.
/// An UNPUBLISHED room is invisible to anyone without a role on it.
/// </summary>
public static bool CanPlayerSeeRoom(RoomDBClasses.RoomRoot? room, ulong playerId)
{
if (room == null) return false;
if (PlayerIsBannedFrom(room, playerId)) return false;
if (PlayerIsMemberOf(room, playerId)) return true; // own/co-own/mod/host
return IsRoomListed(room); // others only see what is PUBLIC; unlisted/private stay off their lists
}
/// <summary>
/// May this player ENTER the room at all - in an instance of their own if need be?
///
/// Published rooms may be entered by anyone: a published PRIVATE room hands the
/// visitor their own fresh session (see CanPlayerShareInstance), which is what lets
/// them play it with their own friends without getting into the owner's session.
/// Unpublished rooms are members-only.
/// </summary>
public static bool CanPlayerEnterRoom(RoomDBClasses.RoomRoot? room, ulong playerId)
{
if (room == null) return false;
if (PlayerIsBannedFrom(room, playerId)) return false;
if (PlayerIsMemberOf(room, playerId)) return true;
// A dorm is never open, published or not.
if (room.IsDorm) return false;
return IsRoomPublished(room);
}
/// <summary>
/// May this player be dropped into an instance SOMEONE ELSE is already in?
///
/// Only for Public rooms, or when they hold a role on the room. This is the line
/// between "published private" and "public": both are visible and enterable, but a
/// published private room gives each visitor their own session instead of pooling
/// strangers into the owner's.
/// </summary>
public static bool CanPlayerShareInstance(RoomDBClasses.RoomRoot? room, ulong playerId)
{
if (room == null) return false;
if (PlayerIsBannedFrom(room, playerId)) return false;
if (PlayerIsMemberOf(room, playerId)) return true;
return room.Accessibility == RoomDBClasses.RoomAccessibility.Public;
}
/// <summary>Sets the room's publish state (0 = unpublished). Returns false if missing.</summary>
public static bool SetRoomPublishState(ulong roomId, int publishState)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return false;
room.PublishState = publishState;
if (publishState > 0) room.PublishedAt = DateTime.UtcNow;
// Publishing makes a room Public for everyone; unpublishing drops it back to its
// subroom's accessibility (and it becomes members-only via CanPlayerSeeRoom).
room.Accessibility = EffectiveRoomAccessibility(room);
col.Update(room);
return true;
}
public static List<RoomDBClasses.RoomRoot> AttachSubRooms(List<RoomDBClasses.RoomRoot> rooms)
{
if (rooms == null || rooms.Count == 0) return rooms ?? new List<RoomDBClasses.RoomRoot>();
foreach (var room in rooms)
{
if (room == null) continue;
room.SubRooms = subroom_col.Find(s => s.RoomId == room.RoomId).ToList();
}
return rooms;
}
/// <summary>
/// Every room the player should see under "My Rooms": ones they created, plus any
/// they hold a role on. A room stays listed until the player's role is removed or
/// the room is deleted - it is never dropped for being unsaved or unpublished.
/// </summary>
public static List<RoomDBClasses.RoomRoot> GetRoomsForPlayer(ulong playerId)
{
// A role of Banned is still "a role", so a room the player had been BANNED from
// was listed among their own rooms. Creator always counts; otherwise the role has
// to be one that actually makes them part of the room.
var rooms = col.FindAll()
.Where(r => r != null
&& (r.CreatorAccountId == playerId
|| PlayerIsMemberOf(r, playerId)))
.ToList();
return AttachSubRooms(rooms);
}
public static List<RoomDBClasses.RoomRoot> GetRoomsByCreator(ulong playerId)
{
var rooms = col.Find(x => x.CreatorAccountId == playerId).ToList();
return AttachSubRooms(rooms);
}
/// <summary>
/// A single PAGE of a creator's rooms, ordered newest-first, attaching subrooms ONLY to the
/// page returned. GetRoomsByCreator loads EVERY room the creator owns and runs a subroom query
/// per room - fine for a normal player, but @Importer owns thousands, so a profile view was
/// loading everything at once and overloading the server. This caps it at the DB level.
/// </summary>
public static List<RoomDBClasses.RoomRoot> GetRoomsByCreatorPaged(ulong playerId, int skip, int take, bool attachSubrooms = true)
{
if (skip < 0) skip = 0;
if (take <= 0) take = 200;
var page = col.Query()
.Where(x => x.CreatorAccountId == playerId)
.OrderByDescending(x => x.RoomId)
.Skip(skip)
.Limit(take)
.ToList();
return attachSubrooms ? AttachSubRooms(page) : page;
}
/// <summary>Count of a creator's rooms - cheap (no room objects loaded).</summary>
public static int CountRoomsByCreator(ulong playerId) => col.Count(x => x.CreatorAccountId == playerId);
/// <summary>
/// Rooms the player has a role on (Creator/CoOwner/etc.) but did not create -
/// what the client's "contributed to" listing expects.
/// </summary>
/// <summary>
/// Every room this player has a hand in - the ones they CREATED plus the ones they hold a
/// role on. This is what the profile's room list is drawn from.
///
/// It used to exclude the player's own rooms outright (`CreatorAccountId != playerId`),
/// which is why a profile read "32 Rooms" above a list holding exactly one: the single
/// room they had been invited to co-own. A creator IS a contributor to their own room -
/// the owner is simply the first role on it - so they belong in this list ahead of
/// everyone else's. (The Room Showcase is a separate, deliberately-picked handful; it is
/// NOT a place to dump these. See RoomController.RoomShowcaseForPlayer.)
///
/// Own rooms come first, newest first; then the rooms they were invited to. Capped, because
/// this attaches subrooms to every room it returns.
/// </summary>
public static List<RoomDBClasses.RoomRoot> GetRoomsContributedTo(ulong playerId)
{
var all = col.FindAll().ToList();
var mine = all.Where(r => r.CreatorAccountId == playerId)
.OrderByDescending(r => r.CreatedAt);
var invited = all.Where(r => r.CreatorAccountId != playerId
&& r.Roles != null
&& r.Roles.Any(x => (ulong)x.AccountId == playerId));
return AttachSubRooms(mine.Concat(invited)
.Take(Campus_live_rewrite.api.server.ConfigCampus.ProfileRoomsCap)
.ToList());
}
public static List<RoomDBClasses.RoomRoot> Search(string p0, bool isBase = false)
{
if (string.IsNullOrWhiteSpace(p0))
return new List<RoomDBClasses.RoomRoot>();
var terms = p0.ToLowerInvariant()
.Split(new char[] { ' ', '+' }, StringSplitOptions.RemoveEmptyEntries);
// Start with the basic "Not a Dorm" filter
var query = col.Query().Where(r => !r.IsDorm);
// Only enforce Public accessibility if isBase is false
if (!isBase)
{
query = query.Where(r => r.Accessibility == RoomDBClasses.RoomAccessibility.Public);
}
foreach (var term in terms)
{
if (term.StartsWith("#"))
{
string tag = term.Substring(1);
// Using BsonExpression ensures LiteDB looks directly into the array
// This looks for any element in the 'Tags' array where the 'Tag' field matches
query = query.Where("$.Tags[*].Tag ANY = @0", tag);
}
else
{
string nameTerm = term;
query = query.Where(r => r.Name.ToLower().Contains(nameTerm));
}
}
return AttachSubRooms(query.ToList());
}
public static ulong GetAllRoomsCount()
{
return (ulong)col.Count();
}
/// <summary>All non-dorm rooms WITHOUT attaching subrooms - for listings (admin table) that
/// only need top-level fields. Avoids one subroom query per room (brutal at thousands of rooms).</summary>
public static List<RoomDBClasses.RoomRoot> GetAllRoomsLite()
{
return col.Query().Where(r => !r.IsDorm).ToList();
}
public static void DeleteAllRoomsFromPlayerId(ulong creatorId)
{
var roomsToDelete = col.Find(x => x.CreatorAccountId == creatorId).ToList();
foreach (var room in roomsToDelete)
{
subroom_col.DeleteMany(x => x.RoomId == room.RoomId);
col.DeleteMany(x => x.RoomId == room.RoomId);
}
Console.WriteLine($"Deleted {roomsToDelete.Count} rooms and their subrooms for creator {creatorId}.");
}
public static RoomDBClasses.RoomRoot? SetRoomName(ulong roomId, string roomName)
{
var room = col.FindOne(x => x.RoomId == roomId);
var existingRoom = col.FindOne(x => x.Name == roomName && x.RoomId != roomId);
if (existingRoom != null)
{
return null;
}
if (room != null)
{
try
{
room.Name = roomName;
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
public static RoomDBClasses.RoomRoot? es()
{
var room = col.FindOne(x => x.RoomId == 53);
if (room != null)
{
try
{
room.CurrentSnapshotId = "98cfdcad-b680-4b33-e94c-08dd76d7c12b";
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
public static RoomDBClasses.RoomRoot? SetRoomDescription(ulong roomId, string NewDescription)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
try
{
room.Description = NewDescription;
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
public static RoomDBClasses.RoomRoot? SetRoomImageName(ulong roomId, string imageName)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
try
{
room.ImageName = imageName;
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
public static RoomDBClasses.RoomRoot? SetRoomAccessibility(ulong roomId, RoomDBClasses.RoomAccessibility roomAccessibility)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
try
{
room.Accessibility = roomAccessibility;
// The effective accessibility is read from the Home subroom (PrimaryAccessibility),
// so the subrooms must follow or "publish" changed nothing anyone could see.
foreach (var sr in subroom_col.Find(x => x.RoomId == roomId).ToList())
{
sr.Accessibility = roomAccessibility;
subroom_col.Update(sr);
}
bool published = roomAccessibility == RoomDBClasses.RoomAccessibility.Public
|| roomAccessibility == RoomDBClasses.RoomAccessibility.Unlisted;
room.PublishState = published ? 1 : 0;
if (published && (room.PublishedAt == default || room.PublishedAt < room.CreatedAt)) room.PublishedAt = DateTime.UtcNow;
col.Update(room);
Console.WriteLine($"[rooms] room {roomId} ({room.Name}) accessibility -> {roomAccessibility} ({(published ? "published" : "unpublished")})");
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
public static RoomDBClasses.RoomRoot? SetRoomCloning(ulong roomId, bool cloningEnabled)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
try
{
room.CloningAllowed = cloningEnabled;
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
// supportsScreens=True&supportsWalkVR=False&supportsTeleportVR=True&supportsJuniors=True
public static RoomDBClasses.RoomRoot? SetRoomRestrictions(ulong roomId, bool supportsScreens, bool supportsWalkVR, bool supportsTeleportVR, bool supportsJuniors)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
try
{
room.SupportsScreens = supportsScreens;
room.SupportsWalkVR = supportsWalkVR;
room.SupportsTeleportVR = supportsTeleportVR;
room.SupportsJuniors = supportsJuniors;
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
public static bool UserCanEditRoom(ulong roomId, ulong accountId)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null)
return false;
if (room.CreatorAccountId == accountId)
return true;
return IsCoOwner(room, accountId);
}
public static RoomDBClasses.RoomRoot? SetRoomLoadscreen(ulong roomid, string? imageName, string? title, string? subtitle)
{
var room = col.FindOne(x => x.RoomId == roomid);
if (room == null)
return null;
room.LoadScreens = new List<LoadScreen>
{
new LoadScreen
{
ImageName = imageName ?? "",
Title = title ?? "",
Subtitle = subtitle ?? ""
}
};
col.Update(room);
return room;
}
public static RoomDBClasses.RoomRoot SetRoomWarningMask(ulong roomId, WarningMaskType warningMask)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
try
{
room.WarningMask = warningMask;
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
/// <summary>
/// The reference's room tag edit (rooms.app.ts / applyRoomTagEdit). Two request shapes reach
/// PUT /rooms/{id}/tags and the FIELDS say which:
/// - a lone `tag=<name>`: TOGGLE that owner tag (the five "main" tags pvp/quest/game/
/// hangout/art act as radio buttons);
/// - anything else is the whole-state save from room settings
/// (`autoTag=limitsv2&tag=roleplay&tag=social&primaryGenreTag=roleplay`): `tag` is the
/// COMPLETE set of owner (Type 0) tags, `autoTag` ADDS derived Type 1 tags (limitsv2,
/// beta - the ones Beta rooms and Limits V2 need to be recognised), `primaryGenreTag`
/// flags the genre.
/// The previous implementation toggled every posted tag, so a settings save with three
/// tags flipped them, and `autoTag` wiped the list to just "beta" as an owner tag.
/// </summary>
public static RoomDBClasses.RoomRoot? ApplyRoomTagEdit(ulong roomId, string? toggle, List<string>? tags, List<string> autoTags, string? primaryGenre)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return null;
var next = (room.Tags ?? new List<TagClass>()).Where(t => t != null && !string.IsNullOrWhiteSpace(t.Tag)).ToList();
string Key(TagClass t) => t.Tag.Trim().ToLowerInvariant();
var mainTags = new HashSet<string> { "pvp", "quest", "game", "hangout", "art" };
if (tags != null)
{
var posted = tags.Select(t => t.Trim().ToLowerInvariant()).Where(t => t.Length > 0).Distinct().ToList();
var kept = next.Where(t => t.Type != 0 && !posted.Contains(Key(t))).ToList();
foreach (var tag in posted)
kept.Add(next.FirstOrDefault(t => Key(t) == tag) ?? new TagClass { Tag = tag, Type = 0 });
next = kept;
}
else if (!string.IsNullOrWhiteSpace(toggle))
{
string lower = toggle.Trim().ToLowerInvariant();
var existing = next.FirstOrDefault(t => Key(t) == lower);
if (existing != null) next.Remove(existing);
else
{
if (mainTags.Contains(lower)) next = next.Where(t => !mainTags.Contains(Key(t))).ToList();
next.Add(new TagClass { Tag = lower, Type = 0 });
}
}
foreach (var auto in autoTags)
{
string lower = auto.Trim().ToLowerInvariant();
if (lower.Length == 0) continue;
var existing = next.FirstOrDefault(t => Key(t) == lower);
if (existing != null) existing.Type = 1;
else next.Add(new TagClass { Tag = lower, Type = 1 });
}
if (!string.IsNullOrWhiteSpace(primaryGenre))
{
string lower = primaryGenre.Trim().ToLowerInvariant();
foreach (var t in next) t.IsPrimaryGenre = null;
var chosen = next.FirstOrDefault(t => Key(t) == lower);
if (chosen != null) chosen.IsPrimaryGenre = true;
else next.Add(new TagClass { Tag = lower, Type = 0, IsPrimaryGenre = true });
}
room.Tags = next;
col.Update(room);
return room;
}
public static RoomDBClasses.RoomRoot? SetRoomTags(List<string> tags, ulong roomId, int type = 0)
{
col.EnsureIndex(x => x.RoomId);
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null)
return null;
if (tags == null || tags.Count == 0)
return room;
// Normalize input tags: trim, lowercase, remove empty, distinct
var newTags = tags
.Select(t => t.Trim().ToLowerInvariant())
.Where(t => !string.IsNullOrWhiteSpace(t))
.Distinct()
.ToList();
if (room.Tags == null)
room.Tags = new List<TagClass>();
foreach (var newTag in newTags)
{
var existingTag = room.Tags.FirstOrDefault(t =>
t.Tag.Equals(newTag, StringComparison.OrdinalIgnoreCase));
if (existingTag != null)
{
// TagClass exists -> remove it
room.Tags.Remove(existingTag);
}
else
{
// TagClass does not exist -> add it
room.Tags.Add(new TagClass
{
Tag = newTag,
Type = type
});
}
}
col.Update(room);
return room;
}
public static bool DoesRoomExist(ulong roomId)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
return true;
}
else
{
return false;
}
}
public static ulong? CloneRoom(ulong roomId, string RoomName, ulong playerId)
{
// ✅ Deep clone to detach from DB references
RoomDBClasses.RoomRoot? original = GetRoom(roomId);
RoomDBClasses.RoomRoot? room_data = JsonConvert.DeserializeObject<RoomDBClasses.RoomRoot>(JsonConvert.SerializeObject(original));
if (room_data?.Name == "MakerRoom")
goto clone_room_data;
if (room_data?.Name == "TheBackDoor")
return null;
if (room_data?.Name == "DormRoom")
return null;
if (room_data.CloningAllowed &&
(room_data.Accessibility == RoomDBClasses.RoomAccessibility.Public ||
room_data.Accessibility == RoomDBClasses.RoomAccessibility.Unlisted))
goto clone_room_data;
if (RoomName == "MakerRoom")
goto clone_room_data;
if (!room_data.CloningAllowed && room_data.CreatorAccountId != playerId && !room_data.IsDorm)
return null;
var existingRoom = col.FindOne(x => x.Name == room_data.Name && x.RoomId != room_data.RoomId);
if (existingRoom != null)
return null;
clone_room_data:
room_data.RoomId = 0;
room_data.CreatorAccountId = playerId;
room_data.CloningAllowed = false;
room_data.Name = RoomName;
room_data.ImageName = "DefaultRoomImage.jpg";
room_data.CreatedAt = DateTime.Now;
room_data.Accessibility = RoomDBClasses.RoomAccessibility.Private;
room_data.PublishState = 0;
// A player's room is not one of Rec Room's own. The template's flags came across on the
// copy: IsDeveloperOwned=true is what kept every player-made room out of the profile's
// created-rooms list (the client hides developer-owned rooms there), and the template's
// RankedEntityId meant dozens of rooms shared one ranking key.
room_data.IsDeveloperOwned = false;
room_data.IsRecRoomApproved = false;
room_data.PublishedAt = default;
room_data.RankedEntityId = "";
// The subrooms carry the accessibility the owner actually sets (PrimaryAccessibility
// reads the Home subroom), and a template's are Public - so a fresh room was "published"
// the moment it was created and every profile listed it. A new room is a private draft
// until its owner publishes it from room settings.
if (room_data.SubRooms != null)
foreach (var sr in room_data.SubRooms) sr.Accessibility = RoomDBClasses.RoomAccessibility.Private;
room_data.IsRRO = false;
room_data.IsDorm = false;
room_data.Stats.CheerCount = 0;
room_data.Stats.FavoriteCount = 0;
room_data.Stats.VisitorCount = 0;
room_data.Stats.VisitCount = 0;
room_data.Tags = new List<TagClass>();
room_data.Roles = new List<RoleClass>
{
new RoleClass
{
AccountId = (int)playerId,
InvitedRole = 0,
Role = RoomDBClasses.Role_data.Creator
}
};
ulong NewRoomId = RoomDB.AddRoom(room_data);
Console.WriteLine($"[clone_room] New room ID returned from add_room: {NewRoomId}");
if (NewRoomId != 0)
{
var made = col.FindOne(x => x.RoomId == NewRoomId);
if (made != null && made.RankedEntityId != NewRoomId.ToString())
{
made.RankedEntityId = NewRoomId.ToString();
col.Update(made);
}
}
return NewRoomId;
}
public static object? GetRoomSaveById(
ulong roomId,
ulong subroomId,
ulong subRoomDataSaveId, int target, int version)
{
var subroom = subroom_col.FindOne(x =>
x.RoomId == roomId &&
x.SubRoomId == subroomId);
if (subroom == null)
return null;
var save =
(subroom.CurrentSave != null &&
subroom.CurrentSave.SubRoomDataSaveId == subRoomDataSaveId)
? subroom.CurrentSave
: subroom.AllSaves?
.FirstOrDefault(x => x.SubRoomDataSaveId == subRoomDataSaveId);
if (save == null)
return null;
return MapSave(save, subroomId, target, version);
}
private class UnityAssetInfo
{
public string UnityAssetId { get; set; }
public int Target { get; set; }
public int Version { get; set; }
public string Filename { get; set; }
public string Hash { get; set; }
}
private static object MapSave(RoomDBClasses.CurrentSave save, ulong subroomid, int target, int version)
{
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "BakedBulk", $"{save.UnityAssetId}_assets.json");
UnityAssetInfo matchingAsset = null;
if (!string.IsNullOrEmpty(save.UnityAssetId) && System.IO.File.Exists(path))
{
var unityAssets = JsonConvert.DeserializeObject<List<UnityAssetInfo>>(System.IO.File.ReadAllText(path));
matchingAsset = unityAssets.FirstOrDefault(u => u.Target == target && u.Version == version);
}
var result = new Dictionary<string, object>
{
["UnitySubAssets"] = new List<object>(),
["ReferencedUnityAssets"] = new List<object>(),
["SubRoomDataSaveId"] = save.SubRoomDataSaveId,
["SubRoomId"] = subroomid,
["DataBlob"] = save.DataBlob,
["ReferencedUnityAssetIds"] = save.ReferencedUnityAssetIds ?? new List<string>(),
["PersistenceVersion"] = save.PersistenceVersion,
["OMVersion"] = save.OMVersion,
["UgcSubVersion"] = save.UgcSubVersion,
["SavedByAccountId"] = save.SavedByAccountId,
["SavedOnPlatform"] = save.SavedOnPlatform,
["SavedOnDeviceClass"] = save.SavedOnDeviceClass,
["Description"] = save.Description,
["Tags"] = new List<object>(),
["ModerationState"] = save.ModerationState,
["CreatedAt"] = save.CreatedAt
};
// Only include UnityAssetId if it exists
if (!string.IsNullOrEmpty(save.UnityAssetId))
{
result["UnityAssetId"] = save.UnityAssetId;
}
// Only include UnityAsset if matching asset exists
if (matchingAsset != null)
{
result["UnityAsset"] = matchingAsset.Filename;
}
return result;
}
/// <summary>
/// Sets a subroom's UnitySceneId - the scene the CLIENT loads for that subroom.
///
/// This is what decides which map you actually walk into, and it is not the same
/// thing as the .room save blob:
///
/// - a Rec Room Original's geometry ships INSIDE the client and is selected by
/// this id. Such rooms have no save at all and never needed one.
/// - a player-built room loads a template scene by this id, then applies its
/// save blob on top.
///
/// Imported room data carries the id the ORIGINAL service used, which does not
/// necessarily mean the same scene in this client build. Rooms whose id resolves to
/// a template the client happens to have - a75f7547-79eb-47c6-8986-6767abcb4f92 is
/// "Basement" here - load that blank template instead of their real map, which looks
/// exactly like a missing save but has nothing to do with blobs.
///
/// Changing this only rewrites one field; nothing is downloaded and no file moves, so
/// a wrong value costs nothing but another attempt.
/// </summary>
public static RoomDBClasses.SubRoom? GetSubRoom(ulong roomId, ulong subRoomId) =>
subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
public static bool SetSubRoomPermissions(ulong roomId, ulong subRoomId, List<Campus_live_rewrite.api.server.RoomPermissions.Entry> table)
{
var sub = GetSubRoom(roomId, subRoomId);
if (sub == null) return false;
sub.Permissions = table;
subroom_col.Update(sub);
return true;
}
public static bool SetSubRoomScene(ulong roomId, ulong subRoomId, string sceneId)
{
var sub = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (sub == null) return false;
string before = sub.UnitySceneId ?? "(none)";
sub.UnitySceneId = sceneId;
subroom_col.Update(sub);
Console.WriteLine($"[rooms] room {roomId} subroom {subRoomId} scene {before} -> {sceneId}");
return true;
}
public static RoomDBClasses.RoomRoot? SetRoomSubroomRoomFile(
ulong roomId,
ulong subRoomId,
string roomFile,
string roomDesc,
ulong playerId,
Platforms currentplatform,
int persistenceVersion = 0,
int omVersion = 0,
int ugcSubVersion = 0,
string? roomLevelBlob = null)
=> SetRoomSubroomRoomFile(roomId, subRoomId, roomFile, roomDesc, playerId, currentplatform, persistenceVersion, omVersion, ugcSubVersion, roomLevelBlob, true, out _);
/// <summary>
/// Record a save. PUBLISHED and UNPUBLISHED saves are kept apart (reference rooms
/// POST /subrooms/{id}/data): a save lands in the subroom's history and, unless the
/// client asked to AutoPublish, is only STAGED (StagedSubRoomDataSaveId) - players keep
/// loading the last published CurrentSave until the owner publishes it
/// (publish_save). Dorms, and a subroom that has never been published, publish at once.
/// </summary>
public static RoomDBClasses.RoomRoot? SetRoomSubroomRoomFile(
ulong roomId,
ulong subRoomId,
string roomFile,
string roomDesc,
ulong playerId,
Platforms currentplatform,
int persistenceVersion,
int omVersion,
int ugcSubVersion,
string? roomLevelBlob,
bool autoPublish,
out CurrentSave? created)
{
created = null;
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null)
return null;
var subroom = subroom_col.FindOne(x =>
x.RoomId == roomId &&
x.SubRoomId == subRoomId);
if (subroom == null)
return room;
subroom.AllSaves ??= new List<CurrentSave>();
// The history holds EVERY save, the published one included (older rows kept the
// published save only on CurrentSave - fold it in once).
if (subroom.CurrentSave != null && !subroom.AllSaves.Any(s => s.SubRoomDataSaveId == subroom.CurrentSave.SubRoomDataSaveId))
subroom.AllSaves.Add(subroom.CurrentSave);
long maxId = subroom.AllSaves.Count > 0 ? subroom.AllSaves.Max(s => (long)s.SubRoomDataSaveId) : 0;
if (subroom.CurrentSave != null) maxId = Math.Max(maxId, (long)subroom.CurrentSave.SubRoomDataSaveId);
// 💡 Create fresh save object
var newSave = new CurrentSave
{
SubRoomDataSaveId = (ulong)(maxId + 1),
SubRoomId = subroom.SubRoomId,
DataBlob = roomFile,
Description = roomDesc ?? "",
SavedByAccountId = playerId,
SavedOnPlatform = currentplatform,
SavedOnDeviceClass = DeviceClasses.Screen,
CreatedAt = DateTime.UtcNow,
// These arrive on every save request and used to be dropped, so every
// save was stored as 0/0/0. The client compares the versions it wrote
// against what the room reports back; 0 never matched, so the room looked
// modified the moment it loaded and leaving always prompted
// "do you want to save?" even with no changes made.
PersistenceVersion = persistenceVersion,
OMVersion = omVersion,
UgcSubVersion = ugcSubVersion
};
subroom.AllSaves.Add(newSave);
created = newSave;
bool publishNow = autoPublish || room.IsDorm || subroom.CurrentSave == null;
if (publishNow)
{
subroom.CurrentSave = newSave;
subroom.StagedSubRoomDataSaveId = null;
}
else
{
subroom.StagedSubRoomDataSaveId = (long)newSave.SubRoomDataSaveId;
}
subroom.CreatorAccountId ??= (long)playerId; // client NREs on a null creator
subroom_col.Update(subroom);
Console.WriteLine($"[rooms] save {newSave.SubRoomDataSaveId} on room {roomId} subroom {subRoomId} by {playerId}: {(publishNow ? "PUBLISHED" : "staged (unpublished)")}");
// Mirror onto the embedded copy the room document carries, so a room read that does
// not re-attach subrooms still shows the staged / published state.
var emb = room.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId);
if (emb != null)
{
emb.AllSaves = subroom.AllSaves;
emb.CurrentSave = subroom.CurrentSave;
emb.StagedSubRoomDataSaveId = subroom.StagedSubRoomDataSaveId;
col.Update(room);
}
// The save request also carries a ROOM-level blob (RoomData.Filename), which
// was never read - so RoomRoot.DataBlob kept pointing at whatever stale .meta
// it shipped with and never picked up the freshly uploaded one.
bool roomChanged = false;
if (!string.IsNullOrWhiteSpace(roomLevelBlob) && room.DataBlob != roomLevelBlob)
{
room.DataBlob = roomLevelBlob;
roomChanged = true;
}
if (ugcSubVersion > 0 && room.UgcSubVersion != ugcSubVersion)
{
room.UgcSubVersion = ugcSubVersion;
roomChanged = true;
}
if (roomChanged)
col.Update(room);
return room;
}
public static RoomDBClasses.RoomRoot? d(
ulong roomId,
ulong subRoomId,
string roomFile)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null)
return null;
var subroom = subroom_col.FindOne(x =>
x.RoomId == roomId &&
x.SubRoomId == subRoomId);
subroom.CurrentSave.DataBlob = "80c450a999404dbcb792d6d3d2f62a56.room";
subroom_col.Update(subroom);
return room;
}
public static RoomDBClasses.RoomRoot? CreateSubroomForRoom(ulong roomId, string newSubroomName)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null)
{
Console.WriteLine("Room not found.");
return null;
}
var roomSubrooms = subroom_col.Find(x => x.RoomId == roomId).ToList();
ulong nextSubRoomId = roomSubrooms.Count == 0 ? 1 : roomSubrooms.Max(sr => sr.SubRoomId) + 1;
var lastSubroom = roomSubrooms
.OrderByDescending(sr => sr.SubRoomId)
.FirstOrDefault();
var newSubroom = new SubRoom
{
RoomId = roomId,
Name = newSubroomName,
SubRoomId = nextSubRoomId,
AllSaves = new List<CurrentSave>(),
CurrentSave = null,
Accessibility = RoomDBClasses.RoomAccessibility.Public,
UnitySceneId = lastSubroom.UnitySceneId,
MaxPlayers = lastSubroom.MaxPlayers,
};
subroom_col.Insert(newSubroom);
roomSubrooms.Add(newSubroom);
col.Update(room);
return room;
}
/// <summary>A copy of a subroom under a fresh SubRoomId: same scene, settings and saves.</summary>
public static SubRoom? CloneSubroom(ulong roomId, ulong subRoomId)
{
var room = col.FindOne(x => x.RoomId == roomId);
var src = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (room == null || src == null) return null;
var all = subroom_col.Find(x => x.RoomId == roomId).ToList();
ulong nextId = all.Count == 0 ? 1 : all.Max(sr => sr.SubRoomId) + 1;
var copy = new SubRoom
{
RoomId = roomId,
SubRoomId = nextId,
Name = (src.Name ?? "Subroom") + " (copy)",
Accessibility = src.Accessibility,
CreatorAccountId = src.CreatorAccountId,
IsSandbox = src.IsSandbox,
MaxPlayers = src.MaxPlayers,
ShouldAutoStageSaves = src.ShouldAutoStageSaves,
UnitySceneId = src.UnitySceneId,
CurrentSave = src.CurrentSave,
AllSaves = new List<CurrentSave>(src.AllSaves ?? new List<CurrentSave>())
};
subroom_col.Insert(copy);
room.SubRooms ??= new List<SubRoom>();
room.SubRooms.Add(copy);
col.Update(room);
return copy;
}
/// <summary>Put the room's embedded subroom list in the given order (ids not named keep their relative order at the end).</summary>
public static void ReorderSubrooms(ulong roomId, List<ulong> order)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room?.SubRooms == null) return;
var rank = order.Select((id, i) => (id, i)).ToDictionary(t => t.id, t => t.i);
room.SubRooms = room.SubRooms.OrderBy(s => rank.TryGetValue(s.SubRoomId, out var r) ? r : int.MaxValue).ToList();
col.Update(room);
}
/// <summary>Make one of a subroom's saved versions the live one (restore). False when unknown.</summary>
public static bool PublishSubroomSave(ulong roomId, ulong subRoomId, ulong saveId)
{
var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (subroom == null) return false;
var save = (subroom.AllSaves ?? new List<CurrentSave>()).FirstOrDefault(s => s.SubRoomDataSaveId == saveId)
?? (subroom.CurrentSave?.SubRoomDataSaveId == saveId ? subroom.CurrentSave : null);
if (save == null) return false;
subroom.CurrentSave = save;
if (subroom.StagedSubRoomDataSaveId == (long)saveId) subroom.StagedSubRoomDataSaveId = null;
subroom_col.Update(subroom);
var room = col.FindOne(x => x.RoomId == roomId);
var emb = room?.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId);
if (room != null && emb != null) { emb.CurrentSave = save; emb.StagedSubRoomDataSaveId = subroom.StagedSubRoomDataSaveId; col.Update(room); }
return true;
}
public static bool DeleteSubroom(ulong roomId, ulong subRoomId)
{
var subroom = subroom_col.FindOne(x =>
x.RoomId == roomId &&
x.SubRoomId == subRoomId);
if (subroom == null)
return false;
subroom_col.Delete(subroom.Id);
return true;
}
public static bool ModifySubroom(
ulong roomId,
ulong subRoomId,
string name,
RoomDBClasses.RoomAccessibility accessibility,
int maxPlayers)
{
var subroom = subroom_col.FindOne(x =>
x.RoomId == roomId &&
x.SubRoomId == subRoomId);
if (subroom == null)
return false;
subroom.Name = name;
subroom.Accessibility = accessibility;
subroom.MaxPlayers = maxPlayers;
subroom_col.Update(subroom);
// Mirror the change onto the room's embedded subroom copy AND the room-level
// Accessibility (what Search / hot / visibility read), so setting a Rooms 2.0 room to
// Public in-game actually makes it appear in listings/search without a stale field.
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
var emb = room.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId);
if (emb != null) { emb.Name = name; emb.Accessibility = accessibility; emb.MaxPlayers = maxPlayers; }
// Published rooms stay Public no matter what the subroom is set to; unpublished
// rooms mirror the subroom.
room.Accessibility = EffectiveRoomAccessibility(room);
col.Update(room);
}
return true;
}
public static void DeleteRoom(ulong roomId)
{
subroom_col.DeleteMany(x => x.RoomId == roomId);
col.DeleteMany(x => x.RoomId == roomId);
}
public static RoomDBClasses.SubroomSaveResults? GetRoomSaves(ulong roomId, ulong subroomId, int skip = 0, int take = 20)
{
var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subroomId);
if (subroom == null)
return null;
var saves = new List<RoomDBClasses.CurrentSave>();
if (subroom.CurrentSave != null)
saves.Add(subroom.CurrentSave);
if (subroom.AllSaves != null && subroom.AllSaves.Count > 0)
saves.AddRange(subroom.AllSaves);
saves = saves
.GroupBy(x => x.SubRoomDataSaveId)
.Select(g => g.First())
.OrderByDescending(x => x.CreatedAt)
.ToList();
var paged = saves
.Skip(skip)
.Take(take)
.ToList();
return new SubroomSaveResults
{
Results = paged,
TotalCount = saves.Count
};
}
/// <summary>A room's role list (by id). Empty if the room or its Roles are unset.</summary>
public static List<RoomDBClasses.RoleClass> GetRoomRoles(ulong roomId)
{
var room = col.FindOne(x => x.RoomId == roomId);
return room?.Roles ?? new List<RoomDBClasses.RoleClass>();
}
/// <summary>Can this player manage a room's roles? Creator, a co-owner, or a dev/admin.</summary>
public static bool PlayerCanManageRoles(ulong roomId, ulong callerId)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return false;
if (room.CreatorAccountId == callerId) return true;
if (PlayerDB.CheckDevFlag(callerId)) return true;
return IsCoOwner(room, callerId);
}
/// <summary>Co-owner or temporary co-owner (both edit like the creator does).</summary>
public static bool IsCoOwner(RoomDBClasses.RoomRoot room, ulong accountId) =>
room.Roles?.Any(r => (ulong)r.AccountId == accountId
&& (r.Role == RoomDBClasses.Role_data.Creator
|| r.Role == RoomDBClasses.Role_data.CoOwner
|| r.Role == RoomDBClasses.Role_data.TemporaryCoOwner)) ?? false;
/// <summary>
/// Does the player hold at least <paramref name="role"/> (a Role_data value: 10 Host,
/// 20 Moderator, 30 CoOwner) in the room? The creator always passes; a banned entry never
/// does (reference: verifyRole).
/// </summary>
public static bool PlayerHasRoleAtLeast(ulong roomId, ulong accountId, int role)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return false;
if (room.CreatorAccountId == accountId) return true;
var mine = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == accountId);
if (mine == null || mine.Role == RoomDBClasses.Role_data.Banned || mine.Role == RoomDBClasses.Role_data.None) return false;
return (int)mine.Role >= role;
}
/// <summary>Add/update/remove a player's role on a room (by id). None removes it. Returns success.</summary>
/// <summary>Creator, co-owner (temporary too) or contributor: may save the room's contents.</summary>
public static bool CanEditContent(RoomDBClasses.RoomRoot? room, ulong accountId)
{
if (room == null) return false;
if (room.CreatorAccountId == accountId || IsCoOwner(room, accountId)) return true;
return room.Roles?.Any(r => (ulong)r.AccountId == accountId && r.Role == RoomDBClasses.Role_data.Contributor) ?? false;
}
/// <summary>
/// Record a PENDING role invite: InvitedRole = the offered role, Role untouched. Nothing is
/// granted until the invitee accepts (AcceptRoleInvite). A non-zero InvitedRole is what the
/// client renders as "invited, not yet accepted".
/// </summary>
public static bool InviteRoomRole(ulong roomId, long accountId, RoomDBClasses.Role_data role, long invitedBy)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return false;
room.Roles ??= new List<RoomDBClasses.RoleClass>();
var existing = room.Roles.FirstOrDefault(r => r.AccountId == accountId);
if (existing != null) { existing.InvitedRole = (int)role; existing.LastChangedByAccountId = invitedBy; }
else room.Roles.Add(new RoomDBClasses.RoleClass { AccountId = accountId, Role = RoomDBClasses.Role_data.None, InvitedRole = (int)role, LastChangedByAccountId = invitedBy });
col.Update(room);
return true;
}
/// <summary>The role this player has been invited to on the room, or None.</summary>
public static RoomDBClasses.Role_data PendingRoleInvite(ulong roomId, long accountId)
{
var room = col.FindOne(x => x.RoomId == roomId);
var e = room?.Roles?.FirstOrDefault(r => r.AccountId == accountId);
if (e == null || e.InvitedRole == 0) return RoomDBClasses.Role_data.None;
return Enum.IsDefined(typeof(RoomDBClasses.Role_data), (byte)e.InvitedRole) ? (RoomDBClasses.Role_data)(byte)e.InvitedRole : RoomDBClasses.Role_data.None;
}
/// <summary>Accept (grant the invited role) or decline (drop the invite) a pending invite.</summary>
public static bool ResolveRoleInvite(ulong roomId, long accountId, bool accept)
{
var room = col.FindOne(x => x.RoomId == roomId);
var e = room?.Roles?.FirstOrDefault(r => r.AccountId == accountId);
if (room == null || e == null || e.InvitedRole == 0) return false;
if (accept)
{
e.Role = Enum.IsDefined(typeof(RoomDBClasses.Role_data), (byte)e.InvitedRole) ? (RoomDBClasses.Role_data)(byte)e.InvitedRole : RoomDBClasses.Role_data.None;
e.InvitedRole = 0;
if (e.Role == RoomDBClasses.Role_data.None) room.Roles!.Remove(e);
}
else
{
e.InvitedRole = 0;
if (e.Role == RoomDBClasses.Role_data.None) room.Roles!.Remove(e);
}
col.Update(room);
return true;
}
public static bool SetRoomRole(ulong roomId, long accountId, RoomDBClasses.Role_data role, long changedBy)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return false;
room.Roles ??= new List<RoomDBClasses.RoleClass>();
var existing = room.Roles.FirstOrDefault(r => r.AccountId == accountId);
if (role == RoomDBClasses.Role_data.None)
{
if (existing != null) room.Roles.Remove(existing);
}
// Granted directly (no pending-invite step here), so InvitedRole stays 0: a non-zero
// InvitedRole is what the client reads as "invited, not yet accepted".
else if (existing != null)
{
existing.Role = role; existing.InvitedRole = 0; existing.LastChangedByAccountId = changedBy;
}
else
{
room.Roles.Add(new RoomDBClasses.RoleClass { AccountId = accountId, Role = role, InvitedRole = 0, LastChangedByAccountId = changedBy });
}
col.Update(room);
return true;
}
public static bool AddRoleToRoom(string name, int accountId, RoomDBClasses.Role_data roleData, int invitedRole = 0)
{
var room = col.FindOne(x => x.Name == name);
if (room == null)
return false;
room.Roles ??= new List<RoleClass>();
room.Roles.Add(new RoleClass
{
AccountId = accountId,
Role = roleData,
InvitedRole = invitedRole
});
col.Update(room);
return true;
}
public static bool RemoveRoleFromRoom(string name, int accountId)
{
var room = col.FindOne(x => x.Name == name);
if (room == null)
return false;
if (room.Roles == null || room.Roles.Count == 0)
return false;
room.Roles.RemoveAll(r => r.AccountId == accountId);
col.Update(room);
return true;
}
public static RoomDBClasses.RoomRoot SetRoomCreatorId(ulong playerid, string roomname)
{
col.EnsureIndex(x => x.Name);
var room = col.FindOne(x => x.Name == roomname);
if (room != null)
{
room.CreatorAccountId = playerid;
var ownerRole = room.Roles.FirstOrDefault(r => r.Role == Role_data.Creator);
if (ownerRole != null)
{
ownerRole.AccountId = (long)playerid;
}
else
{
}
Console.WriteLine($"Setting the creator id to {playerid}...");
col.Update(room);
Console.WriteLine($"Success!");
return room;
}
return null;
}
public static void Setup()
{
col.EnsureIndex(x => x.CreatorAccountId);
col.EnsureIndex(x => x.RoomId);
col.EnsureIndex(x => x.IsDorm);
subroom_col.EnsureIndex(x => x.SubRoomId);
// Every "subrooms of room X" lookup filters on RoomId. Without this index each one was a
// full scan of the subroom table, and the hot-rooms list did one per room - 2,500 scans
// per request, 30-70 s each on the VPS.
subroom_col.EnsureIndex(x => x.RoomId);
col.EnsureIndex(x => x.Name);
col.EnsureIndex("TagIndex", "$.Tags[*].Tag");
}
}
}
@@ -0,0 +1,222 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// The room economy a creator sets up in Room Settings: room KEYS (a one-off pass sold for
/// tokens or for a room currency), room CURRENCIES (with the purchase offers that sell them
/// for tokens), and room CONSUMABLES (stackable items sold for tokens or a room currency).
/// Plus who owns what: keys per player, currency balances per player, consumable counts per
/// player. Every route that used to answer "[]" or an ack reads and writes this.
///
/// DTO shapes follow the wire-shape notes (roomkeys-v1-room, roomcurrencies-createcurrency,
/// roomcurrencies-purchaseoffersbatch, roomconsumables-by-room).
/// </summary>
public static class RoomEconDB
{
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "RoomEcon.db")));
public class RoomKey
{
[BsonId] public long RoomKeyId { get; set; }
public Guid ReplicationId { get; set; } = Guid.NewGuid();
public long RoomId { get; set; }
public ulong CreatorId { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public int Price { get; set; }
public Guid? PurchaseCurrencyId { get; set; }
public string? ImageName { get; set; }
public int Type { get; set; }
public bool Deleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class RoomCurrency
{
[BsonId] public Guid CurrencyId { get; set; } = Guid.NewGuid();
public long RoomId { get; set; }
public ulong CreatorId { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public long Limit { get; set; } = 1_000_000;
public int Shape { get; set; }
public int Color { get; set; } = -1;
public string? ImageName { get; set; }
public bool Deleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
}
public class CurrencyOffer
{
[BsonId] public Guid CurrencyPurchaseOfferId { get; set; } = Guid.NewGuid();
public Guid CurrencyId { get; set; }
public long RoomId { get; set; }
public int Order { get; set; }
public string Name { get; set; } = "";
public long CurrencyAmount { get; set; }
public long Price { get; set; }
public bool Deleted { get; set; }
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
}
public class RoomConsumable
{
[BsonId] public Guid RoomConsumableId { get; set; } = Guid.NewGuid();
public long RoomId { get; set; }
public ulong CreatorId { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public string? ImageName { get; set; }
public long Price { get; set; }
public Guid? PurchaseCurrencyId { get; set; }
public int MaximumCountPerPurchase { get; set; } = 10;
public bool Deleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
}
public class KeyOwnership
{
[BsonId] public string Key { get; set; } = ""; // "{player}|{roomKeyId}"
public ulong PlayerId { get; set; }
public long RoomKeyId { get; set; }
public long RoomId { get; set; }
public DateTime At { get; set; } = DateTime.UtcNow;
}
public class CurrencyBalance
{
[BsonId] public string Key { get; set; } = ""; // "{player}|{currencyId}"
public ulong PlayerId { get; set; }
public Guid CurrencyId { get; set; }
public long RoomId { get; set; }
public long Balance { get; set; }
}
public class ConsumableOwnership
{
[BsonId] public string Key { get; set; } = ""; // "{player}|{consumableId}"
public ulong PlayerId { get; set; }
public Guid RoomConsumableId { get; set; }
public long RoomId { get; set; }
public int Count { get; set; }
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
}
private static ILiteCollection<RoomKey> Keys() { var c = _db.Value.GetCollection<RoomKey>("keys"); c.EnsureIndex(x => x.RoomId); return c; }
private static ILiteCollection<RoomCurrency> Currencies() { var c = _db.Value.GetCollection<RoomCurrency>("currencies"); c.EnsureIndex(x => x.RoomId); return c; }
private static ILiteCollection<CurrencyOffer> Offers() { var c = _db.Value.GetCollection<CurrencyOffer>("offers"); c.EnsureIndex(x => x.CurrencyId); return c; }
private static ILiteCollection<RoomConsumable> Consumables() { var c = _db.Value.GetCollection<RoomConsumable>("consumables"); c.EnsureIndex(x => x.RoomId); return c; }
private static ILiteCollection<KeyOwnership> KeysOwned() { var c = _db.Value.GetCollection<KeyOwnership>("keys_owned"); c.EnsureIndex(x => x.PlayerId); c.EnsureIndex(x => x.RoomKeyId); return c; }
private static ILiteCollection<CurrencyBalance> Balances() { var c = _db.Value.GetCollection<CurrencyBalance>("balances"); c.EnsureIndex(x => x.PlayerId); return c; }
private static ILiteCollection<ConsumableOwnership> ConsOwned() { var c = _db.Value.GetCollection<ConsumableOwnership>("consumables_owned"); c.EnsureIndex(x => x.PlayerId); c.EnsureIndex(x => x.RoomConsumableId); return c; }
// ---- keys ----------------------------------------------------------------------------
public static RoomKey CreateKey(RoomKey k)
{
var col = Keys();
if (k.RoomKeyId == 0)
{
var last = col.Query().OrderByDescending(x => x.RoomKeyId).Limit(1).FirstOrDefault();
k.RoomKeyId = Math.Max(900_000, (last?.RoomKeyId ?? 0) + 1);
}
col.Insert(k);
return k;
}
public static RoomKey? GetKey(long id) { var k = Keys().FindById(id); return k == null || k.Deleted ? null : k; }
public static List<RoomKey> KeysForRoom(long roomId) => Keys().Query().Where(x => x.RoomId == roomId).ToList().Where(x => !x.Deleted).OrderBy(x => x.CreatedAt).ToList();
public static RoomKey? UpdateKey(long id, Action<RoomKey> edit) { var col = Keys(); var k = col.FindById(id); if (k == null) return null; edit(k); col.Update(k); return k; }
public static bool OwnsKey(ulong player, long keyId) => KeysOwned().FindById($"{player}|{keyId}") != null;
public static bool GrantKey(ulong player, RoomKey k)
{
var col = KeysOwned();
string key = $"{player}|{k.RoomKeyId}";
if (col.FindById(key) != null) return false;
col.Insert(new KeyOwnership { Key = key, PlayerId = player, RoomKeyId = k.RoomKeyId, RoomId = k.RoomId });
return true;
}
public static bool RevokeKey(ulong player, long keyId) => KeysOwned().Delete($"{player}|{keyId}");
public static List<RoomKey> KeysOwnedBy(ulong player)
{
var ids = KeysOwned().Find(x => x.PlayerId == player).Select(x => x.RoomKeyId).ToList();
return ids.Select(GetKey).Where(k => k != null).Select(k => k!).ToList();
}
public static int KeyOwnerCount(long keyId) => KeysOwned().Count(x => x.RoomKeyId == keyId);
// ---- currencies ----------------------------------------------------------------------
public static RoomCurrency CreateCurrency(RoomCurrency c) { Currencies().Insert(c); return c; }
public static RoomCurrency? GetCurrency(Guid id) { var c = Currencies().FindById(id); return c == null || c.Deleted ? null : c; }
public static List<RoomCurrency> CurrenciesForRoom(long roomId) => Currencies().Query().Where(x => x.RoomId == roomId).ToList().Where(x => !x.Deleted).OrderBy(x => x.CreatedAt).ToList();
public static RoomCurrency? UpdateCurrency(Guid id, Action<RoomCurrency> edit) { var col = Currencies(); var c = col.FindById(id); if (c == null) return null; edit(c); c.ModifiedAt = DateTime.UtcNow; col.Update(c); return c; }
public static CurrencyOffer CreateOffer(CurrencyOffer o) { Offers().Insert(o); return o; }
public static CurrencyOffer? GetOffer(Guid id) { var o = Offers().FindById(id); return o == null || o.Deleted ? null : o; }
public static List<CurrencyOffer> OffersFor(Guid currencyId) => Offers().Query().Where(x => x.CurrencyId == currencyId).ToList().Where(x => !x.Deleted).OrderBy(x => x.Order).ThenBy(x => x.Price).ToList();
public static CurrencyOffer? UpdateOffer(Guid id, Action<CurrencyOffer> edit) { var col = Offers(); var o = col.FindById(id); if (o == null) return null; edit(o); o.ModifiedAt = DateTime.UtcNow; col.Update(o); return o; }
public static long GetCurrencyBalance(ulong player, Guid currencyId) => Balances().FindById($"{player}|{currencyId}")?.Balance ?? 0;
public static List<CurrencyBalance> BalancesForRoom(ulong player, long roomId) => Balances().Find(x => x.PlayerId == player && x.RoomId == roomId).ToList();
/// <summary>Add (or take, negative) room currency. Returns the new balance, or null when it would go negative / over the limit.</summary>
public static long? AddCurrency(ulong player, RoomCurrency cur, long delta)
{
var col = Balances();
string key = $"{player}|{cur.CurrencyId}";
var b = col.FindById(key) ?? new CurrencyBalance { Key = key, PlayerId = player, CurrencyId = cur.CurrencyId, RoomId = cur.RoomId, Balance = 0 };
long next = b.Balance + delta;
if (next < 0) return null;
if (cur.Limit > 0 && next > cur.Limit) next = cur.Limit;
b.Balance = next;
col.Upsert(b);
return next;
}
// ---- consumables ---------------------------------------------------------------------
public static RoomConsumable CreateConsumable(RoomConsumable c) { Consumables().Insert(c); return c; }
public static RoomConsumable? GetConsumable(Guid id) { var c = Consumables().FindById(id); return c == null || c.Deleted ? null : c; }
public static List<RoomConsumable> ConsumablesForRoom(long roomId) => Consumables().Query().Where(x => x.RoomId == roomId).ToList().Where(x => !x.Deleted).OrderBy(x => x.CreatedAt).ToList();
public static RoomConsumable? UpdateConsumable(Guid id, Action<RoomConsumable> edit) { var col = Consumables(); var c = col.FindById(id); if (c == null) return null; edit(c); c.ModifiedAt = DateTime.UtcNow; col.Update(c); return c; }
public static int ConsumableCount(ulong player, Guid id) => ConsOwned().FindById($"{player}|{id}")?.Count ?? 0;
public static List<ConsumableOwnership> ConsumablesOwnedInRoom(ulong player, long roomId) => ConsOwned().Find(x => x.PlayerId == player && x.RoomId == roomId).Where(x => x.Count > 0).ToList();
/// <summary>Add (or consume, negative) copies. Returns the new count, or null when there were not enough.</summary>
public static int? AddConsumable(ulong player, RoomConsumable c, int delta)
{
var col = ConsOwned();
string key = $"{player}|{c.RoomConsumableId}";
var o = col.FindById(key) ?? new ConsumableOwnership { Key = key, PlayerId = player, RoomConsumableId = c.RoomConsumableId, RoomId = c.RoomId, Count = 0 };
int next = o.Count + delta;
if (next < 0) return null;
o.Count = next; o.ModifiedAt = DateTime.UtcNow;
col.Upsert(o);
return next;
}
public static int ConsumableSoldCount(Guid id) => ConsOwned().Find(x => x.RoomConsumableId == id).Sum(x => x.Count);
// ---- DTOs ----------------------------------------------------------------------------
public static object KeyDto(RoomKey k) => new
{
k.RoomKeyId, k.ReplicationId, k.RoomId, k.Name, k.Description, k.Price, k.PurchaseCurrencyId,
CreatedAt = k.CreatedAt.ToString("o"), ImageName = k.ImageName ?? "", k.Type
};
public static object CurrencyDto(RoomCurrency c) => new
{
c.CurrencyId, RoomId = (long?)c.RoomId, c.Name, c.Description, CurrencyType = 300, c.Limit, c.Shape, c.Color,
ImageName = c.ImageName ?? "", CreatedAt = c.CreatedAt.ToString("o"), ModifiedAt = c.ModifiedAt.ToString("o")
};
public static object OfferDto(CurrencyOffer o) => new
{
o.CurrencyPurchaseOfferId, o.CurrencyId, o.Order, o.Name, o.CurrencyAmount, o.Price, ModifiedAt = o.ModifiedAt.ToString("o")
};
public static object ConsumableDto(RoomConsumable c) => new
{
c.RoomConsumableId, c.RoomId, c.Name, c.Description, ImageName = c.ImageName ?? "", c.Price, c.PurchaseCurrencyId,
ModifiedAt = c.ModifiedAt.ToString("o"), c.MaximumCountPerPurchase, CreatedAt = c.CreatedAt.ToString("o")
};
}
}
@@ -0,0 +1,224 @@
using LiteDB;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.dbs
{
public class SettingsDB
{
private static readonly string _dbPath = Path.Combine(Environment.CurrentDirectory, "Data", "DBs", "Settings.db");
private static readonly LiteDatabase _db = new LiteDatabase(_dbPath);
// Merge this store's write-ahead log back into the data file.
// See DbMaintenance for why this must be called explicitly.
public static void Checkpoint() => _db.Checkpoint();
private static ILiteCollection<PlayerSetting> PlayerSettings => _db.GetCollection<PlayerSetting>("players_settings");
public static List<Setting> LoadSettings(ulong playerId)
{
return PlayerSettings.FindById(playerId)?.Settings ?? new List<Setting>();
}
public static void SaveSettings(List<Setting> settings, ulong playerId)
{
if (playerId == 0) return;
var player = new PlayerSetting
{
PlayerId = playerId,
Settings = settings
};
PlayerSettings.Upsert(player); // ✅ SAFE
}
public static void SetPlayerSetting(string key, string value, ulong playerId)
{
if (string.IsNullOrWhiteSpace(key) || playerId == 0)
return;
if (key is "SplitTestAssignedSegments" or "Growth.LastEmailPromptTime")
return;
var col = PlayerSettings;
var player = col.FindById(playerId) ?? new PlayerSetting
{
PlayerId = playerId,
Settings = CreateDefaultSettings()
};
var setting = player.Settings.FirstOrDefault(s => s.Key == key);
if (setting != null)
setting.Value = value;
else
player.Settings.Add(new Setting { Key = key, Value = value });
col.Upsert(player); // 🔥 KEY LINE
}
/// <summary>Every step of the new-user / Orientation flow, marked done.</summary>
private static readonly (string Key, string Value)[] OrientationDone =
{
("Recroom.AccountCreation.HasStarted", "true"),
("Recroom.AccountCreation.AcceptedCodeOfConduct", "true"),
("Recroom.AccountCreation.HasChosenUsername", "true"),
("Recroom.AccountCreation.HasCreatedPassword", "true"),
("Recroom.AccountCreation.HasImportedFriends", "true"),
("Recroom.AccountCreation.FinishedAvatarCustomization", "true"),
("Recroom.AccountCreation.HasFinished", "true"),
// Every bit set = every tutorial/orientation step complete.
("TUTORIAL_COMPLETE_MASK", "4294967295"),
("Recroom.OOBE", "1"),
};
/// <summary>
/// Mark this player as having finished Orientation. One upsert, not nine - SetPlayerSetting
/// reads and writes the whole settings document per call, which on a full backfill is a
/// read-modify-write per key per account.
/// </summary>
public static bool MarkOrientationComplete(ulong playerId)
{
if (playerId == 0) return false;
var col = PlayerSettings;
var player = col.FindById(playerId) ?? new PlayerSetting
{
PlayerId = playerId,
Settings = CreateDefaultSettings()
};
player.Settings ??= CreateDefaultSettings();
bool changed = false;
foreach (var (key, value) in OrientationDone)
{
var setting = player.Settings.FirstOrDefault(s => s.Key == key);
if (setting == null) { player.Settings.Add(new Setting { Key = key, Value = value }); changed = true; }
else if (setting.Value != value) { setting.Value = value; changed = true; }
}
if (changed) col.Upsert(player);
return changed;
}
/// <summary>
/// Backfill: mark Orientation complete for every account that already has a settings row.
/// Accounts with no row inherit CreateDefaultSettings(), which already says complete.
/// Returns how many rows were actually changed.
/// </summary>
public static int SkipOrientationForAllPlayers()
{
int n = 0;
foreach (var id in PlayerSettings.FindAll().Select(p => p.PlayerId).ToList())
{
try { if (MarkOrientationComplete(id)) n++; }
catch (Exception ex) { Console.WriteLine($"[orientation] {id}: skip failed - {ex.Message}"); }
}
return n;
}
public static void DeletePlayerKey(string key, ulong playerId)
{
if (string.IsNullOrWhiteSpace(key) || playerId == 0)
return;
var col = PlayerSettings;
var player = col.FindById(playerId);
if (player == null || player.Settings == null)
return;
player.Settings.RemoveAll(s => s.Key == key);
col.Upsert(player);
}
public static string? GetPlayerSettingValue(string key, ulong playerId)
{
if (string.IsNullOrWhiteSpace(key) || playerId == 0)
return null;
// ignored keys
if (key == "SplitTestAssignedSegments")
return null;
if (key == "Growth.LastEmailPromptTime")
return null;
var settings = LoadSettings(playerId);
var setting = settings.Find(s => s.Key == key);
return setting?.Value;
}
public static void SetPlayerSettingFromJson(string jsonData, ulong playerId)
{
if (string.IsNullOrWhiteSpace(jsonData) || playerId == 0)
return;
Setting? setting;
try
{
setting = JsonConvert.DeserializeObject<Setting>(jsonData);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to parse setting JSON: {ex.Message}");
return;
}
if (setting == null || string.IsNullOrEmpty(setting.Key) || setting.Key == "SplitTestAssignedSegments")
return;
SetPlayerSetting(setting.Key, setting.Value ?? "", playerId);
}
public static void DeleteSettings(ulong playerId)
{
if (playerId == 0) return;
var col = PlayerSettings;
col.DeleteMany(x => x.PlayerId == playerId);
}
public static List<Setting> CreateDefaultSettings() => new List<Setting>
{
new Setting { Key = "MOD_BLOCKED_TIME", Value = "0" },
new Setting { Key = "MOD_BLOCKED_DURATION", Value = "0" },
new Setting { Key = "PlayerSessionCount", Value = "0" },
new Setting { Key = "ShowRoomCenter", Value = "1" },
new Setting { Key = "QualitySettings", Value = "1" },
new Setting { Key = "Recroom.OOBE", Value = "1" },
new Setting { Key = "VoiceFilter", Value = "0" },
new Setting { Key = "VIGNETTED_TELEPORT_ENABLED", Value = "0" },
new Setting { Key = "CONTINUOUS_ROTATION_MODE", Value = "0" },
new Setting { Key = "ROTATION_INCREMENT", Value = "0" },
new Setting { Key = "ROTATE_IN_PLACE_ENABLED", Value = "0" },
new Setting { Key = "TeleportBuffer", Value = "0" },
new Setting { Key = "VoiceChat", Value = "1" },
new Setting { Key = "PersonalBubble", Value = "0" },
new Setting { Key = "ShowNames", Value = "1" },
new Setting { Key = "H.264 plugin", Value = "1" },
// Mark account creation as fully done so the client doesn't get stuck in first-boot
// flows that expect Steam / Meta OAuth pages we don't serve.
new Setting { Key = "Recroom.AccountCreation.HasStarted", Value = "true" },
new Setting { Key = "Recroom.AccountCreation.HasFinished", Value = "true" },
new Setting { Key = "Recroom.AccountCreation.HasChosenUsername", Value = "true" },
new Setting { Key = "Recroom.AccountCreation.HasCreatedPassword", Value = "true" },
// The client re-enters the flow on ANY step that reads false, so the three that were
// missing here have to be stated too - not left to whatever the client defaults to.
new Setting { Key = "Recroom.AccountCreation.AcceptedCodeOfConduct", Value = "true" },
new Setting { Key = "Recroom.AccountCreation.HasImportedFriends", Value = "true" },
new Setting { Key = "Recroom.AccountCreation.FinishedAvatarCustomization", Value = "true" },
// Every bit set = every tutorial/orientation step marked complete. Prevents the
// client from trying to load SocialOrientation, which currently loops → logout.
new Setting { Key = "TUTORIAL_COMPLETE_MASK", Value = "4294967295" },
new Setting { Key = "BACKPACK_FAVORITE_TOOL", Value = "-1" }
};
}
}
@@ -0,0 +1,59 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Website-only per-account preferences: the nickname a player gives a friend and which
/// friends are pinned to their home page. Neither exists in the game, so they live here
/// (Data/DBs/SitePrefs.db) instead of on the relationship row, keyed by the OWNER's account.
/// </summary>
public static class SitePrefsDB
{
private static readonly Lazy<LiteDatabase> _db = new(() =>
new LiteDatabase(Path.Combine(Environment.CurrentDirectory, "Data", "DBs", "SitePrefs.db")));
public class Prefs
{
[BsonId] public ulong AccountId { get; set; }
public Dictionary<string, string> Nicknames { get; set; } = new(); // other account id -> nickname
public List<ulong> Pinned { get; set; } = new();
}
private static ILiteCollection<Prefs> Col() => _db.Value.GetCollection<Prefs>("prefs");
public static Prefs Get(ulong accountId) => Col().FindById(accountId) ?? new Prefs { AccountId = accountId };
public static string? NicknameFor(ulong owner, ulong other) =>
Get(owner).Nicknames.TryGetValue(other.ToString(), out var n) && !string.IsNullOrWhiteSpace(n) ? n : null;
public static bool IsPinned(ulong owner, ulong other) => Get(owner).Pinned.Contains(other);
public static string? SetNickname(ulong owner, ulong other, string? nickname)
{
var p = Get(owner);
nickname = (nickname ?? "").Trim();
if (nickname.Length > 24) nickname = nickname[..24];
if (nickname.Length == 0) p.Nicknames.Remove(other.ToString()); else p.Nicknames[other.ToString()] = nickname;
Col().Upsert(p);
return nickname.Length == 0 ? null : nickname;
}
public static bool SetPinned(ulong owner, ulong other, bool on)
{
var p = Get(owner);
p.Pinned.Remove(other);
if (on) { p.Pinned.Insert(0, other); if (p.Pinned.Count > 12) p.Pinned = p.Pinned.Take(12).ToList(); }
Col().Upsert(p);
return on;
}
/// <summary>Drop a friend's nickname and pin when the friendship ends.</summary>
public static void Forget(ulong owner, ulong other)
{
var p = Col().FindById(owner);
if (p == null) return;
bool changed = p.Nicknames.Remove(other.ToString()) | p.Pinned.Remove(other);
if (changed) Col().Update(p);
}
}
}
@@ -0,0 +1,97 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Website support tickets. A signed-in player opens a ticket (from the Support page bot's
/// "talk to a real person" flow); staff (dev / mod / owner) see EVERY ticket and can reply,
/// close and reopen. Stored in its own LiteDB file; checkpointed by DbMaintenance.
/// </summary>
public static class SupportDB
{
private static readonly LiteDatabase DB =
new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/Support.db");
public static void Checkpoint() => DB.Checkpoint();
private static ILiteCollection<Ticket> Tickets => DB.GetCollection<Ticket>("tickets");
static SupportDB()
{
Tickets.EnsureIndex(x => x.AccountId);
Tickets.EnsureIndex(x => x.Status);
}
public sealed class TicketMessage
{
public ulong FromAccountId { get; set; }
public string FromName { get; set; } = "";
public bool Staff { get; set; }
public string Body { get; set; } = "";
public DateTime At { get; set; } = DateTime.UtcNow;
}
public sealed class Ticket
{
public int Id { get; set; } // LiteDB auto-increment PK
public ulong AccountId { get; set; }
public string Username { get; set; } = "";
public string Subject { get; set; } = "";
public string Status { get; set; } = "open"; // "open" | "closed"
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public List<TicketMessage> Messages { get; set; } = new();
}
public static Ticket Create(ulong accountId, string username, string subject, string body)
{
var t = new Ticket
{
AccountId = accountId,
Username = username,
Subject = string.IsNullOrWhiteSpace(subject) ? "Support request" : subject,
Status = "open",
};
t.Messages.Add(new TicketMessage { FromAccountId = accountId, FromName = username, Staff = false, Body = body });
Tickets.Insert(t); // Id auto-assigned
return t;
}
public static Ticket? Get(int id) => Tickets.FindById(id);
public static List<Ticket> ForAccount(ulong accountId) =>
Tickets.Find(x => x.AccountId == accountId).OrderByDescending(x => x.UpdatedAt).ToList();
public static List<Ticket> All(string? status)
{
var rows = status == "open" ? Tickets.Find(x => x.Status == "open")
: status == "closed" ? Tickets.Find(x => x.Status == "closed")
: Tickets.FindAll();
return rows.OrderByDescending(x => x.UpdatedAt).ToList();
}
public static bool AddMessage(int id, ulong fromId, string fromName, bool staff, string body)
{
var t = Tickets.FindById(id);
if (t == null) return false;
t.Messages.Add(new TicketMessage { FromAccountId = fromId, FromName = fromName, Staff = staff, Body = body });
t.UpdatedAt = DateTime.UtcNow;
// A reply on a closed ticket reopens it, so nobody's follow-up is lost in a closed thread.
if (t.Status == "closed") t.Status = "open";
Tickets.Update(t);
return true;
}
public static bool SetStatus(int id, string status)
{
var t = Tickets.FindById(id);
if (t == null) return false;
t.Status = status;
t.UpdatedAt = DateTime.UtcNow;
Tickets.Update(t);
return true;
}
public static int OpenCount() => Tickets.Count(x => x.Status == "open");
}
}
@@ -0,0 +1,94 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Item wishlists (POST /api/itemWishlists/v1/wishlist/add|remove). The client's add 405'd
/// (only the reads existed); the row is recorded here so the add answers and the panel can
/// read it back. The wishlist DTO the client renders is not pinned yet, so the reads stay
/// empty until it is (see the wishlist routes) - nothing is lost, the rows are here.
/// </summary>
public static class WishlistDB
{
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "Wishlists.db")));
public class Row
{
[BsonId] public string Key { get; set; } = ""; // "{account}|{storefront}|{item}"
public ulong AccountId { get; set; }
public int StorefrontType { get; set; }
public int PurchasableItemId { get; set; }
// A UGC (custom avatar item) wish: PurchasableItemId is -1 and this is the item's GUID.
public string? CustomAvatarItemId { get; set; }
public string RawBody { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
private static ILiteCollection<Row> Col()
{
var c = _db.Value.GetCollection<Row>("items");
c.EnsureIndex(x => x.AccountId);
return c;
}
private static string KeyFor(ulong account, int item, string? custom) =>
string.IsNullOrWhiteSpace(custom) ? $"{account}|{item}" : $"{account}|ugc|{custom.ToLowerInvariant()}";
/// <summary>Record a wish; returns the row (existing or new) so the add can answer it.</summary>
public static Row Add(ulong account, int storefront, int item, string? custom, string raw)
{
var key = KeyFor(account, item, custom);
try
{
var existing = Col().FindById(key);
if (existing != null) return existing;
var row = new Row { Key = key, AccountId = account, StorefrontType = storefront, PurchasableItemId = item, CustomAvatarItemId = string.IsNullOrWhiteSpace(custom) ? null : custom, RawBody = raw };
Col().Upsert(row);
return row;
}
catch (Exception ex)
{
Console.WriteLine($"[wishlist] add failed: {ex.Message}");
return new Row { Key = key, AccountId = account, StorefrontType = storefront, PurchasableItemId = item, CustomAvatarItemId = custom };
}
}
public static void Remove(ulong account, int item, string? custom = null)
{
try
{
if (!string.IsNullOrWhiteSpace(custom))
{
string c = custom.ToLowerInvariant();
Col().DeleteMany(x => x.AccountId == account && x.CustomAvatarItemId != null && x.CustomAvatarItemId.ToLower() == c);
}
else Col().DeleteMany(x => x.AccountId == account && x.PurchasableItemId == item && x.CustomAvatarItemId == null);
}
catch (Exception ex) { Console.WriteLine($"[wishlist] remove failed: {ex.Message}"); }
}
/// <summary>The wire entry (real RecNet: WishlistItemId guid, AccountId, PurchasableItemId, CreatedAt;
/// the 2025 client adds CustomAvatarItemId for UGC wishes). The guid is derived from the key so it is stable.</summary>
public static Guid WireId(Row r)
{
using var md5 = System.Security.Cryptography.MD5.Create();
return new Guid(md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes("wish|" + r.Key)));
}
public static object ToWire(Row r)
{
Guid id = WireId(r);
return new
{
WishlistItemId = id,
AccountId = r.AccountId,
PurchasableItemId = r.PurchasableItemId,
CustomAvatarItemId = r.CustomAvatarItemId,
CreatedAt = r.CreatedAt
};
}
public static List<object> WireFor(ulong account) => For(account).Select(ToWire).ToList();
public static List<Row> For(ulong account) => Col().Find(x => x.AccountId == account).OrderBy(x => x.CreatedAt).ToList();
}
}