Files
Campus-25/server/Server/2025-RecRoom-Server-E12354-main/api/dbs/PlayerDB.cs
T
2026-10-14 06:59:51 +02:00

3754 lines
163 KiB
C#

using System;
using System.Numerics;
using System.Security.Principal;
using LiteDB;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using Campus_live_rewrite.Controllers;
using SixLabors.ImageSharp;
using static ImageMetadataDB;
namespace Campus_live_rewrite.api.dbs
{
public class PlayerDB
{
public static LiteDatabase PlayerDBFile = new LiteDatabase(Environment.CurrentDirectory + $"/Data/DBs/Players.db");
private static readonly Random random = new Random();
private static readonly object randLock = new object();
public static bool CreateAccount(Platforms platform, ulong platformId, string deviceId, ulong discord_id, out account_data account)
{
account = new account_data();
Console.WriteLine($"[CreateAccount] === START === platform={platform} platformId={platformId} deviceId='{deviceId}' discord_id={discord_id}");
var col = PlayerDBFile.GetCollection<player_data>("players");
var maxId = col.FindAll().OrderByDescending(x => x.id).FirstOrDefault()?.id ?? 0;
ulong accid = maxId + 1;
account.accountId = accid;
string name = Utils.GetRandomName();
Console.WriteLine($"[CreateAccount] Assigning accid={accid} (maxId was {maxId}), generated name='{name}'");
account = new account_data
{
createdAt = DateTime.UtcNow,
identityFlags = 0,
isJunior = null,
personalPronouns = 0,
platforms = 0,
displayName = name,
username = name,
profileImage = "DefaultPFP.png",
accountId = accid
};
col.Insert(new player_data
{
playerid = account.accountId,
id = accid,
platform = platform,
//platformId = platformId,
CreatedInGame = true,
deviceId = new List<string>
{
deviceId
},
authtoken = Utils.GetRandomString(400),
password = "",
player = new player_info
{
Id = accid,
Username = account.username,
DisplayName = account.displayName,
XP = 0,
Level = 1,
created_at = account.createdAt,
bannerImage = Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage,
profileImage = account.profileImage,
IsJunior = false,
AvoidJuniors = true,
PlayerReputation = new mPlayerReputation
{
Noteriety = 0,
CheerCredit = 20,
CheerGeneral = 1,
CheerHelpful = 1,
CheerGreatHost = 1,
CheerSportsman = 1,
CheerCreative = 1,
SubscriberCount = 0,
SubscribedCount = 0,
SelectedCheer = CheerCategoryEnum.None
},
PlatformIds = new List<mPlatformID>
{
new mPlatformID
{
Platform = platform,
PlatformId = platformId
}
}
},
player_Extra = new player_extra
{
DormRoomID = 1,
IpAddresses = new List<string>(),
DiscordId = discord_id,
Bio = "",
avatar = "{\"FaceFeatures\": \"\",\"HairColor\": \"\",\"OutfitSelections\": \"\",\"SkinColor\": \"\"}",
SavedOutfits = new List<avatar_data_saved>(),
//ModerationBlockDetails = new Moderation_Detail.ModerationBlockDetails()
CheeredRooms = new List<ulong>(),
FavoritedRooms = new List<ulong>(),
sub_count = 0,
SubbedTo = new List<ulong>(),
IsRecentRoomHistoryVisible = true,
Influencer = new Influencer
{
creatorCode = "",
isInfluencer = false,
},
CustomAvatarItems = new Campus_live_rewrite.api.server.Classes.db_class.CustomAvatarItems(),
},
dev_Flag = DevFlag.none
});
//Settings.SaveSettings_db(Settings.CreateDefaultSettings(), accid);
Console.WriteLine($"[CreateAccount] Row inserted. accid={accid} platform={platform} discord_id={discord_id} display='{name}' img='{account.profileImage}'");
HeartbeatDB.CreatePlayerHeartbeat(accid, out _, platform, DeviceClasses.Unknown);
Console.WriteLine($"[CreateAccount] Heartbeat created for accid={accid}");
CreateDormForPlayerId(accid);
Console.WriteLine($"[CreateAccount] Dorm created for accid={accid}. === DONE ===");
return true;
}
// ================================================================================
// WEBSITE ACCOUNT CREATION (Campus accounts are now made on the website, not in game)
//
// The game used to auto-create an account on first launch (AuthController
// grant_type=create_account). That path is disabled - accounts are made here, on the
// site, with a username + password the player chooses, and the game only ever LOGS IN
// to them. The account carries NO platform binding at creation: a browser has no Steam
// id. The Steam id is stamped on the account the first time the player signs in from
// the game (AccountLock.TryEnforceAndBind), which is what "locks the account to your
// Steam" means.
// ================================================================================
/// <summary>Case-insensitive username -> account id, or 0 if no such account.</summary>
public static ulong GetAccountIdByUsernameCI(string username)
{
if (string.IsNullOrWhiteSpace(username)) return 0;
string want = username.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
var p = col.FindAll().FirstOrDefault(x =>
x?.player?.Username != null &&
string.Equals(x.player.Username, want, StringComparison.OrdinalIgnoreCase));
return p?.playerid ?? 0;
}
/// <summary>Case-insensitive: is this exact username already in use?</summary>
public static bool IsUsernameTaken(string username)
{
if (string.IsNullOrWhiteSpace(username)) return true;
string want = username.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll().Any(p =>
p?.player?.Username != null &&
string.Equals(p.player.Username, want, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Create an account from the website. No device id, no platform binding - those are
/// stamped on the first in-game login. The account is marked onboarding-complete so the
/// game drops the player straight in (no first-run "make a room" / username / password
/// flow) since they already chose all of that here.
/// </summary>
public static bool CreateWebAccount(string username, string displayName, string password,
string? email, out account_data account, out string error,
bool isJunior = false, string? birthday = null)
{
account = new account_data();
error = "";
username = (username ?? "").Trim();
displayName = string.IsNullOrWhiteSpace(displayName) ? username : displayName.Trim();
if (IsUsernameTaken(username))
{
error = "That username is already taken.";
return false;
}
var col = PlayerDBFile.GetCollection<player_data>("players");
var maxId = col.FindAll().OrderByDescending(x => x.id).FirstOrDefault()?.id ?? 0;
ulong accid = maxId + 1;
account = new account_data
{
createdAt = DateTime.UtcNow,
identityFlags = 0,
isJunior = isJunior,
personalPronouns = 0,
platforms = 0,
displayName = displayName,
username = username,
profileImage = "DefaultPFP.png",
accountId = accid
};
col.Insert(new player_data
{
playerid = accid,
id = accid,
platform = Platforms.All,
CreatedInGame = false,
deviceId = new List<string>(), // bound on first in-game login
authtoken = Utils.GetRandomString(400),
password = ClientSecurity.HashPassword(password),
player = new player_info
{
Id = accid,
Username = username,
DisplayName = displayName,
Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
Birthday = string.IsNullOrWhiteSpace(birthday) ? null : birthday.Trim(),
XP = 0,
Level = 1,
created_at = account.createdAt,
bannerImage = Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage,
profileImage = account.profileImage,
IsJunior = isJunior,
// Juniors are placed among juniors (don't avoid them); adults avoid juniors by default.
AvoidJuniors = !isJunior,
PlayerReputation = new mPlayerReputation
{
Noteriety = 0,
CheerCredit = 20,
CheerGeneral = 1,
CheerHelpful = 1,
CheerGreatHost = 1,
CheerSportsman = 1,
CheerCreative = 1,
SubscriberCount = 0,
SubscribedCount = 0,
SelectedCheer = CheerCategoryEnum.None
},
PlatformIds = new List<mPlatformID>() // NO steam id yet
},
player_Extra = new player_extra
{
DormRoomID = 1,
IpAddresses = new List<string>(),
DiscordId = 0,
Bio = "",
avatar = "{\"FaceFeatures\": \"\",\"HairColor\": \"\",\"OutfitSelections\": \"\",\"SkinColor\": \"\"}",
SavedOutfits = new List<avatar_data_saved>(),
CheeredRooms = new List<ulong>(),
FavoritedRooms = new List<ulong>(),
sub_count = 0,
SubbedTo = new List<ulong>(),
IsRecentRoomHistoryVisible = true,
Influencer = new Influencer { creatorCode = "", isInfluencer = false },
CustomAvatarItems = new Campus_live_rewrite.api.server.Classes.db_class.CustomAvatarItems(),
},
dev_Flag = DevFlag.none
});
HeartbeatDB.CreatePlayerHeartbeat(accid, out _, Platforms.All, DeviceClasses.Unknown);
CreateDormForPlayerId(accid);
// Mark first-run onboarding + tutorial complete so the game goes straight in - the
// player already chose username/password on the website (mirrors the Kyle seed in
// Program.cs and SettingsDB.CreateDefaultSettings).
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasStarted", "true", accid);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasFinished", "true", accid);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasChosenUsername", "true", accid);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasCreatedPassword","true", accid);
SettingsDB.SetPlayerSetting("TUTORIAL_COMPLETE_MASK", "4294967295", accid);
Console.WriteLine($"[web-signup] created account {accid} @{username} (\"{displayName}\")"
+ (email != null ? " with email" : ""));
return true;
}
/// <summary>
/// The account's bound platform id for a platform (0 if none). Ignores the placeholder
/// 0 entry that some legacy rows carry.
/// </summary>
public static ulong GetAccountPlatformId(ulong accid, Platforms platform)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var p = col.FindOne(x => x.playerid == accid);
var ids = p?.player?.PlatformIds;
if (ids == null) return 0;
var match = ids.FirstOrDefault(pid => pid.Platform == platform && pid.PlatformId != 0);
return match?.PlatformId ?? 0;
}
/// <summary>Append a platform id to the account if it is not already present.</summary>
public static void AddPlatformId(ulong accid, Platforms platform, ulong platformId)
{
if (platformId == 0) return;
var col = PlayerDBFile.GetCollection<player_data>("players");
var p = col.FindOne(x => x.playerid == accid);
if (p?.player == null) return;
p.player.PlatformIds ??= new List<mPlatformID>();
if (p.player.PlatformIds.Any(pid => pid.Platform == platform && pid.PlatformId == platformId))
return;
p.player.PlatformIds.Add(new mPlatformID { Platform = platform, PlatformId = platformId });
col.Update(p);
Console.WriteLine($"[account-lock] bound {platform} id {platformId} to account {accid}");
}
/// <summary>Every account carrying this platform id (used by the one-account-per-Steam lock).</summary>
public static List<ulong> AccountsWithPlatformId(Platforms platform, ulong platformId)
{
if (platformId == 0) return new List<ulong>();
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll()
.Where(x => x.player?.PlatformIds != null &&
x.player.PlatformIds.Any(pid => pid.Platform == platform && pid.PlatformId == platformId))
.Select(x => x.playerid)
.ToList();
}
// ---- website signup rate limit -----------------------------------------------------
// One successful account creation per IP per ConfigCampus.SignupRateLimitDays (default 7).
// Persisted so a service restart does not reset the window. A tiny row per signup - the
// volume is human-scale, not request-scale.
public class WebSignupRecord
{
public int Id { get; set; }
public string? Ip { get; set; }
public ulong AccountId { get; set; }
public DateTime CreatedAt { get; set; }
}
/// <summary>The limiter's verdict: which layer refused, and when it frees up.</summary>
public enum SignupLimit { None, Ip, Subnet, Global }
/// <summary>
/// May this IP register right now? Checks, in order, the global per-hour cap, the per-IP
/// day/week caps and the /24 subnet cap (see ConfigCampus.SignupsPer*). On refusal says
/// which layer refused and the earliest time it frees up, so the caller can tell the
/// player something true. Blank/loopback/allowlisted IPs skip the per-address layers.
/// </summary>
public static SignupLimit CheckSignupLimit(string? ip, out DateTime nextAllowedUtc)
{
var now = DateTime.UtcNow;
nextAllowedUtc = now;
// Admin-allowlisted IPs test signup flows without tripping the caps (see
// ConfigCampus.AdminRateLimitEnabled).
if (AdminProtectionMiddleware.IsLimitExempt(ip)) return SignupLimit.None;
var col = PlayerDBFile.GetCollection<WebSignupRecord>("web_signups");
col.EnsureIndex(r => r.Ip);
col.EnsureIndex(r => r.CreatedAt);
// Global cap. This is a MASS-CREATION circuit breaker, not a growth cap, and for a
// long time it behaved as the latter: at 15/hour across every address on the internet
// it was the ONLY limit that ever fired. In one 12-hour window it refused 399 signups
// - 120 of them headsets retrying create_account, 34 distinct devices - while letting
// 19 through, and not one refusal came from the per-IP or per-subnet caps. Every real
// player who arrived after the 15th that hour was told "try again later"; on Quest
// that is a hard login failure the client then retries every ~8 s forever.
//
// So volume alone no longer trips it. Mass creation is CONCENTRATED - one actor behind
// a handful of networks - because the per-IP (1/day) and per-/24 (4/day) caps already
// bound what any single network can produce. Organic growth is DIFFUSE: a hundred
// strangers on a hundred networks. The breaker now fires only when an hour is both
// over the ceiling AND coming from few enough networks to be one actor.
if (ConfigCampus.SignupsPerHour > 0)
{
var hourAgo = now.AddHours(-1);
var lastHour = col.Find(r => r.CreatedAt >= hourAgo).OrderBy(r => r.CreatedAt).ToList();
if (lastHour.Count >= ConfigCampus.SignupsPerHour)
{
// Distinct networks behind the hour. An unattributable IP counts as its own
// network rather than being pooled with the other unattributable ones, so a
// flood cannot look diffuse just by stripping the address.
int networks = lastHour
.Select(r => SubnetKey(r.Ip) ?? ("ip:" + (r.Ip ?? "")))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
if (networks < ConfigCampus.SignupBurstDistinctNetworks)
{
nextAllowedUtc = lastHour[lastHour.Count - ConfigCampus.SignupsPerHour].CreatedAt.AddHours(1);
Console.WriteLine($"[signup-guard] GLOBAL cap tripped: {lastHour.Count} signups in the last hour " +
$"from only {networks} distinct network(s) - mass creation, not growth");
return SignupLimit.Global;
}
Console.WriteLine($"[signup-guard] {lastHour.Count} signups in the last hour across {networks} " +
$"distinct networks - over the ceiling but diffuse, allowed");
}
}
if (string.IsNullOrWhiteSpace(ip)) return SignupLimit.None;
ip = ip.Trim();
if (ip == "127.0.0.1" || ip == "::1" || ip == "localhost") return SignupLimit.None;
if (ConfigCampus.SignupExemptIps != null && ConfigCampus.SignupExemptIps.Contains(ip)) return SignupLimit.None;
var weekAgo = now.AddDays(-7);
var dayAgo = now.AddDays(-1);
var mine = col.Find(r => r.Ip == ip && r.CreatedAt >= weekAgo).OrderBy(r => r.CreatedAt).ToList();
if (ConfigCampus.SignupsPerIpPerWeek > 0 && mine.Count >= ConfigCampus.SignupsPerIpPerWeek)
{
nextAllowedUtc = mine[mine.Count - ConfigCampus.SignupsPerIpPerWeek].CreatedAt.AddDays(7);
return SignupLimit.Ip;
}
var mineToday = mine.Where(r => r.CreatedAt >= dayAgo).ToList();
if (ConfigCampus.SignupsPerIpPerDay > 0 && mineToday.Count >= ConfigCampus.SignupsPerIpPerDay)
{
nextAllowedUtc = mineToday[mineToday.Count - ConfigCampus.SignupsPerIpPerDay].CreatedAt.AddDays(1);
return SignupLimit.Ip;
}
// Network block - /24 for IPv4, /64 for IPv6. Same window as the per-IP day cap.
string? block = SubnetKey(ip);
if (block != null && ConfigCampus.SignupsPerSubnetPerDay > 0)
{
// Compare on the computed KEY, not a text prefix: one IPv6 address can be written
// several ways ("2001:db8::1" / "2001:db8:0:0::1"), so matching the stored string
// missed hosts that a real /64 comparison catches.
var inBlock = col.Find(r => r.CreatedAt >= dayAgo)
.Where(r => string.Equals(SubnetKey(r.Ip), block, StringComparison.OrdinalIgnoreCase))
.OrderBy(r => r.CreatedAt).ToList();
if (inBlock.Count >= ConfigCampus.SignupsPerSubnetPerDay)
{
nextAllowedUtc = inBlock[inBlock.Count - ConfigCampus.SignupsPerSubnetPerDay].CreatedAt.AddDays(1);
return SignupLimit.Subnet;
}
}
return SignupLimit.None;
}
/// <summary>
/// The network an address belongs to: the /24 for IPv4, the /64 for IPv6, null when the
/// string is not an address at all.
///
/// IPv6 used to return null, so an IPv6 client had NO subnet cap - the per-/24 rule simply
/// did not apply to it and the global hourly ceiling was the only thing covering that
/// hole, which is part of why the ceiling had to be set so low. A /64 is what a
/// residential customer is handed, so it is the IPv6 equivalent of the /24 here.
/// </summary>
private static string? SubnetKey(string? ip)
{
if (string.IsNullOrWhiteSpace(ip)) return null;
if (!System.Net.IPAddress.TryParse(ip.Trim(), out var addr)) return null;
var b = addr.GetAddressBytes();
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
return "v4:" + b[0] + "." + b[1] + "." + b[2];
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
{
var sb = new System.Text.StringBuilder("v6:");
for (int i = 0; i < 8; i++) sb.Append(b[i].ToString("x2")); // the /64 prefix
return sb.ToString();
}
return null;
}
public static void RecordSignup(string? ip, ulong accountId)
{
var col = PlayerDBFile.GetCollection<WebSignupRecord>("web_signups");
col.Insert(new WebSignupRecord
{
Ip = string.IsNullOrWhiteSpace(ip) ? "" : ip.Trim(),
AccountId = accountId,
CreatedAt = DateTime.UtcNow
});
}
public static bool ImportPlayers(string importPath)
{
Console.WriteLine($"[IMPORT] Starting player import");
Console.WriteLine($"[IMPORT] Import path: {importPath}");
try
{
if (!File.Exists(importPath))
{
Console.WriteLine($"[ERROR] Import file not found: {importPath}");
return false;
}
Console.WriteLine("[IMPORT] Reading import file...");
var json = File.ReadAllText(importPath);
Console.WriteLine($"[IMPORT] File size: {json.Length} bytes");
Console.WriteLine("[IMPORT] Deserializing player data...");
var importedPlayers = JsonConvert.DeserializeObject<List<player_data>>(json);
if (importedPlayers == null)
{
Console.WriteLine("[ERROR] Deserialization returned null (invalid JSON?)");
return false;
}
if (importedPlayers.Count == 0)
{
Console.WriteLine("[WARN] Import file contained 0 players");
return false;
}
Console.WriteLine($"[IMPORT] Players found in file: {importedPlayers.Count}");
var col = PlayerDBFile.GetCollection<player_data>("players");
int inserted = 0;
int skipped = 0;
foreach (var player in importedPlayers)
{
if (player == null)
{
Console.WriteLine("[WARN] Encountered null player entry, skipping");
skipped++;
continue;
}
Console.WriteLine($"[IMPORT] Processing player ID: {player.id}");
try
{
// Avoid duplicate IDs
if (col.Exists(x => x.id == player.id))
{
Console.WriteLine($"[SKIP] Duplicate player ID {player.id}");
skipped++;
continue;
}
player.player_Extra.DormRoomID = RoomDB.clone_dorm_room(player.playerid);
col.Insert(player);
var name = player.player.Username ?? "<unknown>";
var displayName = player.player.DisplayName ?? "<unknown>";
var displayEmoji = player.player.DisplayEmoji ?? "";
Console.WriteLine($"[OK] Inserted player ({player.id}) @{name} {displayName} {displayEmoji}");
inserted++;
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to insert player ID {player.id}: {ex.Message}");
skipped++;
}
}
Console.WriteLine("[IMPORT] Import completed");
Console.WriteLine($"[IMPORT] Inserted: {inserted}");
Console.WriteLine($"[IMPORT] Skipped: {skipped}");
Console.WriteLine($"[IMPORT] Total processed: {importedPlayers.Count}");
return inserted > 0;
}
catch (JsonException jex)
{
Console.WriteLine($"[ERROR] JSON parsing error: {jex.Message}");
return false;
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Import failed with exception: {ex}");
return false;
}
}
public static bool ExportPlayers(string exportPath)
{
try
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var allPlayers = col.FindAll().ToList();
var json = JsonConvert.SerializeObject(allPlayers, Formatting.Indented);
File.WriteAllText(exportPath, json);
Console.WriteLine($"Exported {allPlayers.Count} players to {exportPath}");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Export failed: {ex.Message}");
return false;
}
}
public static bool CreateDormForPlayerId(ulong playerid, bool log = false)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
// Was: no null check, so an unknown player id threw here instead of
// reporting failure to the caller.
if (player == null)
{
Console.WriteLine($"[CreateDormForPlayerId] no player row for id {playerid}");
return false;
}
if (player.player_Extra != null)
{
ulong roomid = RoomDB.clone_dorm_room(playerid);
player.player_Extra.DormRoomID = roomid;
col.Update(player);
if (log)
{
Console.WriteLine($"Room id is {roomid} and successfully updaeted player");
}
return true;
}
return false;
}
public static void EnsureDormsForAllPlayers()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
// Get all players
var players = col.FindAll();
foreach (var player in players)
{
if (player?.player == null || player.player_Extra == null)
continue;
// Missing or invalid dorm
if (player.player_Extra.DormRoomID == null || player.player_Extra.DormRoomID == 1)
{
ulong roomId = RoomDB.clone_dorm_room(player.player.Id);
player.player_Extra.DormRoomID = roomId;
col.Update(player);
Console.WriteLine($"Created dorm {roomId} for player {player.player.Id}");
}
}
}
public static void EnsureDormsDontBeStupid(bool log = false)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var players = col.FindAll();
int fixCount = 0;
foreach (var player in players)
{
if (player?.player == null || player.player_Extra == null)
continue;
var room = RoomDB.GetRoom((ulong)player.player_Extra.DormRoomID);
// Check if the room is missing or simply isn't flagged as a Dorm
if (room == null || !room.IsDorm)
{
if (log)
{
string reason = (room == null) ? "Room Missing" : "Room not a Dorm";
Console.WriteLine($"[DormFix] Player: {player.player.Username} ({player.player.Id}) | Reason: {reason}");
}
CreateDormForPlayerId(player.player.Id);
fixCount++;
}
}
if (log && fixCount > 0)
{
Console.WriteLine($"[DormFix] Finished. Fixed {fixCount} player dorm records.");
}
}
public static ulong GetDormId(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (playerid == 1)
{
return 1;
}
// Was: dereferenced player.player_Extra with no null check, so an unknown
// player id threw NullReferenceException here.
if (player == null)
{
Console.WriteLine($"[GetDormId] no player row for id {playerid} - no dorm");
return 0;
}
if (player.player_Extra != null)
{
return (ulong)player.player_Extra.DormRoomID;
}
// Was: `return 1`. That is player 1's dorm, so any player whose row had no
// player_Extra was silently handed SOMEONE ELSE'S dorm room. 0 means "no
// dorm yet" and makes the caller create a real one.
Console.WriteLine($"[GetDormId] player {playerid} has no player_Extra - no dorm assigned yet");
return 0;
}
public static bool GetLogins(Platforms platform, ulong platformId, out List<AccountLogin> accounts)
{
accounts = new List<AccountLogin>();
var col = PlayerDBFile.GetCollection<player_data>("players");
// Pull all players (or consider filtering by another simple field if possible)
var allPlayers = col.FindAll().ToList();
// Filter in memory
var players = allPlayers
.Where(player => player.player.PlatformIds != null &&
player.player.PlatformIds.Any(pid => pid.Platform == platform &&
pid.PlatformId == platformId))
.ToList();
if (players.Count == 0)
return false;
foreach (var player in players)
{
var pid = player.player.PlatformIds.First(p => p.Platform == platform &&
p.PlatformId == platformId);
accounts.Add(new AccountLogin
{
accountId = player.playerid,
createdAt = player.player.created_at,
lastLoginTime = player.player.last_login_time,
platform = (Platforms)pid.Platform,
platformId = pid.PlatformId.ToString()
});
}
accounts = accounts
.OrderByDescending(a => a.lastLoginTime)
.ToList();
return true;
}
// A synthesized stand-in for an account id that has no DB row. Mirrors RecFlare's
// defaultAccount(): a "Player{id}" placeholder so the client can render a card instead
// of choking on a 404 / missing entry. The account_data_web getters supply the rest of
// the coalesced defaults (profileImage -> DefaultProfileImage.jpg, banner/emoji -> "").
private static account_data_web DefaultAccountWeb(ulong id)
{
// The login placeholder (see PlatformIdentity.LoginPlaceholderAccountId): the headset's
// cached-login picker asks for this id to label the "type your password" entry. Answer
// with a neutral identity, not "Player0" - that read as a real account failing to log in.
if (id == Campus_live_rewrite.api.server.PlatformIdentity.LoginPlaceholderAccountId)
return new account_data_web
{
accountId = id,
createdAt = DateTime.UtcNow,
username = Campus_live_rewrite.api.server.PlatformIdentity.LoginPlaceholderUsername,
displayName = Campus_live_rewrite.api.server.PlatformIdentity.LoginPlaceholderDisplayName,
profileImage = "DefaultPFP.png",
isJunior = false,
identityFlags = 0,
personalPronouns = 0,
platforms = 1 // Oculus - the platform the placeholder is served on
};
return new account_data_web
{
accountId = id,
createdAt = DateTime.UtcNow,
username = $"Player{id}",
displayName = $"Player{id}",
isJunior = false,
identityFlags = 0,
personalPronouns = 0,
platforms = 0
};
}
// A valid, non-negative `platforms` value for an account DTO. The account's primary
// platform int (Steam=0, Oculus=1, ...), or 0 if none is known. NEVER returns -1: the
// old code seeded a Platforms.All (-1) accumulator and OR'd onto it, so it was always
// -1, and -1 is not a real platform - the client's strict account parser rejects it and
// then NO player card / profile renders (own or others'). Same class as statusVisibility=4
// and deviceClass=-1.
public static long PrimaryPlatformValue(player_data? player)
{
var firstReal = player?.player?.PlatformIds?
.Select(p => (int)p.Platform)
.FirstOrDefault(v => v >= 0) ?? -1;
if (firstReal >= 0) return firstReal;
if (player != null && (int)player.platform >= 0) return (int)player.platform;
return 0;
}
public static string GetAccountsBulk(List<ulong> playerIds)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
col.EnsureIndex(x => x.id);
// ONE entry per requested id, in the requested set. A missing row is SYNTHESIZED
// (a Player{id} default), never dropped or 404'd - otherwise a profile / friends
// list / player list that references a deleted or never-existed id (e.g. the purged
// orphan ids 2,3,5) renders that card BLANK. This is exactly why single-account
// reads were 404ing (AccountById -> here -> empty array -> 404). Matches RecFlare's
// bulk, which guarantees one entry per requested id via defaultAccount().
var requested = playerIds?.Distinct().ToList() ?? new List<ulong>();
if (requested.Count == 0) return "[]";
var found = col.Find(x => requested.Contains(x.playerid))
.Where(p => p?.player != null)
.GroupBy(p => p.playerid)
.ToDictionary(g => g.Key, g => g.First());
var accounts = requested
.Select(id =>
{
if (!found.TryGetValue(id, out var player))
return DefaultAccountWeb(id);
// `platforms` MUST be a valid, non-negative number. It used to start at
// Platforms.All (-1) and OR onto it, so it was ALWAYS -1 - and -1 is not a
// real platform. The RecFlare account DTO emits a plain small number (0 by
// default; the reference never combines PlatformIds into it). A -1 here is
// the same out-of-range trap as statusVisibility=4 / deviceClass=-1: the
// client's strict account parser chokes on it and NO player card / profile
// renders (own or others'). Emit the account's primary platform int (Steam=0,
// Oculus=1, ...), defaulting to 0, never -1.
long platformsValue = 0;
var firstReal = player.player.PlatformIds?
.Select(p => (int)p.Platform)
.FirstOrDefault(v => v >= 0) ?? -1;
if (firstReal >= 0) platformsValue = firstReal;
else if ((int)player.platform >= 0) platformsValue = (int)player.platform;
return new account_data_web
{
accountId = player.playerid,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
displayEmoji = player.player.DisplayEmoji,
username = player.player.Username,
identityFlags = player.player.identityFlags,
isJunior = player.player.IsJunior,
personalPronouns = player.player.pronounFlags,
platforms = platformsValue,
profileImage = player.player.profileImage,
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage
};
})
.OrderBy(a => a.accountId)
.ToList();
return JsonConvert.SerializeObject(accounts);
}
public static string GetAccountsBulkAdmin(List<ulong> playerIds)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
col.EnsureIndex(x => x.id);
var players = col.Find(x => playerIds.Contains(x.playerid)).ToList();
if (players.Count == 0)
{
return "[]";
}
var accounts = players
.Select(player =>
{
return new
{
accountId = player.playerid,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
displayEmoji = player.player.DisplayEmoji,
username = player.player.Username,
identityFlags = player.player.identityFlags,
isJunior = player.player.IsJunior,
personalPronouns = player.player.pronounFlags,
platforms = PrimaryPlatformValue(player),
profileImage = player.player.profileImage,
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage,
bio = player.player_Extra.Bio,
mbd = JsonConvert.DeserializeObject(PlayerDB.Get_moderation_block(player.playerid)),
PlatformIDs = player.player.PlatformIds.Select(p => new
{
p.Platform,
PlatformId = p.PlatformId.ToString(),
// Username = Steam.GetAccountInfoAsync(p.PlatformId.ToString()).Result?.PersonaName ?? ""
}),
level = player.player.Level,
xp = player.player.XP
};
})
.OrderBy(a => a.accountId)
.ToList();
return JsonConvert.SerializeObject(accounts);
}
public static player_data? getfullaccount_token(ulong playerid, ulong platformid, int platform)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return null;
var ids = player.player.PlatformIds;
if (ids == null || ids.Count == 0)
return null;
bool match = ids.Any(p => p.PlatformId == platformid);
return match ? player : null;
}
/*public static player_data? GetFullAccount(ulong playerid, ulong plaformid, int plaform, string device_id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
if (player.platformId == plaformid && player.deviceId.Contains(device_id))
return player;
return null;
}
*/
public static player_data? GetFullAccount(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
return player;
}
// ---- Currency balances ------------------------------------------------
// Stored per player in player_extra.Balances (CurrencyType -> amount).
public static long GetBalance(ulong playerId, int currencyType)
{
var acc = GetFullAccount(playerId);
if (acc?.player_Extra?.Balances == null) return 0;
return acc.player_Extra.Balances.TryGetValue(currencyType, out var v) ? v : 0;
}
public static Dictionary<int, long> GetAllBalances(ulong playerId)
{
var acc = GetFullAccount(playerId);
return acc?.player_Extra?.Balances ?? new Dictionary<int, long>();
}
/// <summary>Adds (or subtracts) an amount and returns the NEW balance. Clamped at 0.</summary>
public static long AddBalance(ulong playerId, int currencyType, long delta)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player == null) return 0;
player.player_Extra ??= new player_extra();
player.player_Extra.Balances ??= new Dictionary<int, long>();
long current = player.player_Extra.Balances.TryGetValue(currencyType, out var v) ? v : 0;
long updated = current + delta;
if (updated < 0) updated = 0;
player.player_Extra.Balances[currencyType] = updated;
col.Update(player);
return updated;
}
public static player_data? GetFullAccountByName(string username)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Username == username);
if (player == null)
{
return null;
}
return player;
}
public static player_info? GetAccountById(ulong id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindById(id);
return player?.player;
}
public static account_data_me? GetAccountMeSafe(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
// See PrimaryPlatformValue: a valid, non-negative platform number, NEVER -1 (which
// is what the old Platforms.All accumulator always produced and which breaks the
// client's account parser - the owner's OWN /account/me card wouldn't render).
account_data_me account = new account_data_me
{
accountId = player.playerid,
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
email = player.player.Email,
displayEmoji = player.player.DisplayEmoji,
username = player.player.Username,
identityFlags = player.player.identityFlags,
isJunior = player.player.IsJunior,
// Juniors are treated as juniors client-side (chat/social restrictions).
TreatAsJunior = player.player.IsJunior,
personalPronouns = player.player.pronounFlags,
platforms = PrimaryPlatformValue(player),
profileImage = player.player.profileImage,
};
// The client requires a present, valid birthday. Use the real one entered at signup
// when we have it; otherwise keep the fixed placeholder the DTO defaults to.
if (!string.IsNullOrWhiteSpace(player.player.Birthday))
{
account.birthday = player.player.Birthday;
account.hasBirthday = true;
}
else if (string.Equals(SettingsDB.GetPlayerSettingValue("Recroom.AccountCreation.HasFinished", player.playerid), "false", StringComparison.OrdinalIgnoreCase))
{
// An in-game account still in its creation flow with no birthday on record: tell
// the client so its title-screen account-creation flow (birthday -> avatar ->
// username -> password) has a page it cannot skip. Website accounts entered a
// birthday at signup and never hit this.
account.hasBirthday = false;
}
/*
account_data_me account = new account_data_me
{
accountId = player.playerid,
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
displayEmoji = player.player.DisplayEmoji,
username = player.player.Username,
identityFlags = player.player.identityFlags,
isJunior = player.player.IsJunior,
personalPronouns = player.player.pronounFlags,
platforms = (long)combinedPlatforms,
profileImage = player.player.profileImage,
};
*/
return account;
}
public static string Get_moderation_block(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return JsonConvert.SerializeObject(new Moderation_Detail.ModerationBlockDetails());
}
var block = player.player_Extra.ModerationBlockDetails;
// Only an ACTIVE penalty is a moderation block. A stored record with Duration <= 0 (or
// no set-time) is NOT active - e.g. a logged report with no penalty applied
// (IsBan=false, Duration=0). The client counts ANY non-empty moderationBlockDetails as a
// recent moderation incident and then blocks club/room creation ("Creation Restricted -
// too many moderation incidents"), so a non-active record MUST read back as empty.
if (block == null || block.Duration <= 0 || block.ModerationSetUnixTime <= 0)
return JsonConvert.SerializeObject(new Moderation_Detail.ModerationBlockDetails());
// Permanent ban.
if (block.Duration == int.MaxValue)
return JsonConvert.SerializeObject(block);
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int elapsed = (int)(now - block.ModerationSetUnixTime);
int remaining = block.Duration - elapsed;
if (remaining <= 0)
{
// Timeout has run out - clear it and report no block.
player.player_Extra.ModerationBlockDetails = null;
col.Update(player);
return JsonConvert.SerializeObject(new Moderation_Detail.ModerationBlockDetails());
}
// Still serving time: report the block with the time that's left.
block.Duration = remaining;
return JsonConvert.SerializeObject(block);
}
public static string Set_moderation_block(
ulong playerid,
Moderation_Detail.ReportCategory category,
int duration,
long gameSessionId,
bool? isHostKick,
string? message,
ulong? playerIdReporter,
bool? isBan,
ulong bannedByPlayerId,
bool addAppealMessage = true
)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return "{}";
if (addAppealMessage && isBan == true)
{
message += "\nTo appeal this ban, Make a ticket in https://discord.gg/xH8MMwK5Bk.";
}
var newBlock = new Moderation_Detail.ModerationBlockDetails
{
ReportCategory = category,
Duration = duration,
GameSessionId = gameSessionId,
IsHostKick = isHostKick,
Message = message,
PlayerIdReporter = playerIdReporter,
IsBan = isBan,
ModerationSetUnixTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
BannedByPlayerId = bannedByPlayerId
};
player.player_Extra.ModerationBlockDetails = newBlock;
col.Update(player);
// Every moderation action is logged with WHO did it to WHOM and why. Without this
// there is no way to tell a real moderator action from a bug calling this path -
// and a 1-second isBan looks identical to "the game keeps closing itself" from the
// player's side, because the block clears before anyone can inspect it.
Console.WriteLine(
$"[moderation] target={playerid} by={bannedByPlayerId} " +
$"isBan={isBan} isHostKick={isHostKick} duration={duration}s " +
$"category={category} reporter={playerIdReporter?.ToString() ?? "-"} " +
$"msg=\"{(message ?? "").Replace("\n", " ")}\"");
// A ban has to actually REMOVE them from the room.
//
// The client drops to its dorm and then resyncs against the presence heartbeat
// - and the heartbeat still said "you are in room X", so it put the banned
// player straight back into the room they were just banned from. Clearing the
// room instance means the resync has nowhere to send them.
if (isBan == true)
{
try
{
HeartbeatDB.UpdatePlayerHeartbeat(playerid, null, online: false);
Console.WriteLine($"[ban] cleared room instance for banned player {playerid}");
}
catch (Exception ex)
{
Console.WriteLine($"[ban] could not clear heartbeat for {playerid}: {ex.Message}");
}
}
try
{
Notifications.SendToPlayer(playerid, JsonConvert.SerializeObject(WebsocketEvents.CreateBan(newBlock)));
}
catch { }
return JsonConvert.SerializeObject(newBlock);
}
public static bool HasActiveModerationBlock(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return false;
var block = player.player_Extra.ModerationBlockDetails;
if (block == null || block.Duration <= 0 || block.ModerationSetUnixTime <= 0)
return false;
if (block.Duration == int.MaxValue) // Permanent ban
return true;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int elapsed = (int)(now - block.ModerationSetUnixTime);
int remaining = block.Duration - elapsed;
if (remaining <= 0)
{
return false;
}
return true;
}
public static bool CheckDevFlag(ulong id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.id == id);
if (player != null && (player.dev_Flag & DevFlag.dev) == DevFlag.dev)
{
return true;
}
return false;
}
public static bool CheckModFlag(ulong id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.id == id);
if (player != null && (player.dev_Flag & DevFlag.mod) == DevFlag.mod)
{
return true;
}
return false;
}
public static void set_player_avatar(string avatar, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
// Was: guarded `player_Setting1.player != null` but then wrote to
// player_Setting1.player_Extra - the wrong field. A row with a null
// player_Extra threw NullReferenceException while saving the avatar.
if (player_Setting1?.player_Extra != null)
{
player_Setting1.player_Extra.avatar = avatar;
col.Update(player_Setting1);
return;
}
Console.WriteLine($"[set_player_avatar] no player_Extra for {playerid} - avatar not saved");
}
public static (bool Cheered, bool Favorited) GetInteraction(ulong playerid, ulong roomid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return (false, false);
bool cheered = player.player_Extra.CheeredRooms?.Contains(roomid) ?? false;
bool favorited = player.player_Extra.FavoritedRooms?.Contains(roomid) ?? false;
return (cheered, favorited);
}
public static bool ToggleCheer(ulong playerid, ulong roomid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null) return false;
if (player.player_Extra.CheeredRooms == null)
player.player_Extra.CheeredRooms = new List<ulong>();
bool nowCheered;
if (player.player_Extra.CheeredRooms.Contains(roomid))
{
player.player_Extra.CheeredRooms.Remove(roomid);
nowCheered = false;
RoomDB.UpdateRoomCheerCount(roomid, -1);
}
else
{
player.player_Extra.CheeredRooms.Add(roomid);
nowCheered = true;
RoomDB.UpdateRoomCheerCount(roomid, +1);
}
col.Update(player);
return nowCheered;
}
public static bool ToggleFavorite(ulong playerid, ulong roomid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null) return false;
if (player.player_Extra.FavoritedRooms == null)
player.player_Extra.FavoritedRooms = new List<ulong>();
bool nowFavorited;
if (player.player_Extra.FavoritedRooms.Contains(roomid))
{
player.player_Extra.FavoritedRooms.Remove(roomid);
nowFavorited = false;
RoomDB.UpdateRoomFavoriteCount(roomid, -1);
}
else
{
player.player_Extra.FavoritedRooms.Add(roomid);
nowFavorited = true;
RoomDB.UpdateRoomFavoriteCount(roomid, +1);
}
col.Update(player);
return nowFavorited;
}
public static List<account_data> Search(string text)
{
// GET /Accounts/account/search with no `name` query parameter passes null
// here, and the first thing the loop does is text.StartsWith("@") - an
// unhandled NullReferenceException that Kestrel turns into a 500.
// Verified live: probing the route with no argument returned HTTP 500.
// An empty search matches nothing, so return an empty list.
if (string.IsNullOrWhiteSpace(text))
return new List<account_data>();
// Match on USERNAME first (the reference server prefix-matches username, and the
// People search is by @handle), and also on DISPLAY NAME so searching the shown name
// works too. Case-insensitive Contains, which is a superset of the reference's prefix
// match. A leading "@" is stripped so "@eli" and "eli" behave the same.
//
// The previous version only checked username when the query STARTED WITH "@", and
// otherwise matched display name ONLY - so searching a plain "eli" (which is a
// USERNAME) never checked usernames and returned []. It also left `found=true` when a
// row had a null DisplayName, which quietly matched every such account.
string q = text.Trim().TrimStart('@').ToLowerInvariant();
if (q.Length == 0)
return new List<account_data>();
var col = PlayerDBFile.GetCollection<player_data>("players");
List<account_data> list = new List<account_data>();
foreach (player_data room in col.FindAll())
{
var p = room?.player;
if (p == null)
continue;
bool matchUsername = !string.IsNullOrEmpty(p.Username)
&& p.Username.ToLowerInvariant().Contains(q);
bool matchDisplay = !string.IsNullOrEmpty(p.DisplayName)
&& p.DisplayName.ToLowerInvariant().Contains(q);
if (!matchUsername && !matchDisplay)
continue;
var room_data = GetAccountBulk(room.playerid);
if (room_data is null)
continue;
list.Add(room_data);
}
return list;
}
public static account_data GetAccountBulk(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
return new account_data
{
accountId = player.playerid,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
username = player.player.Username,
identityFlags = 0,
isJunior = player.player.IsJunior,
personalPronouns = 0,
platforms = PrimaryPlatformValue(player),
profileImage = player.player.profileImage,
};
}
public static bool SetPlayerUsername(string username, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
// Case-INSENSITIVE: "kyle" next to "Kyle" is the whole point of a spoofed @name.
var existingplayer = col.FindAll().FirstOrDefault(x => x.player?.Username != null
&& string.Equals(x.player.Username, username, StringComparison.OrdinalIgnoreCase) && x.playerid != playerid);
if (existingplayer != null)
{
return false;
}
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.Username = username;
col.Update(player_Setting1);
RoomDB.RenameDormForPlayer(playerid);
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
return true;
}
}
return false;
}
/// <summary>
/// Every platform id linked to this account for a platform, in the order stored.
/// An account may hold several - a player with two Steam accounts signs in on
/// either and lands here.
/// </summary>
public static List<ulong> GetPlatformIds(ulong playerid, Platforms platform)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var row = col.FindOne(x => x.playerid == playerid);
return row?.player?.PlatformIds?
.Where(p => p.Platform == platform)
.Select(p => p.PlatformId)
.ToList()
?? new List<ulong>();
}
/// <summary>First platform id for this platform, or 0. Convenience for display.</summary>
public static ulong GetPlatformId(ulong playerid, Platforms platform) =>
GetPlatformIds(playerid, platform).FirstOrDefault();
/// <summary>
/// Every account carrying this platform id, ignoring <paramref name="ignorePlayerId"/>.
///
/// Informational only - the admin panel reports shared ids rather than refusing
/// them. Sharing IS allowed by design, but it is worth knowing about: GetLogins
/// resolves a sign-in by (platform, platformId), so when several accounts hold the
/// same id, that lookup returns them all and the caller picks.
/// </summary>
public static List<ulong> FindPlayersByPlatformId(Platforms platform, ulong platformId, ulong ignorePlayerId = 0)
{
if (platformId == 0) return new List<ulong>();
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll()
.Where(x => x.playerid != ignorePlayerId &&
x.player?.PlatformIds != null &&
x.player.PlatformIds.Any(p => p.Platform == platform && p.PlatformId == platformId))
.Select(x => x.playerid)
.ToList();
}
/// <summary>
/// Replaces every link for this platform with <paramref name="platformIds"/>.
/// An empty list unlinks the platform entirely. Duplicates within the list are
/// collapsed; the same id living on OTHER accounts is left alone - that is
/// deliberate, several accounts may share one Steam id.
/// </summary>
public static bool SetPlatformIds(ulong playerid, Platforms platform, IEnumerable<ulong> platformIds)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var row = col.FindOne(x => x.playerid == playerid);
if (row?.player == null) return false;
row.player.PlatformIds ??= new List<mPlatformID>();
row.player.PlatformIds.RemoveAll(p => p.Platform == platform);
foreach (var id in platformIds.Where(i => i != 0).Distinct())
row.player.PlatformIds.Add(new mPlatformID { Platform = platform, PlatformId = id });
col.Update(row);
return true;
}
/// <summary>Single-id form of <see cref="SetPlatformIds"/>; 0 unlinks.</summary>
public static bool SetPlatformId(ulong playerid, Platforms platform, ulong platformId) =>
SetPlatformIds(playerid, platform, platformId == 0 ? Array.Empty<ulong>() : new[] { platformId });
/// <summary>Sets (or with 0, clears) the linked Discord account.</summary>
public static bool SetDiscordId(ulong playerid, ulong discordId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var row = col.FindOne(x => x.playerid == playerid);
if (row?.player == null) return false;
row.player_Extra ??= new player_extra();
row.player_Extra.DiscordId = discordId;
col.Update(row);
return true;
}
/// <summary>
/// Finds whoever already has this Discord id linked, ignoring
/// <paramref name="ignorePlayerId"/>. Returns 0 when nobody does.
/// </summary>
public static ulong FindPlayerByDiscordId(ulong discordId, ulong ignorePlayerId = 0)
{
if (discordId == 0) return 0;
var col = PlayerDBFile.GetCollection<player_data>("players");
var owner = col.FindAll().FirstOrDefault(x =>
x.playerid != ignorePlayerId &&
x.player_Extra != null &&
x.player_Extra.DiscordId == discordId);
return owner?.playerid ?? 0;
}
/// <summary>
/// Link a Discord user to a Campus account (website Settings > Discord tab + the /link bot
/// command). Sets BOTH the id and the display tag. This is ONLY an association for the bot
/// and the site - it grants NO dev/admin/moderation privilege of any kind. Pass discordId 0
/// to unlink.
/// </summary>
public static bool SetDiscordLink(ulong playerid, ulong discordId, string? discordUsername)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.DiscordId = discordId;
record.player_Extra.DiscordUsername = discordId == 0 ? null : discordUsername;
col.Update(record);
Console.WriteLine($"[discord-link] account {playerid} {(discordId == 0 ? "UNLINKED" : $"linked to discord {discordId} ({discordUsername})")}");
return true;
}
/// <summary>Whether a moderator has locked this player's @name / display name (admin panel).</summary>
public static bool IsNameLocked(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return record?.player_Extra?.NameLocked == true;
}
/// <summary>Lock or unlock a player's names. Admin edits are never gated by this.</summary>
public static bool SetNameLock(ulong playerid, bool locked)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.NameLocked = locked;
col.Update(record);
Console.WriteLine($"[names] account {playerid} (@{record.player.Username}) name lock {(locked ? "ON" : "off")}");
return true;
}
public static bool SetPlayerDev(DevFlag dev_Flag, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == playerid);
if (playerRecord != null)
{
if (playerRecord.player != null)
{
playerRecord.dev_Flag = dev_Flag;
col.Update(playerRecord);
return true;
}
}
return false;
}
public static bool SetAllDev()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
foreach (var playerRecord in col.FindAll())
{
if (playerRecord.player != null)
{
playerRecord.dev_Flag = DevFlag.dev;
col.Update(playerRecord);
return true;
}
}
return false;
}
public static account_login<account_data> GetAccountPassword(string username, string password, bool isAdminPanel)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return new account_login<account_data>
{
success = false,
error = "Your username or password is empty, please enter a username and password.",
};
}
var player = col.FindOne(x => x.player.Username == username);
if (player == null)
{
return new account_login<account_data>
{
success = false,
error = "The account you are trying to login to does not exist.",
};
}
if (string.IsNullOrEmpty(player.password))
{
return new account_login<account_data>
{
success = false,
error = "The password or username you entered don't match the account you are trying to login to."
};
}
if (string.IsNullOrEmpty(password))
{
return new account_login<account_data>
{
success = false,
error = "The password or username you entered don't match the account you are trying to login to.",
};
}
if (isAdminPanel)
{
var devCheckResult = PlayerDB.CheckDevFlag(player.playerid);
if (!devCheckResult)
{
return new account_login<account_data>
{
success = false,
error = "You are not a developer",
};
}
}
bool isValid = ClientSecurity.VerifyPassword(password, player.password);
if (!isValid)
{
string oldHash = ClientSecurity.GetHashString(password);
if (string.Equals(player.password, oldHash, StringComparison.OrdinalIgnoreCase))
{
player.password = ClientSecurity.HashPassword(password);
col.Update(player);
}
else
{
return new account_login<account_data>
{
success = false,
error = "The password or username you entered don't match the account you are trying to login to."
};
}
}
return new account_login<account_data>
{
success = true,
error = "",
accid = player.playerid,
};
}
public static void SetPlayerPassword(string pass, ulong playerid, bool log)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == playerid);
if (playerRecord != null && playerRecord.player != null)
{
playerRecord.password = ClientSecurity.HashPassword(pass);
col.Update(playerRecord);
if (log)
{
Console.WriteLine($"[Password Change] PlayerID: {playerid} has changed their password.");
}
}
}
public static bool SetPlayerBio(string bio, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player_Extra.Bio = bio;
col.Update(player_Setting1);
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
return true;
}
}
return false;
}
public static async Task<bool> SetPlayerLevel(int level, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.Level = level;
try
{
await Notifications.SendToAll(JsonConvert.SerializeObject(WebsocketEvents.createResponse_levelupdate(playerid, level, Progress.CumulativeXpForLevel(level, 0))));
}
catch
{
}
col.Update(player_Setting1);
return true;
}
}
return false;
}
public static player_data? GetPlayerByDiscordId(ulong discordId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player_Extra.DiscordId == discordId);
return player;
}
public static void SetDisplayName(string username, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.DisplayName = username;
col.Update(player_Setting1);
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
return;
}
}
}
public static bool SetRoleFlag(ulong playerid, DevFlag flag, bool enable)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
if (enable) record.dev_Flag |= flag;
else record.dev_Flag &= ~flag;
col.Update(record);
return true;
}
/// <summary>
/// Sets XP and/or Level directly (admin panel). Pass null to leave one unchanged.
/// Returns the values actually stored, or null if the player does not exist.
/// </summary>
public static (int XP, int Level)? SetProgression(ulong playerid, int? xp, int? level)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return null;
if (xp.HasValue) record.player.XP = xp.Value < 0 ? 0 : xp.Value;
if (level.HasValue) record.player.Level = level.Value < 0 ? 0 : level.Value;
col.Update(record);
return (record.player.XP, record.player.Level);
}
/// <summary>
/// Grants XP to a player and applies any level-ups it now pays for, using the real
/// progression curve (Progress.LEVEL_REQUIRED_XP). XP is progress INTO the current level;
/// a grant that fills the level carries the remainder forward. Returns the resulting
/// (Level, XP) and how many levels were gained, or null if the player has no row.
/// This is what a game reward calls - it was the missing piece: nothing granted XP, so
/// players never levelled up. Mirrors RecFlare's addXp.
/// </summary>
public static (int Level, int XP, int LevelsGained)? AddXp(ulong playerid, int amount)
{
if (amount <= 0)
{
var cur = Progress.GetPlayerLevel(playerid);
return (cur.Level, cur.XP, 0);
}
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return null;
int startLevel = record.player.Level < 1 ? 1 : record.player.Level;
int newXp = (record.player.XP < 0 ? 0 : record.player.XP) + amount;
var (level, xp) = Progress.ApplyLevelUps(startLevel, newXp);
int levelsGained = level - startLevel;
record.player.Level = level;
record.player.XP = xp;
col.Update(record);
// Push the new level/XP so the crown + progress bar update in real time (no relog) -
// AddXp used to be silent, so a level earned in-game only showed after a relog. A level
// CHANGE broadcasts (everyone's view of this player's crown updates); an XP-only gain
// pings just the owner so their bar advances. Same PlayerProgressionLevelUpdate event
// the admin level set + the progression GET use.
try
{
// Report CUMULATIVE XP (client derives the crown level from XP - see
// Progress.CumulativeXpForLevel), not the raw into-level XP.
int reportXp = Progress.CumulativeXpForLevel(level, xp);
string msg = JsonConvert.SerializeObject(WebsocketEvents.createResponse_levelupdate(playerid, level, reportXp));
if (levelsGained > 0) Notifications.FireAndForget(Notifications.SendToAll(msg), "levelup broadcast");
else Notifications.FireAndForget(Notifications.SendToPlayer(playerid, msg), "xp update");
}
catch { }
// Every level crossed pays its box (reference: grantLevelUpGifts). After the level is
// stored, so a failed roll costs a prize and not the level.
if (levelsGained > 0)
{
try { Campus_live_rewrite.api.server.LevelRewards.GrantLevelUps(playerid, startLevel, level); }
catch (Exception ex) { Console.WriteLine($"[levelrewards] {playerid}: {ex.Message}"); }
}
return (level, xp, levelsGained);
}
/// <summary>
/// Claims a once-per-day reward. Returns false if it was already claimed today,
/// in which case nothing is written.
/// </summary>
public static bool TryClaimDailyReward(ulong playerid, string rewardKey)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.DailyRewardClaims ??= new Dictionary<string, string>();
string today = DateTime.UtcNow.ToString("yyyy-MM-dd");
if (record.player_Extra.DailyRewardClaims.TryGetValue(rewardKey, out var last) && last == today)
return false;
record.player_Extra.DailyRewardClaims[rewardKey] = today;
col.Update(record);
return true;
}
// ---- Gift boxes -------------------------------------------------------
// ---- Avatar item ownership ------------------------------------------------
//
// The 2023 server treats AvatarItems.json as a CATALOG - the list of every item
// that exists - and keeps what each player owns on the player record, seeded from
// DefaultAvatarItems.json. The 2025 server had no ownership list at all and simply
// served the whole catalog from /api/avatar/v4/items, so everybody owned
// everything. These helpers restore the 2023 model.
/// <summary>
/// Canonical key for comparing two descriptors. Trailing empty colour slots are
/// dropped, so "guid,,," and "guid," compare equal, while "guid,colour,,"
/// stays distinct from "guid,,," - every colour of an item shares a base guid, so
/// keying on the guid alone would merge Knight Cape (Red) into Knight Cape (Green).
/// </summary>
public static string NormalizeAvatarItemDesc(string? desc)
{
if (string.IsNullOrWhiteSpace(desc)) return "";
var parts = desc.Trim().Split(',').Select(p => p.Trim()).ToList();
while (parts.Count > 1 && string.IsNullOrEmpty(parts[^1])) parts.RemoveAt(parts.Count - 1);
// Compare on the EXPANDED form of every id.
//
// Rec Room writes ids two ways - a full UUID, or a 22-char base64 "short guid" -
// and avatar_items.json used to mix both. The catalog has since been rewritten to
// full UUIDs throughout (see Data/APIS/items/README-avatar-items.md), but stored
// ownership rows, DefaultAvatarItems.json and the storefronts still carry whichever
// spelling they were written with. Expanding here means "guid,8XajS2adOEOqzqnLy6-zcQ"
// and "guid,4ba376f1-9d66-4338-aace-a9cbcbafb371" are the same item, so nobody
// loses an item they own just because the catalog changed spelling.
for (int i = 0; i < parts.Count; i++)
{
var expanded = Campus_live_rewrite.api.server.Classes.ItemGuids.NormalizeGuid(parts[i]);
if (expanded != null) parts[i] = expanded;
}
return string.Join(",", parts);
}
/// <summary>
/// The starter wardrobe every non-developer account begins with. Loaded once.
/// </summary>
private static List<string>? _defaultAvatarItems;
private static readonly object _defaultAvatarItemsLock = new();
public static List<string> DefaultAvatarItemDescs()
{
if (_defaultAvatarItems != null) return _defaultAvatarItems;
lock (_defaultAvatarItemsLock)
{
if (_defaultAvatarItems != null) return _defaultAvatarItems;
var list = new List<string>();
try
{
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "DefaultAvatarItems.json");
if (File.Exists(path))
{
// The file is a flat array of descriptor strings.
var arr = Newtonsoft.Json.JsonConvert.DeserializeObject<List<string>>(File.ReadAllText(path));
foreach (var d in arr ?? new List<string>())
if (!string.IsNullOrWhiteSpace(d)) list.Add(d.Trim());
}
else
{
Console.WriteLine($"[items] DefaultAvatarItems.json not found at {path} - new accounts will start empty");
}
}
catch (Exception ex)
{
Console.WriteLine($"[items] could not read DefaultAvatarItems.json: {ex.Message}");
}
_defaultAvatarItems = list;
Console.WriteLine($"[items] loaded {list.Count} default starter avatar item(s)");
return _defaultAvatarItems;
}
}
/// <summary>
/// Everything this account owns, as canonical keys. Grants the starter set on
/// first read so existing accounts need no migration.
/// </summary>
public static HashSet<string> OwnedAvatarItemDescs(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
record.player_Extra ??= new player_extra();
if (record.player_Extra.AvatarItems == null)
{
record.player_Extra.AvatarItems = new List<string>(DefaultAvatarItemDescs());
col.Update(record);
Console.WriteLine($"[items] granted {record.player_Extra.AvatarItems.Count} starter item(s) to {playerid}");
}
var set = new HashSet<string>(
record.player_Extra.AvatarItems.Select(NormalizeAvatarItemDesc).Where(d => d.Length > 0),
StringComparer.OrdinalIgnoreCase);
// Unioned on READ rather than written into the wardrobe row, so every existing account
// gets it with no migration and it cannot drift out of sync with the catalog.
set.UnionWith(Campus_live_rewrite.api.server.HairGrants.FreeHairKeys());
return set;
}
/// <summary>Adds an item to a player's wardrobe. Returns true if it was new.</summary>
/// <summary>The normalised keys of every avatar item the player owns (defaults included).</summary>
public static HashSet<string> GetOwnedAvatarItemKeys(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var items = record?.player_Extra?.AvatarItems ?? DefaultAvatarItemDescs();
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var d in items) { var k = NormalizeAvatarItemDesc(d); if (k.Length > 0) set.Add(k); }
set.UnionWith(Campus_live_rewrite.api.server.HairGrants.FreeHairKeys());
return set;
}
// Consumable descriptors come in short-guid and UUID spellings; own them by the UUID form.
public static string NormalizeConsumableDesc(string? desc)
{
string s = (desc ?? "").Trim();
if (s.Length == 0) return "";
return (Campus_live_rewrite.api.server.Classes.ItemGuids.NormalizeGuid(s) ?? s).ToLowerInvariant();
}
/// <summary>The player's consumable stock (desc key -> count), empty when they own none.</summary>
public static Dictionary<string, int> OwnedConsumables(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return new Dictionary<string, int>(record?.player_Extra?.Consumables ?? new Dictionary<string, int>(), StringComparer.OrdinalIgnoreCase);
}
public static void GrantConsumable(ulong playerid, string? desc, int count)
{
string ck = NormalizeConsumableDesc(desc);
if (ck.Length == 0 || count <= 0) return;
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return;
record.player_Extra ??= new player_extra();
record.player_Extra.Consumables ??= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
record.player_Extra.Consumables[ck] = (record.player_Extra.Consumables.TryGetValue(ck, out var have) ? have : 0) + count;
col.Update(record);
}
public static bool GrantAvatarItem(ulong playerid, string? desc)
{
string key = NormalizeAvatarItemDesc(desc);
if (key.Length == 0) return false;
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.AvatarItems ??= new List<string>(DefaultAvatarItemDescs());
if (record.player_Extra.AvatarItems.Any(d => NormalizeAvatarItemDesc(d).Equals(key, StringComparison.OrdinalIgnoreCase)))
return false;
record.player_Extra.AvatarItems.Add(desc!.Trim());
col.Update(record);
Console.WriteLine($"[items] granted '{desc!.Trim()}' to {playerid}");
return true;
}
/// <summary>Developers get the whole catalog - the dev locker.</summary>
public static bool HasDevLocker(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return record != null && (record.dev_Flag & DevFlag.dev) == DevFlag.dev;
}
/// <summary>
/// Marks CONTENTLESS pending gift boxes as opened, so they stop lingering forever. An empty
/// box (no gift drop, no avatar item, no consumable, no equipment) is filtered out of the
/// gifts list the client is sent - it can never be opened there and would sit in the DB
/// counting toward the gift badge as a "random gift" that won't clear. Marking it opened
/// retires it. Returns how many were retired.
/// </summary>
public static int MarkEmptyGiftBoxesOpened(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var boxes = record?.player_Extra?.PendingGiftBoxes;
if (boxes == null || boxes.Count == 0) return 0;
int cleaned = 0;
foreach (var g in boxes)
{
if (g.Opened) continue;
// Currency / XP boxes count as content (a token box has no item and is real).
if (!Campus_live_rewrite.api.server.GiftBoxes.HasContent(g)) { g.Opened = true; cleaned++; }
}
if (cleaned > 0) col.Update(record);
return cleaned;
}
public static bool AddPendingGiftBox(ulong playerid, int giftContext, string? message, ulong fromAccountId = 0)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.PendingGiftBoxes ??= new List<PendingGiftBox>();
record.player_Extra.PendingGiftBoxes.Add(new PendingGiftBox
{
GiftContext = giftContext,
Message = message,
FromAccountId = fromAccountId
});
col.Update(record);
return true;
}
/// <summary>Stores a fully-populated gift box (store purchases carry item data).</summary>
/// <summary>Backfill a numeric Id onto any pending gift box that still has NumericId 0 (older
/// reward/select boxes). Without it the gifts endpoint emits a guid Id the client rejects.</summary>
// ---- gift package ids: ONE sequence for the whole server ------------------------------
//
// A gift box id has to be unique across PLAYERS, not just within one player's list. The
// client networks a spawned box into the room by its package id, and "Open All" walks the
// boxes it can see: with per-player ids (reward boxes 1001+ per player, purchases from a
// counter that restarted at 5000 on every deploy) two players' boxes in the same room
// shared ids. Player.log, opening gifts in the Rec Center after another player had: the
// client posts consume for 5003 and 5002 (the other player's, already opened - "no matching
// unopened box"), throws "Gift is missing from the list", and only then reaches its own
// 5004 - which the other player ALSO had as 5004. The reference's received_gift table is
// AUTOINCREMENT for the same reason.
//
// Seeded once above every id already stored, so nothing pending collides with a new box.
private sealed class Counter { public int Id { get; set; } public int Value { get; set; } }
private static readonly object _giftIdLock = new();
public static int NextGiftPackageId()
{
lock (_giftIdLock)
{
var counters = PlayerDBFile.GetCollection<Counter>("counters");
var row = counters.FindById(1);
if (row == null)
{
int max = 100_000;
try
{
foreach (var p in PlayerDBFile.GetCollection<player_data>("players").FindAll())
foreach (var b in p.player_Extra?.PendingGiftBoxes ?? new List<PendingGiftBox>())
if (b.NumericId > max) max = b.NumericId;
}
catch { }
row = new Counter { Id = 1, Value = max };
Console.WriteLine($"[gifts] gift package id sequence seeded at {max}");
}
row.Value++;
counters.Upsert(row);
return row.Value;
}
}
/// <summary>True when a box with this id is (or was) some OTHER player's.</summary>
public static bool GiftBoxBelongsToSomeoneElse(ulong playerid, int numericId)
{
try
{
foreach (var p in PlayerDBFile.GetCollection<player_data>("players").Find(x => x.playerid != playerid))
if ((p.player_Extra?.PendingGiftBoxes ?? new List<PendingGiftBox>()).Any(b => b.NumericId == numericId))
return true;
}
catch { }
return false;
}
public static void EnsureGiftBoxNumericIds(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var boxes = record?.player_Extra?.PendingGiftBoxes;
if (boxes == null || boxes.Count == 0) return;
bool changed = false;
foreach (var b in boxes)
if (b.NumericId == 0) { b.NumericId = NextGiftPackageId(); changed = true; }
if (changed) col.Update(record);
}
// The guid is compared on its UUID form: Equipments.json spells every skin as a full UUID,
// but 112 of the 187 skin rows in the Watch store spell the SAME skins as 22-char short
// guids. Compared as text they never matched, so a bought skin was stored under a key
// that /equipment/v2/getUnlocked could never find - "Can't load item" on the box, and the
// skin never turned up in the locker.
public static string EquipmentSkinKey(string? prefab, string? guid)
{
string g = Campus_live_rewrite.api.server.Classes.ItemGuids.NormalizeGuid(guid) ?? (guid ?? "").Trim().ToLowerInvariant();
return $"{(prefab ?? "").Trim().ToLowerInvariant()}|{g}";
}
public static HashSet<string> OwnedEquipmentSkins(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return new HashSet<string>(record?.player_Extra?.EquipmentSkins ?? new List<string>(), StringComparer.OrdinalIgnoreCase);
}
public static bool OwnsCustomItem(ulong playerid, string caid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return record?.player_Extra?.CustomItems?.Any(c => string.Equals(c, caid, StringComparison.OrdinalIgnoreCase)) == true;
}
public static HashSet<string> OwnedCustomItems(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return new HashSet<string>(record?.player_Extra?.CustomItems ?? new List<string>(), StringComparer.OrdinalIgnoreCase);
}
/// <summary>Put a UGC item in the player's wardrobe. Returns true if it was new.</summary>
public static bool GrantCustomItem(ulong playerid, string? caid)
{
if (string.IsNullOrWhiteSpace(caid)) return false;
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.CustomItems ??= new List<string>();
if (record.player_Extra.CustomItems.Any(c => string.Equals(c, caid, StringComparison.OrdinalIgnoreCase))) return false;
record.player_Extra.CustomItems.Add(caid);
col.Update(record);
Console.WriteLine($"[ugc] {playerid} now owns custom item {caid}");
return true;
}
/// <summary>Put a skin in the player's locker. Returns true if it was new.</summary>
public static bool GrantEquipmentSkin(ulong playerid, string? prefab, string? guid)
{
if (string.IsNullOrWhiteSpace(prefab) || string.IsNullOrWhiteSpace(guid)) return false;
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.EquipmentSkins ??= new List<string>();
string key = EquipmentSkinKey(prefab, guid);
if (record.player_Extra.EquipmentSkins.Any(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase))) return false;
record.player_Extra.EquipmentSkins.Add(key);
col.Update(record);
Console.WriteLine($"[skins] {playerid} now owns skin {prefab} {guid}");
return true;
}
public static bool AddPendingGiftBoxFull(ulong playerid, PendingGiftBox box)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.PendingGiftBoxes ??= new List<PendingGiftBox>();
// Every gift box MUST have a numeric Id. The gifts endpoint emits the guid Id when
// NumericId is 0, but the client's gift model expects a NUMBER there and rejects the whole
// gifts array as "Malformed Response" if it sees a guid - which silently killed every
// reward gift box (and made the reward watch open then instantly close). Purchases already
// set NumericId; reward/select boxes didn't, so assign one here - from the server-wide
// sequence (NextGiftPackageId), never a per-player count.
if (box.NumericId == 0) box.NumericId = NextGiftPackageId();
record.player_Extra.PendingGiftBoxes.Add(box);
// A purchased or gifted item has to enter the wardrobe too, now that owning it
// is no longer implied by the catalog being served to everyone. Done here, in
// the same write, so a bought item can never be left unowned.
if (!string.IsNullOrWhiteSpace(box.AvatarItemDesc))
{
string key = NormalizeAvatarItemDesc(box.AvatarItemDesc);
record.player_Extra.AvatarItems ??= new List<string>(DefaultAvatarItemDescs());
if (!record.player_Extra.AvatarItems.Any(d =>
NormalizeAvatarItemDesc(d).Equals(key, StringComparison.OrdinalIgnoreCase)))
{
record.player_Extra.AvatarItems.Add(box.AvatarItemDesc!.Trim());
Console.WriteLine($"[items] {playerid} now owns '{box.AvatarItemDesc!.Trim()}' ({box.FriendlyName})");
}
}
if (!string.IsNullOrWhiteSpace(box.CustomAvatarItemId))
{
record.player_Extra.CustomItems ??= new List<string>();
if (!record.player_Extra.CustomItems.Any(c => string.Equals(c, box.CustomAvatarItemId, StringComparison.OrdinalIgnoreCase)))
{
record.player_Extra.CustomItems.Add(box.CustomAvatarItemId!);
Console.WriteLine($"[ugc] {playerid} now owns custom item {box.CustomAvatarItemId} ({box.FriendlyName})");
}
}
// A consumable: counted into the player's own stock (was: everyone had every
// consumable with an infinite count, so nothing was ever really "got").
if (!string.IsNullOrWhiteSpace(box.ConsumableItemDesc))
{
record.player_Extra.Consumables ??= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
string ck = NormalizeConsumableDesc(box.ConsumableItemDesc);
int add = box.ConsumableCount > 0 ? box.ConsumableCount : 1;
record.player_Extra.Consumables[ck] = (record.player_Extra.Consumables.TryGetValue(ck, out var have) ? have : 0) + add;
Console.WriteLine($"[consumables] {playerid} +{add} {box.FriendlyName} -> {record.player_Extra.Consumables[ck]}");
}
// An equipment SKIN enters the locker the same way an avatar item enters the wardrobe -
// in this write, so a bought skin can never be left unowned (it used to be: skins were
// served to everyone, so nothing ever recorded a purchase).
if (!string.IsNullOrWhiteSpace(box.EquipmentPrefabName) && !string.IsNullOrWhiteSpace(box.EquipmentModificationGuid))
{
record.player_Extra.EquipmentSkins ??= new List<string>();
string skey = EquipmentSkinKey(box.EquipmentPrefabName, box.EquipmentModificationGuid);
if (!record.player_Extra.EquipmentSkins.Any(k => string.Equals(k, skey, StringComparison.OrdinalIgnoreCase)))
{
record.player_Extra.EquipmentSkins.Add(skey);
Console.WriteLine($"[skins] {playerid} now owns skin {box.EquipmentPrefabName} {box.EquipmentModificationGuid} ({box.FriendlyName})");
}
}
col.Update(record);
return true;
}
public static List<PendingGiftBox> GetPendingGiftBoxes(ulong playerid)
{
var acc = GetFullAccount(playerid);
var all = acc?.player_Extra?.PendingGiftBoxes;
if (all == null) return new List<PendingGiftBox>();
return all.Where(g => !g.Opened).ToList();
}
// ================= Daily objectives / weekly challenges (quests) =================
// Per-player progress only - the objective/challenge CATALOG (descriptions, targets,
// rewards) is client-side. See APIController objectives + challenge handlers.
public static (List<ObjectiveProgress> objectives, List<ObjectiveGroupProgress> groups)
GetObjectivesProgress(ulong playerid)
{
var ex = GetFullAccount(playerid)?.player_Extra;
return (ex?.Objectives ?? new List<ObjectiveProgress>(),
ex?.ObjectiveGroups ?? new List<ObjectiveGroupProgress>());
}
/// <summary>
/// Upsert one objective's progress on the (Group, Index) key. Returns true ONLY on the call
/// that first completes it while its reward is unclaimed - the once-only reward latch - so
/// the caller grants the reward exactly once. Hands back the parent group's state.
/// </summary>
public static bool UpdateObjective(ulong playerid, long group, int index,
double progress, double visualProgress, bool isCompleted,
out ObjectiveGroupProgress groupState)
{
groupState = new ObjectiveGroupProgress { Group = group };
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.Objectives ??= new List<ObjectiveProgress>();
record.player_Extra.ObjectiveGroups ??= new List<ObjectiveGroupProgress>();
var o = record.player_Extra.Objectives.FirstOrDefault(x => x.Group == group && x.Index == index);
if (o == null)
{
o = new ObjectiveProgress { Group = group, Index = index };
record.player_Extra.Objectives.Add(o);
}
o.Progress = progress;
o.VisualProgress = visualProgress;
bool justCompleted = isCompleted && !o.HasClaimedReward;
if (isCompleted) o.IsCompleted = true;
if (justCompleted) o.HasClaimedReward = true;
var g = record.player_Extra.ObjectiveGroups.FirstOrDefault(x => x.Group == group);
if (g == null)
{
g = new ObjectiveGroupProgress { Group = group };
record.player_Extra.ObjectiveGroups.Add(g);
}
groupState = g;
col.Update(record);
return justCompleted;
}
public static ObjectiveGroupProgress ClearObjectiveGroup(ulong playerid, long group)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var g = new ObjectiveGroupProgress { Group = group, IsCompleted = true, ClearedAt = DateTime.UtcNow.ToString("o") };
if (record == null) return g;
record.player_Extra ??= new player_extra();
record.player_Extra.ObjectiveGroups ??= new List<ObjectiveGroupProgress>();
var existing = record.player_Extra.ObjectiveGroups.FirstOrDefault(x => x.Group == group);
if (existing == null) { record.player_Extra.ObjectiveGroups.Add(g); }
else { existing.IsCompleted = true; existing.ClearedAt = g.ClearedAt; g = existing; }
col.Update(record);
return g;
}
public static HashSet<int> GetCompletedChallengeIds(ulong playerid, int mapId)
{
var set = new HashSet<int>();
var st = GetFullAccount(playerid)?.player_Extra?.ChallengeStatuses;
if (st != null)
foreach (var s in st)
if (s.ChallengeMapId == mapId && s.Complete) set.Add(s.ChallengeId);
return set;
}
/// <summary>Latch one challenge's completion within a rotation (false-&gt;true only). Returns the stored state.</summary>
public static bool RecordChallengeProgress(ulong playerid, int mapId, int challengeId, bool complete)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.ChallengeStatuses ??= new List<ChallengeStatus>();
var s = record.player_Extra.ChallengeStatuses
.FirstOrDefault(x => x.ChallengeMapId == mapId && x.ChallengeId == challengeId);
if (s == null)
{
s = new ChallengeStatus { ChallengeMapId = mapId, ChallengeId = challengeId };
record.player_Extra.ChallengeStatuses.Add(s);
}
s.Complete = s.Complete || complete;
s.UpdatedAt = DateTime.UtcNow.ToString("o");
col.Update(record);
return s.Complete;
}
/// <summary>At-most-once gift claim for a challenge map. Returns true only on the FIRST claim.</summary>
public static bool ClaimChallengeGift(ulong playerid, int mapId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.ClaimedChallengeMaps ??= new List<int>();
if (record.player_Extra.ClaimedChallengeMaps.Contains(mapId)) return false;
record.player_Extra.ClaimedChallengeMaps.Add(mapId);
col.Update(record);
return true;
}
/// <summary>
/// Opens (consumes) a gift box for a player. Returns the box that was opened, or null
/// when the player has no such unopened box.
///
/// The item was already granted at PURCHASE time (see AddPendingGiftBoxFull) - opening
/// is only the cosmetic reveal, so this just marks the box opened so it stops showing
/// as pending. Without this the client's POST /api/avatar/v2/gifts/consume 404'd, the
/// box was never marked opened, and GetPendingGiftBoxes handed it straight back - so a
/// box could be "opened" over and over and never went away.
///
/// The client sends back whatever id we emitted in the gifts list, which is NumericId
/// for purchases and the guid Id otherwise (see the Gifts endpoint), so match on both.
/// </summary>
/// <summary>Flip a box's PayOnOpen off. Returns true only for the call that flipped it.</summary>
public static bool ClearGiftBoxPayOnOpen(ulong playerid, PendingGiftBox box)
{
lock (_giftPayLock)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var stored = record?.player_Extra?.PendingGiftBoxes?.FirstOrDefault(g =>
(box.NumericId != 0 && g.NumericId == box.NumericId) || (box.NumericId == 0 && g.Id == box.Id));
if (stored == null || (!stored.PayOnOpen && !stored.XpOnOpen)) return false;
stored.PayOnOpen = false; stored.XpOnOpen = false;
box.PayOnOpen = false; box.XpOnOpen = false;
col.Update(record);
return true;
}
}
private static readonly object _giftPayLock = new();
/// <summary>Persist PayOnOpen=true on an already-stored box.</summary>
public static void SetGiftBoxPayOnOpen(ulong playerid, PendingGiftBox box)
{
lock (_giftPayLock)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var stored = record?.player_Extra?.PendingGiftBoxes?.FirstOrDefault(g =>
(box.NumericId != 0 && g.NumericId == box.NumericId) || (box.NumericId == 0 && g.Id == box.Id));
if (stored == null) return;
stored.PayOnOpen = true;
col.Update(record);
}
}
public static PendingGiftBox? ConsumeGiftBox(ulong playerid, string? id, int numericId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var boxes = record?.player_Extra?.PendingGiftBoxes;
if (boxes == null) return null;
var box = boxes.FirstOrDefault(g => !g.Opened &&
((numericId != 0 && g.NumericId == numericId) ||
(!string.IsNullOrEmpty(id) && g.Id == id)));
if (box == null) return null;
box.Opened = true;
col.Update(record);
Console.WriteLine($"[gifts] {playerid} opened box {(numericId != 0 ? numericId.ToString() : id)} ({box.FriendlyName})");
return box;
}
public static bool SetPlayerSteamId(ulong steamId, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return false;
record.platform = Platforms.Steam;
record.player.PlatformIds ??= new List<mPlatformID>();
// Replace any existing Steam entry, add if missing.
var existing = record.player.PlatformIds.FirstOrDefault(p => p != null && p.Platform == Platforms.Steam);
if (existing != null) existing.PlatformId = steamId;
else record.player.PlatformIds.Add(new mPlatformID { Platform = Platforms.Steam, PlatformId = steamId });
col.Update(record);
return true;
}
/// <summary>The account using this email (case-insensitive), or 0.</summary>
public static ulong GetAccountIdByEmail(string email)
{
if (string.IsNullOrWhiteSpace(email)) return 0;
var col = PlayerDBFile.GetCollection<player_data>("players");
var hit = col.FindAll().FirstOrDefault(x => x.player?.Email != null &&
string.Equals(x.player.Email.Trim(), email.Trim(), StringComparison.OrdinalIgnoreCase));
return hit?.playerid ?? 0;
}
public static bool SetPlayerEmail(string? email, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return false;
record.player.Email = email;
col.Update(record);
return true;
}
public static bool SetLastLoginTime(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (player != null)
{
player.player.last_login_time = DateTime.UtcNow;
col.Update(player);
return true;
}
return false;
}
public static bool ResetCheerCreditIfNewDay(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (player?.player?.PlayerReputation == null)
return false;
DateTime lastLoginDate = player.player.last_login_time.Date;
DateTime today = DateTime.UtcNow.Date;
if (lastLoginDate < today)
{
player.player.PlayerReputation.CheerCredit = 20;
player.player.last_login_time = DateTime.UtcNow;
col.Update(player);
return true;
}
return false;
}
public static bool AddIpAddress(ulong playerid, string ip)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (player != null)
{
if (player.player_Extra.IpAddresses == null)
player.player_Extra.IpAddresses = new List<string>();
string encryptedIp = Utils.EncryptAes(ip);
if (!player.player_Extra.IpAddresses.Contains(encryptedIp))
player.player_Extra.IpAddresses.Add(encryptedIp);
col.Update(player);
return true;
}
return false;
}
public static List<string> GetDecryptedIpAddresses(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
List<string> result = new();
if (player?.player_Extra?.IpAddresses != null)
{
foreach (string encryptedIp in player.player_Extra.IpAddresses)
{
try
{
result.Add(Utils.DecryptAes(encryptedIp));
}
catch
{
}
}
}
return result;
}
public static void SetPlayerEmoji(string emoji, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.DisplayEmoji = emoji;
col.Update(player_Setting1);
return;
}
}
}
public static void SetPlayerPronounFlags(int pronounflag, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.pronounFlags = pronounflag;
col.Update(player_Setting1);
}
else
{
}
}
else
{
}
}
public static void AddSubscription(ulong fromPlayerId, ulong toPlayerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == fromPlayerId);
if (playerRecord == null || playerRecord.player_Extra == null)
throw new InvalidOperationException($"player_Extra for player {fromPlayerId} not found.");
// check if already subscribed
if (!playerRecord.player_Extra.SubbedTo.Contains(toPlayerId))
{
playerRecord.player_Extra.SubbedTo.Add(toPlayerId);
col.Update(playerRecord);
IncrementSubscriberCount(toPlayerId);
}
}
public static void IncrementSubscriberCount(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player != null && player.player_Extra != null)
{
player.player_Extra.sub_count++;
col.Update(player);
}
}
public static void RemoveSubscription(ulong fromPlayerId, ulong toPlayerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == fromPlayerId);
if (playerRecord == null || playerRecord.player_Extra == null)
return;
if (playerRecord.player_Extra.SubbedTo.Remove(toPlayerId))
{
col.Update(playerRecord);
DecrementSubscriberCount(toPlayerId);
}
}
public static void DecrementSubscriberCount(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player != null && player.player_Extra != null && player.player_Extra.sub_count > 0)
{
player.player_Extra.sub_count--;
col.Update(player);
}
}
/// <summary>Everyone subscribed to <paramref name="playerId"/> (the creator's subscribers).</summary>
public static List<ulong> GetSubscriberIds(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll()
.Where(x => x.player_Extra?.SubbedTo != null && x.player_Extra.SubbedTo.Contains(playerId))
.Select(x => x.playerid)
.ToList();
}
public static List<ulong> GetSubscribedToIds(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == playerId);
if (playerRecord != null && playerRecord.player_Extra != null)
{
return playerRecord.player_Extra.SubbedTo;
}
return new List<ulong>();
}
public static void SetPlayerIdentityFlags(int IdentityFlag, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.identityFlags = IdentityFlag;
col.Update(player_Setting1);
}
else
{
}
}
else
{
}
}
public static void SetPlayerImageName(string imageName, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player == null)
return;
player.player.profileImage = imageName;
col.Update(player);
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
}
public static void SetPlayerBanner(string imageName, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player == null)
return;
player.player.bannerImage = imageName;
col.Update(player);
}
public static int GetAccountCountByPlatformId(ulong platformId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.Count(x =>
x.player.PlatformIds != null &&
x.player.PlatformIds
.Select(pid => pid.PlatformId)
.Any(id => id == platformId)
);
}
public static int GetAccountCountByDiscordId(ulong discordId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.Count(x =>
x.player_Extra != null &&
x.player_Extra.DiscordId == discordId
);
}
public static List<ulong> GetAccountIdsByDiscordId(ulong discordId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
return col
.Find(x =>
x.player_Extra != null &&
x.player_Extra.DiscordId == discordId
)
.Select(x => (ulong)x.playerid)
.ToList();
}
public static ulong GetPlayerCount()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var players = col.FindAll().ToList();
if (players.Count == 0)
return 0;
ulong minID = players.Min(p => p.playerid);
ulong maxID = players.Max(p => p.playerid);
ulong expectedCount = (ulong)(maxID - minID + 1);
return expectedCount;
}
public static List<object> Find_all_player_filtered(string? id, string? displayName, string? minLevel, string? jrState, string? platformId, string? deviceId, bool onlyBannedPlayers)
{
var col = PlayerDBFile.GetCollection<player_data>("players"); List<object> list = new List<object>();
foreach (player_data player in col.FindAll())
{
if (id != null && player.playerid.ToString() != id)
continue;
if (displayName != null && !player.player.DisplayName.Contains(displayName, StringComparison.OrdinalIgnoreCase))
continue;
if (minLevel != null && int.TryParse(minLevel, out int minLvl))
{
if (player.player.Level < minLvl)
continue;
}
if (jrState != null && jrState != "N/A")
{
bool expectedJunior = jrState == "true";
if (player.player.IsJunior != expectedJunior)
continue;
}
if (!string.IsNullOrEmpty(platformId) && (player.player.PlatformIds == null || !player.player.PlatformIds.Any(pid => pid.PlatformId.ToString() == platformId)))
{
continue;
}
if (onlyBannedPlayers)
{
var block = player.player_Extra.ModerationBlockDetails;
if (block == null || block.IsBan != true || !HasActiveModerationBlock(player.playerid))
continue;
}
if (deviceId != null && !string.Equals(player.deviceId.ToString(), deviceId, StringComparison.OrdinalIgnoreCase))
continue;
var room_data = GetAccountBulk_full(player.playerid);
if (room_data != null)
list.Add(room_data);
}
return list;
}
public static object? GetAccountBulk_full(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
List<object> list = new List<object>();
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
// The admin row template reads `player.PlatformID` (singular) — surface the first
// non-zero linked platform id so accounts created via /create-account show up as
// "Steam 76561…" instead of "Steam undefined".
var firstPlatformId = player?.player?.PlatformIds?
.FirstOrDefault(p => p != null && p.PlatformId != 0)?.PlatformId.ToString();
// PlatformID above is the first non-zero id of ANY platform, so on a Quest
// account it is an Oculus id even though the admin table labels the column
// "Steam". The Steam allowlist must never be fed that, so surface the real
// Steam id separately - empty when the account has none.
var steamId = player?.player?.PlatformIds?
.FirstOrDefault(p => p != null && p.Platform == Platforms.Steam && p.PlatformId != 0)
?.PlatformId.ToString() ?? "";
return new
{
AccountId = player?.playerid,
Level = player?.player?.Level,
xp = player?.player?.XP,
DisplayName = player?.player?.DisplayName,
DeviceId = player?.deviceId,
Username = player?.player?.Username,
IsJunior = player?.player?.IsJunior,
ProfileImage = player?.player?.profileImage,
LastLoginTime = player?.player?.last_login_time,
CreatedAt = player?.player?.created_at,
PlatformID = firstPlatformId ?? "",
SteamID = steamId,
PlatformIDs = player?.player?.PlatformIds,
// Linked Discord (website Settings > Discord tab). Empty/0 when not linked.
DiscordUsername = player?.player_Extra?.DiscordUsername ?? "",
DiscordId = (player?.player_Extra?.DiscordId ?? 0) == 0 ? "" : (player!.player_Extra!.DiscordId).ToString(),
dormroom_id = player?.player_Extra?.DormRoomID,
DevFlagRaw = (int)(player?.dev_Flag ?? DevFlag.none),
IsDev = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.dev) == DevFlag.dev,
IsMod = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.mod) == DevFlag.mod,
IsScreenshare = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.screenshare) == DevFlag.screenshare,
IsSupport = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.support) == DevFlag.support,
IsInfluencer = player?.player_Extra?.Influencer?.isInfluencer == true,
};
}
public static void DeletePlayer(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
col.Delete(playerid);
Console.WriteLine("Deleted player id " + playerid);
}
else
{
Console.WriteLine($"Player with ID {playerid} not found.");
}
}
public static int DeletePlayersWithNullLastLogin()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var minDate = DateTime.MinValue;
var playersToDelete = col
.Find(x => x.player.last_login_time == minDate)
.Select(x => x.playerid)
.ToList();
int deleted = 0;
foreach (var id in playersToDelete)
{
if (DeletePlayerFull(id))
deleted++;
}
Console.WriteLine($"Deleted {deleted} players with default LastLogin.");
return deleted;
}
public static bool DeletePlayerFull(ulong playerid)
{
try
{
DeletePlayer(playerid);
RoomDB.DeleteAllRoomsFromPlayerId(playerid);
HeartbeatDB.ClearPlayerHeartbeat(playerid);
SettingsDB.DeleteSettings(playerid);
LinkDB.DeleteLinksFromPlayerId(playerid);
ImageMetadataDB.DeleteImagesFromPlayerId(playerid);
return true;
}
catch
{
return false;
}
}
public static string SetRecentRooms(ulong playerid, ulong room_id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (player != null)
{
if (player.player.recent_rooms == null)
{
player.player.recent_rooms = new List<ulong>();
}
player.player.recent_rooms.Remove(room_id);
player.player.recent_rooms.Insert(0, room_id);
col.Update(player);
}
return "";
}
public static string GetRecentRooms(ulong playerId, int skip = 0, int take = 20)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerId);
if (player == null || player.player.recent_rooms == null || !player.player.recent_rooms.Any())
{
return "[]";
}
var paginatedRoomIds = player.player.recent_rooms
.Skip(Math.Max(0, skip))
.Take(Math.Max(1, take))
.ToList();
var visitedRooms = new List<RoomDBClasses.RoomRoot>();
foreach (ulong roomId in paginatedRoomIds)
{
var room = RoomDB.GetRoom(roomId);
if (room != null)
{
visitedRooms.Add(room);
}
}
// Strip blobs the client cannot download - this listing is fetched moments
// before every join and the client can join straight from a cached entry.
Campus_live_rewrite.api.server.RoomBlobFilter.Strip(visitedRooms);
return JsonConvert.SerializeObject(visitedRooms, Formatting.None);
}
public static string ClearRecentRoomsForAllPlayers()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var allPlayers = col.FindAll();
foreach (var player in allPlayers)
{
if (player.player.recent_rooms != null && player.player.recent_rooms.Count > 0)
{
player.player.recent_rooms = new List<ulong>();
col.Update(player);
}
}
return "";
}
public static string GetFavoritedRooms(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
// Was: `player == null || player.player_Extra.FavoritedRooms == null` -
// if player_Extra itself was null the second clause threw NRE before the
// check could help.
if (player?.player_Extra?.FavoritedRooms == null)
{
return "[]";
}
var favRooms = new List<RoomDBClasses.RoomRoot>();
foreach (ulong roomId in player.player_Extra.FavoritedRooms)
{
var room = RoomDB.GetRoom(roomId);
if (room != null)
{
favRooms.Add(room);
}
}
return JsonConvert.SerializeObject(favRooms);
}
/// <summary>
/// Records a device this account has logged in from. An account may accumulate
/// SEVERAL - a player is allowed to play on more than one machine, and each new
/// one is remembered the first time they sign in on it.
///
/// The restriction is on CREATION, not login: see CanCreateAccountOnDevice. A
/// device that already belongs to an account cannot be used to register another,
/// which is what stops alt farms, while a real player moving to a second device
/// just signs in and has it added here.
///
/// (This used to overwrite the list with a single id - one account, one device.
/// That made a legitimate second machine look like a device move on every login,
/// and it meant the creation check below had nothing reliable to test against.)
///
/// Blank ids are ignored rather than stored, so a login that sends no device id
/// cannot wipe the binding (and cannot create an unbannable empty-id account).
///
/// Device bans still work: AccountsOnDevice matches by CONTAINMENT, so banning a
/// device finds every account that has ever used it. Note the trade-off that comes
/// with allowing several devices - banning one does not stop a player who has
/// another registered.
/// </summary>
public static void SetPlayerDeviceId(string DeviceId, ulong playerid)
{
if (string.IsNullOrWhiteSpace(DeviceId)) return;
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player == null) return;
string id = DeviceId.Trim();
var current = player.deviceId ?? new List<string>();
// Already known for this account - nothing to write.
if (current.Contains(id)) return;
current.Add(id);
player.deviceId = current;
col.Update(player);
Console.WriteLine($"[device] account {playerid} added device '{id}' "
+ $"({current.Count} total)");
}
/// <summary>Drop device bindings (the account lock replacing an idle device). The flat
/// anti-alt deviceId list is left alone: that device stays "used" for alt purposes.</summary>
public static void UnbindDevices(ulong playerid, IEnumerable<string> deviceIds)
{
var ids = new HashSet<string>(deviceIds, StringComparer.Ordinal);
if (ids.Count == 0) return;
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.DeviceBindings == null) return;
int before = player.DeviceBindings.Count;
player.DeviceBindings.RemoveAll(b => b.DeviceId != null && ids.Contains(b.DeviceId));
if (player.DeviceBindings.Count != before) col.Update(player);
}
/// <summary>The account's device bindings (device id + the platform it signed in on).
/// Empty for legacy rows until the next login re-binds them.</summary>
public static List<mDeviceBind> GetDeviceBindings(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
return player?.DeviceBindings ?? new List<mDeviceBind>();
}
/// <summary>
/// Record that this account signed in from this device on this platform. Writes BOTH the
/// flat deviceId list (the anti-alt source of truth) and the platform-aware DeviceBindings
/// list (used by the account lock's one-PC-one-Quest cap). Idempotent per (device,platform).
/// </summary>
public static void BindDevice(string? deviceId, Platforms platform, ulong playerid)
{
if (string.IsNullOrWhiteSpace(deviceId)) return;
string id = deviceId.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player == null) return;
bool changed = false;
var flat = player.deviceId ?? new List<string>();
if (!flat.Contains(id)) { flat.Add(id); player.deviceId = flat; changed = true; }
var binds = player.DeviceBindings ?? new List<mDeviceBind>();
var mine = binds.FirstOrDefault(b => b.DeviceId == id && b.Platform == platform);
if (mine == null)
{
binds.Add(new mDeviceBind { DeviceId = id, Platform = platform, LastSeen = DateTime.UtcNow });
player.DeviceBindings = binds;
changed = true;
}
else
{
// Touch: the account lock lets a NEW device of the same class take over when this
// one has been idle (AccountLock), so every login has to stamp it.
mine.LastSeen = DateTime.UtcNow;
player.DeviceBindings = binds;
changed = true;
}
if (changed)
{
col.Update(player);
Console.WriteLine($"[device] account {playerid} bound device '{id}' on {platform} "
+ $"({binds.Select(b => b.DeviceId).Distinct().Count()} device(s))");
}
}
/// <summary>
/// Clear an account's device bindings so it can be signed in from fresh hardware - the
/// operator's "reset" when a player legitimately replaces their PC or headset. Clears both
/// the flat deviceId list and the platform-aware DeviceBindings.
/// </summary>
public static bool ClearDeviceBindings(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null) return false;
player.deviceId = new List<string>();
player.DeviceBindings = new List<mDeviceBind>();
col.Update(player);
Console.WriteLine($"[device] account {playerid} device bindings CLEARED");
return true;
}
/// <summary>
/// May a NEW account be created from this device?
///
/// False once any existing account has used it. This is the whole anti-alt rule:
/// one account per device at creation time, while logins from further devices are
/// unrestricted (SetPlayerDeviceId).
///
/// A blank device id returns TRUE - it carries no information, and refusing on it
/// would lock out every caller that does not send one. Callers that care must
/// require a non-empty id themselves before asking.
/// </summary>
public static bool CanCreateAccountOnDevice(string? deviceId, out ulong existingAccountId)
{
existingAccountId = 0;
if (string.IsNullOrWhiteSpace(deviceId)) return true;
var owners = AccountsOnDevice(deviceId).Where(o => !AccountLock.IsExempt(o)).ToList();
if (owners.Count == 0) return true;
// A device may hold one game-made account and one website account (see
// ConfigCampus.MaxAccountsPerDevice). Creating in-game takes the game-made slot: refused
// once the device is full, or once it already has a game-made account.
existingAccountId = owners[0];
if (owners.Count >= ConfigCampus.MaxAccountsPerDevice) return false;
var gameMade = owners.FirstOrDefault(o => OriginOf(o) == AccountOrigin.InGame);
if (gameMade != 0) { existingAccountId = gameMade; return false; }
return true;
}
public enum AccountOrigin { Unknown, InGame, Website }
/// <summary>
/// Where an account was made. Rows created since the flag exists say so explicitly; older
/// rows are Unknown, and an Unknown account fits EITHER of a device's two slots (it only
/// counts toward the cap), so nobody who existed before the rule is locked out by it.
/// </summary>
public static AccountOrigin OriginOf(ulong accountId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var p = col.FindOne(x => x.playerid == accountId);
if (p?.CreatedInGame == null) return AccountOrigin.Unknown;
return p.CreatedInGame.Value ? AccountOrigin.InGame : AccountOrigin.Website;
}
/// <summary>The account's single device id, or null if it has never logged in.</summary>
public static string? GetDeviceId(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
var list = player?.deviceId;
if (list == null || list.Count == 0) return null;
return string.IsNullOrWhiteSpace(list[0]) ? null : list[0];
}
/// <summary>
/// Cached-login picker entries for the accounts bound to this device on this platform
/// (see ConfigCampus.QuestDeviceCachedLogin). requirePassword=false: device ownership is the
/// credential, the cached_login grant re-checks the same binding before issuing a token.
/// </summary>
public static List<AccountLogin> GetDeviceLogins(string? deviceId, Platforms platform, ulong platformId)
{
var result = new List<AccountLogin>();
if (string.IsNullOrWhiteSpace(deviceId)) return result;
string id = deviceId.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
foreach (var p in col.FindAll())
{
if (p?.player == null || p.deviceId == null || !p.deviceId.Contains(id)) continue;
// Only a binding made from THIS platform class counts (a PC login on some shared
// device id must not turn into a headset cached login).
if (p.DeviceBindings == null || !p.DeviceBindings.Any(b => b.DeviceId == id && b.Platform == platform)) continue;
result.Add(new AccountLogin
{
accountId = p.playerid,
createdAt = p.player.created_at,
lastLoginTime = p.player.last_login_time,
platform = platform,
platformId = platformId.ToString(),
requirePassword = false
});
}
return result.OrderByDescending(a => a.lastLoginTime).ToList();
}
/// <summary>Is this account bound to this device on this platform?</summary>
public static bool IsDeviceBoundTo(ulong accountId, string? deviceId, Platforms platform)
{
if (string.IsNullOrWhiteSpace(deviceId)) return false;
string id = deviceId.Trim();
var binds = GetDeviceBindings(accountId);
return binds.Any(b => b.DeviceId == id && b.Platform == platform);
}
/// <summary>
/// Every account bound to this device id. Surfaced by the admin panel so a device
/// ban shows what else it takes out (alt accounts).
/// </summary>
public static List<ulong> AccountsOnDevice(string? deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId)) return new List<ulong>();
string id = deviceId.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll()
.Where(x => x.deviceId != null && x.deviceId.Contains(id))
.Select(x => x.playerid)
.ToList();
}
/// <summary>Clears an account-level ban. Device/IP bans are lifted separately (BanDB).</summary>
public static bool ClearModerationBlock(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player_Extra == null) return false;
player.player_Extra.ModerationBlockDetails = null;
col.Update(player);
return true;
}
public static void SetPlayerDiscordId(ulong DiscordId, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
if (player.player != null)
{
player.player_Extra.DiscordId = DiscordId;
col.Update(player);
return;
}
}
}
public static string GetPlayerNameById(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
return player?.player.Username ?? "Unknown";
}
public static ulong GetPlayerIdByName(string name)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Username == name);
return player?.player.Id ?? 0;
}
public static string GetAvatarSaved(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
// Was: only `player` was null-checked, then player_Extra.SavedOutfits was
// dereferenced - NRE when player_Extra is null. And SerializeObject(null)
// returns the literal "null", not "[]", which the client cannot parse as a
// list. Both paths now yield a real empty array.
var saved = player?.player_Extra?.SavedOutfits;
if (saved == null || saved.Count == 0)
{
return "[]";
}
// The newer client's Saved Outfits page (SavedOutfitsWatchUIFlow) deserializes each
// outfit into the StoredOutfit envelope and THROWS "Expected LegacyData but was null"
// (DCJDHBOIBBC.MissingLegacyData) on any entry without a `LegacyData` object - which
// then blanks the whole page. Serializing the STRUCTURED avatar_data_saved (which has
// no LegacyData field) is exactly that failure. So project each outfit into the shape
// the client expects:
// - Preferred: round-trip the RAW document the client posted (SaveOutfit stores it in
// .Avatar); it already carries LegacyData/Selections/DataVersion verbatim.
// - Fallback (older saves / server-made presets with no raw body): synthesize the
// LegacyData envelope from the flat fields so LegacyData is never null.
var arr = new Newtonsoft.Json.Linq.JArray();
foreach (var o in saved)
{
Newtonsoft.Json.Linq.JObject? raw = null;
if (!string.IsNullOrWhiteSpace(o.Avatar))
{
try { raw = Newtonsoft.Json.Linq.JObject.Parse(o.Avatar); } catch { raw = null; }
}
if (raw != null && raw["LegacyData"] != null &&
raw["LegacyData"]!.Type != Newtonsoft.Json.Linq.JTokenType.Null)
{
// Slot/Name are the authority for slot overwrite - keep them in sync with the
// structured fields in case the stored raw body predated them.
raw["Slot"] = (long)o.Slot;
if (o.Name != null) raw["Name"] = o.Name;
arr.Add(raw);
continue;
}
arr.Add(new Newtonsoft.Json.Linq.JObject
{
["LegacyData"] = new Newtonsoft.Json.Linq.JObject
{
["SelectionsV1"] = o.OutfitSelections,
["SelectionsV2"] = o.OutfitSelectionsV2,
["FaceFeatures"] = o.FaceFeatures,
["SkinColor"] = o.SkinColor,
["HairColor"] = o.HairColor
},
["Selections"] = new Newtonsoft.Json.Linq.JArray(),
["DataVersion"] = 9,
["CustomizationSettings"] = (string?)null,
["ThumbnailFileName"] = o.ImageName ?? o.PreviewImageName,
["Name"] = o.Name,
["Accessibility"] = 0,
["Slot"] = (long)o.Slot
});
}
return arr.ToString(Newtonsoft.Json.Formatting.None);
}
public static string SetAvatarSaved(ulong playerid, List<avatar_data_saved> avatars)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
// Was: `player == null` only - player_Extra was dereferenced unchecked.
if (player?.player_Extra == null)
return "[]";
if (player.player_Extra.SavedOutfits == null)
player.player_Extra.SavedOutfits = new List<avatar_data_saved>();
var incomingSlots = avatars.Select(a => a.Slot).ToHashSet();
player.player_Extra.SavedOutfits.RemoveAll(a => incomingSlots.Contains(a.Slot));
player.player_Extra.SavedOutfits.AddRange(avatars);
col.Update(player);
return JsonConvert.SerializeObject(player.player_Extra.SavedOutfits);
}
public static void SetPlayerCheer(CheerCategoryEnum cheer = CheerCategoryEnum.None, ulong playerid = 0)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player.PlayerReputation != null)
{
player_Setting1.player.PlayerReputation.SelectedCheer = cheer;
col.Update(player_Setting1);
return;
}
}
}
public static CheerCategoryEnum? GetPlayerCheer(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player.PlayerReputation != null)
{
return player_Setting1.player.PlayerReputation.SelectedCheer;
}
}
return null;
}
public static List<ulong> GetInfluencerIds()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var result = col.Query().Where(x => x.player_Extra.Influencer.isInfluencer == true).Select(x => x.playerid).ToList();
return result;
}
public static player_data? GetInfluencerByCreatorCode(string creatorcode)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player_Extra.Influencer.creatorCode == creatorcode && x.player_Extra.Influencer.isInfluencer == true);
return player;
}
public static bool IsInfluencer(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
return player?.player_Extra?.Influencer?.isInfluencer == true;
}
public static void SetIsInfluencer(ulong playerid, bool IsInfluencer)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return;
if (player.player_Extra == null)
player.player_Extra = new player_extra();
if (player.player_Extra.Influencer == null)
player.player_Extra.Influencer = new Influencer();
player.player_Extra.Influencer.isInfluencer = IsInfluencer;
col.Update(player);
}
public static void SetCreatorCode(ulong playerid, string creatorcode)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return;
if (player.player_Extra == null)
player.player_Extra = new player_extra();
if (player.player_Extra.Influencer == null)
player.player_Extra.Influencer = new Influencer();
player.player_Extra.Influencer.creatorCode = creatorcode;
col.Update(player);
}
public static void SupportInfluencer(ulong requestplayerid, ulong SupportId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == requestplayerid);
if (player != null)
{
if (player.player_Extra.Influencer == null)
{
player.player_Extra.Influencer = new Influencer();
}
player.player_Extra.Influencer.SupportingInfluencer = SupportId;
col.Update(player);
return;
}
}
public static ulong? GetMyInfluencer(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
if (player.player_Extra.Influencer != null)
{
return player.player_Extra.Influencer.SupportingInfluencer;
}
}
return 0;
}
// message funcs
public static List<MessageData> GetMessages(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player == null)
return new List<MessageData>();
if (player.player_Extra.Messages == null)
player.player_Extra.Messages = new List<MessageData>();
return player.player_Extra.Messages;
}
public static MessageData? AddMessage(ulong toPlayerId, ulong fromPlayerId, WebsocketEvents.MessageType type, string? data, ulong? roomId = null, ulong? playerEventId = null)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == toPlayerId);
if (player == null)
return null;
if (player.player_Extra.Messages == null)
player.player_Extra.Messages = new List<MessageData>();
var msg = new MessageData()
{
Id = GenerateRandomMessageId(),
FromPlayerId = fromPlayerId,
SentTime = DateTime.UtcNow,
Type = type,
Data = data,
RoomId = roomId,
PlayerEventId = playerEventId
};
player.player_Extra.Messages.Add(msg);
col.Update(player);
// Nudge the recipient's open website tabs to re-fetch the inbox (cheers, coach
// messages, ...). No-op if they have no browser tab open.
Campus_live_rewrite.api.server.SiteNotifications.Notify(toPlayerId);
return msg;
}
public static void DeleteMessage(ulong playerId, ulong messageId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player == null)
return;
if (player.player_Extra.Messages == null || player.player_Extra.Messages.Count == 0)
return;
var message = player.player_Extra.Messages.FirstOrDefault(m => m.Id == messageId);
if (message == null)
return;
player.player_Extra.Messages.Remove(message);
// Save changes
col.Update(player);
}
public static ulong GenerateRandomMessageId()
{
lock (randLock)
{
byte[] buffer = new byte[8];
random.NextBytes(buffer);
return BitConverter.ToUInt64(buffer, 0);
}
}
// message funcs
public static int SetCheerCredit(int cheerCredit, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.PlayerReputation.CheerCredit = cheerCredit;
col.Update(player_Setting1);
return cheerCredit;
}
}
return 0;
}
public static void Setup()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
col.EnsureIndex(x => x.playerid, true);
col.EnsureIndex(x => x.platform);
col.EnsureIndex(x => x.player.PlatformIds);
col.EnsureIndex(x => x.player.Username);
col.EnsureIndex(x => x.deviceId);
col.EnsureIndex(x => x.authtoken);
col.EnsureIndex(x => x.id);
col.EnsureIndex(x => x.player_Extra.Messages);
col.EnsureIndex("infl", "playerExtra.Influencer.isInfluencer");
}
public static string? GetPlayerData(ulong playerId, ulong roomId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player?.player_Extra?.MyRoomData?.PlayerData == null)
return null;
var entry = player.player_Extra.MyRoomData.PlayerData.FirstOrDefault(p => p.RoomId == roomId);
return entry?.Data;
}
public static bool SetPlayerData(ulong playerId, ulong roomId, string data)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player == null)
return false;
if (player.player_Extra == null)
player.player_Extra = new player_extra();
if (player.player_Extra.MyRoomData == null)
player.player_Extra.MyRoomData = new MyRoomData();
if (player.player_Extra.MyRoomData.PlayerData == null)
player.player_Extra.MyRoomData.PlayerData = new List<PlayerData>();
var existingEntry = player.player_Extra.MyRoomData.PlayerData.FirstOrDefault(p => p.RoomId == roomId);
if (existingEntry != null)
{
existingEntry.Data = data;
}
else
{
player.player_Extra.MyRoomData.PlayerData.Add(new PlayerData
{
RoomId = roomId,
Data = data
});
}
return col.Update(player);
}
public static PlayerPhotoTaggingSetting GetPlayerPhotoTaggingSetting(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
if (player.player != null)
{
return player.player_Extra.PlayerPhotoTaggingSetting;
}
}
return PlayerPhotoTaggingSetting.Anyone;
}
public static bool SetPlayerPhotoTaggingSetting(ulong playerid, PlayerPhotoTaggingSetting playerPhotoTaggingSetting)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
if (player.player != null)
{
player.player_Extra.PlayerPhotoTaggingSetting = playerPhotoTaggingSetting;
col.Update(player);
return true;
}
}
return false;
}
}
}