the files
This commit is contained in:
@@ -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 <= 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->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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user