the files

This commit is contained in:
niko
2026-10-14 06:59:51 +02:00
parent f6175893fc
commit 2040419525
1855 changed files with 6735668 additions and 0 deletions
@@ -0,0 +1,292 @@
using System.Security.Principal;
using Newtonsoft.Json;
using Campus_live_rewrite.api;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes.db_class;
using CampusLiveSource.Controllers;
using static Campus_live_rewrite.api.server.Moderation_Detail;
namespace CampusLiveSource.api.admin
{
internal static class DevChatCommands
{
public static string? ParseCommand(string msg, string context, ulong playerId)
{
if (string.IsNullOrWhiteSpace(msg))
return null;
if (!msg.StartsWith("!"))
return null;
if (!PlayerDB.CheckDevFlag(playerId))
return "You are not allowed to use dev commands.";
string[] parts = msg.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
string command = parts[0].ToLowerInvariant();
string args = parts.Length > 1 ? parts[1] : "";
return command switch
{
"!bring_all" => HandleBringAll(args),
"!goto" => HandleGoto(args, playerId),
"!help" => HandleHelp(),
"!add_room_role" => HandleAddRoomRole(args, playerId),
"!remove_room_role" => HandleRemoveRoomRole(args, playerId),
"!ban" => HandleBan(args, playerId),
"!kick" => HandleKick(args),
_ => "Unknown command. Use !help"
};
}
// ------------------------------------------------------------
// BRING ALL
// ------------------------------------------------------------
private static string HandleBringAll(string msg)
{
if (string.IsNullOrWhiteSpace(msg))
return "Usage: !bring_all Room.Subroom [private]";
bool isPrivate = false;
msg = msg.Trim();
if (msg.EndsWith(" private", StringComparison.OrdinalIgnoreCase))
{
isPrivate = true;
msg = msg[..^8].Trim();
}
string room;
string subroom = "";
var split = msg.Split('.', 2, StringSplitOptions.RemoveEmptyEntries);
room = split[0];
if (split.Length == 2)
subroom = split[1];
var session = Sessions.CreateRoom(room, subroom, isPrivate);
foreach (var (playerId, _) in NotificationsController.WebSockets.ToArray())
{
try
{
HeartbeatDB.UpdatePlayerHeartbeat(playerId, session);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to move player {playerId}: {ex.Message}");
}
}
// ❗ Fallback ONLY if you don't have online tracking
// for (ulong id = 1; id <= PlayerDB.GetMaxPlayerId(); id++)
// HeartbeatDB.UpdatePlayerHeartbeat(id, session);
return $"Sent all players to {(isPrivate ? "PRIVATE " : "")}{room}" +
(string.IsNullOrEmpty(subroom) ? "" : $".{subroom}");
}
// ------------------------------------------------------------
// GOTO
// ------------------------------------------------------------
private static string HandleGoto(string arg, ulong playerId)
{
if (string.IsNullOrWhiteSpace(arg))
return "Usage: !goto Room.Subroom";
string room;
string subroom = "";
var split = arg.Split('.', 2, StringSplitOptions.RemoveEmptyEntries);
room = split[0];
if (split.Length == 2)
subroom = split[1];
var session = Sessions.CreateRoom(room, subroom);
HeartbeatDB.UpdatePlayerHeartbeat(playerId, session);
return string.IsNullOrEmpty(subroom)
? $"Teleporting you to {room}"
: $"Teleporting you to {room}.{subroom}";
}
// ------------------------------------------------------------
// HELP
// ------------------------------------------------------------
private static string HandleHelp()
{
return @"
!bring_all <Room.Subroom> [private]
Sends all players to the specified room.
Example: !bring_all RecCenter.Home private
!goto <Room.Subroom>
Teleports you to a room.
Example: !goto RecCenter.Home
!add_room_role <PlayerName|me>, <RoomName>, <Role>
Roles: None, Banned, Host, Moderator, CoOwner, TemporaryCoOwner, Creator
!remove_room_role <PlayerName|me>, <RoomName>
Removes ALL roles for a player in a room.
!ban <PlayerName>, <DurationSeconds>, <Category>, [Message]
Example: !ban ThriftyEmu, 3600, Cheating, Please follow the rules
".Trim();
}
// ------------------------------------------------------------
// ADD ROOM ROLE
// ------------------------------------------------------------
private static string HandleAddRoomRole(string args, ulong callerId)
{
if (string.IsNullOrWhiteSpace(args))
return "Usage: !add_room_role PlayerName, RoomName, RoleName";
var split = args.Split(',', StringSplitOptions.RemoveEmptyEntries);
ulong targetId;
string roomName;
string roleName;
if (split.Length == 2)
{
targetId = callerId;
roomName = split[0].Trim();
roleName = split[1].Trim();
}
else if (split.Length == 3)
{
string playerName = split[0].Trim();
roomName = split[1].Trim();
roleName = split[2].Trim();
targetId = playerName.Equals("me", StringComparison.OrdinalIgnoreCase)
? callerId
: PlayerDB.GetPlayerIdByName(playerName);
if (targetId == 0)
return $"Player '{playerName}' not found.";
}
else
{
return "Invalid format. Use: !add_room_role PlayerName, RoomName, RoleName";
}
if (!Enum.TryParse<RoomDBClasses.Role_data>(roleName, true, out var role))
return $"Invalid role '{roleName}'.";
bool result = RoomDB.AddRoleToRoom(roomName, (int)targetId, role);
if (!result)
return $"Room '{roomName}' not found.";
if (role == RoomDBClasses.Role_data.Creator)
RoomDB.SetRoomCreatorId(targetId, roomName);
return $"Added role {role} to room {roomName} for player ID {targetId}.";
}
// ------------------------------------------------------------
// REMOVE ROOM ROLE
// ------------------------------------------------------------
private static string HandleRemoveRoomRole(string args, ulong callerId)
{
if (string.IsNullOrWhiteSpace(args))
return "Usage: !remove_room_role PlayerName, RoomName";
var split = args.Split(',', StringSplitOptions.RemoveEmptyEntries);
if (split.Length != 2)
return "Invalid format. Use: !remove_room_role PlayerName, RoomName";
string playerName = split[0].Trim();
string roomName = split[1].Trim();
ulong targetId = playerName.Equals("me", StringComparison.OrdinalIgnoreCase)
? callerId
: PlayerDB.GetPlayerIdByName(playerName);
if (targetId == 0)
return $"Player '{playerName}' not found.";
bool result = RoomDB.RemoveRoleFromRoom(roomName, (int)targetId);
return result
? $"Removed all roles from room '{roomName}' for player '{playerName}'."
: $"Room '{roomName}' not found or no roles assigned.";
}
// ------------------------------------------------------------
// BAN
// ------------------------------------------------------------
private static string HandleBan(string args, ulong devid)
{
if (string.IsNullOrWhiteSpace(args))
return "Usage: !ban PlayerName, DurationSeconds, Category, Message";
var split = args.Split(',', 4, StringSplitOptions.RemoveEmptyEntries);
if (split.Length < 3)
return "Invalid format. Use: !ban PlayerName, DurationSeconds, Category, Message";
string playerName = split[0].Trim();
string durationStr = split[1].Trim();
string categoryStr = split[2].Trim();
string message = split.Length == 4 ? split[3].Trim() : "";
ulong playerId = PlayerDB.GetPlayerIdByName(playerName);
if (playerId == 0)
return $"Player '{playerName}' not found.";
if (!int.TryParse(durationStr, out int duration) || duration < 0)
return "Invalid duration. Must be a non-negative number.";
if (!Enum.TryParse<Moderation_Detail.ReportCategory>(categoryStr, true, out var category))
category = Moderation_Detail.ReportCategory.Moderator;
PlayerDB.Set_moderation_block(
playerId,
category,
duration,
gameSessionId: -1,
isHostKick: false,
message: message,
playerIdReporter: null,
isBan: true,
bannedByPlayerId: devid
);
return $"Player '{playerName}' banned for {duration} seconds ({category}).";
}
private static string HandleKick(string args)
{
if (string.IsNullOrWhiteSpace(args))
return "Usage: !kick PlayerName";
var split = args.Split(',', 4, StringSplitOptions.RemoveEmptyEntries);
if (split.Length < 3)
return "Invalid format. Use: !kick PlayerName";
string playerName = split[0].Trim();
ulong playerId = PlayerDB.GetPlayerIdByName(playerName);
if (playerId == 0)
return $"Player '{playerName}' not found.";
PlayerDB.Set_moderation_block(
playerId,
ReportCategory.Moderator,
duration: 1,
gameSessionId: -1,
isHostKick: true,
message: "You have been kicked.",
playerIdReporter: null,
isBan: false,
bannedByPlayerId: 0
);
return $"Player '{playerName}' has been kicked to their Dorm Room.";
}
}
}
@@ -0,0 +1,572 @@
using System.Linq;
using System.Text.RegularExpressions;
using Discord;
using Discord.Interactions;
using Discord.WebSocket;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.DiscordBot
{
public class BotCommands : InteractionModuleBase<SocketInteractionContext>
{
// Custom ids for the /user-panel buttons. Prefix with "up:" so the modal handlers can
// grab their own inputs unambiguously.
private const string BtnEmail = "up:email";
private const string BtnPassword = "up:password";
private const string BtnUsername = "up:username";
private const string BtnDisplay = "up:display";
private const string BtnPfp = "up:pfp";
// Where uploaded profile pictures live. This path is one of the roots that
// ImagesController serves from — files dropped here appear at /img/{filename}.
private static string PlayerImagesDir =>
Path.Combine(Environment.CurrentDirectory, "Data", "Images", "player_images");
private const long MaxPfpBytes = 8L * 1024 * 1024; // 8 MB
// -----------------------------------------------------------------------------------
// /get-user
// -----------------------------------------------------------------------------------
[SlashCommand("get-user", "Get Campus user by ID or name.")]
public async Task GetUserByIdOrName(string userInput)
{
try
{
player_data? player;
if (ulong.TryParse(userInput, out ulong playerId))
player = PlayerDB.GetFullAccount(playerId);
else
player = PlayerDB.GetFullAccountByName(userInput);
if (player == null)
{
await RespondAsync("No user found with the provided ID or name.", ephemeral: true);
return;
}
var bio = player.player_Extra?.Bio;
if (string.IsNullOrWhiteSpace(bio)) bio = "This player hasn't written a bio yet!";
var embed = new EmbedBuilder()
.WithTitle(player.player.Username)
.WithDescription(
$"Username: **@{player.player.Username}**\n" +
$"Display Name: **{player.player.DisplayName}**\n" +
$"Level: **{player.player.Level}**\n" +
$"Bio: ```{bio}```\n" +
$"Linked Discord: <@{player.player_Extra?.DiscordId}>")
.WithColor(new Color(155, 92, 255))
.WithThumbnailUrl($"https://gameauth.ddns.net/img/{player.player.profileImage}?cropSquare=true")
.Build();
await RespondAsync(embed: embed);
}
catch
{
await RespondAsync("Something went wrong. Please try again.", ephemeral: true);
}
}
// -----------------------------------------------------------------------------------
// /leaderboard
// -----------------------------------------------------------------------------------
[SlashCommand("leaderboard", "Display the leaderboard for Campus rooms.")]
public async Task ShowLeaderboardAsync(string roomname, int statchannel)
{
try
{
var room = RoomDB.GetRoomByName(roomname);
if (room == null)
{
await RespondAsync("Room not found!", ephemeral: true);
return;
}
var leaderboard = LeaderboardDB.GetNearbyScores(1, statchannel, room.RoomId, 0);
if (leaderboard == null || leaderboard.Count == 0)
{
await RespondAsync("There is no leaderboard or there are no scores for this room!", ephemeral: true);
return;
}
var embedBuilder = new EmbedBuilder()
.WithTitle($"Campus - Top Players in ^{roomname} Leaderboard")
.WithDescription("Here are the top players.")
.WithColor(new Color(155, 92, 255))
.WithFooter("Keep playing to climb the ranks!");
int rank = 1;
foreach (var score in leaderboard)
{
var player = PlayerDB.GetFullAccount((ulong)score.PlayerId);
if (player == null) continue;
string rankSuffix = rank switch { 1 => "st", 2 => "nd", 3 => "rd", _ => "th" };
embedBuilder.AddField($"{rank}{rankSuffix} - @{player.player.Username} (Score: {score.Score})", true);
rank++;
}
await RespondAsync(embed: embedBuilder.Build());
}
catch
{
await RespondAsync("Something went wrong. Please try again.", ephemeral: true);
}
}
// -----------------------------------------------------------------------------------
// /create-account (username + password, optional platform + platform id)
// -----------------------------------------------------------------------------------
public enum CreatePlatform
{
None = 0,
Steam = 1,
Meta = 2
}
[SlashCommand("create-account", "Create a Campus account bound to your Discord (username + password).")]
public async Task CreateCampusAccount(
[Summary("username", "1-20 chars, letters/numbers/underscore only.")] string username,
[Summary("password", "8+ chars. Kept secret; only you will see it.")] string password,
[Summary("platform", "Optional: Steam or Meta (leave empty for Discord-only).")] CreatePlatform platform = CreatePlatform.None,
[Summary("platform_id", "Optional: your Steam ID (76561...) or Meta ID. Ignored if platform is empty.")] string? platform_id = null)
{
try
{
ulong discordId = Context.User.Id;
Console.WriteLine($"[/create-account] discord={discordId} tag={Context.User.Username} tried username='{username}' platform={platform} platformId='{platform_id}'");
username = (username ?? "").Trim();
password = password ?? "";
if (username.Length < 1 || username.Length > 20 || !Regex.IsMatch(username, @"^[a-zA-Z0-9_]+$"))
{
await RespondAsync("Username must be 1-20 chars, letters/numbers/underscore only.", ephemeral: true);
return;
}
if (password.Length < 8)
{
await RespondAsync("Password must be at least 8 characters.", ephemeral: true);
return;
}
if (PlayerDB.GetAccountCountByDiscordId(discordId) > 0)
{
await RespondAsync("You already have a Campus account linked to this Discord.", ephemeral: true);
return;
}
if (PlayerDB.GetFullAccountByName(username) != null)
{
await RespondAsync($"Username **{username}** is already taken. Try another.", ephemeral: true);
return;
}
// Resolve platform + platform id.
Platforms platformEnum;
ulong platformIdParsed = 0UL;
string deviceTag;
switch (platform)
{
case CreatePlatform.Steam:
platformEnum = Platforms.Steam;
deviceTag = $"discord-slash-steam-{discordId}";
if (!string.IsNullOrWhiteSpace(platform_id))
{
if (!ulong.TryParse(platform_id.Trim(), out platformIdParsed) || !platform_id.Trim().StartsWith("7656"))
{
await RespondAsync("Steam ID looks invalid — expected a 17-digit number starting with 7656.", ephemeral: true);
return;
}
if (PlayerDB.GetAccountCountByPlatformId(platformIdParsed) > 0)
{
await RespondAsync("That Steam ID is already linked to another Campus account.", ephemeral: true);
return;
}
}
break;
case CreatePlatform.Meta:
platformEnum = Platforms.Oculus;
deviceTag = $"discord-slash-meta-{discordId}";
if (!string.IsNullOrWhiteSpace(platform_id))
{
if (!ulong.TryParse(platform_id.Trim(), out platformIdParsed))
{
await RespondAsync("Meta ID must be numeric.", ephemeral: true);
return;
}
if (PlayerDB.GetAccountCountByPlatformId(platformIdParsed) > 0)
{
await RespondAsync("That Meta ID is already linked to another Campus account.", ephemeral: true);
return;
}
}
break;
default:
platformEnum = Platforms.RecNet;
deviceTag = $"discord-slash-{discordId}";
break;
}
account_data acc;
if (!PlayerDB.CreateAccount(platformEnum, platformIdParsed, deviceTag, discordId, out acc))
{
await RespondAsync("Account creation failed. Try again later.", ephemeral: true);
return;
}
if (!PlayerDB.SetPlayerUsername(username, acc.accountId))
{
await RespondAsync($"Username **{username}** was taken while creating. Try another.", ephemeral: true);
return;
}
PlayerDB.SetDisplayName(username, acc.accountId);
PlayerDB.SetPlayerPassword(password, acc.accountId, log: false);
Console.WriteLine($"[/create-account] SUCCESS discord={discordId} accid={acc.accountId} username='{username}' platform={platformEnum}");
string platformNote = platform == CreatePlatform.None
? "Discord-only account (no platform linked)."
: $"Linked platform: **{platform}**" + (platformIdParsed > 0 ? $" ({platformIdParsed})" : " (no ID provided)");
await RespondAsync(
$"Account **{username}** created and linked to your Discord.\n" +
$"{platformNote}\n" +
$"You can log in at https://gameauth.ddns.net/login",
ephemeral: true);
}
catch (Exception ex)
{
Console.WriteLine($"[/create-account] EXCEPTION: {ex}");
await RespondAsync("Something went wrong. Try again.", ephemeral: true);
}
}
// -----------------------------------------------------------------------------------
// /link (link THIS Discord to an existing Campus account, via the code from the website
// Settings > Discord tab). Isolated single purpose: it only records the
// Discord<->account association (PlayerDB.SetDiscordLink) - it grants no admin/dev
// access and touches nothing to do with the admin panel login.
// -----------------------------------------------------------------------------------
[SlashCommand("link", "Link your Discord to your Campus account using the code from the website (Settings > Discord).")]
public async Task LinkAccount(
[Summary("code", "The 6-character code from gameauth.ddns.net > Settings > Discord")] string code)
{
try
{
ulong discordId = Context.User.Id;
string tag = Context.User.Username;
if (!DiscordLinkCodes.TryResolve(code, out ulong accountId))
{
await RespondAsync("That code is invalid or expired. Open **Settings > Discord** on the website and press **Link Discord** for a fresh code (valid 10 minutes).", ephemeral: true);
return;
}
var acc = PlayerDB.GetFullAccount(accountId);
if (acc?.player == null)
{
await RespondAsync("Couldn't find that Campus account. Try generating a new code.", ephemeral: true);
return;
}
// This Discord already tied to a DIFFERENT account?
ulong otherAcc = PlayerDB.FindPlayerByDiscordId(discordId, accountId);
if (otherAcc != 0)
{
await RespondAsync($"Your Discord is already linked to another Campus account (id {otherAcc}). Unlink it there first (Settings > Discord > Unlink).", ephemeral: true);
return;
}
// This account already tied to a DIFFERENT Discord?
ulong existing = acc.player_Extra?.DiscordId ?? 0;
if (existing != 0 && existing != discordId)
{
await RespondAsync("That Campus account is already linked to a different Discord. Unlink it on the website first.", ephemeral: true);
return;
}
PlayerDB.SetDiscordLink(accountId, discordId, tag);
DiscordLinkCodes.Consume(code);
Console.WriteLine($"[/link] discord={discordId} ({tag}) linked to account {accountId} (@{acc.player.Username})");
await RespondAsync($"Linked! Your Discord is now connected to **@{acc.player.Username}**. You can manage your account with `/user-panel`.", ephemeral: true);
}
catch (Exception ex)
{
Console.WriteLine($"[/link] EXCEPTION: {ex}");
await RespondAsync("Something went wrong linking your account. Try again.", ephemeral: true);
}
}
// -----------------------------------------------------------------------------------
// /user-panel (attach email, reset password, change @username, change display name)
// -----------------------------------------------------------------------------------
[SlashCommand("user-panel", "Manage your Campus account: email, password, username, display name.")]
public async Task UserPanel()
{
var discordId = Context.User.Id;
var accountIds = PlayerDB.GetAccountIdsByDiscordId(discordId);
if (accountIds == null || !accountIds.Any())
{
await RespondAsync(
"No Campus account is linked to your Discord yet. Use `/create-account` first.",
ephemeral: true);
return;
}
var player = PlayerDB.GetFullAccount(accountIds.First());
if (player?.player == null)
{
await RespondAsync("Couldn't load your account. Please try again later.", ephemeral: true);
return;
}
var embed = new EmbedBuilder()
.WithTitle("Your Campus Account")
.WithDescription(
$"**Username:** @{player.player.Username}\n" +
$"**Display Name:** {player.player.DisplayName}\n" +
$"**Email:** {(string.IsNullOrWhiteSpace(player.player.Email) ? "(not set)" : player.player.Email)}\n" +
$"**Account ID:** {player.player.Id}\n" +
$"**Level:** {player.player.Level}\n\n" +
$"Pick an action below.")
.WithColor(new Color(155, 92, 255))
.WithThumbnailUrl($"https://gameauth.ddns.net/img/{player.player.profileImage}?cropSquare=true")
.Build();
var buttons = new ComponentBuilder()
.WithButton("Attach Email", BtnEmail, ButtonStyle.Secondary)
.WithButton("Reset Password", BtnPassword, ButtonStyle.Danger)
.WithButton("Change @Username", BtnUsername, ButtonStyle.Primary)
.WithButton("Change Display Name", BtnDisplay, ButtonStyle.Primary)
.WithButton("Change Profile Picture", BtnPfp, ButtonStyle.Secondary)
.Build();
await RespondAsync(embed: embed, components: buttons, ephemeral: true);
}
// ---------------- Button handlers → open modals ----------------
[ComponentInteraction(BtnEmail)]
public async Task OpenEmailModal() =>
await Context.Interaction.RespondWithModalAsync<EmailModal>("up-modal:email");
[ComponentInteraction(BtnPassword)]
public async Task OpenPasswordModal() =>
await Context.Interaction.RespondWithModalAsync<PasswordModal>("up-modal:password");
[ComponentInteraction(BtnUsername)]
public async Task OpenUsernameModal() =>
await Context.Interaction.RespondWithModalAsync<UsernameModal>("up-modal:username");
[ComponentInteraction(BtnDisplay)]
public async Task OpenDisplayModal() =>
await Context.Interaction.RespondWithModalAsync<DisplayModal>("up-modal:display");
[ComponentInteraction(BtnPfp)]
public async Task PfpInfo() =>
await RespondAsync(
"Discord modals can't accept file uploads. Run `/set-pfp` and attach your image there — " +
"PNG/JPG/GIF/WebP up to 8 MB. It'll replace your current profile picture immediately.",
ephemeral: true);
// -----------------------------------------------------------------------------------
// /set-pfp (upload a new profile picture — attachments only work via slash params)
// -----------------------------------------------------------------------------------
[SlashCommand("set-pfp", "Upload a new profile picture for your Campus account.")]
public async Task SetPfp(
[Summary("image", "PNG, JPG, GIF, or WebP. Max 8 MB.")] IAttachment image)
{
await DeferAsync(ephemeral: true);
var accId = ResolveMyAccountId();
if (accId == 0)
{
await FollowupAsync("No Campus account is linked to your Discord. Use `/create-account` first.", ephemeral: true);
return;
}
if (image == null || string.IsNullOrEmpty(image.Url))
{
await FollowupAsync("No image was attached.", ephemeral: true);
return;
}
if (image.Size > MaxPfpBytes)
{
await FollowupAsync($"That file is {image.Size / 1024 / 1024} MB — max is 8 MB.", ephemeral: true);
return;
}
var ctype = (image.ContentType ?? "").ToLowerInvariant();
var allowedTypes = new[] { "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp" };
if (!allowedTypes.Any(t => ctype.StartsWith(t)))
{
await FollowupAsync($"Unsupported file type `{image.ContentType}`. Use PNG, JPG, GIF, or WebP.", ephemeral: true);
return;
}
try
{
Directory.CreateDirectory(PlayerImagesDir);
// Filename: pfp_{accId}_{timestamp}.{ext}. Timestamp defeats Discord/CDN caches
// so the new picture appears right away instead of the client showing the old one.
var ext = Path.GetExtension(image.Filename ?? "").ToLowerInvariant();
if (string.IsNullOrWhiteSpace(ext) || ext.Length > 6)
{
ext = ctype.Contains("png") ? ".png"
: ctype.Contains("jpeg") ? ".jpg"
: ctype.Contains("gif") ? ".gif"
: ctype.Contains("webp") ? ".webp"
: ".png";
}
var filename = $"pfp_{accId}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}{ext}";
var fullPath = Path.Combine(PlayerImagesDir, filename);
using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(20) })
{
var bytes = await http.GetByteArrayAsync(image.Url);
if (bytes.Length > MaxPfpBytes)
{
await FollowupAsync("File too large after download.", ephemeral: true);
return;
}
await File.WriteAllBytesAsync(fullPath, bytes);
}
PlayerDB.SetPlayerImageName(filename, accId);
Console.WriteLine($"[/set-pfp] discord={Context.User.Id} accid={accId} saved {filename} ({image.Size} bytes)");
await FollowupAsync(
$"Profile picture updated. Preview: https://gameauth.ddns.net/img/{filename}?cropSquare=true",
ephemeral: true);
}
catch (Exception ex)
{
Console.WriteLine($"[/set-pfp] EXCEPTION discord={Context.User.Id} accid={accId}: {ex}");
await FollowupAsync("Something went wrong while uploading your image. Try again.", ephemeral: true);
}
}
// ---------------- Modal shapes ----------------
public class EmailModal : IModal
{
public string Title => "Attach Email";
[InputLabel("Email address")]
[ModalTextInput("email", TextInputStyle.Short, placeholder: "you@example.com", maxLength: 120)]
public string Email { get; set; } = "";
}
public class PasswordModal : IModal
{
public string Title => "Reset Password";
[InputLabel("New password (8+ chars)")]
[ModalTextInput("password", TextInputStyle.Short, placeholder: "New password", minLength: 8, maxLength: 100)]
public string Password { get; set; } = "";
}
public class UsernameModal : IModal
{
public string Title => "Change @Username";
[InputLabel("New username (1-20 chars, a-z 0-9 _)")]
[ModalTextInput("username", TextInputStyle.Short, placeholder: "yourname", minLength: 1, maxLength: 20)]
public string Username { get; set; } = "";
}
public class DisplayModal : IModal
{
public string Title => "Change Display Name";
[InputLabel("New display name (1-32 chars)")]
[ModalTextInput("display", TextInputStyle.Short, placeholder: "Your Display Name", minLength: 1, maxLength: 32)]
public string Display { get; set; } = "";
}
// ---------------- Modal submit handlers ----------------
[ModalInteraction("up-modal:email")]
public async Task SubmitEmail(EmailModal modal)
{
var accId = ResolveMyAccountId();
if (accId == 0) { await RespondAsync("No linked account.", ephemeral: true); return; }
var email = (modal.Email ?? "").Trim();
if (!Regex.IsMatch(email, @"^[^@\s]+@[^@\s]+\.[^@\s]+$"))
{
await RespondAsync("That doesn't look like a valid email.", ephemeral: true);
return;
}
var ok = PlayerDB.SetPlayerEmail(email, accId);
Console.WriteLine($"[/user-panel] email set discord={Context.User.Id} accid={accId} ok={ok}");
await RespondAsync(ok ? $"Email updated to `{email}`." : "Failed to update email.", ephemeral: true);
}
[ModalInteraction("up-modal:password")]
public async Task SubmitPassword(PasswordModal modal)
{
var accId = ResolveMyAccountId();
if (accId == 0) { await RespondAsync("No linked account.", ephemeral: true); return; }
var pw = modal.Password ?? "";
if (pw.Length < 8)
{
await RespondAsync("Password must be at least 8 characters.", ephemeral: true);
return;
}
PlayerDB.SetPlayerPassword(pw, accId, log: false);
Console.WriteLine($"[/user-panel] password reset discord={Context.User.Id} accid={accId}");
await RespondAsync("Password updated.", ephemeral: true);
}
[ModalInteraction("up-modal:username")]
public async Task SubmitUsername(UsernameModal modal)
{
var accId = ResolveMyAccountId();
if (accId == 0) { await RespondAsync("No linked account.", ephemeral: true); return; }
var uname = (modal.Username ?? "").Trim();
if (uname.Length < 1 || uname.Length > 20 || !Regex.IsMatch(uname, @"^[a-zA-Z0-9_]+$"))
{
await RespondAsync("Username must be 1-20 chars, letters/numbers/underscore only.", ephemeral: true);
return;
}
if (PlayerDB.GetFullAccountByName(uname) != null)
{
await RespondAsync($"Username **{uname}** is already taken.", ephemeral: true);
return;
}
var ok = PlayerDB.SetPlayerUsername(uname, accId);
Console.WriteLine($"[/user-panel] username change discord={Context.User.Id} accid={accId} new='{uname}' ok={ok}");
await RespondAsync(ok ? $"Username updated to **@{uname}**." : "Failed to update username.", ephemeral: true);
}
[ModalInteraction("up-modal:display")]
public async Task SubmitDisplay(DisplayModal modal)
{
var accId = ResolveMyAccountId();
if (accId == 0) { await RespondAsync("No linked account.", ephemeral: true); return; }
var dn = (modal.Display ?? "").Trim();
if (dn.Length < 1 || dn.Length > 32)
{
await RespondAsync("Display name must be 1-32 characters.", ephemeral: true);
return;
}
PlayerDB.SetDisplayName(dn, accId);
Console.WriteLine($"[/user-panel] display change discord={Context.User.Id} accid={accId} new='{dn}'");
await RespondAsync($"Display name updated to **{dn}**.", ephemeral: true);
}
// Resolve the caller's Campus account id from their Discord id. 0 if none.
private ulong ResolveMyAccountId()
{
var ids = PlayerDB.GetAccountIdsByDiscordId(Context.User.Id);
return (ids != null && ids.Any()) ? ids.First() : 0UL;
}
}
}
@@ -0,0 +1,145 @@
using Discord;
using Discord.WebSocket;
using Discord.Interactions;
using System;
using System.Reflection;
using System.Threading.Tasks;
public class DiscordBot
{
private readonly DiscordSocketClient _client;
private readonly InteractionService _commands;
private readonly IServiceProvider _services;
private readonly IServiceProvider _serviceProvider;
private static string BOT_TOKEN = "MTUzNTk2NzMxNjcyOTI2NjIwNg.G-QIxD.QJRRm5JXDwF3kS6R1FIIludkhgdmRaMV6rBp2o";
public DiscordBot(IServiceProvider services)
{
// Minimal intents needed for slash commands: Guilds only. Fewer intents = fewer
// Discord-portal toggles needed and no privileged-intent gate.
_client = new DiscordSocketClient(new DiscordSocketConfig
{
GatewayIntents = GatewayIntents.Guilds,
AlwaysDownloadUsers = false,
LogLevel = LogSeverity.Info
});
_commands = new InteractionService(_client.Rest);
_serviceProvider = services;
}
public async Task RunAsync()
{
_client.Log += Log;
_client.Ready += Ready;
_client.Connected += () => { Console.WriteLine("[DiscordBot] Gateway Connected."); return Task.CompletedTask; };
_client.Disconnected += ex => { Console.WriteLine($"[DiscordBot] Gateway Disconnected: {ex?.Message}"); return Task.CompletedTask; };
_client.InteractionCreated += HandleInteraction;
Console.WriteLine("[DiscordBot] Logging in...");
await _client.LoginAsync(TokenType.Bot, BOT_TOKEN);
Console.WriteLine("[DiscordBot] Login OK. Starting gateway...");
await _client.StartAsync();
Console.WriteLine("[DiscordBot] StartAsync returned. Waiting for Ready (fallback timer at 20s).");
// Fallback: if Ready hasn't run within 20s, kick off registration anyway.
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(20));
if (!_hasRegistered)
{
Console.WriteLine("[DiscordBot] Ready still hasn't fired — running RegisterCommandsAsync via fallback.");
await RegisterCommandsAsync();
}
});
await Task.Delay(-1);
}
private bool _hasRegistered = false;
private async Task Ready()
{
Console.WriteLine($"[DiscordBot] READY event fired. Logged in as {_client.CurrentUser?.Username}#{_client.CurrentUser?.Discriminator} ({_client.CurrentUser?.Id}). Guilds visible: {_client.Guilds.Count}");
await RegisterCommandsAsync();
}
private async Task RegisterCommandsAsync()
{
if (_hasRegistered) return;
_hasRegistered = true;
try
{
Console.WriteLine("[DiscordBot] Loading command modules...");
await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services);
Console.WriteLine($"[DiscordBot] Loaded {_commands.SlashCommands.Count} slash commands.");
// Known guild IDs — REST call works even if the gateway hasn't cached the guild yet.
var targetGuildIds = new HashSet<ulong>
{
1526392715590500362, // Andromeda • 2023 (user-provided)
};
// Merge in whatever the gateway has cached (fresh guilds the bot joins later).
foreach (var g in _client.Guilds) targetGuildIds.Add(g.Id);
Console.WriteLine($"[DiscordBot] Registering commands to {targetGuildIds.Count} guild(s): {string.Join(",", targetGuildIds)}");
foreach (var gid in targetGuildIds)
{
try
{
await _commands.RegisterCommandsToGuildAsync(gid);
Console.WriteLine($"[DiscordBot] ✅ Registered commands to guild {gid}.");
}
catch (Exception gex)
{
Console.WriteLine($"[DiscordBot] ❌ Failed to register to guild {gid}: {gex.Message}");
}
}
// NOTE: Do NOT register commands globally — if a command exists both globally and
// per-guild, Discord's client shows it TWICE in the picker. Wipe any stale globals
// that a previous build left behind so old duplicates disappear.
try
{
await _client.Rest.BulkOverwriteGlobalCommands(Array.Empty<Discord.ApplicationCommandProperties>());
Console.WriteLine("[DiscordBot] Cleared any previously-registered global commands.");
}
catch (Exception gex)
{
Console.WriteLine($"[DiscordBot] Global clear failed: {gex.Message}");
}
_client.JoinedGuild += async g =>
{
try
{
await _commands.RegisterCommandsToGuildAsync(g.Id);
Console.WriteLine($"[DiscordBot] ✅ Auto-registered commands to newly joined guild '{g.Name}' ({g.Id}).");
}
catch (Exception jex)
{
Console.WriteLine($"[DiscordBot] ❌ Auto-register failed for '{g.Name}': {jex.Message}");
}
};
}
catch (Exception ex)
{
Console.WriteLine($"[DiscordBot] RegisterCommandsAsync EXCEPTION: {ex}");
}
}
private Task Log(LogMessage log)
{
Console.WriteLine(log);
return Task.CompletedTask;
}
private async Task HandleInteraction(SocketInteraction interaction)
{
// Handle the interaction using the InteractionService
var context = new SocketInteractionContext(_client, interaction);
await _commands.ExecuteCommandAsync(context, _serviceProvider);
}
}
@@ -0,0 +1,235 @@
using Campus_live_rewrite.api.server.Classes.db_class;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.dbs;
using System.Security.Principal;
using static Campus_live_rewrite.api.WebsocketEvents;
using Campus_live_rewrite.api.server;
using Microsoft.IdentityModel.Tokens.Experimental;
namespace Campus_live_rewrite.api
{
public class WebsocketEvents
{
public static Reponse create_player_updated_account2_Response(string id, account_data_me account)
{
return new Reponse
{
Id = id,
Msg = account
};
}
public static Reponse create_sub(string id, ulong playerid, int membershipType)
{
return new Reponse
{
Id = id,
Msg = new Sub
{
creatorAccountId = playerid,
clubId = 1,
membershipType = membershipType,
}
};
}
public static Reponse createResponse_levelupdate(ulong playerid, int level, int xp)
{
return new Reponse
{
Id = "PlayerProgressionLevelUpdate",
Msg = new
{
PlayerId = playerid,
Level = level,
XP = xp,
}
};
}
public static Reponse CreateResponseReputationUpdate(ulong playerId)
{
var acc = PlayerDB.GetFullAccount(playerId);
return new Reponse
{
Id = "ReputationUpdate",
Msg = new
{
AccountId = playerId,
IsCheerful = true, // always true (see reputation endpoints) - a false here restricts club/room creation client-side
Noteriety = acc.player.PlayerReputation.Noteriety,
SelectedCheer = acc.player.PlayerReputation.SelectedCheer,
CheerCredit = acc.player.PlayerReputation.CheerCredit,
CheerGeneral = acc.player.PlayerReputation.CheerGeneral,
CheerHelpful = acc.player.PlayerReputation.CheerHelpful,
CheerCreative = acc.player.PlayerReputation.CheerCreative,
CheerGreatHost = acc.player.PlayerReputation.CheerGreatHost,
CheerSportsman = acc.player.PlayerReputation.CheerSportsman,
SubscriberCount = acc.player_Extra.sub_count,
SubscribedCount = 0,
RecRoomDeveloper = PlayerDB.CheckDevFlag(acc.player.Id)
}
};
}
// Pushes a currency balance to the owner so an admin grant shows without reopening the
// store. Id is the ResponseResults number as a string (the same convention CreateLogout
// "6" / CreateBan "22" use), and the Msg mirrors the EXACT array shape the client already
// parses from GET /api/storefronts/v4/balance/{type}, so a client that routes this event
// through the same reader applies it, and one that doesn't simply ignores an unknown id.
public static Reponse createResponse_balanceupdate(int currencyType, long balance)
{
return new Reponse
{
Id = ((int)ResponseResults.StorefrontBalanceUpdate).ToString(),
// ONE object, not an array: the client decodes StorefrontBalanceUpdate (61) as a
// single BalanceResponse {Balance, CurrencyType, Platform} (reference:
// pushBalanceUpdate). The array form the GET endpoint uses failed to decode here,
// so the number in the corner never moved until a relog. Platform is the
// account-wide bucket (-2, ALL_PLATFORMS) - the client keeps a balance PER
// (CurrencyType, Platform) and SUMS them, so this must name the same bucket the
// GET /storefronts/v4/balance and the purchase responses report or it creates a
// phantom second bucket that is added to the real one forever.
Msg = new
{
Balance = balance,
CurrencyType = currencyType,
Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket
}
};
}
public static Reponse CreateMessageResponse(MessageData messageData)
{
return new Reponse
{
Id = "2",
Msg = messageData
};
}
/// <summary>MessageDeleted (3): the client drops that notification from its list (reference MessageDeletedPayload {Id}).</summary>
public static Reponse CreateMessageDeleted(ulong messageId) => new Reponse { Id = "3", Msg = new { Id = messageId } };
public static Reponse CreateLogout(ulong id)
{
return new Reponse
{
Id = "6", // 6 = Logout
Msg = new
{
}
};
}
public static Reponse CreateBan(Moderation_Detail.ModerationBlockDetails block)
{
return new Reponse
{
Id = "22",
Msg = block
};
}
public class Reponse
{
public string? Id { get; set; }
public object? Msg { get; set; }
}
public enum ResponseResults
{
RelationshipChanged = 1,
MessageReceived,
MessageDeleted,
PresenceHeartbeatResponse,
RefreshLogin,
Logout,
SubscriptionUpdateProfile = 11,
SubscriptionUpdatePresence,
SubscriptionUpdateGameSession,
SubscriptionUpdateRoom = 15,
SubscriptionUpdateRoomPlaylist,
ModerationQuitGame = 20,
ModerationUpdateRequired,
ModerationKick,
ModerationKickAttemptFailed,
ModerationRoomBan,
ServerMaintenance,
GiftPackageReceived = 30,
GiftPackageReceivedImmediate,
GiftPackageRewardSelectionReceived,
ProfileJuniorStatusUpdate = 40,
RelationshipsInvalid = 50,
StorefrontBalanceAdd = 60,
StorefrontBalanceUpdate,
StorefrontBalancePurchase,
ConsumableMappingAdded = 70,
ConsumableMappingRemoved,
PlayerEventCreated = 80,
PlayerEventUpdated,
PlayerEventDeleted,
PlayerEventResponseChanged,
PlayerEventResponseDeleted,
PlayerEventStateChanged,
ChatMessageReceived = 90,
CommunityBoardUpdate = 95,
CommunityBoardAnnouncementUpdate,
InventionModerationStateChanged = 100,
FreeGiftButtonItemsAdded = 110,
LocalRoomKeyCreated = 120,
LocalRoomKeyDeleted
}
public enum MessageType
{
GameInvite,
GameInviteDeclined,
GameJoinFailed,
PartyActivitySwitch,
FriendInvite,
VoteToKick,
GameInviteV2,
PartyActivitySwitchV2,
RequestGameInvite = 10,
RequestGameInviteDeclined,
FriendStatusOnline = 20,
TextMessage = 30,
FriendRequestAccepted = 40,
PlayerCheer = 50,
PlayerCheerAnonymous,
RoomCoOwnerAdded = 60,
RoomCoOwnerRemoved,
RoomCoOwnerInvited,
CreatorPublishedNewRoom = 70,
PlayerAttendingEvent = 80,
PlayerEventInvitation,
GroupInvitation = 90,
PlayerJoinedGroup,
CoachMessage = 100
}
public static Reponse createResponse_msg(string message, ulong playerid)
{
return new Reponse
{
Id = "2",
Msg = new
{
FromPlayerId = (long)playerid,
Id = rng.Next(1, 0x7ffffff),
SentTime = DateTime.UtcNow,
Type = 100,
Data = message,
RoomId = 1
}
};
}
private static readonly Random rng = new Random();
}
}
@@ -0,0 +1,159 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// LiteDB backup + restore for the admin "Database" tab.
/// • Snapshots every .db in Data/DBs three times a day (every 8h) into Data/Backups/backup_UTCstamp/
/// • Prunes snapshots older than 3 days.
/// • Restore is STAGED (a marker file) and applied at next startup BEFORE any DB is opened —
/// the only safe moment to overwrite the live files, which are otherwise held open by LiteDB.
/// The admin endpoint stages the restore then restarts the process (systemd Restart=always).
/// </summary>
public static class BackupService
{
private static readonly string DbDir = Path.Combine("Data", "DBs");
private static readonly string BackupRoot = Path.Combine("Data", "Backups");
private static readonly string RestoreMarker = Path.Combine(BackupRoot, ".restore");
private const int RetentionDays = 3;
private const int IntervalHours = 8; // 3x/day
private static Timer? _timer;
// ── startup restore (call FIRST in Main, before any DB access) ──────────────────────
public static void RestorePendingIfAny()
{
try
{
if (!File.Exists(RestoreMarker)) return;
string name = SafeName(File.ReadAllText(RestoreMarker).Trim());
string dir = Path.Combine(BackupRoot, name);
if (name.Length > 0 && Directory.Exists(dir))
{
Directory.CreateDirectory(DbDir);
// Drop stale write-ahead logs so a restored .db is not overlaid by an old WAL.
foreach (var log in Directory.GetFiles(DbDir, "*-log.db"))
try { File.Delete(log); } catch { }
int n = 0;
foreach (var f in Directory.GetFiles(dir, "*.db"))
{
File.Copy(f, Path.Combine(DbDir, Path.GetFileName(f)), overwrite: true);
n++;
}
Console.WriteLine($"[backup] RESTORED {n} files from '{name}' before startup");
}
File.Delete(RestoreMarker);
}
catch (Exception ex) { Console.WriteLine($"[backup] restore-on-start failed: {ex.Message}"); }
}
// ── schedule ────────────────────────────────────────────────────────────────────────
public static void Start()
{
try
{
Directory.CreateDirectory(BackupRoot);
Prune();
_timer = new Timer(_ =>
{
try { CreateBackup(); Prune(); }
catch (Exception ex) { Console.WriteLine($"[backup] scheduled run failed: {ex.Message}"); }
}, null, TimeSpan.FromHours(IntervalHours), TimeSpan.FromHours(IntervalHours));
Console.WriteLine($"[backup] scheduled every {IntervalHours}h, keeping {RetentionDays} days");
}
catch (Exception ex) { Console.WriteLine($"[backup] start failed: {ex.Message}"); }
}
public static void Stop() { _timer?.Dispose(); _timer = null; }
// ── create / prune / list ────────────────────────────────────────────────────────────
public static string CreateBackup()
{
DbMaintenance.CheckpointAll(quiet: true); // merge WAL into the .db files first
Directory.CreateDirectory(BackupRoot);
string stamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
string dir = Path.Combine(BackupRoot, "backup_" + stamp);
Directory.CreateDirectory(dir);
int n = 0;
if (Directory.Exists(DbDir))
foreach (var f in Directory.GetFiles(DbDir, "*.db"))
{
if (f.EndsWith("-log.db")) continue; // WAL already merged above
try { File.Copy(f, Path.Combine(dir, Path.GetFileName(f)), overwrite: true); n++; }
catch (Exception ex) { Console.WriteLine($"[backup] copy {Path.GetFileName(f)} failed: {ex.Message}"); }
}
Console.WriteLine($"[backup] created backup_{stamp} ({n} files)");
return "backup_" + stamp;
}
public static void Prune()
{
try
{
if (!Directory.Exists(BackupRoot)) return;
var cutoff = DateTime.UtcNow.AddDays(-RetentionDays);
foreach (var d in Directory.GetDirectories(BackupRoot))
{
var ts = ParseStamp(Path.GetFileName(d));
if (ts.HasValue && ts.Value < cutoff)
try { Directory.Delete(d, true); Console.WriteLine($"[backup] pruned {Path.GetFileName(d)}"); } catch { }
}
}
catch (Exception ex) { Console.WriteLine($"[backup] prune failed: {ex.Message}"); }
}
public class BackupInfo
{
public string name { get; set; } = "";
public string createdAt { get; set; } = "";
public long sizeBytes { get; set; }
public int fileCount { get; set; }
}
public static List<BackupInfo> List()
{
var outp = new List<BackupInfo>();
if (!Directory.Exists(BackupRoot)) return outp;
foreach (var d in Directory.GetDirectories(BackupRoot).OrderByDescending(x => x))
{
var files = Directory.GetFiles(d, "*.db");
var ts = ParseStamp(Path.GetFileName(d));
outp.Add(new BackupInfo
{
name = Path.GetFileName(d),
createdAt = (ts ?? DateTime.UtcNow).ToString("yyyy-MM-ddTHH:mm:ssZ"),
sizeBytes = files.Sum(f => { try { return new FileInfo(f).Length; } catch { return 0; } }),
fileCount = files.Length
});
}
return outp;
}
// Stage a restore for the next start. Returns false if the named backup doesn't exist.
public static bool StageRestore(string name)
{
name = SafeName(name);
if (name.Length == 0 || !Directory.Exists(Path.Combine(BackupRoot, name))) return false;
Directory.CreateDirectory(BackupRoot);
File.WriteAllText(RestoreMarker, name);
Console.WriteLine($"[backup] staged restore of '{name}' — applies on next start");
return true;
}
// ── helpers ──────────────────────────────────────────────────────────────────────────
private static string SafeName(string? raw) // no path traversal; a plain folder name only
=> Path.GetFileName((raw ?? "").Trim());
private static DateTime? ParseStamp(string folderName)
{
var m = Regex.Match(folderName, @"backup_(\d{8})_(\d{6})");
if (!m.Success) return null;
return DateTime.TryParseExact(m.Groups[1].Value + m.Groups[2].Value, "yyyyMMddHHmmss",
CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dt)
? dt : (DateTime?)null;
}
}
}
@@ -0,0 +1,163 @@
using LiteDB;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Device and IP bans.
///
/// An account ban (player_extra.ModerationBlockDetails) only stops that one account -
/// the player makes a new one and is straight back in. A device or IP ban is checked
/// at LOGIN, before a token is issued, so every account from that machine or address
/// is refused.
///
/// Lives in the existing Players.db so it is covered by the WAL checkpointing in
/// DbMaintenance rather than adding another file that never checkpoints.
/// </summary>
public static class BanDB
{
public const string Device = "device";
public const string Ip = "ip";
public class BanEntry
{
[BsonId]
public string Key { get; set; } = ""; // "device:<id>" or "ip:<encrypted>"
public string Type { get; set; } = ""; // Device | Ip
public string Value { get; set; } = ""; // device id, or AES-encrypted IP
public ulong PlayerId { get; set; } // account the ban originated from
public ulong BannedBy { get; set; }
public string? Reason { get; set; }
public long CreatedAt { get; set; }
/// <summary>Unix seconds when this lapses. 0 = permanent.</summary>
public long ExpiresAt { get; set; }
}
private static ILiteCollection<BanEntry> Col
{
get
{
var col = PlayerDB.PlayerDBFile.GetCollection<BanEntry>("bans");
col.EnsureIndex(x => x.Type);
col.EnsureIndex(x => x.PlayerId);
return col;
}
}
private static string KeyFor(string type, string value) => $"{type}:{value}";
private static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
/// <summary>
/// IPs are stored AES-encrypted, matching player_extra.IpAddresses. Utils.EncryptAes
/// uses a fixed IV, so the same address always produces the same ciphertext - which
/// is what makes a stored IP comparable at all.
/// </summary>
private static string NormalizeIp(string ip) => Utils.EncryptAes((ip ?? "").Trim());
// ---- writing -----------------------------------------------------------
/// <summary>Bans a device id. durationSeconds &lt;= 0 (or int.MaxValue) = permanent.</summary>
public static bool BanDevice(string deviceId, ulong playerId, ulong bannedBy, string? reason, int durationSeconds)
{
if (string.IsNullOrWhiteSpace(deviceId)) return false;
return Upsert(Device, deviceId.Trim(), playerId, bannedBy, reason, durationSeconds);
}
/// <summary>Bans an IP address (plaintext in; stored encrypted).</summary>
public static bool BanIp(string ip, ulong playerId, ulong bannedBy, string? reason, int durationSeconds)
{
if (string.IsNullOrWhiteSpace(ip)) return false;
return Upsert(Ip, NormalizeIp(ip), playerId, bannedBy, reason, durationSeconds);
}
private static bool Upsert(string type, string value, ulong playerId, ulong bannedBy, string? reason, int durationSeconds)
{
long expires = (durationSeconds <= 0 || durationSeconds == int.MaxValue)
? 0
: Now + durationSeconds;
// Drop any cached "not banned" decision so the 502 applies immediately.
if (type == Ip) BannedIpMiddleware.Forget(value == null ? null : Utils.DecryptAes(value));
Col.Upsert(new BanEntry
{
Key = KeyFor(type, value),
Type = type,
Value = value,
PlayerId = playerId,
BannedBy = bannedBy,
Reason = reason,
CreatedAt = Now,
ExpiresAt = expires
});
return true;
}
// ---- checking ----------------------------------------------------------
public static BanEntry? GetDeviceBan(string? deviceId)
=> string.IsNullOrWhiteSpace(deviceId) ? null : GetActive(Device, deviceId.Trim());
public static BanEntry? GetIpBan(string? ip)
=> string.IsNullOrWhiteSpace(ip) ? null : GetActive(Ip, NormalizeIp(ip));
public static bool IsDeviceBanned(string? deviceId) => GetDeviceBan(deviceId) != null;
public static bool IsIpBanned(string? ip) => GetIpBan(ip) != null;
/// <summary>Returns the entry only if it is still in force; expired entries are removed.</summary>
private static BanEntry? GetActive(string type, string value)
{
var entry = Col.FindById(KeyFor(type, value));
if (entry == null) return null;
if (entry.ExpiresAt > 0 && entry.ExpiresAt <= Now)
{
Col.Delete(entry.Key); // self-cleaning: a lapsed ban stops applying
return null;
}
return entry;
}
// ---- lifting -----------------------------------------------------------
public static bool UnbanDevice(string? deviceId)
=> !string.IsNullOrWhiteSpace(deviceId) && Col.Delete(KeyFor(Device, deviceId!.Trim()));
public static bool UnbanIp(string? ip)
{
if (string.IsNullOrWhiteSpace(ip)) return false;
BannedIpMiddleware.Forget(ip); // release immediately, don't wait for the cache
return Col.Delete(KeyFor(Ip, NormalizeIp(ip!)));
}
/// <summary>
/// Lifts every device/IP ban that originated from this account. Used by the admin
/// panel's Unban so lifting a ban actually lets the player back in, instead of
/// clearing the account block while their device stays blacklisted.
/// </summary>
public static int UnbanAllForPlayer(ulong playerId)
{
var entries = Col.Find(x => x.PlayerId == playerId).ToList();
int n = 0;
foreach (var e in entries)
if (Col.Delete(e.Key)) n++;
// An unban must let them straight back in, not after the cache expires.
if (n > 0) BannedIpMiddleware.ForgetAll();
return n;
}
// ---- listing -----------------------------------------------------------
public static List<BanEntry> ForPlayer(ulong playerId)
=> Col.Find(x => x.PlayerId == playerId).ToList();
public static List<BanEntry> All()
=> Col.FindAll().OrderByDescending(x => x.CreatedAt).ToList();
}
}
@@ -0,0 +1,57 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// In-game bug reports (the watch's "Report a bug" posts to /ado/bug - Azure DevOps on the real
/// service). Everything the client sent is kept verbatim (fields, plus any attached files under
/// Data/BugReports) and shown on the admin panel's Reports tab. The exact field names are not
/// pinned, so the raw dump is the source of truth and Title/Description are best-effort picks.
/// </summary>
public static class BugReportDB
{
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "BugReports.db")));
public class Report
{
[BsonId] public int Id { get; set; }
public ulong AccountId { get; set; }
public string Username { get; set; } = "";
public string Title { get; set; } = "";
public string Description { get; set; } = "";
public string Category { get; set; } = "";
public string Room { get; set; } = "";
public string Raw { get; set; } = "";
public List<string> Files { get; set; } = new();
public string Platform { get; set; } = "";
public DateTime At { get; set; } = DateTime.UtcNow;
public bool Resolved { get; set; }
public string? ResolvedBy { get; set; }
public string? Note { get; set; }
}
private static ILiteCollection<Report> Col()
{
var c = _db.Value.GetCollection<Report>("reports");
c.EnsureIndex(x => x.At);
return c;
}
public static Report Add(Report r) { Col().Insert(r); return r; }
public static List<Report> Recent(bool includeResolved, int take = 200) =>
Col().FindAll().Where(r => includeResolved || !r.Resolved).OrderByDescending(r => r.At).Take(take).ToList();
public static int OpenCount() => Col().Count(r => !r.Resolved);
public static bool Resolve(int id, string by, string? note, bool resolved = true)
{
var col = Col();
var r = col.FindById(id);
if (r == null) return false;
r.Resolved = resolved; r.ResolvedBy = by; r.Note = note;
col.Update(r);
return true;
}
}
}
@@ -0,0 +1,297 @@
using LiteDB;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes.db_class;
using static Campus_live_rewrite.api.server.Classes.db_class.ChatDBClasses;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Chat threads + messages (Data/DBs/Chat.db). One thread per exact member set for DMs and
/// groups (fetch-or-create, like the reference's GetChatBetweenPlayers), party threads as
/// type-1 threads, and per-member read / snooze / favorite state. Every write that another
/// member should see is pushed over the notifications hub as ChatMessageReceived so the
/// conversation (and the in-room bubble) updates without polling.
/// </summary>
public class ChatDB
{
public const int SystemSenderId = -5;
// The client's ChatThreadType: 0 Player (DM / group), 1 Club, 2 Party. Party threads were
// stored and served as 1 - a CLUB thread to the client, which is why the party's chat
// rendered as an ordinary conversation named after the other member.
public const int ThreadTypeUser = 0;
public const int ThreadTypeClub = 1;
public const int ThreadTypeParty = 2;
public const int MaxThreadMembers = 50;
public static LiteDatabase ChatDBFile = new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/Chat.db");
private static ILiteCollection<ChatThread> Threads => ChatDBFile.GetCollection<ChatThread>("threads");
private static ILiteCollection<ChatMessage> Messages => ChatDBFile.GetCollection<ChatMessage>("messages");
private static readonly object _idLock = new();
static ChatDB()
{
Threads.EnsureIndex(x => x.ChatThreadId, unique: true);
Threads.EnsureIndex(x => x.PlayerIds);
Messages.EnsureIndex(x => x.ChatThreadId);
Messages.EnsureIndex(x => x.ChatMessageId, unique: true);
// One-time migration: party threads created while the type was mis-numbered (1) become 2.
// No club threads exist in this store (clubs chat through their own service), so every
// type-1 thread here is a party.
try
{
int n = 0;
foreach (var t in Threads.Find(x => x.ChatThreadType == ThreadTypeClub).ToList())
{ t.ChatThreadType = ThreadTypeParty; Threads.Update(t); n++; }
if (n > 0) Console.WriteLine($"[chat] migrated {n} party thread(s) to ChatThreadType {ThreadTypeParty}");
}
catch (Exception ex) { Console.WriteLine($"[chat] party type migration failed: {ex.Message}"); }
}
private static long NextThreadId()
{
var last = Threads.Query().OrderByDescending(x => x.ChatThreadId).Limit(1).FirstOrDefault();
return (last?.ChatThreadId ?? 0) + 1;
}
private static long NextMessageId()
{
var last = Messages.Query().OrderByDescending(x => x.ChatMessageId).Limit(1).FirstOrDefault();
return (last?.ChatMessageId ?? 0) + 1;
}
public static string StartedChatContents(ulong by) =>
JsonConvert.SerializeObject(new MessageJson { Type = MessageContentType.Text, Version = 1, Data = $"Player <@U{by}> started a chat" });
// ---------------- threads ----------------
/// <summary>Create a thread. type 0 = DM/group (posts the "started a chat" notice), 1 = party.</summary>
public static ChatThread CreateThread(List<ulong> memberIds, ulong creatorid, string? name = null, int type = ThreadTypeUser)
{
ChatThread thread;
lock (_idLock)
{
thread = new ChatThread
{
ChatThreadId = NextThreadId(),
PlayerIds = memberIds.Distinct().ToList(),
ChatThreadName = name ?? "",
ChatThreadType = type,
CreatorPlayerId = creatorid,
CreatedAt = DateTime.UtcNow,
LastActivityAt = DateTime.UtcNow,
IsFavorited = false,
SnoozedUntil = null,
LastReadMessageId = 0,
Messages = new List<ChatMessage>()
};
Threads.Insert(thread);
}
if (type == ThreadTypeUser)
AddMessage(thread.ChatThreadId, SystemSenderId, StartedChatContents(creatorid));
return GetThread(thread.ChatThreadId)!;
}
/// <summary>
/// The existing type-0 thread whose members are EXACTLY this set, else null. Keeps one
/// conversation per pair/group: every "message X" used to mint a brand-new thread.
/// </summary>
public static ChatThread? FindThreadWithExactMembers(List<ulong> memberIds)
{
var want = memberIds.Distinct().OrderBy(x => x).ToList();
if (want.Count == 0) return null;
ulong probe = want[0];
var candidates = Threads.Find(x => x.PlayerIds.Contains(probe) && x.ChatThreadType == ThreadTypeUser).ToList();
var hit = candidates
.Where(t => t.PlayerIds != null && t.PlayerIds.Distinct().OrderBy(x => x).SequenceEqual(want))
.OrderByDescending(t => t.LastActivityAt ?? DateTime.MinValue).ThenByDescending(t => t.ChatThreadId)
.FirstOrDefault();
return hit == null ? null : GetThread(hit.ChatThreadId);
}
public static ChatThread GetOrCreateThreadWithMembers(List<ulong> memberIds, ulong creatorId)
=> FindThreadWithExactMembers(memberIds) ?? CreateThread(memberIds, creatorId);
/// <summary>The caller's party thread (type 1, most recent) or null.</summary>
public static ChatThread? GetPartyThreadFor(ulong playerId)
{
var t = Threads.Find(x => x.PlayerIds.Contains(playerId) && x.ChatThreadType == ThreadTypeParty)
.OrderByDescending(x => x.LastActivityAt ?? DateTime.MinValue).ThenByDescending(x => x.ChatThreadId).FirstOrDefault();
return t == null ? null : GetThread(t.ChatThreadId);
}
public static ChatThread? GetThread(long threadId)
{
var thread = Threads.FindOne(x => x.ChatThreadId == threadId);
if (thread == null) return null;
thread.Messages = Messages.Find(x => x.ChatThreadId == threadId).OrderBy(x => x.ChatMessageId).ToList();
thread.MemberStates ??= new();
thread.PlayerIds ??= new();
return thread;
}
/// <summary>Threads the player is on, most recently active first, each with only its latest message.</summary>
public static List<ChatThread> GetThreadsForPlayer(ulong playerId, int maxCount)
{
// Most recently active first; threads from before LastActivityAt existed sort by id.
var list = Threads.Find(x => x.PlayerIds.Contains(playerId))
.OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt ?? DateTime.MinValue)
.ThenByDescending(x => x.ChatThreadId)
.Take(maxCount).ToList();
foreach (var t in list)
{
t.Messages = Messages.Find(x => x.ChatThreadId == t.ChatThreadId).OrderByDescending(x => x.ChatMessageId).Take(1).ToList();
t.MemberStates ??= new();
t.PlayerIds ??= new();
}
return list;
}
public static bool IsMember(long threadId, ulong playerId)
{
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
return t?.PlayerIds != null && t.PlayerIds.Contains(playerId);
}
public static ChatThread? AddMember(long threadId, ulong playerId)
{
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
if (t == null) return null;
t.PlayerIds ??= new();
if (!t.PlayerIds.Contains(playerId)) { t.PlayerIds.Add(playerId); t.LastActivityAt = DateTime.UtcNow; Threads.Update(t); }
return GetThread(threadId);
}
public static ChatThread? RemoveMember(long threadId, ulong playerId)
{
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
if (t == null) return null;
t.PlayerIds ??= new();
if (t.PlayerIds.Remove(playerId)) { t.LastActivityAt = DateTime.UtcNow; Threads.Update(t); }
return GetThread(threadId);
}
public static ChatThread? SetName(long threadId, string name)
{
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
if (t == null) return null;
t.ChatThreadName = name ?? "";
Threads.Update(t);
return GetThread(threadId);
}
// ---------------- per-member state ----------------
public static ChatMemberState StateFor(ChatThread t, ulong playerId)
{
t.MemberStates ??= new();
if (t.MemberStates.TryGetValue(playerId.ToString(), out var s) && s != null) return s;
// No entry yet: fall back to the old shared thread-level values.
return new ChatMemberState { LastReadMessageId = t.LastReadMessageId, SnoozedUntil = t.SnoozedUntil, IsFavorited = t.IsFavorited };
}
public static void UpdateState(long threadId, ulong playerId, Action<ChatMemberState> change)
{
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
if (t == null) return;
t.MemberStates ??= new();
string key = playerId.ToString();
if (!t.MemberStates.TryGetValue(key, out var s) || s == null)
s = new ChatMemberState { LastReadMessageId = t.LastReadMessageId, SnoozedUntil = t.SnoozedUntil, IsFavorited = t.IsFavorited };
change(s);
t.MemberStates[key] = s;
Threads.Update(t);
}
/// <summary>Move the read pointer forward (never back, never past the latest message).</summary>
public static void MarkRead(long threadId, ulong playerId, long? upToMessageId = null)
{
var t = GetThread(threadId);
if (t == null) return;
long latest = t.Messages.Count > 0 ? t.Messages[^1].ChatMessageId : 0;
long target = Math.Min(upToMessageId ?? latest, latest);
UpdateState(threadId, playerId, s => { if (target > s.LastReadMessageId) s.LastReadMessageId = target; });
}
// ---------------- messages ----------------
public static ChatMessage? GetMessage(long messageId) => Messages.FindOne(x => x.ChatMessageId == messageId);
public static ChatMessage? AddMessage(long threadId, int senderId, string jsonContents)
{
var thread = Threads.FindOne(x => x.ChatThreadId == threadId);
if (thread == null) return null;
ChatMessage msg;
lock (_idLock)
{
msg = new ChatMessage
{
ChatMessageId = NextMessageId(),
ChatThreadId = threadId,
SenderPlayerId = senderId,
TimeSent = DateTime.UtcNow,
Contents = jsonContents,
ModerationState = 0
};
Messages.Insert(msg);
}
thread.LastActivityAt = msg.TimeSent;
// The sender has read their own message.
if (senderId > 0)
{
thread.MemberStates ??= new();
string key = ((ulong)senderId).ToString();
if (!thread.MemberStates.TryGetValue(key, out var s) || s == null) s = new ChatMemberState();
s.LastReadMessageId = msg.ChatMessageId;
thread.MemberStates[key] = s;
}
Threads.Update(thread);
return msg;
}
public static object MessageDto(ChatMessage m) => new
{
chatMessageId = m.ChatMessageId,
chatThreadId = m.ChatThreadId,
senderPlayerId = m.SenderPlayerId,
timeSent = m.TimeSent.ToUniversalTime(), // LiteDB hands back local time; the client wants a UTC instant
contents = m.Contents,
moderationState = m.ModerationState
};
/// <summary>Push a message to every member of its thread (ChatMessageReceived, or another frame id).</summary>
public static void Push(ChatThread thread, ChatMessage m, string frameId = "ChatMessageReceived")
{
var payload = JsonConvert.SerializeObject(new { Id = frameId, Msg = MessageDto(m) });
Notifications.FireAndForget(Notifications.SendToPlayers(thread.PlayerIds.ToList(), payload), $"chat push {frameId}");
}
/// <summary>Post a system (-5) notice into a thread and push it.</summary>
public static ChatMessage? SystemNotice(long threadId, string text)
{
var contents = JsonConvert.SerializeObject(new MessageJson { Type = MessageContentType.Text, Version = 1, Data = text });
var m = AddMessage(threadId, SystemSenderId, contents);
if (m == null) return null;
var t = GetThread(threadId);
if (t != null) Push(t, m);
return m;
}
// Kept for older callers.
public static ChatThread? RenameThread(long threadId, ulong senderId, string newThreadName)
{
var t = SetName(threadId, newThreadName);
if (t != null) SystemNotice(threadId, $"Player <@U{senderId}> renamed the chat");
return GetThread(threadId);
}
public static ChatThread? LeaveThread(long threadId, ulong senderId)
{
// Notice first (the leaver is still a member and gets the push that tells their
// client the thread is gone), then drop the membership.
SystemNotice(threadId, $"Player <@U{senderId}> left");
return RemoveMember(threadId, senderId);
}
}
}
@@ -0,0 +1,502 @@
using LiteDB;
// Club storage for the in-game Clubs feature. Ported to match the RecFlare reference
// (apps/clubs): clubs + membership rows, the { error, success, value } create envelope,
// and the ClubDetails payload the client renders a club screen from.
//
// Global namespace (like ImageMetadataDB) so controllers reference ClubDB.* unqualified.
public static class ClubDB
{
private static readonly string DbPath = Path.Combine("Data", "DBs", "Clubs.db");
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(DbPath));
private static LiteDatabase Db => _db.Value;
// Membership tiers (ClubMembershipType): -1 banned, 0 none, 1 pending request,
// 2 pending invite, 3 denied, 10 member, 20 moderator, 30 co-owner, 100 creator.
public const int TierNone = 0, TierMember = 10, TierModerator = 20, TierCoOwner = 30, TierCreator = 100;
public const string DefaultMainImage = "DefaultImgPurple";
public const int MaxNameLength = 40;
public const int MaxDescriptionLength = 512;
public class Club
{
public int ClubId { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public string Category { get; set; } = "Social";
public int Visibility { get; set; } = 1; // 0 Private, 1 Public
public int Joinability { get; set; } = 0; // 0 Open, 1 InviteOnly, 2 AskToJoin
public bool AllowJuniors { get; set; } = true;
public string MainImageName { get; set; } = DefaultMainImage;
public int ClubType { get; set; } = 0; // 0 regular (never 1 = subscription)
public int? ClubhouseRoomId { get; set; } = null;
public ulong CreatorAccountId { get; set; }
public bool IsRRO { get; set; } = false;
public int MinLevel { get; set; } = 0;
public int State { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public List<string> CustomTags { get; set; } = new();
public List<string> AdditionalImageNames { get; set; } = new();
}
public class ClubMember
{
public int ClubMemberId { get; set; }
public int ClubId { get; set; }
public ulong AccountId { get; set; }
public int MembershipType { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>A club noticeboard post (reference: ClubAnnouncement).</summary>
public class ClubAnnouncement
{
public int AnnouncementId { get; set; }
public int ClubId { get; set; }
public ulong AccountId { get; set; }
public string Title { get; set; } = "";
public string Body { get; set; } = "";
public string ImageName { get; set; } = "";
public string Meta { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ModifiedAt { get; set; }
}
/// <summary>The club a player spawns into (their "home club"); one row per account.</summary>
public class HomeClub
{
public int Id { get; set; }
public ulong AccountId { get; set; }
public int ClubId { get; set; }
}
private static ILiteCollection<HomeClub> Homes()
{
var c = Db.GetCollection<HomeClub>("home_clubs");
c.EnsureIndex(x => x.AccountId, true);
return c;
}
public static int GetHomeClubId(ulong accountId) => Homes().FindOne(h => h.AccountId == accountId)?.ClubId ?? 0;
public static void SetHomeClub(ulong accountId, int clubId)
{
var col = Homes();
var h = col.FindOne(x => x.AccountId == accountId);
if (h == null) col.Insert(new HomeClub { AccountId = accountId, ClubId = clubId });
else { h.ClubId = clubId; col.Update(h); }
}
public static void ClearHomeClub(ulong accountId) => Homes().DeleteMany(x => x.AccountId == accountId);
/// <summary>How far a player has read a club's noticeboard (the unread badge).</summary>
public class AnnouncementRead
{
public int Id { get; set; }
public int ClubId { get; set; }
public ulong AccountId { get; set; }
public int LastReadAnnouncementId { get; set; }
}
// ---- CREATOR (subscription) CLUBS ----
// Subscribing to a player joins that player's auto-created "creator club" (ClubType 1). They
// are not stored: the id is 5_000_000 + creator account id (ClubsController.CreatorClubId)
// and the club is synthesised from the account + its subscriber list whenever it's asked
// for, so /club/{id}, /details, /members and the noticeboard all work for it. Before this
// every one of those 404'd (the "click a player's subscribers" screen was blank).
public const long CreatorClubBase = 5_000_000L;
public static bool IsCreatorClub(long clubId) => clubId >= CreatorClubBase;
public static ulong CreatorOfClub(long clubId) => IsCreatorClub(clubId) ? (ulong)(clubId - CreatorClubBase) : 0;
public static Club? CreatorClub(ulong creatorAccountId)
{
var acc = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountById(creatorAccountId);
if (acc == null) return null;
var full = Campus_live_rewrite.api.dbs.PlayerDB.GetFullAccount(creatorAccountId);
return new Club
{
ClubId = (int)(CreatorClubBase + (long)creatorAccountId),
Name = acc.Username ?? creatorAccountId.ToString(),
Description = "",
Category = "CreatorAutoTag",
Visibility = 1,
Joinability = 0,
AllowJuniors = true,
MainImageName = "DefaultClubImage2k.jpg",
ClubType = 1,
ClubhouseRoomId = null,
CreatorAccountId = creatorAccountId,
IsRRO = false,
MinLevel = 0,
State = 0,
CreatedAt = full?.player?.created_at ?? DateTime.UtcNow,
};
}
private static ILiteCollection<Club> Clubs()
{
var c = Db.GetCollection<Club>("clubs");
c.EnsureIndex(x => x.ClubId, true);
c.EnsureIndex(x => x.CreatorAccountId);
return c;
}
private static ILiteCollection<ClubMember> Members()
{
var c = Db.GetCollection<ClubMember>("members");
c.EnsureIndex(x => x.ClubId);
c.EnsureIndex(x => x.AccountId);
return c;
}
private static int NextId<T>(ILiteCollection<T> col, System.Linq.Expressions.Expression<System.Func<T, int>> key)
{
var last = col.Query().OrderByDescending(key).Limit(1).FirstOrDefault();
return last == null ? 1 : (key.Compile()(last) + 1);
}
public static int CountByCreator(ulong accountId) =>
Clubs().Count(x => x.CreatorAccountId == accountId);
public static Club? GetById(int clubId) =>
IsCreatorClub(clubId) ? CreatorClub(CreatorOfClub(clubId)) : Clubs().FindById(clubId);
public static List<Club> GetByCreator(ulong accountId) =>
Clubs().Query().Where(x => x.CreatorAccountId == accountId && x.ClubType == 0)
.OrderByDescending(x => x.ClubId).ToList();
// Clubs the account belongs to (member tier or above), excluding subscription clubs.
public static List<Club> GetByMember(ulong accountId)
{
var ids = Members().Query()
.Where(m => m.AccountId == accountId && m.MembershipType >= TierMember)
.ToList().Select(m => m.ClubId).Distinct().ToList();
if (ids.Count == 0) return new();
return Clubs().Query().Where(x => ids.Contains(x.ClubId) && x.ClubType == 0)
.OrderByDescending(x => x.ClubId).ToList();
}
public static int MemberCount(int clubId) =>
IsCreatorClub(clubId)
? Campus_live_rewrite.api.dbs.PlayerDB.GetSubscriberIds(CreatorOfClub(clubId)).Count
: Members().Count(m => m.ClubId == clubId && m.MembershipType >= TierMember);
public static List<ClubMember> GetMembers(int clubId, int? membershipType, string? sortBy)
{
List<ClubMember> list;
if (IsCreatorClub(clubId))
{
// The creator owns their club; every subscriber is a plain member.
ulong creator = CreatorOfClub(clubId);
list = new List<ClubMember> { new() { ClubMemberId = 0, ClubId = clubId, AccountId = creator, MembershipType = TierCreator } };
int n = 1;
foreach (var sub in Campus_live_rewrite.api.dbs.PlayerDB.GetSubscriberIds(creator))
list.Add(new ClubMember { ClubMemberId = n++, ClubId = clubId, AccountId = sub, MembershipType = TierMember });
}
else list = Members().Query().Where(m => m.ClubId == clubId).ToList();
if (membershipType.HasValue) list = list.Where(m => m.MembershipType == membershipType.Value).ToList();
list = sortBy switch
{
"1" => list.OrderBy(m => m.AccountId).ToList(),
"2" => list.OrderBy(m => m.CreatedAt).ToList(),
_ => list.OrderByDescending(m => m.MembershipType).ThenBy(m => m.CreatedAt).ToList(),
};
return list;
}
// The caller's tier in a club (0 = none / signed out).
public static int MyMembership(int clubId, ulong accountId)
{
if (accountId == 0) return TierNone;
if (IsCreatorClub(clubId))
{
ulong creator = CreatorOfClub(clubId);
if (creator == accountId) return TierCreator;
return Campus_live_rewrite.api.dbs.PlayerDB.GetSubscribedToIds(accountId).Contains(creator) ? TierMember : TierNone;
}
var m = Members().FindOne(x => x.ClubId == clubId && x.AccountId == accountId);
return m?.MembershipType ?? TierNone;
}
// Create a club and seed the creator's membership row (tier 100). Returns the new club.
public static Club CreateClub(ulong creator, string name, string description, string category,
int visibility, int joinability, bool allowJuniors, int minLevel)
{
var clubs = Clubs();
var club = new Club
{
ClubId = NextId(clubs, x => x.ClubId),
Name = name,
Description = description ?? "",
Category = string.IsNullOrWhiteSpace(category) ? "Social" : category,
Visibility = visibility,
Joinability = joinability,
AllowJuniors = allowJuniors,
MainImageName = DefaultMainImage,
ClubType = 0,
CreatorAccountId = creator,
MinLevel = minLevel < 0 ? 0 : minLevel,
CreatedAt = DateTime.UtcNow,
CustomTags = new(),
};
clubs.Insert(club);
var members = Members();
members.Insert(new ClubMember
{
ClubMemberId = NextId(members, x => x.ClubMemberId),
ClubId = club.ClubId,
AccountId = creator,
MembershipType = TierCreator,
CreatedAt = DateTime.UtcNow,
});
return club;
}
// Set the club's main image (the club photo). Returns the updated club, or null.
public static Club? SetMainImage(int clubId, string imageName)
{
var col = Clubs();
var club = col.FindById(clubId);
if (club == null) return null;
club.MainImageName = imageName;
col.Update(club);
return club;
}
// Set one gallery image by position (packed: past-the-end appends). Returns updated club.
public static Club? SetAdditionalImage(int clubId, int index, string imageName)
{
var col = Clubs();
var club = col.FindById(clubId);
if (club == null) return null;
club.AdditionalImageNames ??= new();
if (index < 0 || index >= club.AdditionalImageNames.Count) club.AdditionalImageNames.Add(imageName);
else club.AdditionalImageNames[index] = imageName;
col.Update(club);
return club;
}
// Remove one gallery image and shift the rest up (no blank slot). Returns updated club.
public static Club? RemoveAdditionalImage(int clubId, int index)
{
var col = Clubs();
var club = col.FindById(clubId);
if (club == null) return null;
club.AdditionalImageNames ??= new();
if (index >= 0 && index < club.AdditionalImageNames.Count) club.AdditionalImageNames.RemoveAt(index);
col.Update(club);
return club;
}
// Edit a club's details (co-owner+). Every param is optional: null = leave unchanged.
// A non-null customTags REPLACES the tag set (capped at 3). Returns the updated club, or null.
public static Club? UpdateDetails(int clubId, string? name, string? description, string? category,
int? visibility, int? joinability, bool? allowJuniors, int? minLevel, List<string>? customTags)
{
var col = Clubs();
var club = col.FindById(clubId);
if (club == null) return null;
if (!string.IsNullOrWhiteSpace(name)) club.Name = name.Trim();
if (description != null) club.Description = description;
if (!string.IsNullOrWhiteSpace(category)) club.Category = category.Trim();
if (visibility.HasValue) club.Visibility = visibility.Value;
if (joinability.HasValue) club.Joinability = joinability.Value;
if (allowJuniors.HasValue) club.AllowJuniors = allowJuniors.Value;
if (minLevel.HasValue) club.MinLevel = minLevel.Value < 0 ? 0 : minLevel.Value;
if (customTags != null) club.CustomTags = customTags.Take(3).ToList();
col.Update(club);
return club;
}
// ---- ANNOUNCEMENTS (the club noticeboard) ----
private static ILiteCollection<ClubAnnouncement> Announcements()
{
var c = Db.GetCollection<ClubAnnouncement>("announcements");
c.EnsureIndex(x => x.AnnouncementId, true);
c.EnsureIndex(x => x.ClubId);
return c;
}
private static ILiteCollection<AnnouncementRead> Reads()
{
var c = Db.GetCollection<AnnouncementRead>("announcement_reads");
c.EnsureIndex(x => x.ClubId);
c.EnsureIndex(x => x.AccountId);
return c;
}
/// <summary>A club's noticeboard, newest first.</summary>
public static List<ClubAnnouncement> GetAnnouncements(int clubId) =>
Announcements().Query().Where(a => a.ClubId == clubId).ToList()
.OrderByDescending(a => a.AnnouncementId).ToList();
public static int LatestAnnouncementId(int clubId) =>
Announcements().Query().Where(a => a.ClubId == clubId).ToList()
.Select(a => a.AnnouncementId).DefaultIfEmpty(0).Max();
public static ClubAnnouncement PostAnnouncement(int clubId, ulong accountId, string? title, string? body, string? imageName, string? meta)
{
var col = Announcements();
var a = new ClubAnnouncement
{
AnnouncementId = NextId(col, x => x.AnnouncementId),
ClubId = clubId,
AccountId = accountId,
Title = (title ?? "").Trim(),
Body = (body ?? "").Trim(),
ImageName = (imageName ?? "").Trim(),
Meta = meta ?? "",
};
col.Insert(a);
// The poster has obviously read their own post.
MarkAnnouncementsRead(clubId, accountId, a.AnnouncementId);
return a;
}
public static ClubAnnouncement? EditAnnouncement(int announcementId, string? title, string? body, string? imageName, string? meta)
{
var col = Announcements();
var a = col.FindOne(x => x.AnnouncementId == announcementId);
if (a == null) return null;
if (title != null) a.Title = title.Trim();
if (body != null) a.Body = body;
if (imageName != null) a.ImageName = imageName.Trim();
if (meta != null) a.Meta = meta;
a.ModifiedAt = DateTime.UtcNow;
col.Update(a);
return a;
}
public static ClubAnnouncement? DeleteAnnouncement(int announcementId)
{
var col = Announcements();
var a = col.FindOne(x => x.AnnouncementId == announcementId);
if (a != null) col.DeleteMany(x => x.AnnouncementId == announcementId);
return a;
}
public static int LastReadAnnouncementId(int clubId, ulong accountId) =>
Reads().FindOne(r => r.ClubId == clubId && r.AccountId == accountId)?.LastReadAnnouncementId ?? 0;
public static void MarkAnnouncementsRead(int clubId, ulong accountId, int upToAnnouncementId)
{
if (accountId == 0) return;
var col = Reads();
var r = col.FindOne(x => x.ClubId == clubId && x.AccountId == accountId);
if (r == null) col.Insert(new AnnouncementRead { ClubId = clubId, AccountId = accountId, LastReadAnnouncementId = upToAnnouncementId });
else if (upToAnnouncementId > r.LastReadAnnouncementId) { r.LastReadAnnouncementId = upToAnnouncementId; col.Update(r); }
}
public static bool HasUnreadAnnouncements(int clubId, ulong accountId) =>
LatestAnnouncementId(clubId) > LastReadAnnouncementId(clubId, accountId);
public static void Checkpoint()
{
try { Db.Checkpoint(); } catch { /* best-effort */ }
}
// ---- browse ----------------------------------------------------------------------------
public const int TierBanned = -1, TierPendingRequest = 1, TierPendingInvite = 2, TierDenied = 3;
/// <summary>
/// The Clubs browse page (reference searchClubs): public, non-subscription clubs, optionally
/// one category (exact, case-insensitive) and a name/description substring; sort 1 = newest,
/// 2 = by name, anything else = most members first. Returns the page and the full match count.
/// </summary>
public static (List<Club> page, int total) Search(string? category, string? query, string? sort, int count)
{
var all = Clubs().Query().Where(x => x.Visibility == 1 && x.ClubType == 0).ToList();
string wanted = (category ?? "").Trim();
string term = (query ?? "").Trim();
var matched = all.Where(c =>
(wanted.Length == 0 || string.Equals(c.Category, wanted, StringComparison.OrdinalIgnoreCase)) &&
(term.Length == 0
|| (c.Name ?? "").Contains(term, StringComparison.OrdinalIgnoreCase)
|| (c.Description ?? "").Contains(term, StringComparison.OrdinalIgnoreCase))).ToList();
var counts = matched.ToDictionary(c => c.ClubId, c => MemberCount(c.ClubId));
matched = sort switch
{
"1" => matched.OrderByDescending(c => c.CreatedAt).ToList(),
"2" => matched.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(),
_ => matched.OrderByDescending(c => counts[c.ClubId]).ThenByDescending(c => c.CreatedAt).ToList(),
};
return (matched.Take(count).ToList(), matched.Count);
}
/// <summary>Clubs an account created that anyone may see (public ones), for their profile.</summary>
public static List<Club> PublicByCreator(ulong accountId) =>
Clubs().Query().Where(x => x.CreatorAccountId == accountId && x.ClubType == 0 && x.Visibility == 1)
.OrderByDescending(x => x.ClubId).ToList();
// ---- membership writes -----------------------------------------------------------------
private static void Upsert(int clubId, ulong accountId, int tier)
{
var members = Members();
var m = members.FindOne(x => x.ClubId == clubId && x.AccountId == accountId);
if (m == null)
{
members.Insert(new ClubMember
{
ClubMemberId = NextId(members, x => x.ClubMemberId),
ClubId = clubId, AccountId = accountId, MembershipType = tier, CreatedAt = DateTime.UtcNow,
});
}
else { m.MembershipType = tier; members.Update(m); }
}
/// <summary>
/// The caller asks to join (reference requesttojoin / join): an Open club takes them in as a
/// Member, an AskToJoin club records a pending request, an InviteOnly club refuses. A pending
/// invite is accepted by asking. Banned stays banned. Returns the resulting tier.
/// </summary>
public static int RequestJoin(int clubId, ulong accountId)
{
var club = Clubs().FindById(clubId);
if (club == null) return TierNone;
int have = MyMembership(clubId, accountId);
if (have == TierBanned || have >= TierMember) return have;
if (have == TierPendingInvite) { Upsert(clubId, accountId, TierMember); return TierMember; }
if (club.Joinability == 0) { Upsert(clubId, accountId, TierMember); return TierMember; }
if (club.Joinability == 2) { Upsert(clubId, accountId, TierPendingRequest); return TierPendingRequest; }
return have; // InviteOnly: nothing changes
}
/// <summary>Leave (or withdraw a request). The creator can't; a ban stays. Returns the new tier.</summary>
public static int Leave(int clubId, ulong accountId)
{
int have = MyMembership(clubId, accountId);
if (have == TierCreator || have == TierBanned) return have;
Members().DeleteMany(x => x.ClubId == clubId && x.AccountId == accountId);
return TierNone;
}
/// <summary>A co-owner adds / promotes / demotes someone (reference members/invite). Never the creator, never to Creator.</summary>
public static bool SetTier(int clubId, ulong accountId, int tier)
{
var club = Clubs().FindById(clubId);
if (club == null || club.CreatorAccountId == accountId) return false;
if (tier != TierMember && tier != TierModerator && tier != TierCoOwner && tier != TierBanned
&& tier != TierPendingInvite && tier != TierNone) return false;
if (tier == TierNone) { Members().DeleteMany(x => x.ClubId == clubId && x.AccountId == accountId); return true; }
Upsert(clubId, accountId, tier);
return true;
}
public static Club? SetClubhouse(int clubId, int? roomId)
{
var clubs = Clubs();
var c = clubs.FindById(clubId);
if (c == null) return null;
c.ClubhouseRoomId = roomId;
clubs.Update(c);
return c;
}
/// <summary>Delete a club with its memberships and announcements (creator only - the caller checks).</summary>
public static bool Delete(int clubId)
{
var clubs = Clubs();
if (clubs.FindById(clubId) == null) return false;
Members().DeleteMany(x => x.ClubId == clubId);
try { Db.GetCollection<ClubAnnouncement>("announcements").DeleteMany(x => x.ClubId == clubId); } catch { }
clubs.Delete(clubId);
return true;
}
}
@@ -0,0 +1,78 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// A player's own curated lists (the Lists service's `list` table in the reference): the
/// Play menu's "Saved for Later" row is `__SavedForLater_Rooms` (type 1, room ids), created by
/// the client itself through PUT /lists/curatedlists/{name}/items/{itemId}/createlistifneeded.
/// One list per (owner, type, name).
/// </summary>
public static class CuratedListDB
{
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "CuratedLists.db")));
public class PlayerList
{
[BsonId] public long ListId { get; set; }
public ulong CreatorAccountId { get; set; }
public string Name { get; set; } = "";
public int Type { get; set; } = 1;
public int Accessibility { get; set; } = 0;
public List<string> ItemIds { get; set; } = new();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
private static ILiteCollection<PlayerList> Col()
{
var c = _db.Value.GetCollection<PlayerList>("lists");
c.EnsureIndex(x => x.CreatorAccountId);
return c;
}
/// <summary>
/// A stable id for (owner, type, name) - the same list has the same id before and after it
/// is created, so a read that answers a not-yet-created reserved list (`__SavedForLater_Rooms`)
/// hands out the id the later write will use. Positive, 63-bit.
/// </summary>
public static long IdFor(ulong owner, int type, string name)
{
unchecked
{
ulong h = 1469598103934665603UL;
foreach (char ch in (name ?? "").ToLowerInvariant()) { h ^= ch; h *= 1099511628211UL; }
h ^= (ulong)type; h *= 1099511628211UL;
h ^= owner; h *= 1099511628211UL;
return (long)(h & 0x7FFF_FFFF_FFFF_FFFFUL) | 1L;
}
}
public static PlayerList? Get(ulong owner, int type, string name) =>
Col().FindById(IdFor(owner, type, name));
public static PlayerList? GetById(long listId) => Col().FindById(listId);
public static PlayerList AddItem(ulong owner, int type, string name, int accessibility, string itemId)
{
var col = Col();
long id = IdFor(owner, type, name);
var list = col.FindById(id) ?? new PlayerList
{
ListId = id, CreatorAccountId = owner, Name = name, Type = type, Accessibility = accessibility, CreatedAt = DateTime.UtcNow
};
if (!list.ItemIds.Contains(itemId)) list.ItemIds.Add(itemId);
col.Upsert(list);
return list;
}
public static PlayerList? RemoveItem(ulong owner, int type, string name, string itemId)
{
var col = Col();
var list = col.FindById(IdFor(owner, type, name));
if (list == null) return null;
list.ItemIds.RemoveAll(x => x == itemId);
col.Update(list);
return list;
}
}
}
@@ -0,0 +1,119 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
// LiteDB v5 writes through a write-ahead log (`<name>-log.db`) and only merges
// it back into the main data file on Checkpoint() - which happens automatically
// ONLY on LiteDatabase.Dispose(), or once the log passes CheckpointSize
// (default 1000 pages / ~8MB).
//
// Every LiteDatabase in this project is a `static` field that is never disposed,
// and `systemctl stop campus` kills the process without unwinding statics. So no
// checkpoint ever ran, and the logs sat just under the 1000-page auto-threshold
// (573-825 pages) and never tripped it either.
//
// The result, as of 2026-08-21: Heartbeats.db and Settings.db were 8192 bytes -
// a bare header page - while Heartbeats-log.db was 5.6MB and Settings-log.db
// 6.4MB. 100% of that data lived in the WAL and none of it in the data file.
//
// That matters beyond tidiness because install.sh deliberately preserves
// Data/DBs/* across deploys, so those logs were copied forward verbatim into
// every new build. Deploying a known-good server could not fix a data-level
// problem, because the data came along with it. That is why swapping in a
// working server still broke.
public static class DbMaintenance
{
// Checkpoint every store. Each is wrapped separately so one bad file can't
// stop the rest from flushing.
private static Timer? _autoCheckpoint;
// Checkpoint on a schedule, not just at shutdown.
//
// Writes land in <name>-log.db and are only merged into <name>.db by a
// checkpoint. Until then the data is recoverable ONLY by replaying that log.
// Measured 2026-08-23: a setting written and read back survived a restart, but
// Settings.db was still 8,192 bytes (untouched since 12 Aug) while
// Settings-log.db had grown to 7.37 MB - i.e. every setting the server has ever
// stored lived in the log alone.
//
// That works until the log is lost or truncated, at which point everything
// written since the last checkpoint disappears at once. Shutdown-only
// checkpointing means a hard kill (crash, SIGKILL, power loss) never merges.
//
// A periodic checkpoint bounds that exposure to `minutes`. It only flushes data
// already committed - it cannot change any response or alter stored values.
public static void StartAutoCheckpoint(int minutes = 5)
{
if (_autoCheckpoint != null) return;
var period = TimeSpan.FromMinutes(minutes);
_autoCheckpoint = new Timer(_ =>
{
try { CheckpointAll(quiet: true); }
catch (Exception ex) { Console.WriteLine($"[DbMaintenance] auto-checkpoint failed: {ex.Message}"); }
}, null, period, period);
Console.WriteLine($"[DbMaintenance] auto-checkpoint every {minutes} min");
}
public static void StopAutoCheckpoint()
{
_autoCheckpoint?.Dispose();
_autoCheckpoint = null;
}
public static void CheckpointAll(bool quiet = false)
{
Flush("Players", () => PlayerDB.PlayerDBFile.Checkpoint(), quiet);
Flush("Rooms", () => RoomDB.rooms_db.Checkpoint(), quiet);
Flush("Chat", () => ChatDB.ChatDBFile.Checkpoint(), quiet);
Flush("Events", () => EventDB.EventDBFile.Checkpoint(), quiet);
Flush("PlayersFriends", () => FriendsDB.PlayerFriendDBFile.Checkpoint(), quiet);
Flush("Heartbeats", HeartbeatDB.Checkpoint, quiet);
Flush("Settings", SettingsDB.Checkpoint, quiet);
Flush("Leaderboard", LeaderboardDB.Checkpoint, quiet);
Flush("Support", SupportDB.Checkpoint, quiet);
Flush("Clubs", ClubDB.Checkpoint, quiet);
Flush("Inventions", InventionDB.Checkpoint, quiet);
}
private static void Flush(string name, Action action, bool quiet = false)
{
try
{
action();
if (!quiet) Console.WriteLine($"[DbMaintenance] checkpointed {name}");
}
catch (Exception ex)
{
Console.WriteLine($"[DbMaintenance] FAILED to checkpoint {name}: {ex.Message}");
}
}
// Print the data-file / log-file split at startup. A main file stuck at 8192
// bytes with a multi-megabyte log means the checkpoint path is broken again.
public static void ReportWalState()
{
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "DBs");
if (!Directory.Exists(dir))
{
Console.WriteLine($"[DbMaintenance] no DB directory at {dir}");
return;
}
foreach (var db in Directory.GetFiles(dir, "*.db").OrderBy(f => f))
{
if (db.EndsWith("-log.db", StringComparison.OrdinalIgnoreCase)) continue;
var main = new FileInfo(db);
var log = new FileInfo(db[..^3] + "-log.db");
long logLen = log.Exists ? log.Length : 0;
string flag = (main.Length <= 8192 && logLen > 1024 * 1024)
? " <-- ALL DATA IS IN THE LOG, main file is empty"
: "";
Console.WriteLine(
$"[DbMaintenance] {main.Name,-24} data={main.Length,10:n0} log={logLen,12:n0}{flag}");
}
}
}
}
@@ -0,0 +1,129 @@
using LiteDB;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Creator earnings. When a player buys something another player MADE - an invention, a UGC
/// clothing item, a room key, room currency, a room consumable - the maker gets
/// ConfigCampus.CreatorEarningsPercent (30%) of what the buyer spent, in the same currency.
///
/// It is not paid on the spot: an earning PENDS for ConfigCampus.CreatorEarningsPendDays (14)
/// and is then paid on the first SUNDAY (00:00 UTC) on or after that. The Coach / system
/// accounts and the room importer never earn. Every payout is logged and pushes the balance,
/// so the number in the corner moves when it lands.
/// </summary>
public static class EarningsDB
{
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "Earnings.db")));
public class Entry
{
[BsonId] public int Id { get; set; }
public ulong SellerId { get; set; }
public ulong BuyerId { get; set; }
public string Kind { get; set; } = ""; // invention / ugc / roomkey / roomcurrency / roomconsumable
public string ItemId { get; set; } = "";
public string ItemName { get; set; } = "";
public int CurrencyType { get; set; } = 2;
public long Spent { get; set; }
public long Share { get; set; }
public DateTime BoughtAt { get; set; } = DateTime.UtcNow;
public DateTime AvailableAt { get; set; }
public DateTime PayoutAt { get; set; }
public DateTime? PaidAt { get; set; }
}
private static ILiteCollection<Entry> Col()
{
var c = _db.Value.GetCollection<Entry>("earnings");
c.EnsureIndex(x => x.SellerId);
c.EnsureIndex(x => x.PaidAt);
return c;
}
/// <summary>The first Sunday 00:00 UTC on or after <paramref name="t"/>.</summary>
public static DateTime NextSunday(DateTime t)
{
var day = new DateTime(t.Year, t.Month, t.Day, 0, 0, 0, DateTimeKind.Utc);
int add = ((int)DayOfWeek.Sunday - (int)day.DayOfWeek + 7) % 7;
if (add == 0 && day < t) add = 7;
return day.AddDays(add);
}
private static bool CanEarn(ulong seller)
{
if (seller == 0) return false;
var acc = PlayerDB.GetAccountById(seller);
if (acc == null) return false;
foreach (var n in ConfigCampus.NoEarningsUsernames)
if (string.Equals(acc.Username, n, StringComparison.OrdinalIgnoreCase)) return false;
return true;
}
/// <summary>Record a sale. Returns the entry, or null when the seller does not earn (buying your own, Coach, unknown).</summary>
public static Entry? Record(ulong seller, ulong buyer, string kind, string itemId, string itemName, int currencyType, long spent)
{
if (spent <= 0 || seller == buyer || !CanEarn(seller)) return null;
long share = (long)Math.Floor(spent * ConfigCampus.CreatorEarningsPercent / 100.0);
if (share <= 0) return null;
var now = DateTime.UtcNow;
var available = now.AddDays(ConfigCampus.CreatorEarningsPendDays);
var e = new Entry
{
SellerId = seller, BuyerId = buyer, Kind = kind, ItemId = itemId, ItemName = itemName,
CurrencyType = currencyType, Spent = spent, Share = share,
BoughtAt = now, AvailableAt = available, PayoutAt = NextSunday(available)
};
try { Col().Insert(e); }
catch (Exception ex) { Console.WriteLine($"[earnings] record failed: {ex.Message}"); return null; }
Console.WriteLine($"[earnings] {seller} earns {share} of {spent} (currency {currencyType}) from {buyer} buying {kind} '{itemName}' - pays out {e.PayoutAt:yyyy-MM-dd}");
return e;
}
/// <summary>Pay every earning whose Sunday has come. Called by the timer and on demand.</summary>
public static int PayDue()
{
int paid = 0;
try
{
var col = Col();
var now = DateTime.UtcNow;
var due = col.Find(x => x.PaidAt == null).Where(x => x.PayoutAt <= now).ToList();
foreach (var group in due.GroupBy(x => (x.SellerId, x.CurrencyType)))
{
long total = group.Sum(x => x.Share);
if (total <= 0) continue;
long balance = PlayerDB.AddBalance(group.Key.SellerId, group.Key.CurrencyType, total);
foreach (var e in group) { e.PaidAt = now; col.Update(e); paid++; }
GiftBoxes.PushBalance(group.Key.SellerId, group.Key.CurrencyType, balance, "creator earnings");
Console.WriteLine($"[earnings] paid {total} (currency {group.Key.CurrencyType}) to {group.Key.SellerId} for {group.Count()} sale(s) -> balance {balance}");
}
}
catch (Exception ex) { Console.WriteLine($"[earnings] payout failed: {ex.Message}"); }
return paid;
}
public static List<Entry> ForPlayer(ulong seller, int take = 200) =>
Col().Find(x => x.SellerId == seller).OrderByDescending(x => x.BoughtAt).Take(take).ToList();
public static List<Entry> Recent(int take = 200) =>
Col().FindAll().OrderByDescending(x => x.BoughtAt).Take(take).ToList();
public static (long pending, long paid, DateTime? nextPayout) Summary(ulong seller)
{
var all = Col().Find(x => x.SellerId == seller).ToList();
long pending = all.Where(x => x.PaidAt == null).Sum(x => x.Share);
long paidTotal = all.Where(x => x.PaidAt != null).Sum(x => x.Share);
DateTime? next = all.Where(x => x.PaidAt == null).Select(x => (DateTime?)x.PayoutAt).Min();
return (pending, paidTotal, next);
}
private static System.Threading.Timer? _timer;
/// <summary>Check for due payouts every 15 minutes (and once at boot).</summary>
public static void Start()
{
_timer ??= new System.Threading.Timer(_ => PayDue(), null, TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(15));
}
}
}
@@ -0,0 +1,82 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
// What each player has equipped, per equipment prefab.
//
// The client POSTs /api/equipment/v1/update every time a skin is equipped, sending
// the full entry for that prefab:
//
// [{"PrefabName":"[ShareCamera]","ModificationGuid":"48c97ace-...",
// "FriendlyName":"Camping Share Camera","Tooltip":"","Rarity":0,
// "ThumbnailImageName":null,"Favorited":true}]
//
// That route did not exist, so the POST 404'd and the choice lived only in the
// running client - at the next login everyone was back on the default skin.
//
// Stored as a collection inside Players.db rather than its own file, so it rides the
// existing checkpoint (DbMaintenance.CheckpointAll) instead of adding another
// write-ahead log that never gets merged.
public static class EquipmentDB
{
public class EquippedItem
{
public string PrefabName { get; set; } = "";
public string ModificationGuid { get; set; } = "";
public string? FriendlyName { get; set; }
public string? Tooltip { get; set; }
public int Rarity { get; set; }
public string? ThumbnailImageName { get; set; }
public bool Favorited { get; set; }
}
public class PlayerEquipment
{
[BsonId]
public ulong PlayerId { get; set; }
public List<EquippedItem> Equipped { get; set; } = new();
}
private static readonly ILiteCollection<PlayerEquipment> _col =
PlayerDB.PlayerDBFile.GetCollection<PlayerEquipment>("player_equipment");
public static List<EquippedItem> Get(ulong playerId) =>
_col.FindById(playerId)?.Equipped ?? new List<EquippedItem>();
/// <summary>
/// Records the state the client just sent, keyed by prefab AND modification guid.
/// Returns the player's full loadout afterwards.
///
/// Keying by prefab alone loses data. One request carries SEVERAL entries for the
/// same prefab - the newly equipped skin, plus the one it replaced so its flag can
/// be cleared:
///
/// [MakerPen] Professor Maker Pen (the new one)
/// [MakerPen] Host 2 Maker Pen (the old one, flag being cleared)
///
/// Replacing "all entries for [MakerPen]" therefore kept only whichever happened to
/// come LAST in the array - frequently the one being switched OFF - so the skin came
/// back unequipped. The client decides which entry is current; our job is to store
/// exactly what it told us, per item.
/// </summary>
public static List<EquippedItem> Update(ulong playerId, IEnumerable<EquippedItem>? items)
{
var row = _col.FindById(playerId) ?? new PlayerEquipment { PlayerId = playerId };
if (items == null) return row.Equipped;
foreach (var item in items)
{
if (item == null || string.IsNullOrWhiteSpace(item.PrefabName)) continue;
row.Equipped.RemoveAll(e =>
string.Equals(e.PrefabName, item.PrefabName, StringComparison.OrdinalIgnoreCase) &&
string.Equals(e.ModificationGuid, item.ModificationGuid, StringComparison.OrdinalIgnoreCase));
row.Equipped.Add(item);
}
_col.Upsert(row);
return row.Equipped;
}
}
}
@@ -0,0 +1,300 @@
using Discord;
using LiteDB;
using Campus_live_rewrite.api.server.Classes.db_class;
using static Campus_live_rewrite.api.server.Classes.db_class.EventDBClasses;
namespace Campus_live_rewrite.api.dbs
{
public class EventDB
{
public static LiteDatabase EventDBFile = new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/Events.db");
private static ILiteCollection<FullPlayerEvent> Events => EventDBFile.GetCollection<FullPlayerEvent>("events");
private static ulong GetNextId()
{
var last = Events.Query()
.OrderByDescending(x => x.PlayerEventId)
.FirstOrDefault();
return last == null ? 1UL : last.PlayerEventId + 1;
}
public static FullPlayerEvent? GetFullEventById(ulong eventId)
{
return Events.FindById(eventId);
}
/// <summary>Apply an edit to a stored event and persist it. Null when the event is unknown.</summary>
public static FullPlayerEvent? Mutate(ulong eventId, Action<FullPlayerEvent> edit)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
edit(ev);
Events.Update(ev);
return ev;
}
public static FullPlayerEvent? SetEventName(ulong eventId, string NewName)
{
var ev = Events.FindById(eventId);
if (ev == null)
return null;
ev.Name = NewName;
Events.Update(ev);
return ev;
}
public static FullPlayerEvent? SetEventImageName(ulong eventId, string NewImageName)
{
var ev = Events.FindById(eventId);
if (ev == null)
return null;
ev.ImageName = NewImageName;
Events.Update(ev);
return ev;
}
public static FullPlayerEvent? SetEventDescription(ulong eventId, string newDescription)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
ev.Description = newDescription;
Events.Update(ev);
return ev;
}
public static FullPlayerEvent? SetEventTime(ulong eventId, DateTime start, DateTime end)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
ev.StartTime = start;
ev.EndTime = end;
Events.Update(ev);
return ev;
}
public static FullPlayerEvent? SetEventAccessibility(ulong eventId, EventDBClasses.PlayerEventAccessibility accessibility)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
ev.Accessibility = accessibility;
Events.Update(ev);
return ev;
}
public static FullPlayerEvent? SetEventTags(ulong eventId, List<string> tags)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
ev.Tags = (tags ?? new List<string>())
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => new EventDBClasses.FullTag { Tag = t.Trim(), Type = EventDBClasses.TagType.Unknown1 })
.ToList();
Events.Update(ev);
return ev;
}
public static List<FullPlayerEvent> SearchEventByQuery(string? query)
{
if (string.IsNullOrWhiteSpace(query))
{
return Events.Query()
.OrderBy(e => e.StartTime)
.ToList();
}
query = query.Trim();
bool isTagSearch =
query.StartsWith("#") ||
query.StartsWith("%23");
if (isTagSearch)
{
string tag = query
.Replace("%23", "")
.TrimStart('#')
.ToLower();
return Events
.Find(Query.EQ("Tags.Tag", tag))
.OrderBy(e => e.StartTime)
.ToList();
}
string nameQuery = query.ToLower();
return Events.Query()
.Where(e =>
e.Name != null &&
e.Name.ToLower().Contains(nameQuery))
.OrderBy(e => e.StartTime)
.ToList();
}
/// <summary>
/// Events scheduled in a given room. /api/playerevents/v1/room/{RoomId} used to answer
/// a hardcoded "[]" (marked "todo db"), so a room never showed the events booked in it.
/// </summary>
public static List<FullPlayerEvent> GetEventsForRoom(ulong roomId)
{
return Events.Query()
.Where(e => e.RoomId == roomId)
.OrderBy(e => e.StartTime)
.ToList();
}
/// <summary>
/// Events happening right now - started, not yet ended. /api/playerevents/v1/searchlive
/// was another hardcoded "[]", so the "live now" list was always empty however many
/// events were running.
/// </summary>
public static List<FullPlayerEvent> GetLiveEvents()
{
var now = DateTime.UtcNow;
return Events.Query()
.ToList()
.Where(e => e.StartTime.ToUniversalTime() <= now && e.EndTime.ToUniversalTime() >= now)
.OrderBy(e => e.StartTime)
.ToList();
}
/// <summary>
/// Records a player's RSVP and keeps AttendeeCount in step with it. One response per
/// player per event - responding again replaces the previous answer.
/// </summary>
public static FullPlayerEvent? SetResponse(ulong eventId, ulong playerId, PlayerEventResponseType type)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
ev.Responses ??= new List<FullEventResponses>();
ev.Responses.RemoveAll(r => r.PlayerId == playerId);
if (type != PlayerEventResponseType.None)
{
ulong nextId = ev.Responses.Count == 0 ? 1UL : ev.Responses.Max(r => r.PlayerEventResponseId) + 1;
ev.Responses.Add(new FullEventResponses
{
PlayerEventResponseId = nextId,
PlayerEventId = eventId,
PlayerId = playerId,
CreatedAt = DateTime.UtcNow,
Type = type
});
}
// AttendeeCount was never maintained - it sat at 0 no matter how many people
// responded, so every event advertised itself as empty.
ev.AttendeeCount = ev.Responses.Count(r => r.Type == PlayerEventResponseType.Yes);
Events.Update(ev);
return ev;
}
/// <summary>
/// Whether a player may enter an event's room, and why not when they may not.
///
/// Rules:
/// - the CREATOR may join at any time, including before it starts (they have to be
/// able to set the room up).
/// - anyone INVITED may join at any time. An invite is a response row: the invite
/// flow writes Pending, and Yes / Interested / No all mean the player is already
/// part of the event, so any response row counts.
/// - everyone else may only join once it has actually started, and not after it has
/// ended.
///
/// Returns true when entry is allowed; `reason` explains a refusal for the log.
/// </summary>
public static bool CanPlayerJoin(FullPlayerEvent ev, ulong playerId, out string reason)
{
reason = "";
if (ev.CreatorPlayerId == playerId)
return true;
bool invited = ev.Responses?.Any(r => r.PlayerId == playerId) == true;
if (invited)
return true;
var now = DateTime.UtcNow;
// Doors open a few minutes early so people can be in the room when it starts.
if (now < ev.StartTime.ToUniversalTime().AddMinutes(-Campus_live_rewrite.api.server.ConfigCampus.EventEarlyJoinMinutes))
{
reason = $"event has not started (starts {ev.StartTime:u}, not invited)";
return false;
}
if (now > ev.EndTime.ToUniversalTime())
{
reason = $"event already ended ({ev.EndTime:u})";
return false;
}
return true;
}
/// <summary>Marks an event live / not live. Used by roominstance/{id}/inprogress.</summary>
public static FullPlayerEvent? SetBroadcastingInstance(ulong eventId, ulong roomInstanceId, bool inProgress)
{
var ev = Events.FindById(eventId);
if (ev == null) return null;
ev.BroadcastingRoomInstanceId = inProgress ? roomInstanceId : 0;
ev.State = inProgress ? 1 : 0;
Events.Update(ev);
return ev;
}
/// <summary>The event currently broadcasting to a given room instance, if any.</summary>
public static FullPlayerEvent? GetEventForRoomInstance(ulong roomInstanceId)
{
if (roomInstanceId == 0) return null;
return Events.Query()
.Where(e => e.BroadcastingRoomInstanceId == roomInstanceId)
.FirstOrDefault();
}
/// <summary>Removes an event outright. Used by the admin panel for moderation.</summary>
public static bool DeleteEvent(ulong eventId) => Events.Delete(eventId);
public static FullPlayerEvent CreateEvent(ulong creatorPlayerId, EventDBClasses.CreateEventRequest request)
{
var ev = new FullPlayerEvent
{
PlayerEventId = GetNextId(),
CreatorPlayerId = creatorPlayerId,
RoomId = request.RoomId,
SubRoomId = request.SubRoomId,
ClubId = request.ClubId,
Name = request.Name,
Description = request.Description,
ImageName = request.ImageName,
Tags = request.Tags?.Select(t => new EventDBClasses.FullTag
{
Tag = t,
Type = EventDBClasses.TagType.Unknown1
}).ToList(),
StartTime = request.StartTime,
EndTime = request.EndTime,
AttendeeCount = 0,
State = 0,
Accessibility = request.Accessibility,
IsMultiInstance = request.IsMultiInstance,
SupportMultiInstanceRoomChat = request.SupportMultiInstanceRoomChat,
DefaultBroadcastPermissions = (EventDBClasses.BroadcastPermissionsEnum)request.DefaultBroadcastPermissions,
CanRequestBroadcastPermissions = (EventDBClasses.BroadcastPermissionsEnum)request.CanRequestBroadcastPermissions,
Responses = new List<EventDBClasses.FullEventResponses>()
};
Events.Insert(ev);
return ev;
}
}
}
@@ -0,0 +1,774 @@
using System.Net.WebSockets;
using LiteDB;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes.db_class;
using Campus_live_rewrite.Controllers;
using static Campus_live_rewrite.api.WebsocketEvents;
using static Campus_live_rewrite.Controllers.AccountsController;
namespace Campus_live_rewrite.api.dbs
{
public class FriendsDB
{
public static LiteDatabase PlayerFriendDBFile = new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/PlayersFriends.db");
// ==========================
// GET ALL RELATIONSHIPS
// ==========================
public static List<RelationshipDetail> GetRelationships(ulong playerId)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
List<RelationshipDetail> list = new();
// Read the caller's OWN side of each pair (PlayerID == caller). The client's
// RelationshipResponse is projected from the caller's point of view: PlayerID is the
// OTHER player, RelationshipType/Favorited/Ignored/Muted are the CALLER's own values
// (RecFlare's toResponse does exactly this).
//
// This used to read the OTHER player's row instead (filter OtherPlayerID == caller),
// which returned that player's flags and, worse, the OTHER side's request direction -
// so a friend request you SENT showed up as one you RECEIVED (and the flags a favorite
// will set below would have landed on the wrong person's list).
foreach (var data in col.FindAll())
{
if (data.Relationship == null || data.Relationship.PlayerID != playerId)
continue;
list.Add(new RelationshipDetail
{
Id = data.Relationship.Id,
PlayerID = data.Relationship.OtherPlayerID, // the OTHER player (client's shape)
OtherPlayerID = playerId, // the caller
RelationshipType = data.Relationship.RelationshipType,
Muted = data.Relationship.Muted,
Ignored = data.Relationship.Ignored,
Favorited = data.Relationship.Favorited,
VoiceVolume = data.Relationship.VoiceVolume
});
}
return list;
}
// ==========================
// BLOCK ("ignore" on the wire). Unlike favorite / mute this lands on BOTH sides of the
// pair: the blocker's row gets Local, the blocked player's row gets Remote (Mutual when
// both have blocked each other), so each of them reads Ignored = true and their clients
// hide and mute the other. Only the blocker's own part is theirs to lift: the blocked
// player's "unblock" cannot clear a Remote, so a block stays until the player who set it
// takes it off. Blocking also ends a friendship / pending request between the two.
// Both players get a live RelationshipChanged frame (id 1) - which is what the client
// repaints its card / friends list from - plus a website refresh.
// ==========================
public static RelationshipDetail SetBlock(ulong caller, ulong target, bool value)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var mine = GetOrCreateRow(col, caller, target);
var theirs = GetOrCreateRow(col, target, caller);
static ReciprocalStatus With(ReciprocalStatus s, ReciprocalStatus part, bool on)
{
bool local = s == ReciprocalStatus.Local || s == ReciprocalStatus.Mutual;
bool remote = s == ReciprocalStatus.Remote || s == ReciprocalStatus.Mutual;
if (part == ReciprocalStatus.Local) local = on; else remote = on;
return local && remote ? ReciprocalStatus.Mutual : local ? ReciprocalStatus.Local : remote ? ReciprocalStatus.Remote : ReciprocalStatus.None;
}
bool wasBlocking = mine.Relationship.Ignored == ReciprocalStatus.Local || mine.Relationship.Ignored == ReciprocalStatus.Mutual;
mine.Relationship.Ignored = With(mine.Relationship.Ignored, ReciprocalStatus.Local, value);
theirs.Relationship.Ignored = With(theirs.Relationship.Ignored, ReciprocalStatus.Remote, value);
bool unfriended = false;
if (value && (mine.Relationship.RelationshipType != RelationshipType.None || theirs.Relationship.RelationshipType != RelationshipType.None))
{
mine.Relationship.RelationshipType = RelationshipType.None;
theirs.Relationship.RelationshipType = RelationshipType.None;
unfriended = true;
}
col.Update(mine);
col.Update(theirs);
if (unfriended)
{
try { RemoveFriendInviteMessage(caller, target); RemoveFriendInviteMessage(target, caller); } catch { }
try { SitePrefsDB.Forget(caller, target); SitePrefsDB.Forget(target, caller); } catch { } // nickname / home pin go with the friendship
}
string what = value ? (wasBlocking ? "re-blocked" : "blocked") : (wasBlocking ? "unblocked" : "asked to unblock (not theirs to lift)");
Console.WriteLine($"[block] {caller} {what} {target}: mine={mine.Relationship.Ignored} theirs={theirs.Relationship.Ignored}{(unfriended ? " (friendship / request ended)" : "")}");
// Live to both sides: the frame the client's RelationshipChanged handler repaints
// from, in each player's own point of view.
Notifications.FireAndForget(Notifications.SendToPlayer(caller, JsonConvert.SerializeObject(new Reponse { Id = "1", Msg = RelationshipView(mine.Relationship) })), "block changed (self)");
Notifications.FireAndForget(Notifications.SendToPlayer(target, JsonConvert.SerializeObject(new Reponse { Id = "1", Msg = RelationshipView(theirs.Relationship) })), "block changed (other)");
Notifications.FireAndForget(Notifications.SendHubInvocation(caller, "RelationshipsInvalid"), "block invalidate (self)");
Notifications.FireAndForget(Notifications.SendHubInvocation(target, "RelationshipsInvalid"), "block invalidate (other)");
SiteNotifications.Notify(caller);
SiteNotifications.Notify(target);
// Each now sees the other as offline (PresenceIsBlocked); refresh what they see.
Notifications.FireAndForget(Notifications.PushPresenceTo(caller, target), "block presence refresh (self)");
Notifications.FireAndForget(Notifications.PushPresenceTo(target, caller), "block presence refresh (other)");
return new RelationshipDetail
{
Id = mine.Relationship.Id,
PlayerID = target,
OtherPlayerID = caller,
RelationshipType = mine.Relationship.RelationshipType,
Muted = mine.Relationship.Muted,
Ignored = mine.Relationship.Ignored,
Favorited = mine.Relationship.Favorited,
VoiceVolume = mine.Relationship.VoiceVolume
};
}
/// <summary>The caller's voice volume for one player (their own side of the row).</summary>
public static void SetVoiceVolume(ulong caller, ulong target, int volume)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var row = GetOrCreateRow(col, caller, target);
row.Relationship.VoiceVolume = volume;
col.Update(row);
}
/// <summary>True when either of the two has blocked the other.</summary>
public static bool IsBlockedEitherWay(ulong a, ulong b)
{
if (a == 0 || b == 0 || a == b) return false;
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var row = col.FindOne(x => x.PlayerID == a && x.Relationship.OtherPlayerID == b);
if (row?.Relationship != null && row.Relationship.Ignored != ReciprocalStatus.None) return true;
var other = col.FindOne(x => x.PlayerID == b && x.Relationship.OtherPlayerID == a);
return other?.Relationship != null && other.Relationship.Ignored != ReciprocalStatus.None;
}
/// <summary>True when <paramref name="blocker"/> has blocked <paramref name="other"/> (their own Local part).</summary>
public static bool HasBlocked(ulong blocker, ulong other)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var row = col.FindOne(x => x.PlayerID == blocker && x.Relationship.OtherPlayerID == other);
return row?.Relationship != null && (row.Relationship.Ignored == ReciprocalStatus.Local || row.Relationship.Ignored == ReciprocalStatus.Mutual);
}
private static YourFriendData GetOrCreateRow(ILiteCollection<YourFriendData> col, ulong owner, ulong other)
{
var row = col.FindOne(x => x.PlayerID == owner && x.Relationship.OtherPlayerID == other);
if (row != null) return row;
row = new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = owner,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = owner,
OtherPlayerID = other,
RelationshipType = RelationshipType.None,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
col.Insert(row);
return row;
}
// ==========================
// SET A PER-SIDE FLAG (favorite / ignore / mute) on the CALLER's own side.
// Mirrors RecFlare's setRelationshipFlag: the write lands on the caller's side of the
// pair, and flagging someone you have no relationship with creates a bare (None) row to
// hold the flag - so you can favorite/ignore/mute a player you were never friends with.
// Value maps to ReciprocalStatus.Local (set, serializes as 1) or None (clear, 0), which
// is the 0/1 the client reads for each flag. Returns the caller-POV relationship.
// ==========================
public static RelationshipDetail SetRelationshipFlag(ulong caller, ulong target, FriendInfo flag, bool value)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var row = col.FindOne(x => x.PlayerID == caller && x.Relationship.OtherPlayerID == target);
if (row == null)
{
row = new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = caller,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = caller,
OtherPlayerID = target,
RelationshipType = RelationshipType.None,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
col.Insert(row);
}
var status = value ? ReciprocalStatus.Local : ReciprocalStatus.None;
switch (flag)
{
case FriendInfo.Favorited: row.Relationship.Favorited = status; break;
case FriendInfo.Muted: row.Relationship.Muted = status; break;
case FriendInfo.Ignored: row.Relationship.Ignored = status; break;
}
col.Update(row);
// Realtime: the caller's game re-reads its relationships (favorite star, mute/ignore
// state) and their website tabs refresh - the same channels a friend request uses.
// A favorite also changes what the caller may SEE of the target ("Appear Online To:
// Favorites"), so re-send that one presence to them with the new relationship applied.
Notifications.FireAndForget(Notifications.SendHubInvocation(caller, "RelationshipsInvalid"), "relationship-flag invalidate");
Notifications.FireAndForget(Notifications.SendToPlayer(caller, JsonConvert.SerializeObject(new Reponse { Id = "1", Msg = RelationshipView(row.Relationship) })), "relationship-flag changed");
SiteNotifications.Notify(caller);
if (flag == FriendInfo.Favorited)
Notifications.FireAndForget(Notifications.PushPresenceTo(caller, target), "favorite presence refresh");
// Return in the caller's POV shape (PlayerID = the other player), the same projection
// GetRelationships uses, so the caller can apply it directly.
return new RelationshipDetail
{
Id = row.Relationship.Id,
PlayerID = target,
OtherPlayerID = caller,
RelationshipType = row.Relationship.RelationshipType,
Muted = row.Relationship.Muted,
Ignored = row.Relationship.Ignored,
Favorited = row.Relationship.Favorited,
VoiceVolume = row.Relationship.VoiceVolume
};
}
// ==========================
// COUNT ONLINE FRIENDS - the caller's accepted friends that currently hold a live
// notification websocket. Replaces the hardcoded int.MaxValue/0 stub.
// ==========================
public static int CountOnlineFriends(ulong playerId)
{
return GetRelationships(playerId)
.Where(r => r.RelationshipType == RelationshipType.Friend)
.Select(r => r.PlayerID)
.Distinct()
.Count(fid => CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(fid));
}
// ==========================
// SEND FRIEND REQUEST
// ==========================
public static RelationshipDetail SendFriendRequest(ulong fromPlayer, ulong toPlayer)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
// Blocked either way: nothing is sent and nobody is told; the caller just gets their
// current (None) relationship back.
if (IsBlockedEitherWay(fromPlayer, toPlayer))
{
Console.WriteLine($"[friends] {fromPlayer} -> {toPlayer}: friend request refused, one has blocked the other");
var blockedRow = GetOrCreateRow(col, fromPlayer, toPlayer).Relationship;
return new RelationshipDetail
{
Id = blockedRow.Id, PlayerID = toPlayer, OtherPlayerID = fromPlayer,
RelationshipType = blockedRow.RelationshipType, Muted = blockedRow.Muted, Ignored = blockedRow.Ignored,
Favorited = blockedRow.Favorited, VoiceVolume = blockedRow.VoiceVolume
};
}
// ---- A -> B ----
var relA = col.FindOne(x =>
x.PlayerID == fromPlayer &&
x.Relationship.OtherPlayerID == toPlayer
);
if (relA == null)
{
relA = new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = fromPlayer,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = fromPlayer,
OtherPlayerID = toPlayer,
RelationshipType = RelationshipType.None,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
col.Insert(relA);
}
// ---- B -> A ----
var relB = col.FindOne(x =>
x.PlayerID == toPlayer &&
x.Relationship.OtherPlayerID == fromPlayer
);
if (relB == null)
{
relB = new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = toPlayer,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = toPlayer,
OtherPlayerID = fromPlayer,
RelationshipType = RelationshipType.None,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
col.Insert(relB);
}
// Update types
relA.Relationship.RelationshipType = RelationshipType.FriendRequestSent;
relB.Relationship.RelationshipType = RelationshipType.FriendRequestReceived;
col.Update(relA);
col.Update(relB);
//todo: send ws event to anounce the player that it got a new friend request
// Websocket pushes. fromPlayer is always the SENDER, toPlayer the RECIPIENT (both
// callers pass that order).
// - sender: their side is now "Sent" (relationship changed -> shows Pending).
// - recipient: the incoming request, as BOTH a relationship change (so the friends
// UI updates live) AND a message notification "<sender> sent you a friend request".
// The message used to be sent to the SENDER, so the recipient's game never got the
// heads-up and the request only showed on a later fetch.
Notifications.SendToPlayer(
fromPlayer,
JsonConvert.SerializeObject(CreateResponseFriendRequestSent(toPlayer))
);
Notifications.SendToPlayer(
toPlayer,
JsonConvert.SerializeObject(CreateResponseFriendRequestReceived(fromPlayer, toPlayer))
);
// A stored FriendInvite message for the recipient: the in-game notifications list is
// /api/messages/v2/get, so a request that only ever went out as a live frame vanished
// from it on the next open - "friend requests don't show unless you search for the
// player". One per sender: re-sending replaces rather than stacks. Removed again when
// the request is accepted, declined or cancelled (RemoveFriendInviteMessage).
RemoveFriendInviteMessage(toPlayer, fromPlayer);
var inviteMsg = PlayerDB.AddMessage(toPlayer, fromPlayer, WebsocketEvents.MessageType.FriendInvite, "");
if (inviteMsg != null)
Notifications.FireAndForget(Notifications.SendToPlayer(toPlayer, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(inviteMsg))), "friend-request message");
// (The extra "FriendInvite" message frame that used to go out here as well showed up
// as a SECOND notification for the same request - one alert per request now.)
// Fire the client's OWN hub handlers (proven by the 2023 server) so the request shows
// in-game live: FriendRequestReceived raises the alert on the recipient, and
// RelationshipsInvalid makes BOTH sides re-fetch /api/relationships so the incoming
// request and the sender's "pending" state appear without a relog.
Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "FriendRequestReceived", (long)fromPlayer), "friend-request alert");
Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "RelationshipChanged", RelationshipView(relB.Relationship)), "friend-request changed (recipient)");
Notifications.FireAndForget(Notifications.SendHubInvocation(fromPlayer, "RelationshipChanged", RelationshipView(relA.Relationship)), "friend-request changed (sender)");
Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "RelationshipsInvalid"), "friend-request invalidate (recipient)");
Notifications.FireAndForget(Notifications.SendHubInvocation(fromPlayer, "RelationshipsInvalid"), "friend-request invalidate (sender)");
return relA.Relationship;
}
/// <summary>
/// Befriend with no pending-request step (the in-game handshake). A pending request either
/// way, or an existing friendship, is simply accepted / re-confirmed; otherwise the pair is
/// minted as a request from <paramref name="me"/> and accepted in the same call, so both
/// sides end up Friend with the usual "relationship changed" pushes.
/// </summary>
public static RelationshipDetail? AddFriendDirect(ulong me, ulong other)
{
if (me == 0 || other == 0 || me == other) return null;
var accepted = AcceptFriendRequest(other, me);
if (accepted != null) return accepted;
SendFriendRequest(me, other);
return AcceptFriendRequest(me, other);
}
public static RelationshipDetail? AcceptFriendRequest(ulong fromPlayer, ulong toPlayer)
{
if (fromPlayer == 0 || toPlayer == 0 || fromPlayer == toPlayer) return null;
// The request is being answered: its notification goes, whichever side held it.
RemoveFriendInviteMessage(fromPlayer, toPlayer);
RemoveFriendInviteMessage(toPlayer, fromPlayer);
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var relA = col.FindOne(x => x.PlayerID == fromPlayer && x.Relationship.OtherPlayerID == toPlayer);
var relB = col.FindOne(x => x.PlayerID == toPlayer && x.Relationship.OtherPlayerID == fromPlayer);
static bool IsRequest(YourFriendData? r) =>
r?.Relationship != null &&
(r.Relationship.RelationshipType == RelationshipType.FriendRequestSent ||
r.Relationship.RelationshipType == RelationshipType.FriendRequestReceived);
static bool IsFriend(YourFriendData? r) =>
r?.Relationship != null && r.Relationship.RelationshipType == RelationshipType.Friend;
// There must be a real, pending request on at least one side (or an already-formed
// friendship we're just re-confirming). This blocks "accepting" a request nobody sent -
// otherwise the row-healing below would let anyone force-friend a stranger.
if (!IsRequest(relA) && !IsRequest(relB) && !IsFriend(relA) && !IsFriend(relB))
return null;
// Heal a half-formed pair: if one side's row is missing (e.g. a partial write or an
// orphan cleanup removed it), recreate it so acceptance ALWAYS ends in a mutual
// friendship rather than silently doing nothing. Mirrors SendFriendRequest's row shape.
YourFriendData MakeRow(ulong owner, ulong other) => new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = owner,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = owner,
OtherPlayerID = other,
RelationshipType = RelationshipType.None,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
if (relA == null) { relA = MakeRow(fromPlayer, toPlayer); col.Insert(relA); }
if (relB == null) { relB = MakeRow(toPlayer, fromPlayer); col.Insert(relB); }
// Work out who ORIGINALLY SENT the request (had FriendRequestSent) vs who ACCEPTED it
// (had FriendRequestReceived), BEFORE overwriting both to Friend. The two callers pass
// fromPlayer/toPlayer in opposite orders (website: accepter,sender - game: sender,accepter),
// so we can't trust the arg order - the row types are the source of truth. Without this
// the "X accepted your request" notice went to whoever happened to be fromPlayer, so the
// ACCEPTER got told the SENDER accepted (backwards).
ulong sender, accepter;
if (relA.Relationship.RelationshipType == RelationshipType.FriendRequestSent) { sender = fromPlayer; accepter = toPlayer; }
else if (relB.Relationship.RelationshipType == RelationshipType.FriendRequestSent) { sender = toPlayer; accepter = fromPlayer; }
else { sender = fromPlayer; accepter = toPlayer; } // no pending side (re-confirm) - harmless default
// Set both sides to Friend
relA.Relationship.RelationshipType = RelationshipType.Friend;
relB.Relationship.RelationshipType = RelationshipType.Friend;
col.Update(relA);
col.Update(relB);
// Relationship-changed -> BOTH clients update their friends list live (order-agnostic).
Notifications.SendToPlayer(fromPlayer,
JsonConvert.SerializeObject(CreateResponseFriendAccepted(toPlayer, fromPlayer)));
Notifications.SendToPlayer(toPlayer,
JsonConvert.SerializeObject(CreateResponseFriendAccepted(fromPlayer, toPlayer)));
// "<accepter> accepted your friend request" -> ONLY the original sender, naming the
// accepter. Also persisted as a saved message so it shows in the sender's website inbox,
// survives, and (via the AddMessage -> SiteNotifications.Notify hook) updates the web live.
Notifications.SendToPlayer(sender,
JsonConvert.SerializeObject(CreateResponseFriendAcceptedMsg(accepter)));
PlayerDB.AddMessage(sender, accepter, WebsocketEvents.MessageType.FriendRequestAccepted, null);
// Refresh BOTH friends lists live (the client's own RelationshipsInvalid handler).
Notifications.FireAndForget(Notifications.SendHubInvocation(fromPlayer, "RelationshipsInvalid"), "accept invalidate");
Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "RelationshipsInvalid"), "accept invalidate");
// return the accepted version
return relA.Relationship;
}
private static WebsocketEvents.Reponse CreateResponseFriendAcceptedMsg(ulong fromPlayer) => new WebsocketEvents.Reponse
{
Id = "2",
Msg = new
{
Id = (ulong)new Random().Next(),
FromPlayerId = fromPlayer,
SentTime = DateTime.UtcNow,
Type = WebsocketEvents.MessageType.FriendRequestAccepted,
Data = "",
RoomId = (ulong?)null,
PlayerEventId = (ulong?)null,
}
};
public static void DeleteFriends(ulong playerId)
{
if (playerId == 0) return;
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
col.DeleteMany(x => x.PlayerID == playerId);
}
// The RelationshipChanged (1) payload is EXACTLY the five fields the client's
// RelationshipResponse has (reference RelationshipChangedPayload: PlayerID,
// RelationshipType, Muted, Ignored, Favorited) - the same strict parser that dropped the
// whole /relationships/v2/get array over the extra Id/OtherPlayerID/VoiceVolume keys.
// These frames used to carry the raw RelationshipDetail (with its ~1.79-trillion Id), so
// the live "you have a friend request" frame never parsed and requests sent from the
// website only showed up on the next relationships fetch. PlayerID = the OTHER player.
private static object RelWire(ulong otherPlayer, RelationshipType type, bool favorited = false, bool muted = false, ReciprocalStatus ignored = ReciprocalStatus.None) => new
{
PlayerID = otherPlayer,
RelationshipType = (int)type,
Muted = muted ? 1 : 0,
Ignored = IgnoredWire(ignored),
Favorited = favorited ? 1 : 0
};
public static Reponse CreateResponseFriendAccepted(ulong playerid, ulong fromplayerid)
=> new Reponse { Id = "1", Msg = RelWire(playerid, RelationshipType.Friend) };
public static Reponse CreateResponseFriendRemoved(ulong playerid, ulong fromplayerid)
=> new Reponse { Id = "1", Msg = RelWire(playerid, RelationshipType.None) };
// Sent to the RECIPIENT. Client convention (see GetRelationships): PlayerID = the OTHER
// player (the sender), OtherPlayerID = the caller (the recipient). OtherPlayerID used to be
// set to the sender too, which is wrong and can stop the client rendering the request.
private static WebsocketEvents.Reponse CreateResponseFriendRequestReceived(ulong fromPlayer, ulong toPlayer) => new WebsocketEvents.Reponse
{
Id = "1",
Msg = RelWire(fromPlayer, RelationshipType.FriendRequestReceived)
};
private static WebsocketEvents.Reponse CreateResponseFriendRequestMsg(ulong fromPlayer) => new WebsocketEvents.Reponse
{
Id = "2",
Msg = new
{
Id = (ulong)new Random().Next(),
FromPlayerId = fromPlayer,
SentTime = DateTime.UtcNow,
Type = WebsocketEvents.MessageType.FriendInvite,
Data = "",
RoomId = (ulong?)null,
PlayerEventId = (ulong?)null,
}
};
// The relationship as the client's RelationshipChanged handler expects it (the same shape
// /relationships/v2/get lists): PlayerID = the OTHER player from the receiver's view.
private static object RelationshipView(RelationshipDetail r) =>
RelWire(r.OtherPlayerID, r.RelationshipType, r.Favorited != ReciprocalStatus.None, r.Muted != ReciprocalStatus.None, r.Ignored);
/// <summary>
/// What the wire `Ignored` means: "I have blocked this player" - the caller's OWN part of
/// the pair (Local / Mutual). A Remote (the other player blocked me) is kept for the
/// server-side gates but is never shown: the blocked player is not told, and reading it
/// as true made their client draw the block as if THEY had blocked the blocker.
/// </summary>
public static bool IsOwnBlock(ReciprocalStatus s) => s == ReciprocalStatus.Local || s == ReciprocalStatus.Mutual;
/// <summary>
/// The wire `Ignored` is the client's ReciprocalStatus, from the receiver's point of view:
/// 0 None - nobody blocked anybody
/// 1 Local - I blocked them (their card shows Blocked + my Unblock button)
/// 2 Remote - they blocked me (their card shows Blocked, no Unblock - not mine to lift)
/// 3 Mutual - both (Unblock lifts only MY part; theirs stays -> 2)
/// Sending a flat 0/1 was the bug: a 1 on both rows drew the block as the blocked
/// player's own (they got an Unblock button), and a 0 hid it from them entirely.
/// </summary>
public static int IgnoredWire(ReciprocalStatus s) => (int)s;
/// <summary>Drop the stored "X sent you a friend request" notification (and tell the game).</summary>
public static void RemoveFriendInviteMessage(ulong toPlayer, ulong fromPlayer)
{
try
{
var full = PlayerDB.GetFullAccount(toPlayer);
var stale = full?.player_Extra?.Messages?
.Where(m => m.Type == WebsocketEvents.MessageType.FriendInvite && m.FromPlayerId == fromPlayer)
.Select(m => m.Id).ToList();
if (stale == null || stale.Count == 0) return;
foreach (var id in stale)
{
PlayerDB.DeleteMessage(toPlayer, id);
Notifications.FireAndForget(Notifications.SendToPlayer(toPlayer, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageDeleted(id))), "friend-request message gone");
}
SiteNotifications.Notify(toPlayer);
}
catch (Exception ex) { Console.WriteLine($"[friends] invite message cleanup {fromPlayer}->{toPlayer}: {ex.Message}"); }
}
public static RelationshipDetail RemoveFriend(ulong playerA, ulong playerB)
{
// website extras (nickname / home pin) don't outlive the friendship
try { SitePrefsDB.Forget(playerA, playerB); SitePrefsDB.Forget(playerB, playerA); } catch { }
// Decline / cancel / unfriend: any pending "sent you a friend request" notification goes.
RemoveFriendInviteMessage(playerA, playerB);
RemoveFriendInviteMessage(playerB, playerA);
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
// A -> B
var relA = col.FindOne(x => x.PlayerID == playerA &&
x.Relationship.OtherPlayerID == playerB);
// B -> A
var relB = col.FindOne(x => x.PlayerID == playerB &&
x.Relationship.OtherPlayerID == playerA);
// If they were never connected, return empty relationship
if (relA == null && relB == null)
{
return new RelationshipDetail
{
PlayerID = playerA,
OtherPlayerID = playerB,
RelationshipType = RelationshipType.None
};
}
// Cancelling a PENDING request (or declining one) - tell the other side the request is
// gone, so their notification disappears instead of lingering until they act on it.
bool wasPending = (relA?.Relationship.RelationshipType == RelationshipType.FriendRequestSent
|| relA?.Relationship.RelationshipType == RelationshipType.FriendRequestReceived
|| relB?.Relationship.RelationshipType == RelationshipType.FriendRequestSent
|| relB?.Relationship.RelationshipType == RelationshipType.FriendRequestReceived);
// Delete A->B
if (relA != null)
col.Delete(relA.Id);
// Delete B->A
if (relB != null)
col.Delete(relB.Id);
if (wasPending)
{
Notifications.FireAndForget(Notifications.SendHubInvocation(playerB, "FriendRequestCancelled", (long)playerA), "friend-request cancelled");
Notifications.FireAndForget(Notifications.SendHubInvocation(playerB, "RelationshipChanged", RelationshipView(new RelationshipDetail { PlayerID = playerB, OtherPlayerID = playerA, RelationshipType = RelationshipType.None, VoiceVolume = 100 })), "friend-request cancelled (changed)");
Notifications.FireAndForget(Notifications.SendHubInvocation(playerA, "RelationshipChanged", RelationshipView(new RelationshipDetail { PlayerID = playerA, OtherPlayerID = playerB, RelationshipType = RelationshipType.None, VoiceVolume = 100 })), "friend-request cancelled (self)");
Console.WriteLine($"[friends] {playerA} cancelled/declined the pending request with {playerB}");
}
// Send WS update to player A
Notifications.SendToPlayer(
playerA,
JsonConvert.SerializeObject(
CreateResponseFriendRemoved(playerA, playerB)
)
);
// Send WS update to player B
Notifications.SendToPlayer(
playerB,
JsonConvert.SerializeObject(
CreateResponseFriendRemoved(playerB, playerA)
)
);
// Refresh BOTH friends lists live (unfriend / decline / cancel).
Notifications.FireAndForget(Notifications.SendHubInvocation(playerA, "RelationshipsInvalid"), "remove invalidate");
Notifications.FireAndForget(Notifications.SendHubInvocation(playerB, "RelationshipsInvalid"), "remove invalidate");
// Return final relationship state
return new RelationshipDetail
{
PlayerID = playerA,
OtherPlayerID = playerB,
RelationshipType = RelationshipType.None,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
};
}
public static RelationshipDetail AddFriend(ulong playerA, ulong playerB)
{
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
// A -> B
var relA = col.FindOne(x => x.PlayerID == playerA &&
x.Relationship.OtherPlayerID == playerB);
if (relA == null)
{
relA = new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = playerA,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = playerA,
OtherPlayerID = playerB,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
col.Insert(relA);
}
// B -> A
var relB = col.FindOne(x => x.PlayerID == playerB &&
x.Relationship.OtherPlayerID == playerA);
if (relB == null)
{
relB = new YourFriendData
{
Id = ObjectId.NewObjectId(),
PlayerID = playerB,
Relationship = new RelationshipDetail
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PlayerID = playerB,
OtherPlayerID = playerA,
Favorited = ReciprocalStatus.None,
Ignored = ReciprocalStatus.None,
Muted = ReciprocalStatus.None,
VoiceVolume = 100
}
};
col.Insert(relB);
}
// Set both sides to Friend
relA.Relationship.RelationshipType = RelationshipType.Friend;
relB.Relationship.RelationshipType = RelationshipType.Friend;
col.Update(relA);
col.Update(relB);
// WebSocket updates
Notifications.SendToPlayer(
playerA,
JsonConvert.SerializeObject(CreateResponseFriendAccepted(playerA, playerB))
);
Notifications.SendToPlayer(
playerB,
JsonConvert.SerializeObject(CreateResponseFriendAccepted(playerB, playerA))
);
return relA.Relationship;
}
// {"type":1,"target":"Notification","arguments":["{\"id\":\"2\",\"msg\":{\"id\":16212007930,\"fromPlayerId\":1764227894,\"sentTime\":\"2025-06-23T17:20:53.72092Z\",\"type\":4,\"data\":\"\",\"roomId\":null,\"playerEventId\":null}}"]}
private static WebsocketEvents.Reponse CreateResponseFriendRequestSent(ulong toPlayer) => new WebsocketEvents.Reponse
{
Id = "50",
Msg = ""
};
}
}
@@ -0,0 +1,848 @@
using System.Text.Json.Serialization;
using LiteDB;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.dbs
{
public class HeartbeatDB
{
private static readonly LiteDatabase _db = new($"{Environment.CurrentDirectory}/Data/DBs/Heartbeats.db");
// Merge this store's write-ahead log back into the data file.
// See DbMaintenance for why this must be called explicitly.
public static void Checkpoint() => _db.Checkpoint();
private static readonly ILiteCollection<PlayerHeartbeatData> _col =
_db.GetCollection<PlayerHeartbeatData>("players_heartbeat");
public static void Setup()
{
_col.EnsureIndex(x => x.playerid, unique: true);
// Rows written before the key change carry a sequential iD (1, 2, 3...)
// rather than the player's id, so the next insert for a player whose id
// matches an old sequential value collides on _id and throws - which is
// the failure the key change exists to remove.
//
// Heartbeats are transient (GetPlayerHeartbeat falls back to
// CreateDefaultHeartbeat when a player has no row), so dropping stale
// rows costs nothing and is safer than leaving the collision armed.
try
{
var stale = _col.FindAll().Where(x => x.iD != x.playerid).ToList();
if (stale.Count > 0)
{
foreach (var row in stale) _col.Delete(row.iD);
Console.WriteLine($"[HeartbeatDB] migrated: dropped {stale.Count} row(s) keyed by row-count instead of playerid");
}
}
catch (Exception ex)
{
Console.WriteLine($"[HeartbeatDB] key migration skipped: {ex.Message}");
}
// Correct EVERY stored appVersion to the current client build. The old hardcoded
// "20250731.01" is NEWER than the 20250718.01 client, so a player whose row still
// carried it showed to everyone as "ON NEWER VERSION" and couldn't be joined. The
// per-heartbeat fix only touches a row when that player moves; this catches the
// offline/stationary rows at startup so nobody is left stale.
try
{
string want = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion;
var wrong = _col.FindAll().Where(x => x.Heartbeat != null && x.Heartbeat.appVersion != want).ToList();
foreach (var row in wrong) { row.Heartbeat.appVersion = want; _col.Update(row); }
if (wrong.Count > 0)
Console.WriteLine($"[HeartbeatDB] appVersion migration: corrected {wrong.Count} row(s) to {want}");
}
catch (Exception ex)
{
Console.WriteLine($"[HeartbeatDB] appVersion migration skipped: {ex.Message}");
}
}
public class PlayerHeartbeatData
{
// Keyed by playerid. This used to be `(ulong)_col.LongCount() + 1` at every
// insert, which is not a usable primary key: delete any row and the count
// drops, so the next insert reuses an existing id and LiteDB throws
// "Cannot insert duplicate key in unique index '_id'". Two concurrent
// inserts read the same count and collide identically. A throw there aborts
// the write, so the heartbeat row silently stops being updated.
// playerid is already unique (see the index in Setup), so it IS the key.
[BsonId]
public ulong iD { get; set; }
public ulong playerid { get; set; }
public Heartbeat Heartbeat { get; set; }
}
private static Heartbeat CreateDefaultHeartbeat(ulong playerid, Platforms platform, DeviceClasses deviceClasses) =>
new()
{
playerId = (long)playerid,
appVersion = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion,
// 0 = Unknown. Was -1, which is NOT a valid DeviceClasses value - the client's
// strict presence parser threw on the out-of-range enum and dropped the WHOLE
// presence array, so a friends/player list built against it (offline friends all
// get this default) came back EMPTY. Same class of bug as the statusVisibility=4
// fix below; the reference emits `deviceClass ?? 0`.
deviceClass = 0,
isOnline = false,
platform = 0,
roomInstance = null,
statusVisibility = 0, // 0 = Everyone (matches the reference); was 4 (out of range)
vrMovementMode = 1, // reference default (`?? 1`); 0 was also fine but match it
};
// Where we last DELIBERATELY put each player, held in memory alongside the DB row.
//
// The heartbeat answer is authoritative to the client: answer "nowhere" and it
// decides it was disconnected and walks itself home to its dorm. So a room
// instance that goes missing by ACCIDENT - a row that never got written, a lost
// LiteDB update, a restart, a concurrent write - must not be reported as "nowhere".
//
// Only UpdatePlayerHeartbeat writes this, so an entry existing means "we put them
// there on purpose and nothing has deliberately moved them since". A real logout or
// ban clears the entry (it goes through UpdatePlayerHeartbeat with a null instance),
// so intentional "nowhere" still answers nowhere.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, RoomInstance> _placedIn = new();
// Last instance id we answered per player, purely so a CHANGE can be logged. A
// change the player did not ask for is the drop, and this is the line that names it.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, long> _lastAnswered = new();
// ---- Short-term memory that survives a mass drop -------------------------------------
//
// An instance only "exists" while some ONLINE heartbeat row points at it. That is fine
// until the Photon master client (the first player into the instance) leaves: every other
// client in that Photon room runs its master-switch recovery - a full logout + re-login,
// then "join the player I was with" - and the drop itself has just wiped all of their
// rows. So each recovering player asks about a friend whose row is now empty, gets
// "target offline / not in a room", and lands in their dorm. Seen live 2026-09-11 22:12:
// 6 leaves ^RecCenter -> 11 and 64 both reset within 6 s -> all three in dorms.
//
// These two maps remember, for a few minutes, where each player was LAST placed and
// which instances were live, independent of the rows. They are consulted only when the
// live answer is empty, so nothing changes for a player who is simply somewhere else.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<long, (RoomInstance inst, DateTime seen)> _recentInstances = new();
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, (RoomInstance inst, DateTime seen)> _lastPlacement = new();
/// <summary>How long after a drop a player's last instance is still offered for regrouping.</summary>
public static readonly TimeSpan RegroupWindow = TimeSpan.FromMinutes(2);
/// <summary>How long an instance is answerable by id after its last occupant's row is cleared.</summary>
public static readonly TimeSpan InstanceMemory = TimeSpan.FromMinutes(15);
// The instance each player was in BEFORE their latest move, so "were these two just
// together?" can be answered after one of them has already moved on (the party leader
// has left the room by the time the member asks to follow).
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, (long instanceId, DateTime left)> _prevPlacement = new();
private static void Remember(ulong playerid, RoomInstance inst)
{
if (inst == null || inst.roomInstanceId == 0) return;
var now = DateTime.UtcNow;
if (_lastPlacement.TryGetValue(playerid, out var last) && last.inst.roomInstanceId != inst.roomInstanceId)
_prevPlacement[playerid] = (last.inst.roomInstanceId, now);
_recentInstances[inst.roomInstanceId] = (inst, now);
_lastPlacement[playerid] = (inst, now);
}
/// <summary>
/// Were <paramref name="a"/> and <paramref name="b"/> standing in the same instance within
/// the regroup window? True when a's current/last instance equals b's current OR the one b
/// just left. This is what stands in for "same party" on a follow: the client keeps its
/// party on the Photon side and never posts the member list, so being together a moment
/// ago is the best evidence the server has.
/// </summary>
public static bool WereTogetherRecently(ulong a, ulong b)
{
var cutoff = DateTime.UtcNow - RegroupWindow;
long aInst = _lastPlacement.TryGetValue(a, out var la) && la.seen >= cutoff ? la.inst.roomInstanceId : 0;
if (aInst == 0) return false;
if (_lastPlacement.TryGetValue(b, out var lb) && lb.inst.roomInstanceId == aInst) return true;
if (_prevPlacement.TryGetValue(b, out var pb) && pb.instanceId == aInst && pb.left >= cutoff) return true;
return false;
}
/// <summary>The instance this player was in within RegroupWindow, or null.</summary>
public static RoomInstance? RecentInstanceFor(ulong playerid)
{
if (!_lastPlacement.TryGetValue(playerid, out var p)) return null;
return DateTime.UtcNow - p.seen <= RegroupWindow ? p.inst : null;
}
/// <summary>The most recently live instance of a room (public or private) within InstanceMemory, or null.</summary>
public static RoomInstance? RecentInstanceOfRoomAny(ulong roomId)
{
var cutoff = DateTime.UtcNow - InstanceMemory;
return _recentInstances.Values
.Where(v => v.seen >= cutoff && v.inst.roomId == (long)roomId && !string.IsNullOrEmpty(v.inst.photonRoomIdYeee))
.OrderByDescending(v => v.seen)
.Select(v => v.inst)
.FirstOrDefault();
}
/// <summary>The most recently live PUBLIC instance of a room within RegroupWindow, or null.</summary>
public static RoomInstance? RecentInstanceOfRoom(ulong roomId)
{
var cutoff = DateTime.UtcNow - RegroupWindow;
return _recentInstances.Values
.Where(v => v.seen >= cutoff && v.inst.roomId == (long)roomId && !v.inst.isPrivate
&& !string.IsNullOrEmpty(v.inst.photonRoomIdYeee))
.OrderByDescending(v => v.seen)
.Select(v => v.inst)
.FirstOrDefault();
}
/// <summary>
/// Whether this player has a stored heartbeat at all. Distinct from "has no room
/// instance" - a player sitting in a menu has a row with a null instance.
/// </summary>
public static bool HasHeartbeatRow(ulong playerid) =>
_col.FindOne(x => x.playerid == playerid) != null;
/// <summary>
/// Read a player's presence WITHOUT the repair-writes/logging that GetPlayerHeartbeat does -
/// for read-only surfaces like the admin players list that must not mutate state or spam the
/// console when they enumerate everyone. Returns whether they're online and their current
/// room instance (null if nowhere), honoring the in-memory placement fallback.
/// </summary>
public static (bool online, RoomInstance? room) PeekPresence(ulong playerid)
{
// Online = a LIVE notification WebSocket, not the stored isOnline flag. The flag goes
// stale on a hard disconnect (shows "online" forever) and is unaffected by - but also
// doesn't help with - "appear offline". The WebSocket is ground truth: it's opened on
// connect, removed on disconnect, and is independent of the player's status visibility,
// so the admin sees who is ACTUALLY connected even if they're appearing offline to
// everyone else. If they're not connected, they're offline here regardless of the flag.
bool online = CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(playerid);
var hb = _col.FindOne(x => x.playerid == playerid)?.Heartbeat;
RoomInstance? room = hb?.roomInstance;
if (room == null && _placedIn.TryGetValue(playerid, out var placed))
room = placed;
// Only surface a room while actually online, so a stale room from a dropped session
// doesn't read as "still in that room".
return online ? (true, room) : (false, null);
}
/// <summary>
/// Presence of ANOTHER player as it should appear in someone else's friends/player list.
/// Unlike GetPlayerHeartbeat (which is the player's OWN authoritative heartbeat and
/// deliberately refuses to answer "nowhere"), this is what OTHERS see, so it is gated on a
/// LIVE notification WebSocket - the same ground truth PeekPresence uses for the admin panel:
/// * not actually connected -> isOnline=false, roomInstance=null (fixes "shows online in
/// a room they aren't in" after a crash/close, where the stored row keeps isOnline=true
/// and the last room forever, and _placedIn still holds the old instance);
/// * connected but "appear offline" (statusVisibility 3 = No One) -> also reported offline.
/// Side-effect-free: never writes the DB or restores _placedIn, so enumerating a whole
/// friends list doesn't churn repair-writes or spam the console. Enum fields stay valid
/// (appVersion/deviceClass/etc.) so the client's strict presence parser still accepts it.
/// </summary>
public static Heartbeat GetPresenceForList(ulong playerid, bool viewerIsSelf, bool viewerIsFriend, bool viewerIsFavorite, ulong viewerId = 0)
{
// An invite is consent to be seen: while a live invite from this player to the viewer
// exists, the viewer sees them online and in their (private) instance no matter what
// "Appear Online To" says. Without this the invitee's client refused the invite with
// "Cannot accept invite. Target player is offline." whenever the inviter wasn't
// visible to them (non-friend + Friends-only status, or Favorites-only).
if (!viewerIsSelf && viewerId != 0 && viewerId != playerid
&& Campus_live_rewrite.api.server.RoomInvites.HasInviteFromTo(playerid, viewerId))
{
viewerIsFriend = true;
viewerIsFavorite = true;
}
var stored = _col.FindOne(x => x.playerid == playerid)?.Heartbeat;
var hb = stored ?? CreateDefaultHeartbeat(playerid, Platforms.Steam, DeviceClasses.Screen);
hb.serverTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (string.IsNullOrEmpty(hb.lastOnline)) hb.lastOnline = DateTime.UtcNow.ToString("o");
hb.appVersion = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion;
// Defensive clamp: a legacy row with an out-of-range deviceClass (-1) or vrMovementMode
// makes the client's strict presence parser throw and drop the WHOLE friends-list array
// (see the deviceClass=-1 trap). Never serve one from this read.
if (hb.deviceClass < 0) hb.deviceClass = 0; // 0 = Unknown
if (hb.vrMovementMode < 0) hb.vrMovementMode = 1;
// statusVisibility is 0..3 on the client; the old default row carried 4, and one such
// entry in a friends-list page made the client drop the WHOLE page ("some friends are
// missing and their profile won't open"). Clamp on the way out too.
if (hb.statusVisibility < 0 || hb.statusVisibility > 3) hb.statusVisibility = 0;
bool live = CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(playerid);
// "Appear Online To" (statusVisibility): 0 Everyone, 1 Friends, 2 Favorites, 3 No One.
// The player always sees their OWN real presence; others see it only if the setting
// allows THEM. Changing this in-game (PUT player/statusvisibility) therefore reflects to
// friends within one presence poll. Unknown/legacy values are treated as Everyone.
int vis = hb.statusVisibility;
bool hiddenByStatus = !viewerIsSelf && (
vis == 3
|| (vis == 1 && !viewerIsFriend)
|| (vis == 2 && !viewerIsFavorite));
// A block hides presence both ways: neither of them sees the other online or where.
if (!viewerIsSelf && viewerId != 0 && !hiddenByStatus && FriendsDB.IsBlockedEitherWay(viewerId, playerid))
hiddenByStatus = true;
// online-in-no-room: a player whose presence says online but names no instance (just
// kicked, mid-recovery) NREs the 2025 client's friend card (NotInSameInstanceHide
// dereferences the instance when online) - the card renders blank and the profile
// won't open. To everyone else that player reads as offline until they land somewhere.
if (!viewerIsSelf && hb.isOnline && hb.roomInstance == null) hb.isOnline = false;
if (!live || hiddenByStatus)
{
hb.isOnline = false;
hb.roomInstance = null;
}
// "Full" on the friends list / room card: computed from live presence, never stored.
if (hb.roomInstance != null) hb.roomInstance.isFull = InstanceIsFull(hb.roomInstance);
return hb;
}
public static Heartbeat GetPlayerHeartbeat(ulong playerid)
{
var data = _col.FindOne(x => x.playerid == playerid);
var hb = data?.Heartbeat;
// own-heartbeat clamp: the same out-of-range guard GetPresenceForList applies
if (hb != null)
{
if (hb.statusVisibility < 0 || hb.statusVisibility > 3) hb.statusVisibility = 0;
if (hb.deviceClass < 0) hb.deviceClass = 0;
if (hb.vrMovementMode < 0) hb.vrMovementMode = 1;
}
if (hb == null)
{
hb = CreateDefaultHeartbeat(playerid, Platforms.Steam, DeviceClasses.Screen);
// No row at all. If we know where we put them, say that rather than
// "nowhere", and write the row back so matchmaking agrees with us.
if (_placedIn.TryGetValue(playerid, out var placed))
{
hb.roomInstance = placed;
hb.isOnline = true;
Console.WriteLine($"[heartbeat] {playerid}: no row - restored instance " +
$"{placed.roomInstanceId} ({placed.name}) instead of answering 'nowhere'");
try { UpdatePlayerHeartbeat(playerid, placed); } catch { }
}
}
else if (hb.roomInstance == null && _placedIn.TryGetValue(playerid, out var placed))
{
// Row exists but has lost its room instance while we still believe the
// player is somewhere. Nothing deliberate nulls it without clearing
// _placedIn, so this is drift - repair it rather than sending them home.
hb.roomInstance = placed;
hb.isOnline = true;
Console.WriteLine($"[heartbeat] {playerid}: row lost its instance - restored " +
$"{placed.roomInstanceId} ({placed.name}) instead of answering 'nowhere'");
try { UpdatePlayerHeartbeat(playerid, placed); } catch { }
}
// serverTime was DECLARED and never assigned anywhere, so every presence
// heartbeat this server has ever sent carried serverTime = 0 - i.e. 1970. Any
// client-side check that compares our clock to its own sees a ~56 year skew.
// Unix SECONDS, matching the JWT iat/exp the client already parses from our
// tokens and the rest of this codebase (BanDB, moderation timestamps).
hb.serverTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
// Also never set: null reads as "never seen", which is wrong for a player who
// is heartbeating at us right now.
hb.lastOnline = DateTime.UtcNow.ToString("o");
long now = hb.roomInstance?.roomInstanceId ?? 0;
long prev = _lastAnswered.TryGetValue(playerid, out var p) ? p : 0;
if (now != prev)
{
Console.WriteLine($"[heartbeat] {playerid}: answer changed {prev} -> {now} " +
$"({hb.roomInstance?.name ?? "nowhere"})");
_lastAnswered[playerid] = now;
}
return hb;
}
public static string GetPlayerHeartbeatRoomName(ulong playerid)
{
var data = _col.FindOne(x => x.playerid == playerid);
return data?.Heartbeat?.roomInstance?.name ?? "nowhere!";
}
public static bool CreatePlayerHeartbeat(ulong playerid, out Heartbeat heartbeat, Platforms platform, DeviceClasses deviceClasses)
{
if (_col.Exists(x => x.playerid == playerid))
{
heartbeat = null!;
return false;
}
heartbeat = CreateDefaultHeartbeat(playerid, platform, deviceClasses);
_col.Insert(new PlayerHeartbeatData
{
iD = playerid, // keyed by playerid - see note on PlayerHeartbeatData.iD
playerid = playerid,
Heartbeat = heartbeat
});
return true;
}
public static void UpdateVrMovementMode(ulong playerid, int mode)
{
var data = _col.FindOne(x => x.playerid == playerid);
if (data?.Heartbeat == null) return;
data.Heartbeat.vrMovementMode = mode;
_col.Update(data);
}
public static void UpdateStatusVisibility(ulong playerid, int visibility)
{
var data = _col.FindOne(x => x.playerid == playerid);
if (data != null)
{
data.Heartbeat.statusVisibility = visibility;
_col.Update(data);
}
else
{
// Upsert: a player who changes their status before any heartbeat row exists used
// to have the change silently dropped. Create the row so it persists (and so the
// change is honored by GetPresenceForList right away).
var hb = CreateDefaultHeartbeat(playerid, Platforms.Steam, DeviceClasses.Screen);
hb.statusVisibility = visibility;
_col.Insert(new PlayerHeartbeatData { iD = playerid, playerid = playerid, Heartbeat = hb });
}
Console.WriteLine($"[presence] {playerid} statusVisibility -> {visibility}");
}
public static string GetPlayerCurrentRoomName(ulong playerid)
{
var data = _col.FindOne(x => x.playerid == playerid);
return data?.Heartbeat?.roomInstance?.name ?? "not in a room.";
}
/// <summary>
/// Finds a live room instance by its id, whoever is in it.
///
/// connection-info is asked about a SPECIFIC roomInstanceId and used to answer it
/// from the caller's own heartbeat instead. That was survivable only while every
/// public instance shared the Photon room name "room" - any mismatch still landed
/// the player in the same place. With per-instance names a mismatch sends them to
/// the wrong Photon room and the load dies with "disconnect during load".
/// </summary>
/// <summary>
/// The live instances of a room with who is standing in each - the owner's "Instances"
/// tab (reference match GET /room/{id}/instances). Grouped by Photon room, which IS the
/// instance as far as the client is concerned.
/// </summary>
public static List<(RoomInstance inst, List<ulong> players)> LiveInstancesOfRoom(ulong roomId)
{
var rows = _col.FindAll()
.Where(x => x.Heartbeat != null && x.Heartbeat.isOnline && x.Heartbeat.roomInstance != null
&& x.Heartbeat.roomInstance.roomId == (long)roomId
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid))
.ToList();
return rows
.GroupBy(x => string.IsNullOrEmpty(x.Heartbeat!.roomInstance!.photonRoomIdYeee) ? x.Heartbeat.roomInstance.roomInstanceId.ToString() : x.Heartbeat.roomInstance.photonRoomIdYeee!)
.Select(g => (g.First().Heartbeat!.roomInstance!, g.Select(x => x.playerid).Distinct().ToList()))
.OrderBy(t => t.Item1.roomInstanceId)
.ToList();
}
/// <summary>
/// Change a flag on a live instance. Presence keeps a COPY of the instance on every
/// player's row, so a change (private, in progress) has to land on each of them or the
/// next heartbeat serves the old value back.
/// </summary>
public static int UpdateInstanceEverywhere(long roomInstanceId, Action<RoomInstance> edit)
{
int n = 0;
foreach (var row in _col.FindAll().Where(x => x.Heartbeat?.roomInstance != null && x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId).ToList())
{
edit(row.Heartbeat!.roomInstance!);
_col.Update(row);
n++;
}
if (_recentInstances.TryGetValue(roomInstanceId, out var r)) edit(r.inst);
return n;
}
/// <summary>
/// One-off repair of stored presence rows that carry values the client's strict parser
/// refuses (statusVisibility 4 from the old default, deviceClass -1). Returns how many.
/// </summary>
public static int FixOutOfRangePresence()
{
int n = 0;
foreach (var row in _col.FindAll().ToList())
{
var hb = row.Heartbeat;
if (hb == null) continue;
bool changed = false;
if (hb.statusVisibility < 0 || hb.statusVisibility > 3) { hb.statusVisibility = 0; changed = true; }
if (hb.deviceClass < 0) { hb.deviceClass = 0; changed = true; }
if (hb.vrMovementMode < 0) { hb.vrMovementMode = 1; changed = true; }
if (changed) { _col.Update(row); n++; }
}
return n;
}
public static RoomInstance? FindInstanceById(long roomInstanceId)
{
if (roomInstanceId == 0) return null;
var live = _col.FindAll()
.Where(x => x.Heartbeat?.roomInstance != null
&& x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId)
.Select(x => x.Heartbeat!.roomInstance!)
.FirstOrDefault();
if (live != null) return live;
// Nobody's row points at it right now - but if it was live minutes ago, a client that
// still holds the id (a recovering one) can be sent back to the same Photon room.
if (_recentInstances.TryGetValue(roomInstanceId, out var r) && DateTime.UtcNow - r.seen <= InstanceMemory)
return r.inst;
return null;
}
/// <summary>
/// A LIVE instance of a given room, whoever is in it - the same (name, isPrivate) the admin
/// panel shows. Used by the Discord-presence room lookup so it reflects the instance the
/// player is actually in (works for dorms, which the static room API doesn't describe the
/// same way). Returns null if nobody is in an instance of that room right now.
/// </summary>
/// <summary>
/// A player's OWN current room + their status-visibility, straight from their heartbeat row
/// (no WebSocket gating - the caller IS the player, so they're online by definition). Used
/// by the Discord-presence self endpoint: statusVisibility decides whether the room may be
/// shown publicly at all, roomInstance is where they are. Honors the placement fallback.
/// </summary>
public static (RoomInstance? room, int statusVisibility) GetSelfPresence(ulong playerid)
{
var hb = _col.FindOne(x => x.playerid == playerid)?.Heartbeat;
RoomInstance? room = hb?.roomInstance;
if (room == null && _placedIn.TryGetValue(playerid, out var placed)) room = placed;
return (room, hb?.statusVisibility ?? 0);
}
public static RoomInstance? FindInstanceByRoomId(long roomId)
{
if (roomId == 0) return null;
return _col.FindAll()
.Where(x => x.Heartbeat?.roomInstance != null
&& x.Heartbeat.roomInstance.roomId == roomId)
.Select(x => x.Heartbeat!.roomInstance!)
.FirstOrDefault();
}
/// <summary>
/// Finds a public instance of <paramref name="roomId"/> that someone is already in
/// and that still has space, so a second player joining the same room LANDS WITH
/// THEM instead of in a private world of their own.
///
/// This is required by the unique Photon room names in Sessions.CreateRoom. While
/// every public instance shared the constant name "room", players met by accident -
/// and also met everyone in every other room, which was the bug. Now that names are
/// per-instance, matchmaking has to deliberately reuse an instance for people to
/// meet at all.
///
/// Returns null when nobody is in the room, or every instance is full - the caller
/// then creates a fresh one.
/// </summary>
public static RoomInstance? FindJoinableInstance(ulong roomId, ulong excludePlayerId = 0)
{
var occupants = _col.FindAll()
.Where(x => x.playerid != excludePlayerId
&& x.Heartbeat != null
&& x.Heartbeat.isOnline
&& x.Heartbeat.roomInstance != null
&& x.Heartbeat.roomInstance.roomId == (long)roomId
&& !x.Heartbeat.roomInstance.isPrivate
&& !string.IsNullOrEmpty(x.Heartbeat.roomInstance.photonRoomIdYeee)
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid)
// an instance they were kicked out of is not offered to them again
&& !server.InstanceBlocks.IsBlocked(excludePlayerId, x.Heartbeat.roomInstance))
.Select(x => x.Heartbeat.roomInstance!)
.ToList();
if (occupants.Count == 0)
{
// Nobody recorded in the room - but if an instance of it was live moments ago, this
// is most likely a player recovering from the mass drop above. Put them back in the
// same Photon room so the group re-forms instead of each opening a fresh instance.
var recent = RecentInstanceOfRoom(roomId);
if (recent != null && server.InstanceBlocks.IsBlocked(excludePlayerId, recent)) recent = null;
if (recent != null)
Console.WriteLine($"[matchmake] regrouping into recent instance {recent.roomInstanceId} of room {roomId} (photon {recent.photonRoomIdYeee})");
return recent;
}
// Group by the Photon room - that IS the instance as far as the client is
// concerned - and pick the fullest one with space, so players gather together
// rather than scattering one-per-instance.
var best = occupants
.GroupBy(r => r.photonRoomIdYeee)
.Select(g => new { Instance = g.First(), Count = g.Count() })
.Where(g => g.Count < EffectiveCapacity(g.Instance))
.OrderByDescending(g => g.Count)
.FirstOrDefault();
return best?.Instance;
}
/// <summary>
/// Live player count per ROOM id, in one pass over presence - what Room.CCU carries.
/// Rooms with nobody in them are simply absent from the map.
/// </summary>
private static Dictionary<long, int> _ccuCache = new();
private static DateTime _ccuCacheAt = DateTime.MinValue;
public static Dictionary<long, int> LivePlayerCountsByRoom()
{
// Cached for a few seconds: this is called once per room response (including the hot
// list) and a full presence scan per request would be the expensive part of it.
if ((DateTime.UtcNow - _ccuCacheAt).TotalSeconds < 5) return _ccuCache;
var counts = new Dictionary<long, int>();
foreach (var x in _col.FindAll())
{
var inst = x?.Heartbeat?.roomInstance;
if (x?.Heartbeat == null || !x.Heartbeat.isOnline || inst == null) continue;
counts[inst.roomId] = counts.TryGetValue(inst.roomId, out var n) ? n + 1 : 1;
}
_ccuCache = counts; _ccuCacheAt = DateTime.UtcNow;
return counts;
}
/// <summary>
/// The cap that actually applies to an instance: the subroom's CURRENT MaxPlayers (what
/// the room settings show), falling back to the value stamped when the instance was
/// opened, and finally 4 like the reference. Read live so an owner raising the cap in
/// room settings frees the running instance instead of it staying "full" at the old
/// number. Cached briefly - this runs on every heartbeat.
/// </summary>
private static readonly Dictionary<(long, long), (int cap, DateTime at)> _capCache = new();
public static int EffectiveCapacity(RoomInstance? inst)
{
if (inst == null) return 0;
int fromSub = 0;
var key = (inst.roomId, inst.subRoomId);
lock (_capCache)
{
if (_capCache.TryGetValue(key, out var c) && (DateTime.UtcNow - c.at).TotalSeconds < 20) fromSub = c.cap;
else
{
try { fromSub = RoomDB.GetSubRoom((ulong)inst.roomId, (ulong)inst.subRoomId)?.MaxPlayers ?? 0; } catch { fromSub = 0; }
_capCache[key] = (fromSub, DateTime.UtcNow);
if (_capCache.Count > 5000) _capCache.Clear();
}
}
if (fromSub > 0) return fromSub;
return inst.maxCapacity > 0 ? inst.maxCapacity : 4;
}
/// <summary>Whether the instance is at its player cap (the subroom's MaxPlayers).</summary>
public static bool InstanceIsFull(RoomInstance? inst)
{
if (inst == null) return false;
int cap = EffectiveCapacity(inst);
inst.maxCapacity = cap;
return cap > 0 && CountInInstance(inst.roomInstanceId) >= cap;
}
/// <summary>How many online players' presence points at this instance.</summary>
// live-connection gate: a presence row stays "online" after a crash / closed game until
// the player logs in again, so ghosts piled up in an instance and it read as full while
// half the slots were empty. Only a player holding a live notification socket counts.
/// <summary>Players in any instance of this room right now (live websocket only).</summary>
public static int CountInRoom(ulong roomId) =>
_col.FindAll().Count(x => x.Heartbeat != null && x.Heartbeat.isOnline
&& x.Heartbeat.roomInstance != null
&& x.Heartbeat.roomInstance.roomId == (long)roomId
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid));
public static int CountInInstance(long roomInstanceId) =>
_col.FindAll().Count(x => x.Heartbeat != null && x.Heartbeat.isOnline
&& x.Heartbeat.roomInstance != null
&& x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid));
public static void ClearPlayersHeartbeats()
{
foreach (var data in _col.FindAll())
{
UpdatePlayerHeartbeat(data.playerid, null, false);
}
}
public static void ClearPlayerHeartbeat(ulong playerId) =>
UpdatePlayerHeartbeat(playerId, null, false);
/// <summary>
/// When this player was last connected, from the STORED row (GetPlayerHeartbeat rewrites
/// lastOnline to "now" on every read, so it can't be used for this). Stamped by
/// StampLastSeen on every placement and on notification-socket close, so for an offline
/// player it is the moment they disconnected. Null when never seen.
/// </summary>
public static string? LastSeen(ulong playerid) => _col.FindOne(x => x.playerid == playerid)?.Heartbeat?.lastOnline;
public static void StampLastSeen(ulong playerid)
{
try
{
var data = _col.FindOne(x => x.playerid == playerid);
if (data?.Heartbeat == null) return;
data.Heartbeat.lastOnline = DateTime.UtcNow.ToString("o");
_col.Update(data);
}
catch { }
}
public static void UpdatePlayerHeartbeat(
ulong playerid,
RoomInstance? roomInstance,
bool online = true,
Platforms platform = Platforms.All,
DeviceClasses deviceClasses = DeviceClasses.Unknown)
{
// Record the DELIBERATE placement before touching the DB. Everything that
// moves a player goes through here, so this is the one place that can tell
// "we put them nowhere" apart from "they fell off the record by accident".
// See _placedIn on GetPlayerHeartbeat.
if (online && roomInstance != null) { _placedIn[playerid] = roomInstance; Remember(playerid, roomInstance); }
else _placedIn.TryRemove(playerid, out _);
var data = _col.FindOne(x => x.playerid == playerid);
// Capture the prior state so we only push a realtime presence update to friends when it
// ACTUALLY changed (online<->offline, or a different room instance) - the periodic
// heartbeat doesn't come through here, but matchmake/join/dorm/ban all do, some with the
// same instance repeatedly.
long oldInstance = data?.Heartbeat?.roomInstance?.roomInstanceId ?? 0;
bool wasOnline = data?.Heartbeat?.isOnline ?? false;
if (data != null)
{
var hb = data.Heartbeat;
hb.roomInstance = online ? roomInstance : null;
hb.isOnline = online;
hb.lastOnline = DateTime.UtcNow.ToString("o"); // see LastSeen
// Correct any row still carrying the old hardcoded build, so existing players stop
// showing as a version mismatch on their next heartbeat (not just fresh rows).
hb.appVersion = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion;
_col.Update(data);
}
else
{
// Create a new heartbeat and assign the current roomInstance
var hb = CreateDefaultHeartbeat(playerid, platform, deviceClasses);
hb.roomInstance = roomInstance;
hb.isOnline = online;
_col.Insert(new PlayerHeartbeatData
{
iD = playerid, // keyed by playerid - see note on PlayerHeartbeatData.iD
playerid = playerid,
Heartbeat = hb
});
}
long newInstance = (online ? roomInstance?.roomInstanceId : null) ?? 0;
if (wasOnline != online || oldInstance != newInstance)
{
Campus_live_rewrite.api.server.Notifications.FireAndForget(
Campus_live_rewrite.api.server.Notifications.PushPresenceToFriends(playerid),
$"presence-change {playerid}");
}
}
public class Heartbeat
{
public long serverTime { get; set; }
public long playerId { get; set; }
public int statusVisibility { get; set; }
public int platform { get; set; }
public int deviceClass { get; set; }
public int vrMovementMode { get; set; }
public RoomInstance roomInstance { get; set; } // Can be null
public string lastOnline { get; set; } // ISO 8601 string
public bool isOnline { get; set; }
public string appVersion { get; set; }
}
public class FullRoomInstance
{
public RoomInstance? roomInstance { get; set; }
public string? correlationId { get; set; }
}
public class RoomInstance
{
public long roomInstanceId { get; set; }
public long roomId { get; set; }
public long subRoomId { get; set; }
public string? location { get; set; }
public int roomInstanceType { get; set; }
public string? name { get; set; }
// The event/club this instance belongs to (0 = none). The client reads eventId to
// show the "This Event" tab on the room panel when you're in an event instance
// (mirrors the reference RoomInstanceDto's eventId/clubId).
public long eventId { get; set; }
public long clubId { get; set; }
public int maxCapacity { get; set; }
public bool isFull { get; set; }
public bool isPrivate { get; set; }
public bool isInProgress { get; set; }
[JsonIgnore]
public string? photonRoomIdYeee { get; set; } // im crack
public string? voiceServerId { get; set; }
public string? voiceAuthId { get; set; }
public int matchmakingPolicy { get; set; }
// Whether the Photon Voice stream is encrypted. RecFlare sets this to false on every
// room instance, and it MUST be present: absent, the client falls back to its own
// default (encrypt), and then peers encrypt their voice with no shared key exchange
// set up - they connect fine but hear each other as silence, which is exactly the
// "mic works, nobody can hear you" symptom. false = plain voice, everyone hears everyone.
public bool EncryptVoiceChat { get; set; } = false;
}
public enum MatchmakingErrorCode
{
Success,
NoSuchGame,
PlayerNotOnline,
InsufficientSpace,
EventNotStarted,
EventAlreadyFinished,
EventCreatorNotReady,
BlockedFromRoom,
ProfileLocked,
NoBirthday,
MarkedForDelete,
JuniorNotAllowed,
Banned,
AlreadyInBestInstance,
InsufficientRelationship,
UpdateRequired = 16,
AlreadyInTargetInstance,
RegistrationRequired,
UGCNotAllowed,
NoSuchRoom,
RoomCreatorNotReady,
RoomIsNotActive,
RoomBlockedByCreator,
RoomBlockingCreator,
RoomIsPrivate,
RoomInstanceIsPrivate,
DeviceClassNotSupported = 30,
DeviceClassNotSupportedByRoomOwner,
VRMovementModeNotSupportedByRoomOwner,
EventIsPrivate = 35,
RoomInviteExpired = 40,
NoAvailableRegion = 45,
NotorietyTooPoor = 50,
BannedFromRoom = 55
}
}
}
@@ -0,0 +1,714 @@
using LiteDB;
using Newtonsoft.Json;
using System.Collections.Concurrent;
using System.Text.Json.Serialization;
public static class ImageMetadataDB
{
private static readonly string DbPath = Path.Combine("Data", "DBs", "Images.db");
public class FullSavedImage
{
public int Id { get; set; }
public ImageAccessibility Accessibility { get; set; }
public bool AccessibilityLocked { get; set; } = false;
public int CheerCount { get; set; } = 0;
public int CommentCount { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string? Description { get; set; }
public string ImageName { get; set; }
public ulong PlayerEventId { get; set; }
public ulong PlayerId { get; set; }
public int RoomId { get; set; }
//public string Description { get; set; } = "";
public SavedImageTypeEnum Type { get; set; }
public List<ulong> TaggedPlayerIds { get; set; } = new();
// Website-only extra state. The game reads Accessibility (Private/Public) and never sees
// this - a friends-only photo is stored Accessibility=Private (safe in-game), FriendsOnly=true,
// and the WEBSITE shows it to the owner's friends.
public bool FriendsOnly { get; set; } = false;
}
// NOTE: the client's v6 record keys the id as SavedImageId and the type as SavedImageType
// (wire-shapes: api-images-v6). `Id`/`Type` are dropped silently by Utf8Json, which left every
// v6 lookup with SavedImageId 0.
public class ImageV6
{
public long SavedImageId { get; set; }
public SavedImageTypeEnum SavedImageType { get; set; }
public ulong ClubId { get; set; }
public ImageAccessibility Accessibility { get; set; }
public bool AccessibilityLocked { get; set; } = false;
public int CheerCount { get; set; } = 0;
public int CommentCount { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string? Description { get; set; }
public int Id { get; set; }
public string ImageName { get; set; }
public ulong PlayerEventId { get; set; }
public ulong PlayerId { get; set; }
public int RoomId { get; set; }
public List<ulong> TaggedPlayerIds { get; set; } = new();
public SavedImageTypeEnum Type { get; set; }
}
public class ImagesPlayer
{
public ImageAccessibility Accessibility { get; set; }
public bool AccessibilityLocked { get; set; } = false;
public int CheerCount { get; set; } = 0;
public int CommentCount { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string? Description { get; set; }
public string ImageName { get; set; }
public ulong PlayerEventId { get; set; }
public ulong PlayerId { get; set; }
public int RoomId { get; set; }
public int SavedImageId { get; set; }
public SavedImageTypeEnum SavedImageType { get; set; }
public List<ulong> TaggedPlayerIds { get; set; } = new();
public bool FriendsOnly { get; set; } = false;
}
public class ImageMetadata
{
public List<ulong> playerIds { get; set; } = new();
public SavedImageTypeEnum savedImageType { get; set; }
public int roomId { get; set; }
public ImageAccessibility accessibility { get; set; }
public string? description { get; set; }
}
public enum SavedImageTypeEnum
{
None, // 0
ShareCamera, // 1
OutfitThumbnail, // 2
RoomThumbnail, // 3
ProfileThumbnail, // 4
InventionThumbnail, // 5
PlayerEventThumbnail, // 6
RoomLoadScreen // 7
}
public enum ImageAccessibility
{
Private = 0,
Public = 1,
}
public class Cheer
{
public ObjectId Id { get; set; }
public int SavedImageId { get; set; }
public ulong PlayerId { get; set; }
}
public class CheerRequest
{
public int SavedImageId { get; set; }
public bool Cheer { get; set; }
}
/// <summary>A website comment on a photo. CommentCount on the image is kept in step.</summary>
public class ImageComment
{
public int Id { get; set; }
public int SavedImageId { get; set; }
public ulong PlayerId { get; set; }
public string Text { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public static FullSavedImage? GetByName(string name) =>
string.IsNullOrWhiteSpace(name) ? null : GetDb().GetCollection<FullSavedImage>("images").FindOne(x => x.ImageName == name);
public static ImageComment? GetComment(int commentId) =>
GetDb().GetCollection<ImageComment>("comments").FindById(commentId);
public static List<ImageComment> GetComments(int savedImageId) =>
GetDb().GetCollection<ImageComment>("comments").Find(x => x.SavedImageId == savedImageId).OrderBy(x => x.CreatedAt).ToList();
public static ImageComment? AddComment(int savedImageId, ulong playerId, string text)
{
var db = GetDb();
if (db.GetCollection<FullSavedImage>("images").FindById(savedImageId) == null) return null;
var c = new ImageComment { SavedImageId = savedImageId, PlayerId = playerId, Text = text, CreatedAt = DateTime.UtcNow };
db.GetCollection<ImageComment>("comments").Insert(c);
RecountComments(savedImageId);
return c;
}
/// <summary>Delete a comment: its author, or the photo's owner, may.</summary>
public static bool DeleteComment(int commentId, ulong byPlayer)
{
var db = GetDb();
var col = db.GetCollection<ImageComment>("comments");
var c = col.FindById(commentId);
if (c == null) return false;
var img = db.GetCollection<FullSavedImage>("images").FindById(c.SavedImageId);
if (c.PlayerId != byPlayer && (img == null || img.PlayerId != byPlayer)) return false;
col.Delete(commentId);
RecountComments(c.SavedImageId);
return true;
}
private static void RecountComments(int savedImageId)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
var img = col.FindById(savedImageId);
if (img == null) return;
img.CommentCount = db.GetCollection<ImageComment>("comments").Count(x => x.SavedImageId == savedImageId);
col.Update(img);
}
// PERF: Keep a thread-safe singleton LiteDatabase instance to avoid reopening files constantly.
private static readonly Lazy<LiteDatabase> _dbInstance = new(() => new LiteDatabase(DbPath));
public static LiteDatabase GetDb() => _dbInstance.Value;
public static FullSavedImage InsertImage(FullSavedImage image)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
col.EnsureIndex(x => x.Id, true);
// If the image doesn't already have an ID, assign the next sequential ID.
if (image.Id == 0)
{
// Get the highest existing ID and increment by 1.
// PERF: This uses descending order to get the latest ID efficiently.
var last = col.Query()
.OrderByDescending(x => x.Id)
.Limit(1)
.FirstOrDefault();
image.Id = (last?.Id ?? 0) + 1;
}
col.Insert(image);
return image;
}
public static List<FullSavedImage> GetRoomImages(
ulong playerId,
int roomId,
int sort = 0,
int filter = 0,
int take = 50,
int skip = 0)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
// Base query: only images in this room
var query = col.Query()
.Where(x => x.RoomId == roomId);
// Apply filter conditions
switch (filter)
{
case 2: // Only show player's private images
query = query.Where(x => x.PlayerId == playerId);
break;
case 3: // Only images that have been cheered
query = query.Where(x => x.CheerCount > 0);
break;
// filter == 1 will be handled in sorting
}
// Apply sorting
if (sort == 0 && filter == 1)
{
// Special case: newest first when sort=0 & filter=1
query = query.OrderByDescending(x => x.CreatedAt);
}
else
{
query = sort switch
{
1 => query.OrderByDescending(x => x.CreatedAt), // Newest first
2 => query.OrderByDescending(x => x.CheerCount), // Most cheered
3 => query.OrderByDescending(x => x.CommentCount),// Most commented
_ => query.OrderBy(x => x.CreatedAt), // Default: oldest first
};
}
// Apply pagination
return query
.Skip(skip)
.Limit(take)
.ToList();
}
public static List<FullSavedImage> GetGlobalImages()
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
return col.Query()
.Where(x => x.Accessibility == ImageAccessibility.Public)
.OrderByDescending(x => x.CheerCount)
.ToList();
}
public const int SlideshowLimit = 10;
public const int SlideshowMaxLimit = 100;
/// <summary>
/// The global slideshow feed: the most recent PUBLIC ShareCamera photos across all rooms,
/// newest first. Ported from RecFlare's getSlideshowImages. Public-only (Accessibility ==
/// Public) because this endpoint is unauthenticated - it must never surface a private photo.
/// The caller joins Username + RoomName on top of these rows.
/// </summary>
public static List<FullSavedImage> GetSlideshowImages(int limit)
{
if (limit <= 0) limit = SlideshowLimit;
if (limit > SlideshowMaxLimit) limit = SlideshowMaxLimit;
var db = GetDb();
return db.GetCollection<FullSavedImage>("images")
.Query()
.Where(x => x.Accessibility == ImageAccessibility.Public
&& x.Type == SavedImageTypeEnum.ShareCamera)
.OrderByDescending(x => x.Id)
.Limit(limit)
.ToList();
}
/// <summary>
/// The website "Recent Photos" feed: the newest PUBLIC camera photos across all rooms.
/// Like GetSlideshowImages but ALSO includes Type == None, because this server's camera
/// photos are saved as None (only ShareCamera would match nothing - the home feed was empty).
/// Still excludes thumbnails (Outfit/Room/Profile/Invention/PlayerEvent/RoomLoadScreen) so
/// profile pictures and room thumbnails never appear as "photos". Public-only (unauthenticated).
/// </summary>
// Public camera photos taken in one room, newest first - the website room page. Filters
// in the query (not after a Take) so a room full of thumbnails can't crowd real photos
// out of the window; None is included because this server saves camera photos as None.
public static List<FullSavedImage> GetRoomPublicPhotos(int roomId, int limit)
{
if (limit <= 0) limit = 24;
var db = GetDb();
return db.GetCollection<FullSavedImage>("images")
.Query()
.Where(x => x.RoomId == roomId
&& x.Accessibility == ImageAccessibility.Public
&& (x.Type == SavedImageTypeEnum.None || x.Type == SavedImageTypeEnum.ShareCamera))
.OrderByDescending(x => x.Id)
.Limit(limit)
.ToList();
}
public static List<FullSavedImage> GetRecentPublicPhotos(int limit)
{
if (limit <= 0) limit = SlideshowLimit;
if (limit > SlideshowMaxLimit) limit = SlideshowMaxLimit;
// Only the LAST 24 HOURS - "Recent Photos" should be genuinely recent, not a months-deep
// archive. Cutoff computed here (not inline) so LiteDB compares against a fixed value.
// CreatedAt is stored UTC (DateTime.UtcNow default), so compare in UTC.
var cutoff = DateTime.UtcNow.AddHours(-24);
var db = GetDb();
return db.GetCollection<FullSavedImage>("images")
.Query()
.Where(x => x.Accessibility == ImageAccessibility.Public
&& (x.Type == SavedImageTypeEnum.None || x.Type == SavedImageTypeEnum.ShareCamera)
&& x.CreatedAt >= cutoff)
.OrderByDescending(x => x.Id)
.Limit(limit)
.ToList();
}
/// <summary>Every saved photo, newest first - for the admin gallery (no accessibility filter).</summary>
public static List<FullSavedImage> GetAllImages()
{
var db = GetDb();
return db.GetCollection<FullSavedImage>("images")
.Query()
.OrderByDescending(x => x.Id)
.ToList();
}
/// <summary>
/// Deletes one photo: its metadata row, its cheers, and the image file on disk. Returns
/// the deleted row (for logging), or null if there was no such photo.
/// </summary>
public static FullSavedImage? DeleteImage(int id)
{
var db = GetDb();
var imageCol = db.GetCollection<FullSavedImage>("images");
var img = imageCol.FindById(id);
if (img == null) return null;
imageCol.Delete(id);
db.GetCollection<Cheer>("cheers").DeleteMany(c => c.SavedImageId == id);
db.GetCollection<ImageComment>("comments").DeleteMany(c => c.SavedImageId == id);
// Remove the file from wherever /img serves polaroids from. Best-effort: the metadata
// is already gone, so a stray file just 404s and is invisible.
if (!string.IsNullOrWhiteSpace(img.ImageName))
{
foreach (var dir in new[] { "polaroid_images", "player_images" })
{
try
{
var path = Path.Combine(Environment.CurrentDirectory, "Data", "Images", dir, Path.GetFileName(img.ImageName));
if (File.Exists(path)) File.Delete(path);
}
catch (Exception ex) { Console.WriteLine($"[Delete] could not remove file {img.ImageName}: {ex.Message}"); }
}
}
Console.WriteLine($"[Delete] removed photo {id} '{img.ImageName}' taken by {img.PlayerId}");
return img;
}
/// <summary>
/// A player's photos. `sort`: 0 newest first (default), 1 most cheered (the client's
/// "sort by cheers" toggle, same value the reference uses), 2 oldest first. Was unsorted -
/// LiteDB's natural order is insertion order, i.e. oldest first, and the toggle did nothing.
/// </summary>
public static List<ImagesPlayer> GetPlayerImages(ulong playeridthatrequesting, ulong playerid, int sort = 0, int skip = 0, int take = 0)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
var query = col.Query().Where(x => x.PlayerId == playerid);
if (playeridthatrequesting != playerid)
{
query = query.Where(x => x.Accessibility == ImageAccessibility.Public);
}
IEnumerable<FullSavedImage> rows = query.ToEnumerable();
rows = sort switch
{
1 => rows.OrderByDescending(x => x.CheerCount).ThenByDescending(x => x.Id),
2 => rows.OrderBy(x => x.Id),
_ => rows.OrderByDescending(x => x.Id),
};
if (skip > 0) rows = rows.Skip(skip);
if (take > 0) rows = rows.Take(take);
return rows.Select(MapToImagesPlayer).ToList();
}
/// <summary>
/// A player's photo FEED - the images they took PLUS the ones they are tagged in, newest
/// first. This is what the photo tab on a profile shows (RecFlare getPlayerFeed +
/// /api/images/v3/feed/player). Public-only unless the requester is the player themselves.
/// </summary>
public static List<ImagesPlayer> GetPlayerFeed(ulong requester, ulong playerid, int skip = 0, int take = 100, bool viewerIsFriend = false, int sort = 0)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
IEnumerable<FullSavedImage> feed = col.FindAll()
.Where(x => x.PlayerId == playerid
|| (x.TaggedPlayerIds != null && x.TaggedPlayerIds.Contains(playerid)));
// Non-owner sees public photos, plus friends-only ones when they're a friend
// (friends-only is stored Accessibility=Private + FriendsOnly=true).
if (requester != playerid)
feed = feed.Where(x => x.Accessibility == ImageAccessibility.Public
|| (x.FriendsOnly && viewerIsFriend));
feed = sort switch
{
1 => feed.OrderByDescending(x => x.CheerCount).ThenByDescending(x => x.Id),
2 => feed.OrderBy(x => x.Id),
_ => feed.OrderByDescending(x => x.Id),
};
return feed.Skip(skip < 0 ? 0 : skip)
.Take(take <= 0 ? 100 : take)
.Select(MapToImagesPlayer)
.ToList();
}
private static ImagesPlayer MapToImagesPlayer(FullSavedImage x)
{
return new ImagesPlayer
{
Accessibility = x.Accessibility,
AccessibilityLocked = x.AccessibilityLocked,
CheerCount = x.CheerCount,
CommentCount = x.CommentCount,
CreatedAt = x.CreatedAt,
Description = x.Description,
ImageName = x.ImageName,
PlayerEventId = x.PlayerEventId,
PlayerId = x.PlayerId,
RoomId = x.RoomId,
SavedImageId = x.Id,
SavedImageType = x.Type,
TaggedPlayerIds = x.TaggedPlayerIds ?? new(),
FriendsOnly = x.FriendsOnly
};
}
/// <summary>One image row by id (null if none). For the website detail/edit endpoints.</summary>
public static FullSavedImage? GetById(int id)
=> GetDb().GetCollection<FullSavedImage>("images").FindById(id);
/// <summary>Owner-only visibility change: set Accessibility (what the game reads) + FriendsOnly
/// (website). Returns false if the photo doesn't exist.</summary>
public static bool SetVisibility(int id, ImageAccessibility accessibility, bool friendsOnly)
{
var col = GetDb().GetCollection<FullSavedImage>("images");
var img = col.FindById(id);
if (img == null) return false;
img.Accessibility = accessibility;
img.FriendsOnly = friendsOnly;
col.Update(img);
return true;
}
/// <summary>Owner-only caption change.</summary>
public static bool SetDescription(int id, string description)
{
var col = GetDb().GetCollection<FullSavedImage>("images");
var img = col.FindById(id);
if (img == null) return false;
img.Description = description;
col.Update(img);
return true;
}
/// <summary>Remove one tagged player from a photo. Returns the updated tag list, or null if
/// the photo doesn't exist.</summary>
public static List<ulong>? RemoveTag(int id, ulong taggedPlayerId)
{
var col = GetDb().GetCollection<FullSavedImage>("images");
var img = col.FindById(id);
if (img == null) return null;
img.TaggedPlayerIds ??= new();
img.TaggedPlayerIds.RemoveAll(x => x == taggedPlayerId);
col.Update(img);
return img.TaggedPlayerIds;
}
public static void UpdateImageCheerCount(int savedImageId, int cheerCount)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
var img = col.FindById(savedImageId);
if (img != null)
{
img.CheerCount = cheerCount;
col.Update(img);
}
}
public static bool ToggleCheer(int savedImageId, ulong playerId, bool cheer)
{
var db = GetDb();
var col = db.GetCollection<Cheer>("cheers");
var existing = col.FindOne(x => x.SavedImageId == savedImageId && x.PlayerId == playerId);
if (cheer && existing == null)
{
col.Insert(new Cheer { SavedImageId = savedImageId, PlayerId = playerId });
}
else if (!cheer && existing != null)
{
col.Delete(existing.Id);
}
return col.Exists(x => x.SavedImageId == savedImageId && x.PlayerId == playerId);
}
public static int GetCheerCount(int savedImageId)
{
var db = GetDb();
return db.GetCollection<Cheer>("cheers").Count(x => x.SavedImageId == savedImageId);
}
public static bool HasUserCheered(int savedImageId, ulong playerId)
{
var db = GetDb();
return db.GetCollection<Cheer>("cheers")
.Exists(x => x.SavedImageId == savedImageId && x.PlayerId == playerId);
}
private static readonly string OldDir = Path.Combine("Data", "images_metadata");
/// <summary>A v6 record for an image that does not exist - every id 0, so the client's
/// normalising hook is satisfied and nothing renders. Never answer [] here.</summary>
public static ImageV6 EmptyImageV6(string name) => new ImageV6
{
SavedImageId = 0, ImageName = name ?? "", PlayerId = 0, RoomId = 0,
PlayerEventId = 0, ClubId = 0, Description = null,
Accessibility = ImageAccessibility.Public, AccessibilityLocked = false,
SavedImageType = SavedImageTypeEnum.None, CreatedAt = DateTime.UtcNow,
CheerCount = 0, CommentCount = 0
};
public static ImageV6? GetImageV6ByName(string name) => ToV6(
GetDb().GetCollection<FullSavedImage>("images").FindOne(x => x.ImageName == name));
public static ImageV6? GetImageV6ById(long savedImageId) => ToV6(
GetDb().GetCollection<FullSavedImage>("images").FindOne(x => x.Id == (int)savedImageId));
private static ImageV6? ToV6(FullSavedImage? r) => r == null ? null : new ImageV6
{
SavedImageId = r.Id,
Id = r.Id,
ImageName = r.ImageName,
PlayerId = r.PlayerId,
RoomId = r.RoomId,
PlayerEventId = r.PlayerEventId,
ClubId = 0,
Description = r.Description,
Accessibility = r.Accessibility,
AccessibilityLocked = r.AccessibilityLocked,
SavedImageType = r.Type,
Type = r.Type,
CreatedAt = r.CreatedAt,
CheerCount = r.CheerCount,
CommentCount = r.CommentCount,
TaggedPlayerIds = r.TaggedPlayerIds ?? new()
};
public static ImageV6 GetImageByName(string name)
{
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
var record = col.FindOne(x => x.ImageName == name);
if (record == null) return null;
return new ImageV6
{
Accessibility = record.Accessibility,
AccessibilityLocked = record.AccessibilityLocked,
CheerCount = record.CheerCount,
CommentCount = record.CommentCount,
CreatedAt = record.CreatedAt,
Description = record.Description,
Id = record.Id,
ImageName = record.ImageName,
PlayerEventId = record.PlayerEventId,
PlayerId = record.PlayerId,
RoomId = record.RoomId,
TaggedPlayerIds = record.TaggedPlayerIds,
Type = record.Type,
};
}
public static void DeleteImagesFromPlayerId(ulong playerId)
{
var db = GetDb();
var imageCol = db.GetCollection<FullSavedImage>("images");
var cheerCol = db.GetCollection<Cheer>("cheers");
var imagesToDelete = imageCol.Query().Where(x => x.PlayerId == playerId).ToList();
if (imagesToDelete.Count == 0)
return;
foreach (var img in imagesToDelete)
{
imageCol.Delete(img.Id);
cheerCol.DeleteMany(c => c.SavedImageId == img.Id);
}
Console.WriteLine($"[Delete] Removed {imagesToDelete.Count} images and their cheers for PlayerId {playerId}");
}
public static List<FullSavedImage> GetImagesByIds(List<int> ids)
{
if (ids == null || ids.Count == 0)
return new List<FullSavedImage>();
var db = GetDb();
var col = db.GetCollection<FullSavedImage>("images");
// Efficient LiteDB WHERE query using Contains()
return col.Query()
.Where(x => ids.Contains(x.Id))
.ToList();
}
public static void ImportOldJson()
{
if (!Directory.Exists(OldDir))
{
Console.WriteLine($"[Import] Old directory not found: {OldDir}");
return;
}
var db = GetDb();
var imageCol = db.GetCollection<FullSavedImage>("images");
var cheerCol = db.GetCollection<Cheer>("cheers");
imageCol.EnsureIndex(x => x.Id, true);
// PERF: Parallelize JSON import for multiple files.
var files = Directory.GetFiles(OldDir, "*.json");
var cheerFiles = files.Where(f => f.EndsWith("_cheers.json")).ToArray();
var imageFiles = files.Except(cheerFiles).ToArray();
Parallel.ForEach(imageFiles, file =>
{
string fileName = Path.GetFileName(file);
try
{
string json = File.ReadAllText(file);
var images = JsonConvert.DeserializeObject<List<FullSavedImage>>(json);
if (images == null) return;
foreach (var img in images)
{
if (!imageCol.Exists(x => x.Id == img.Id))
{
imageCol.Insert(img);
Console.WriteLine($"[Import] Imported image {img.Id} from {fileName}");
}
else
{
Console.WriteLine($"[Import] Skipped duplicate image {img.Id}");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[Import] Failed to import {fileName}: {ex.Message}");
}
});
Parallel.ForEach(cheerFiles, file =>
{
try
{
string fileName = Path.GetFileName(file);
int savedImageId = int.Parse(fileName.Replace("_cheers.json", ""));
string json = File.ReadAllText(file);
var cheerList = JsonConvert.DeserializeObject<List<ulong>>(json);
if (cheerList == null) return;
foreach (var pid in cheerList)
{
if (!cheerCol.Exists(x => x.SavedImageId == savedImageId && x.PlayerId == pid))
{
cheerCol.Insert(new Cheer { SavedImageId = savedImageId, PlayerId = pid });
}
}
Console.WriteLine($"[Import] Imported {cheerList.Count} cheers for image {savedImageId}");
}
catch (Exception ex)
{
Console.WriteLine($"[Import] Failed to import cheers from {file}: {ex.Message}");
}
});
}
}
@@ -0,0 +1,89 @@
using LiteDB;
// Invention persistence for the Maker Pen "save selection as invention" flow.
// Minimal but real: stores the metadata the client's v6/v9 save posts (the circuit blob
// itself is uploaded separately and referenced by name), assigns an id + replication id,
// and lists a creator's own inventions. Global namespace like ImageMetadataDB/ClubDB.
public static class InventionDB
{
private static readonly string DbPath = Path.Combine("Data", "DBs", "Inventions.db");
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(DbPath));
public static LiteDatabase Db => _db.Value;
public static ILiteCollection<Invention> Collection() => Col();
public class Invention
{
public int InventionId { get; set; }
public string ReplicationId { get; set; } = "";
public ulong CreatorPlayerId { get; set; }
public string Name { get; set; } = "Untitled";
public string Description { get; set; } = "No description yet";
public string ImageName { get; set; } = "";
public string BlobName { get; set; } = ""; // inventionDataFilename (the .inv blob)
public int CurrentVersionNumber { get; set; } = 1;
public int InstantiationCost { get; set; }
public int LightsCost { get; set; }
public int ChipsCost { get; set; }
public int CloudVariablesCost { get; set; }
public int AICost { get; set; }
public int Accessibility { get; set; } = 0; // 0 private until published
public bool IsPublished { get; set; } = false;
public int CreationRoomId { get; set; }
public int Price { get; set; }
public int UgcVersion { get; set; }
public bool HasBetaContent { get; set; }
public string? LongDescription { get; set; }
public string? DisplayMetadataJson { get; set; }
public List<int> ReferencedInventions { get; set; } = new();
public List<string> ReferencedUnityAssetIds { get; set; } = new();
public List<string> Tags { get; set; } = new();
public bool IsFeatured { get; set; } = false; // admin-curated (InventionStore.SetFeatured)
public int NumDownloads { get; set; } = 0; // purchases / pickups
public int CheerCount { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
public DateTime? FirstPublishedAt { get; set; } = null;
}
private static ILiteCollection<Invention> Col()
{
var c = Db.GetCollection<Invention>("inventions");
c.EnsureIndex(x => x.InventionId, true);
c.EnsureIndex(x => x.CreatorPlayerId);
return c;
}
public static Invention Create(Invention inv)
{
var col = Col();
if (inv.InventionId == 0)
{
var last = col.Query().OrderByDescending(x => x.InventionId).Limit(1).FirstOrDefault();
inv.InventionId = (last?.InventionId ?? 0) + 1;
}
if (string.IsNullOrEmpty(inv.ReplicationId)) inv.ReplicationId = Guid.NewGuid().ToString("N");
inv.CreatedAt = inv.ModifiedAt = DateTime.UtcNow;
col.Insert(inv);
return inv;
}
public static Invention? GetById(int id) => Col().FindById(id);
public static List<Invention> GetByCreator(ulong creator) =>
Col().Query().Where(x => x.CreatorPlayerId == creator).OrderByDescending(x => x.InventionId).ToList();
public static Invention? Publish(int id, ulong creator, bool published)
{
var col = Col();
var inv = col.FindById(id);
if (inv == null || inv.CreatorPlayerId != creator) return null;
inv.IsPublished = published;
inv.Accessibility = published ? 1 : 0;
if (published && inv.FirstPublishedAt == null) inv.FirstPublishedAt = DateTime.UtcNow;
inv.ModifiedAt = DateTime.UtcNow;
col.Update(inv);
return inv;
}
public static void Checkpoint() { try { Db.Checkpoint(); } catch { } }
}
@@ -0,0 +1,198 @@
using LiteDB;
// The invention browse / store side of InventionDB: search, feeds, ownership (bought
// inventions), prices, cheers, deletes. Kept next to InventionDB (same LiteDB file) so one
// checkpoint covers both. Everything the client browses is PUBLISHED inventions only; a
// creator always sees their own.
public static class InventionStore
{
private static ILiteCollection<InventionDB.Invention> Col() => InventionDB.Collection();
public class Owned
{
[BsonId] public string Key { get; set; } = ""; // "{player}|{invention}"
public ulong PlayerId { get; set; }
public int InventionId { get; set; }
public int PricePaid { get; set; }
public DateTime At { get; set; } = DateTime.UtcNow;
}
public class Cheer
{
[BsonId] public string Key { get; set; } = ""; // "{player}|{invention}"
public ulong PlayerId { get; set; }
public int InventionId { get; set; }
}
private static ILiteCollection<Owned> OwnedCol()
{
var c = InventionDB.Db.GetCollection<Owned>("owned");
c.EnsureIndex(x => x.PlayerId);
c.EnsureIndex(x => x.InventionId);
return c;
}
private static ILiteCollection<Cheer> CheerCol()
{
var c = InventionDB.Db.GetCollection<Cheer>("cheers");
c.EnsureIndex(x => x.InventionId);
return c;
}
// ---- reads --------------------------------------------------------------------------
public static List<InventionDB.Invention> Published() =>
Col().Query().Where(x => x.IsPublished).ToList();
public static List<InventionDB.Invention> GetByIds(IEnumerable<int> ids)
{
var set = new HashSet<int>(ids);
if (set.Count == 0) return new();
var col = Col();
return set.Select(id => col.FindById(id)).Where(i => i != null).Select(i => i!).ToList();
}
public static List<InventionDB.Invention> ByRoom(long roomId) =>
Col().Query().Where(x => x.CreationRoomId == (int)roomId && x.IsPublished)
.ToList().OrderByDescending(x => x.CreatedAt).ToList();
/// <summary>
/// The store's search. `value` is the text the player typed; "#something" is a tag or a
/// creator's @name (the store's creator filter posts it that way). No text = every published
/// invention, newest first. Terms match the name, the description and the creator's name.
/// </summary>
public static List<InventionDB.Invention> Search(string? value, int skip, int take)
{
var all = Published();
string q = (value ?? "").Trim();
IEnumerable<InventionDB.Invention> hits = all;
if (q.StartsWith("#") && q.Length > 1)
{
string tag = q[1..].Trim();
ulong creator = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountIdByUsernameCI(tag);
hits = all.Where(i =>
(creator != 0 && i.CreatorPlayerId == creator)
|| (i.Tags ?? new()).Any(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase)));
}
else if (q.Length > 0)
{
var terms = q.ToLowerInvariant().Split(new[] { ' ', '+', ',' }, StringSplitOptions.RemoveEmptyEntries);
var names = new Dictionary<ulong, string>();
string CreatorName(ulong id)
{
if (!names.TryGetValue(id, out var n))
{
n = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountById(id)?.Username ?? "";
names[id] = n;
}
return n;
}
hits = all.Where(i =>
{
string hay = ((i.Name ?? "") + " " + (i.Description ?? "") + " " + string.Join(" ", i.Tags ?? new()) + " " + CreatorName(i.CreatorPlayerId)).ToLowerInvariant();
return terms.All(t => hay.Contains(t));
});
}
return hits.OrderByDescending(i => i.CreatedAt).ThenByDescending(i => i.InventionId)
.Skip(Math.Max(0, skip)).Take(take <= 0 ? 100 : take).ToList();
}
/// <summary>Featured / top today: the admin-featured ones first, then by downloads and cheers.</summary>
public static List<InventionDB.Invention> Top(int skip, int take) =>
Published().OrderByDescending(i => i.IsFeatured).ThenByDescending(i => i.NumDownloads + i.CheerCount * 3)
.ThenByDescending(i => i.CreatedAt).Skip(Math.Max(0, skip)).Take(take <= 0 ? 50 : take).ToList();
// ---- ownership ------------------------------------------------------------------------
public static HashSet<int> OwnedIds(ulong player) =>
OwnedCol().Find(x => x.PlayerId == player).Select(x => x.InventionId).ToHashSet();
public static bool Owns(ulong player, int inventionId) =>
OwnedCol().FindById($"{player}|{inventionId}") != null;
public static bool AddOwned(ulong player, int inventionId, int pricePaid)
{
var col = OwnedCol();
string key = $"{player}|{inventionId}";
if (col.FindById(key) != null) return false;
col.Insert(new Owned { Key = key, PlayerId = player, InventionId = inventionId, PricePaid = pricePaid });
var c = Col();
var inv = c.FindById(inventionId);
if (inv != null) { inv.NumDownloads++; c.Update(inv); }
return true;
}
/// <summary>Created + bought, newest first (the maker pen's Inventions tab).</summary>
public static List<InventionDB.Invention> Mine(ulong player)
{
var mine = InventionDB.GetByCreator(player);
var bought = GetByIds(OwnedIds(player));
var byId = new Dictionary<int, InventionDB.Invention>();
foreach (var i in mine.Concat(bought)) byId[i.InventionId] = i;
return byId.Values.OrderByDescending(i => i.CreatedAt).ThenByDescending(i => i.InventionId).ToList();
}
public static bool OwnsAll(ulong player, IEnumerable<int> ids)
{
var owned = OwnedIds(player);
var col = Col();
foreach (int id in ids)
{
var inv = col.FindById(id);
if (inv == null) return false;
if (inv.CreatorPlayerId == player || owned.Contains(id)) continue;
return false;
}
return true;
}
// ---- cheers ---------------------------------------------------------------------------
public static bool IsCheering(ulong player, int inventionId) => CheerCol().FindById($"{player}|{inventionId}") != null;
public static InventionDB.Invention? SetCheer(ulong player, int inventionId, bool cheer)
{
var col = Col();
var inv = col.FindById(inventionId);
if (inv == null) return null;
var cc = CheerCol();
string key = $"{player}|{inventionId}";
bool has = cc.FindById(key) != null;
if (cheer && !has) { cc.Insert(new Cheer { Key = key, PlayerId = player, InventionId = inventionId }); inv.CheerCount++; }
else if (!cheer && has) { cc.Delete(key); inv.CheerCount = Math.Max(0, inv.CheerCount - 1); }
col.Update(inv);
return inv;
}
// ---- creator writes -------------------------------------------------------------------
/// <summary>Edit the creator's own invention; null when it is not theirs (or does not exist).</summary>
public static InventionDB.Invention? Edit(int id, ulong creator, Action<InventionDB.Invention> edit, bool staffOverride = false)
{
var col = Col();
var inv = col.FindById(id);
if (inv == null || (inv.CreatorPlayerId != creator && !staffOverride)) return null;
edit(inv);
inv.ModifiedAt = DateTime.UtcNow;
col.Update(inv);
return inv;
}
public static bool Delete(int id, ulong creator, bool staffOverride = false)
{
var col = Col();
var inv = col.FindById(id);
if (inv == null || (inv.CreatorPlayerId != creator && !staffOverride)) return false;
col.Delete(id);
return true;
}
public static void SetFeatured(int id, bool featured)
{
var col = Col();
var inv = col.FindById(id);
if (inv == null) return;
inv.IsFeatured = featured;
col.Update(inv);
}
}
@@ -0,0 +1,164 @@
using LiteDB;
using static Campus_live_rewrite.api.server.Classes.db_class.LeaderboardDBClasses;
namespace Campus_live_rewrite.api.dbs
{
public class LeaderboardDB
{
private static readonly LiteDatabase DB = new LiteDatabase(
Environment.CurrentDirectory + "/Data/DBs/Leaderboard.db"
);
// Merge this store's write-ahead log back into the data file.
// See DbMaintenance for why this must be called explicitly.
public static void Checkpoint() => DB.Checkpoint();
private static ILiteCollection<Entry> Entries =>
DB.GetCollection<Entry>("leaderboard");
static LeaderboardDB()
{
// Short index names to avoid LiteDB 32-char name limit
Entries.EnsureIndex("pid", x => x.PlayerId);
Entries.EnsureIndex("sc", x => x.StatChannel);
Entries.EnsureIndex("rid", x => x.RoomId);
// Score index for sorting
Entries.EnsureIndex("score", x => x.Score);
}
// ------------------------------------------
// Add or Update Stat
// ------------------------------------------
// `expected` is the client's CurrentStatValue. The reference honours it as an optimistic
// compare-and-set (only overwrite if the row still holds that value), but that silently
// DROPPED real updates here: a returning player's client sends CurrentStatValue 0 (fresh
// session, hasn't re-read the board) while the server still holds their old score, so the
// compare failed and their new score got STUCK. We take the value UNCONDITIONALLY instead
// (the reference's CurrentStatValue=null path) so a score always records; `expected` is only
// logged when it disagreed. The endpoint answers 0 either way.
public static Entry? GetEntry(ulong playerId, int statChannel, ulong roomId) =>
Entries.FindOne(x => x.PlayerId == (int)playerId && x.StatChannel == statChannel && x.RoomId == (long)roomId);
public static RequestResult SetStat(ulong playerId, int statChannel, ulong roomId, int newValue, int? expected = null)
{
try
{
var entry = GetEntry(playerId, statChannel, roomId);
var now = DateTime.UtcNow;
if (entry == null)
{
entry = new Entry
{
PlayerId = (int)playerId,
StatChannel = statChannel,
RoomId = (long)roomId,
Score = newValue,
UpdatedAt = now,
DayGainDate = now.Date,
DayGain = Math.Abs((long)newValue)
};
Entries.Insert(entry);
}
else
{
if (expected.HasValue && entry.Score != expected.Value)
Console.WriteLine($"[leaderboard] {playerId} sc{statChannel} room{roomId}: CurrentStatValue {expected} != stored {entry.Score} - writing {newValue} anyway (would have stuck otherwise)");
long gain = Math.Abs((long)newValue - entry.Score);
if (entry.DayGainDate != now.Date) { entry.DayGainDate = now.Date; entry.DayGain = 0; }
entry.DayGain += gain;
entry.UpdatedAt = now;
entry.Score = newValue;
Entries.Update(entry);
}
// Rank is computed at READ time now (see OrderedBoard), so there is nothing to
// recompute on write - and doing it descending-only here was the bug.
return RequestResult.Success;
}
catch
{
return RequestResult.InvalidStat;
}
}
// ------------------------------------------
// The board's entries in RANK ORDER, computed for THIS read.
//
// Order is decided by the caller's SortAscending, exactly as RecFlare's leaderboard
// does it: descending (highest first) by default, ascending (lowest first) for a
// lowest-wins stat like a race time, with ties broken on the LOWER player id either
// way so the order is stable. Rank (1-based) is assigned by position here, at READ
// time - so the SAME stored board reads correctly in either direction.
//
// This replaced a Rank baked DESCENDING-ONLY into every row at write time: every
// ascending stat (times, fewest-deaths) was ranked backwards - the WORST score shown
// as #1 - and no read could get the right order because the direction was already
// frozen the wrong way.
private static List<Entry> OrderedBoard(int statChannel, ulong roomId, bool sortAscending)
{
var list = Entries.Find(x => x.StatChannel == statChannel && x.RoomId == (long)roomId).ToList();
var ordered = (sortAscending
? list.OrderBy(x => x.Score)
: list.OrderByDescending(x => x.Score))
.ThenBy(x => x.PlayerId) // ties: lower id first, either direction
.ToList();
// Ranks are 0-BASED (RecFlare/live-service convention): the client ADDS ONE before it
// draws, so Rank 0 renders as "#1". Assigning i+1 here made every board show everyone
// one place too low (the winner as "#2").
for (int i = 0; i < ordered.Count; i++) ordered[i].Rank = i;
return ordered;
}
// ------------------------------------------
// Get Player Rank
// ------------------------------------------
public static Entry? GetPlayerRank(ulong playerId, int statChannel, ulong roomId, bool sortAscending = false)
{
return OrderedBoard(statChannel, roomId, sortAscending)
.FirstOrDefault(x => x.PlayerId == (int)playerId);
}
// ------------------------------------------
// Nearby Scores Window - windowSize entries either side of the caller.
// ------------------------------------------
public static List<Entry> GetNearbyScores(
ulong playerId,
int statChannel,
ulong roomId,
int windowSize,
bool sortAscending = false)
{
var board = OrderedBoard(statChannel, roomId, sortAscending);
int myIndex = board.FindIndex(x => x.PlayerId == (int)playerId);
if (myIndex < 0)
return board.Take(windowSize * 2 + 1).ToList(); // caller not on the board yet: top slice
int start = Math.Max(myIndex - windowSize, 0);
int end = Math.Min(myIndex + windowSize, board.Count - 1);
return board.GetRange(start, end - start + 1);
}
// ------------------------------------------
// Range Get - GetRanks API. RankStart/RankEnd are 0-based, inclusive (the client
// sends RankStart=0, RankEnd=9 for the top ten).
// ------------------------------------------
public static List<Entry> GetRanks(
int statChannel,
ulong roomId,
int rankStart,
int rankEnd,
bool sortAscending = false)
{
var board = OrderedBoard(statChannel, roomId, sortAscending);
int start = Math.Max(rankStart, 0);
int count = Math.Max(rankEnd - start + 1, 0);
return board.Skip(start).Take(count).ToList();
}
}
}
@@ -0,0 +1,95 @@
using LiteDB;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Campus_live_rewrite.api.server.Classes;
namespace Campus_live_rewrite.api.dbs
{
public static class LinkDB
{
private static readonly string _dbPath = "Data/DBs/Links.db";
private static readonly string _collection = "actionlinks";
public static ActionLink Insert(ActionLink link)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<ActionLink>(_collection);
col.Insert(link);
return link;
}
public static ActionLink? Get(string linkIdOrCreatorCode)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<ActionLink>(_collection);
ActionLink? link = col.FindById(linkIdOrCreatorCode);
// If not found by ID, try as a creator code (case-insensitive)
if (link == null)
{
string code = linkIdOrCreatorCode.Trim().ToLowerInvariant();
var player = PlayerDB.GetInfluencerByCreatorCode(code);
if (player?.player == null) return null; // unknown link AND unknown creator code
return new ActionLink // {"creatorPlayerId":4213153,"data":"{\"Type\":6}","isValid":true}
{
creatorPlayerId = player.player.Id,
data = JsonConvert.SerializeObject(new { Type = (int)LinkType.Influencer }),
isValid = true
};
}
if (link == null)
return null;
// Automatically invalidate expired links
if (link.ExpiresAt < DateTime.UtcNow && link.isValid)
{
link.isValid = false;
col.Update(link);
}
return link;
}
public static bool Consume(string linkId, bool alwayValid = false)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<ActionLink>(_collection);
var link = col.FindById(linkId);
if (link == null || !link.isValid || link.ExpiresAt < DateTime.UtcNow)
return false;
if (!alwayValid)
{
link.isValid = false;
}
col.Update(link);
return true;
}
public static IEnumerable<ActionLink> GetAll()
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<ActionLink>(_collection);
return col.FindAll().ToList();
}
public static void DeleteLinksFromPlayerId(ulong playerId)
{
using var db = new LiteDatabase(_dbPath);
var col = db.GetCollection<ActionLink>(_collection);
try
{
col.DeleteMany(x => x.creatorPlayerId == playerId);
Console.WriteLine("deleted links from playerid " + playerId);
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}
}
}
@@ -0,0 +1,58 @@
using LiteDB;
using Campus_live_rewrite.api.server;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Append-only record of moderation actions (warnings, kicks, room bans and unbans): who did
/// what to whom, where and why. The reference server keeps `warning` / `report` tables for
/// this; before now a warning here left no trace once its (0-second) block was overwritten.
/// Read by the admin panel's player view; never by the client.
/// </summary>
public static class ModerationLogDB
{
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "ModerationLog.db")));
public class Entry
{
[BsonId] public int Id { get; set; }
public string Kind { get; set; } = ""; // warning / modkick / kick / roomban / roomunban
public ulong TargetId { get; set; }
public ulong ByAccountId { get; set; }
public ulong RoomId { get; set; }
public long GameSessionId { get; set; }
public long DurationSeconds { get; set; }
public int Category { get; set; }
public string Message { get; set; } = "";
public string Note { get; set; } = "";
public DateTime At { get; set; } = DateTime.UtcNow;
}
private static ILiteCollection<Entry> Col()
{
var c = _db.Value.GetCollection<Entry>("actions");
c.EnsureIndex(x => x.TargetId);
return c;
}
public static void Record(string kind, ulong target, ulong by, ulong roomId, long gameSessionId, long durationSeconds,
Moderation_Detail.ReportCategory category, string? message, string? note = null)
{
try
{
Col().Insert(new Entry
{
Kind = kind, TargetId = target, ByAccountId = by, RoomId = roomId, GameSessionId = gameSessionId,
DurationSeconds = durationSeconds, Category = (int)category, Message = message ?? "", Note = note ?? ""
});
}
catch (Exception ex) { Console.WriteLine($"[moderation] log write failed: {ex.Message}"); }
}
public static List<Entry> ForPlayer(ulong target, int take = 50) =>
Col().Find(x => x.TargetId == target).OrderByDescending(x => x.At).Take(take).ToList();
public static List<Entry> Recent(int take = 100) =>
Col().FindAll().OrderByDescending(x => x.At).Take(take).ToList();
}
}
@@ -0,0 +1,3753 @@
using System;
using System.Numerics;
using System.Security.Principal;
using LiteDB;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using Campus_live_rewrite.Controllers;
using SixLabors.ImageSharp;
using static ImageMetadataDB;
namespace Campus_live_rewrite.api.dbs
{
public class PlayerDB
{
public static LiteDatabase PlayerDBFile = new LiteDatabase(Environment.CurrentDirectory + $"/Data/DBs/Players.db");
private static readonly Random random = new Random();
private static readonly object randLock = new object();
public static bool CreateAccount(Platforms platform, ulong platformId, string deviceId, ulong discord_id, out account_data account)
{
account = new account_data();
Console.WriteLine($"[CreateAccount] === START === platform={platform} platformId={platformId} deviceId='{deviceId}' discord_id={discord_id}");
var col = PlayerDBFile.GetCollection<player_data>("players");
var maxId = col.FindAll().OrderByDescending(x => x.id).FirstOrDefault()?.id ?? 0;
ulong accid = maxId + 1;
account.accountId = accid;
string name = Utils.GetRandomName();
Console.WriteLine($"[CreateAccount] Assigning accid={accid} (maxId was {maxId}), generated name='{name}'");
account = new account_data
{
createdAt = DateTime.UtcNow,
identityFlags = 0,
isJunior = null,
personalPronouns = 0,
platforms = 0,
displayName = name,
username = name,
profileImage = "DefaultPFP.png",
accountId = accid
};
col.Insert(new player_data
{
playerid = account.accountId,
id = accid,
platform = platform,
//platformId = platformId,
CreatedInGame = true,
deviceId = new List<string>
{
deviceId
},
authtoken = Utils.GetRandomString(400),
password = "",
player = new player_info
{
Id = accid,
Username = account.username,
DisplayName = account.displayName,
XP = 0,
Level = 1,
created_at = account.createdAt,
bannerImage = Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage,
profileImage = account.profileImage,
IsJunior = false,
AvoidJuniors = true,
PlayerReputation = new mPlayerReputation
{
Noteriety = 0,
CheerCredit = 20,
CheerGeneral = 1,
CheerHelpful = 1,
CheerGreatHost = 1,
CheerSportsman = 1,
CheerCreative = 1,
SubscriberCount = 0,
SubscribedCount = 0,
SelectedCheer = CheerCategoryEnum.None
},
PlatformIds = new List<mPlatformID>
{
new mPlatformID
{
Platform = platform,
PlatformId = platformId
}
}
},
player_Extra = new player_extra
{
DormRoomID = 1,
IpAddresses = new List<string>(),
DiscordId = discord_id,
Bio = "",
avatar = "{\"FaceFeatures\": \"\",\"HairColor\": \"\",\"OutfitSelections\": \"\",\"SkinColor\": \"\"}",
SavedOutfits = new List<avatar_data_saved>(),
//ModerationBlockDetails = new Moderation_Detail.ModerationBlockDetails()
CheeredRooms = new List<ulong>(),
FavoritedRooms = new List<ulong>(),
sub_count = 0,
SubbedTo = new List<ulong>(),
IsRecentRoomHistoryVisible = true,
Influencer = new Influencer
{
creatorCode = "",
isInfluencer = false,
},
CustomAvatarItems = new Campus_live_rewrite.api.server.Classes.db_class.CustomAvatarItems(),
},
dev_Flag = DevFlag.none
});
//Settings.SaveSettings_db(Settings.CreateDefaultSettings(), accid);
Console.WriteLine($"[CreateAccount] Row inserted. accid={accid} platform={platform} discord_id={discord_id} display='{name}' img='{account.profileImage}'");
HeartbeatDB.CreatePlayerHeartbeat(accid, out _, platform, DeviceClasses.Unknown);
Console.WriteLine($"[CreateAccount] Heartbeat created for accid={accid}");
CreateDormForPlayerId(accid);
Console.WriteLine($"[CreateAccount] Dorm created for accid={accid}. === DONE ===");
return true;
}
// ================================================================================
// WEBSITE ACCOUNT CREATION (Campus accounts are now made on the website, not in game)
//
// The game used to auto-create an account on first launch (AuthController
// grant_type=create_account). That path is disabled - accounts are made here, on the
// site, with a username + password the player chooses, and the game only ever LOGS IN
// to them. The account carries NO platform binding at creation: a browser has no Steam
// id. The Steam id is stamped on the account the first time the player signs in from
// the game (AccountLock.TryEnforceAndBind), which is what "locks the account to your
// Steam" means.
// ================================================================================
/// <summary>Case-insensitive username -> account id, or 0 if no such account.</summary>
public static ulong GetAccountIdByUsernameCI(string username)
{
if (string.IsNullOrWhiteSpace(username)) return 0;
string want = username.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
var p = col.FindAll().FirstOrDefault(x =>
x?.player?.Username != null &&
string.Equals(x.player.Username, want, StringComparison.OrdinalIgnoreCase));
return p?.playerid ?? 0;
}
/// <summary>Case-insensitive: is this exact username already in use?</summary>
public static bool IsUsernameTaken(string username)
{
if (string.IsNullOrWhiteSpace(username)) return true;
string want = username.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll().Any(p =>
p?.player?.Username != null &&
string.Equals(p.player.Username, want, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Create an account from the website. No device id, no platform binding - those are
/// stamped on the first in-game login. The account is marked onboarding-complete so the
/// game drops the player straight in (no first-run "make a room" / username / password
/// flow) since they already chose all of that here.
/// </summary>
public static bool CreateWebAccount(string username, string displayName, string password,
string? email, out account_data account, out string error,
bool isJunior = false, string? birthday = null)
{
account = new account_data();
error = "";
username = (username ?? "").Trim();
displayName = string.IsNullOrWhiteSpace(displayName) ? username : displayName.Trim();
if (IsUsernameTaken(username))
{
error = "That username is already taken.";
return false;
}
var col = PlayerDBFile.GetCollection<player_data>("players");
var maxId = col.FindAll().OrderByDescending(x => x.id).FirstOrDefault()?.id ?? 0;
ulong accid = maxId + 1;
account = new account_data
{
createdAt = DateTime.UtcNow,
identityFlags = 0,
isJunior = isJunior,
personalPronouns = 0,
platforms = 0,
displayName = displayName,
username = username,
profileImage = "DefaultPFP.png",
accountId = accid
};
col.Insert(new player_data
{
playerid = accid,
id = accid,
platform = Platforms.All,
CreatedInGame = false,
deviceId = new List<string>(), // bound on first in-game login
authtoken = Utils.GetRandomString(400),
password = ClientSecurity.HashPassword(password),
player = new player_info
{
Id = accid,
Username = username,
DisplayName = displayName,
Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
Birthday = string.IsNullOrWhiteSpace(birthday) ? null : birthday.Trim(),
XP = 0,
Level = 1,
created_at = account.createdAt,
bannerImage = Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage,
profileImage = account.profileImage,
IsJunior = isJunior,
// Juniors are placed among juniors (don't avoid them); adults avoid juniors by default.
AvoidJuniors = !isJunior,
PlayerReputation = new mPlayerReputation
{
Noteriety = 0,
CheerCredit = 20,
CheerGeneral = 1,
CheerHelpful = 1,
CheerGreatHost = 1,
CheerSportsman = 1,
CheerCreative = 1,
SubscriberCount = 0,
SubscribedCount = 0,
SelectedCheer = CheerCategoryEnum.None
},
PlatformIds = new List<mPlatformID>() // NO steam id yet
},
player_Extra = new player_extra
{
DormRoomID = 1,
IpAddresses = new List<string>(),
DiscordId = 0,
Bio = "",
avatar = "{\"FaceFeatures\": \"\",\"HairColor\": \"\",\"OutfitSelections\": \"\",\"SkinColor\": \"\"}",
SavedOutfits = new List<avatar_data_saved>(),
CheeredRooms = new List<ulong>(),
FavoritedRooms = new List<ulong>(),
sub_count = 0,
SubbedTo = new List<ulong>(),
IsRecentRoomHistoryVisible = true,
Influencer = new Influencer { creatorCode = "", isInfluencer = false },
CustomAvatarItems = new Campus_live_rewrite.api.server.Classes.db_class.CustomAvatarItems(),
},
dev_Flag = DevFlag.none
});
HeartbeatDB.CreatePlayerHeartbeat(accid, out _, Platforms.All, DeviceClasses.Unknown);
CreateDormForPlayerId(accid);
// Mark first-run onboarding + tutorial complete so the game goes straight in - the
// player already chose username/password on the website (mirrors the Kyle seed in
// Program.cs and SettingsDB.CreateDefaultSettings).
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasStarted", "true", accid);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasFinished", "true", accid);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasChosenUsername", "true", accid);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasCreatedPassword","true", accid);
SettingsDB.SetPlayerSetting("TUTORIAL_COMPLETE_MASK", "4294967295", accid);
Console.WriteLine($"[web-signup] created account {accid} @{username} (\"{displayName}\")"
+ (email != null ? " with email" : ""));
return true;
}
/// <summary>
/// The account's bound platform id for a platform (0 if none). Ignores the placeholder
/// 0 entry that some legacy rows carry.
/// </summary>
public static ulong GetAccountPlatformId(ulong accid, Platforms platform)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var p = col.FindOne(x => x.playerid == accid);
var ids = p?.player?.PlatformIds;
if (ids == null) return 0;
var match = ids.FirstOrDefault(pid => pid.Platform == platform && pid.PlatformId != 0);
return match?.PlatformId ?? 0;
}
/// <summary>Append a platform id to the account if it is not already present.</summary>
public static void AddPlatformId(ulong accid, Platforms platform, ulong platformId)
{
if (platformId == 0) return;
var col = PlayerDBFile.GetCollection<player_data>("players");
var p = col.FindOne(x => x.playerid == accid);
if (p?.player == null) return;
p.player.PlatformIds ??= new List<mPlatformID>();
if (p.player.PlatformIds.Any(pid => pid.Platform == platform && pid.PlatformId == platformId))
return;
p.player.PlatformIds.Add(new mPlatformID { Platform = platform, PlatformId = platformId });
col.Update(p);
Console.WriteLine($"[account-lock] bound {platform} id {platformId} to account {accid}");
}
/// <summary>Every account carrying this platform id (used by the one-account-per-Steam lock).</summary>
public static List<ulong> AccountsWithPlatformId(Platforms platform, ulong platformId)
{
if (platformId == 0) return new List<ulong>();
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll()
.Where(x => x.player?.PlatformIds != null &&
x.player.PlatformIds.Any(pid => pid.Platform == platform && pid.PlatformId == platformId))
.Select(x => x.playerid)
.ToList();
}
// ---- website signup rate limit -----------------------------------------------------
// One successful account creation per IP per ConfigCampus.SignupRateLimitDays (default 7).
// Persisted so a service restart does not reset the window. A tiny row per signup - the
// volume is human-scale, not request-scale.
public class WebSignupRecord
{
public int Id { get; set; }
public string? Ip { get; set; }
public ulong AccountId { get; set; }
public DateTime CreatedAt { get; set; }
}
/// <summary>The limiter's verdict: which layer refused, and when it frees up.</summary>
public enum SignupLimit { None, Ip, Subnet, Global }
/// <summary>
/// May this IP register right now? Checks, in order, the global per-hour cap, the per-IP
/// day/week caps and the /24 subnet cap (see ConfigCampus.SignupsPer*). On refusal says
/// which layer refused and the earliest time it frees up, so the caller can tell the
/// player something true. Blank/loopback/allowlisted IPs skip the per-address layers.
/// </summary>
public static SignupLimit CheckSignupLimit(string? ip, out DateTime nextAllowedUtc)
{
var now = DateTime.UtcNow;
nextAllowedUtc = now;
// Admin-allowlisted IPs test signup flows without tripping the caps (see
// ConfigCampus.AdminRateLimitEnabled).
if (AdminProtectionMiddleware.IsLimitExempt(ip)) return SignupLimit.None;
var col = PlayerDBFile.GetCollection<WebSignupRecord>("web_signups");
col.EnsureIndex(r => r.Ip);
col.EnsureIndex(r => r.CreatedAt);
// Global cap. This is a MASS-CREATION circuit breaker, not a growth cap, and for a
// long time it behaved as the latter: at 15/hour across every address on the internet
// it was the ONLY limit that ever fired. In one 12-hour window it refused 399 signups
// - 120 of them headsets retrying create_account, 34 distinct devices - while letting
// 19 through, and not one refusal came from the per-IP or per-subnet caps. Every real
// player who arrived after the 15th that hour was told "try again later"; on Quest
// that is a hard login failure the client then retries every ~8 s forever.
//
// So volume alone no longer trips it. Mass creation is CONCENTRATED - one actor behind
// a handful of networks - because the per-IP (1/day) and per-/24 (4/day) caps already
// bound what any single network can produce. Organic growth is DIFFUSE: a hundred
// strangers on a hundred networks. The breaker now fires only when an hour is both
// over the ceiling AND coming from few enough networks to be one actor.
if (ConfigCampus.SignupsPerHour > 0)
{
var hourAgo = now.AddHours(-1);
var lastHour = col.Find(r => r.CreatedAt >= hourAgo).OrderBy(r => r.CreatedAt).ToList();
if (lastHour.Count >= ConfigCampus.SignupsPerHour)
{
// Distinct networks behind the hour. An unattributable IP counts as its own
// network rather than being pooled with the other unattributable ones, so a
// flood cannot look diffuse just by stripping the address.
int networks = lastHour
.Select(r => SubnetKey(r.Ip) ?? ("ip:" + (r.Ip ?? "")))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
if (networks < ConfigCampus.SignupBurstDistinctNetworks)
{
nextAllowedUtc = lastHour[lastHour.Count - ConfigCampus.SignupsPerHour].CreatedAt.AddHours(1);
Console.WriteLine($"[signup-guard] GLOBAL cap tripped: {lastHour.Count} signups in the last hour " +
$"from only {networks} distinct network(s) - mass creation, not growth");
return SignupLimit.Global;
}
Console.WriteLine($"[signup-guard] {lastHour.Count} signups in the last hour across {networks} " +
$"distinct networks - over the ceiling but diffuse, allowed");
}
}
if (string.IsNullOrWhiteSpace(ip)) return SignupLimit.None;
ip = ip.Trim();
if (ip == "127.0.0.1" || ip == "::1" || ip == "localhost") return SignupLimit.None;
if (ConfigCampus.SignupExemptIps != null && ConfigCampus.SignupExemptIps.Contains(ip)) return SignupLimit.None;
var weekAgo = now.AddDays(-7);
var dayAgo = now.AddDays(-1);
var mine = col.Find(r => r.Ip == ip && r.CreatedAt >= weekAgo).OrderBy(r => r.CreatedAt).ToList();
if (ConfigCampus.SignupsPerIpPerWeek > 0 && mine.Count >= ConfigCampus.SignupsPerIpPerWeek)
{
nextAllowedUtc = mine[mine.Count - ConfigCampus.SignupsPerIpPerWeek].CreatedAt.AddDays(7);
return SignupLimit.Ip;
}
var mineToday = mine.Where(r => r.CreatedAt >= dayAgo).ToList();
if (ConfigCampus.SignupsPerIpPerDay > 0 && mineToday.Count >= ConfigCampus.SignupsPerIpPerDay)
{
nextAllowedUtc = mineToday[mineToday.Count - ConfigCampus.SignupsPerIpPerDay].CreatedAt.AddDays(1);
return SignupLimit.Ip;
}
// Network block - /24 for IPv4, /64 for IPv6. Same window as the per-IP day cap.
string? block = SubnetKey(ip);
if (block != null && ConfigCampus.SignupsPerSubnetPerDay > 0)
{
// Compare on the computed KEY, not a text prefix: one IPv6 address can be written
// several ways ("2001:db8::1" / "2001:db8:0:0::1"), so matching the stored string
// missed hosts that a real /64 comparison catches.
var inBlock = col.Find(r => r.CreatedAt >= dayAgo)
.Where(r => string.Equals(SubnetKey(r.Ip), block, StringComparison.OrdinalIgnoreCase))
.OrderBy(r => r.CreatedAt).ToList();
if (inBlock.Count >= ConfigCampus.SignupsPerSubnetPerDay)
{
nextAllowedUtc = inBlock[inBlock.Count - ConfigCampus.SignupsPerSubnetPerDay].CreatedAt.AddDays(1);
return SignupLimit.Subnet;
}
}
return SignupLimit.None;
}
/// <summary>
/// The network an address belongs to: the /24 for IPv4, the /64 for IPv6, null when the
/// string is not an address at all.
///
/// IPv6 used to return null, so an IPv6 client had NO subnet cap - the per-/24 rule simply
/// did not apply to it and the global hourly ceiling was the only thing covering that
/// hole, which is part of why the ceiling had to be set so low. A /64 is what a
/// residential customer is handed, so it is the IPv6 equivalent of the /24 here.
/// </summary>
private static string? SubnetKey(string? ip)
{
if (string.IsNullOrWhiteSpace(ip)) return null;
if (!System.Net.IPAddress.TryParse(ip.Trim(), out var addr)) return null;
var b = addr.GetAddressBytes();
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
return "v4:" + b[0] + "." + b[1] + "." + b[2];
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
{
var sb = new System.Text.StringBuilder("v6:");
for (int i = 0; i < 8; i++) sb.Append(b[i].ToString("x2")); // the /64 prefix
return sb.ToString();
}
return null;
}
public static void RecordSignup(string? ip, ulong accountId)
{
var col = PlayerDBFile.GetCollection<WebSignupRecord>("web_signups");
col.Insert(new WebSignupRecord
{
Ip = string.IsNullOrWhiteSpace(ip) ? "" : ip.Trim(),
AccountId = accountId,
CreatedAt = DateTime.UtcNow
});
}
public static bool ImportPlayers(string importPath)
{
Console.WriteLine($"[IMPORT] Starting player import");
Console.WriteLine($"[IMPORT] Import path: {importPath}");
try
{
if (!File.Exists(importPath))
{
Console.WriteLine($"[ERROR] Import file not found: {importPath}");
return false;
}
Console.WriteLine("[IMPORT] Reading import file...");
var json = File.ReadAllText(importPath);
Console.WriteLine($"[IMPORT] File size: {json.Length} bytes");
Console.WriteLine("[IMPORT] Deserializing player data...");
var importedPlayers = JsonConvert.DeserializeObject<List<player_data>>(json);
if (importedPlayers == null)
{
Console.WriteLine("[ERROR] Deserialization returned null (invalid JSON?)");
return false;
}
if (importedPlayers.Count == 0)
{
Console.WriteLine("[WARN] Import file contained 0 players");
return false;
}
Console.WriteLine($"[IMPORT] Players found in file: {importedPlayers.Count}");
var col = PlayerDBFile.GetCollection<player_data>("players");
int inserted = 0;
int skipped = 0;
foreach (var player in importedPlayers)
{
if (player == null)
{
Console.WriteLine("[WARN] Encountered null player entry, skipping");
skipped++;
continue;
}
Console.WriteLine($"[IMPORT] Processing player ID: {player.id}");
try
{
// Avoid duplicate IDs
if (col.Exists(x => x.id == player.id))
{
Console.WriteLine($"[SKIP] Duplicate player ID {player.id}");
skipped++;
continue;
}
player.player_Extra.DormRoomID = RoomDB.clone_dorm_room(player.playerid);
col.Insert(player);
var name = player.player.Username ?? "<unknown>";
var displayName = player.player.DisplayName ?? "<unknown>";
var displayEmoji = player.player.DisplayEmoji ?? "";
Console.WriteLine($"[OK] Inserted player ({player.id}) @{name} {displayName} {displayEmoji}");
inserted++;
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to insert player ID {player.id}: {ex.Message}");
skipped++;
}
}
Console.WriteLine("[IMPORT] Import completed");
Console.WriteLine($"[IMPORT] Inserted: {inserted}");
Console.WriteLine($"[IMPORT] Skipped: {skipped}");
Console.WriteLine($"[IMPORT] Total processed: {importedPlayers.Count}");
return inserted > 0;
}
catch (JsonException jex)
{
Console.WriteLine($"[ERROR] JSON parsing error: {jex.Message}");
return false;
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Import failed with exception: {ex}");
return false;
}
}
public static bool ExportPlayers(string exportPath)
{
try
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var allPlayers = col.FindAll().ToList();
var json = JsonConvert.SerializeObject(allPlayers, Formatting.Indented);
File.WriteAllText(exportPath, json);
Console.WriteLine($"Exported {allPlayers.Count} players to {exportPath}");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Export failed: {ex.Message}");
return false;
}
}
public static bool CreateDormForPlayerId(ulong playerid, bool log = false)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
// Was: no null check, so an unknown player id threw here instead of
// reporting failure to the caller.
if (player == null)
{
Console.WriteLine($"[CreateDormForPlayerId] no player row for id {playerid}");
return false;
}
if (player.player_Extra != null)
{
ulong roomid = RoomDB.clone_dorm_room(playerid);
player.player_Extra.DormRoomID = roomid;
col.Update(player);
if (log)
{
Console.WriteLine($"Room id is {roomid} and successfully updaeted player");
}
return true;
}
return false;
}
public static void EnsureDormsForAllPlayers()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
// Get all players
var players = col.FindAll();
foreach (var player in players)
{
if (player?.player == null || player.player_Extra == null)
continue;
// Missing or invalid dorm
if (player.player_Extra.DormRoomID == null || player.player_Extra.DormRoomID == 1)
{
ulong roomId = RoomDB.clone_dorm_room(player.player.Id);
player.player_Extra.DormRoomID = roomId;
col.Update(player);
Console.WriteLine($"Created dorm {roomId} for player {player.player.Id}");
}
}
}
public static void EnsureDormsDontBeStupid(bool log = false)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var players = col.FindAll();
int fixCount = 0;
foreach (var player in players)
{
if (player?.player == null || player.player_Extra == null)
continue;
var room = RoomDB.GetRoom((ulong)player.player_Extra.DormRoomID);
// Check if the room is missing or simply isn't flagged as a Dorm
if (room == null || !room.IsDorm)
{
if (log)
{
string reason = (room == null) ? "Room Missing" : "Room not a Dorm";
Console.WriteLine($"[DormFix] Player: {player.player.Username} ({player.player.Id}) | Reason: {reason}");
}
CreateDormForPlayerId(player.player.Id);
fixCount++;
}
}
if (log && fixCount > 0)
{
Console.WriteLine($"[DormFix] Finished. Fixed {fixCount} player dorm records.");
}
}
public static ulong GetDormId(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (playerid == 1)
{
return 1;
}
// Was: dereferenced player.player_Extra with no null check, so an unknown
// player id threw NullReferenceException here.
if (player == null)
{
Console.WriteLine($"[GetDormId] no player row for id {playerid} - no dorm");
return 0;
}
if (player.player_Extra != null)
{
return (ulong)player.player_Extra.DormRoomID;
}
// Was: `return 1`. That is player 1's dorm, so any player whose row had no
// player_Extra was silently handed SOMEONE ELSE'S dorm room. 0 means "no
// dorm yet" and makes the caller create a real one.
Console.WriteLine($"[GetDormId] player {playerid} has no player_Extra - no dorm assigned yet");
return 0;
}
public static bool GetLogins(Platforms platform, ulong platformId, out List<AccountLogin> accounts)
{
accounts = new List<AccountLogin>();
var col = PlayerDBFile.GetCollection<player_data>("players");
// Pull all players (or consider filtering by another simple field if possible)
var allPlayers = col.FindAll().ToList();
// Filter in memory
var players = allPlayers
.Where(player => player.player.PlatformIds != null &&
player.player.PlatformIds.Any(pid => pid.Platform == platform &&
pid.PlatformId == platformId))
.ToList();
if (players.Count == 0)
return false;
foreach (var player in players)
{
var pid = player.player.PlatformIds.First(p => p.Platform == platform &&
p.PlatformId == platformId);
accounts.Add(new AccountLogin
{
accountId = player.playerid,
createdAt = player.player.created_at,
lastLoginTime = player.player.last_login_time,
platform = (Platforms)pid.Platform,
platformId = pid.PlatformId.ToString()
});
}
accounts = accounts
.OrderByDescending(a => a.lastLoginTime)
.ToList();
return true;
}
// A synthesized stand-in for an account id that has no DB row. Mirrors RecFlare's
// defaultAccount(): a "Player{id}" placeholder so the client can render a card instead
// of choking on a 404 / missing entry. The account_data_web getters supply the rest of
// the coalesced defaults (profileImage -> DefaultProfileImage.jpg, banner/emoji -> "").
private static account_data_web DefaultAccountWeb(ulong id)
{
// The login placeholder (see PlatformIdentity.LoginPlaceholderAccountId): the headset's
// cached-login picker asks for this id to label the "type your password" entry. Answer
// with a neutral identity, not "Player0" - that read as a real account failing to log in.
if (id == Campus_live_rewrite.api.server.PlatformIdentity.LoginPlaceholderAccountId)
return new account_data_web
{
accountId = id,
createdAt = DateTime.UtcNow,
username = Campus_live_rewrite.api.server.PlatformIdentity.LoginPlaceholderUsername,
displayName = Campus_live_rewrite.api.server.PlatformIdentity.LoginPlaceholderDisplayName,
profileImage = "DefaultPFP.png",
isJunior = false,
identityFlags = 0,
personalPronouns = 0,
platforms = 1 // Oculus - the platform the placeholder is served on
};
return new account_data_web
{
accountId = id,
createdAt = DateTime.UtcNow,
username = $"Player{id}",
displayName = $"Player{id}",
isJunior = false,
identityFlags = 0,
personalPronouns = 0,
platforms = 0
};
}
// A valid, non-negative `platforms` value for an account DTO. The account's primary
// platform int (Steam=0, Oculus=1, ...), or 0 if none is known. NEVER returns -1: the
// old code seeded a Platforms.All (-1) accumulator and OR'd onto it, so it was always
// -1, and -1 is not a real platform - the client's strict account parser rejects it and
// then NO player card / profile renders (own or others'). Same class as statusVisibility=4
// and deviceClass=-1.
public static long PrimaryPlatformValue(player_data? player)
{
var firstReal = player?.player?.PlatformIds?
.Select(p => (int)p.Platform)
.FirstOrDefault(v => v >= 0) ?? -1;
if (firstReal >= 0) return firstReal;
if (player != null && (int)player.platform >= 0) return (int)player.platform;
return 0;
}
public static string GetAccountsBulk(List<ulong> playerIds)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
col.EnsureIndex(x => x.id);
// ONE entry per requested id, in the requested set. A missing row is SYNTHESIZED
// (a Player{id} default), never dropped or 404'd - otherwise a profile / friends
// list / player list that references a deleted or never-existed id (e.g. the purged
// orphan ids 2,3,5) renders that card BLANK. This is exactly why single-account
// reads were 404ing (AccountById -> here -> empty array -> 404). Matches RecFlare's
// bulk, which guarantees one entry per requested id via defaultAccount().
var requested = playerIds?.Distinct().ToList() ?? new List<ulong>();
if (requested.Count == 0) return "[]";
var found = col.Find(x => requested.Contains(x.playerid))
.Where(p => p?.player != null)
.GroupBy(p => p.playerid)
.ToDictionary(g => g.Key, g => g.First());
var accounts = requested
.Select(id =>
{
if (!found.TryGetValue(id, out var player))
return DefaultAccountWeb(id);
// `platforms` MUST be a valid, non-negative number. It used to start at
// Platforms.All (-1) and OR onto it, so it was ALWAYS -1 - and -1 is not a
// real platform. The RecFlare account DTO emits a plain small number (0 by
// default; the reference never combines PlatformIds into it). A -1 here is
// the same out-of-range trap as statusVisibility=4 / deviceClass=-1: the
// client's strict account parser chokes on it and NO player card / profile
// renders (own or others'). Emit the account's primary platform int (Steam=0,
// Oculus=1, ...), defaulting to 0, never -1.
long platformsValue = 0;
var firstReal = player.player.PlatformIds?
.Select(p => (int)p.Platform)
.FirstOrDefault(v => v >= 0) ?? -1;
if (firstReal >= 0) platformsValue = firstReal;
else if ((int)player.platform >= 0) platformsValue = (int)player.platform;
return new account_data_web
{
accountId = player.playerid,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
displayEmoji = player.player.DisplayEmoji,
username = player.player.Username,
identityFlags = player.player.identityFlags,
isJunior = player.player.IsJunior,
personalPronouns = player.player.pronounFlags,
platforms = platformsValue,
profileImage = player.player.profileImage,
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage
};
})
.OrderBy(a => a.accountId)
.ToList();
return JsonConvert.SerializeObject(accounts);
}
public static string GetAccountsBulkAdmin(List<ulong> playerIds)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
col.EnsureIndex(x => x.id);
var players = col.Find(x => playerIds.Contains(x.playerid)).ToList();
if (players.Count == 0)
{
return "[]";
}
var accounts = players
.Select(player =>
{
return new
{
accountId = player.playerid,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
displayEmoji = player.player.DisplayEmoji,
username = player.player.Username,
identityFlags = player.player.identityFlags,
isJunior = player.player.IsJunior,
personalPronouns = player.player.pronounFlags,
platforms = PrimaryPlatformValue(player),
profileImage = player.player.profileImage,
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage,
bio = player.player_Extra.Bio,
mbd = JsonConvert.DeserializeObject(PlayerDB.Get_moderation_block(player.playerid)),
PlatformIDs = player.player.PlatformIds.Select(p => new
{
p.Platform,
PlatformId = p.PlatformId.ToString(),
// Username = Steam.GetAccountInfoAsync(p.PlatformId.ToString()).Result?.PersonaName ?? ""
}),
level = player.player.Level,
xp = player.player.XP
};
})
.OrderBy(a => a.accountId)
.ToList();
return JsonConvert.SerializeObject(accounts);
}
public static player_data? getfullaccount_token(ulong playerid, ulong platformid, int platform)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return null;
var ids = player.player.PlatformIds;
if (ids == null || ids.Count == 0)
return null;
bool match = ids.Any(p => p.PlatformId == platformid);
return match ? player : null;
}
/*public static player_data? GetFullAccount(ulong playerid, ulong plaformid, int plaform, string device_id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
if (player.platformId == plaformid && player.deviceId.Contains(device_id))
return player;
return null;
}
*/
public static player_data? GetFullAccount(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
return player;
}
// ---- Currency balances ------------------------------------------------
// Stored per player in player_extra.Balances (CurrencyType -> amount).
public static long GetBalance(ulong playerId, int currencyType)
{
var acc = GetFullAccount(playerId);
if (acc?.player_Extra?.Balances == null) return 0;
return acc.player_Extra.Balances.TryGetValue(currencyType, out var v) ? v : 0;
}
public static Dictionary<int, long> GetAllBalances(ulong playerId)
{
var acc = GetFullAccount(playerId);
return acc?.player_Extra?.Balances ?? new Dictionary<int, long>();
}
/// <summary>Adds (or subtracts) an amount and returns the NEW balance. Clamped at 0.</summary>
public static long AddBalance(ulong playerId, int currencyType, long delta)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player == null) return 0;
player.player_Extra ??= new player_extra();
player.player_Extra.Balances ??= new Dictionary<int, long>();
long current = player.player_Extra.Balances.TryGetValue(currencyType, out var v) ? v : 0;
long updated = current + delta;
if (updated < 0) updated = 0;
player.player_Extra.Balances[currencyType] = updated;
col.Update(player);
return updated;
}
public static player_data? GetFullAccountByName(string username)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Username == username);
if (player == null)
{
return null;
}
return player;
}
public static player_info? GetAccountById(ulong id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindById(id);
return player?.player;
}
public static account_data_me? GetAccountMeSafe(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
// See PrimaryPlatformValue: a valid, non-negative platform number, NEVER -1 (which
// is what the old Platforms.All accumulator always produced and which breaks the
// client's account parser - the owner's OWN /account/me card wouldn't render).
account_data_me account = new account_data_me
{
accountId = player.playerid,
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
email = player.player.Email,
displayEmoji = player.player.DisplayEmoji,
username = player.player.Username,
identityFlags = player.player.identityFlags,
isJunior = player.player.IsJunior,
// Juniors are treated as juniors client-side (chat/social restrictions).
TreatAsJunior = player.player.IsJunior,
personalPronouns = player.player.pronounFlags,
platforms = PrimaryPlatformValue(player),
profileImage = player.player.profileImage,
};
// The client requires a present, valid birthday. Use the real one entered at signup
// when we have it; otherwise keep the fixed placeholder the DTO defaults to.
if (!string.IsNullOrWhiteSpace(player.player.Birthday))
{
account.birthday = player.player.Birthday;
account.hasBirthday = true;
}
else if (string.Equals(SettingsDB.GetPlayerSettingValue("Recroom.AccountCreation.HasFinished", player.playerid), "false", StringComparison.OrdinalIgnoreCase))
{
// An in-game account still in its creation flow with no birthday on record: tell
// the client so its title-screen account-creation flow (birthday -> avatar ->
// username -> password) has a page it cannot skip. Website accounts entered a
// birthday at signup and never hit this.
account.hasBirthday = false;
}
/*
account_data_me account = new account_data_me
{
accountId = player.playerid,
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
displayEmoji = player.player.DisplayEmoji,
username = player.player.Username,
identityFlags = player.player.identityFlags,
isJunior = player.player.IsJunior,
personalPronouns = player.player.pronounFlags,
platforms = (long)combinedPlatforms,
profileImage = player.player.profileImage,
};
*/
return account;
}
public static string Get_moderation_block(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return JsonConvert.SerializeObject(new Moderation_Detail.ModerationBlockDetails());
}
var block = player.player_Extra.ModerationBlockDetails;
// Only an ACTIVE penalty is a moderation block. A stored record with Duration <= 0 (or
// no set-time) is NOT active - e.g. a logged report with no penalty applied
// (IsBan=false, Duration=0). The client counts ANY non-empty moderationBlockDetails as a
// recent moderation incident and then blocks club/room creation ("Creation Restricted -
// too many moderation incidents"), so a non-active record MUST read back as empty.
if (block == null || block.Duration <= 0 || block.ModerationSetUnixTime <= 0)
return JsonConvert.SerializeObject(new Moderation_Detail.ModerationBlockDetails());
// Permanent ban.
if (block.Duration == int.MaxValue)
return JsonConvert.SerializeObject(block);
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int elapsed = (int)(now - block.ModerationSetUnixTime);
int remaining = block.Duration - elapsed;
if (remaining <= 0)
{
// Timeout has run out - clear it and report no block.
player.player_Extra.ModerationBlockDetails = null;
col.Update(player);
return JsonConvert.SerializeObject(new Moderation_Detail.ModerationBlockDetails());
}
// Still serving time: report the block with the time that's left.
block.Duration = remaining;
return JsonConvert.SerializeObject(block);
}
public static string Set_moderation_block(
ulong playerid,
Moderation_Detail.ReportCategory category,
int duration,
long gameSessionId,
bool? isHostKick,
string? message,
ulong? playerIdReporter,
bool? isBan,
ulong bannedByPlayerId,
bool addAppealMessage = true
)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return "{}";
if (addAppealMessage && isBan == true)
{
message += "\nTo appeal this ban, Make a ticket in https://discord.gg/xH8MMwK5Bk.";
}
var newBlock = new Moderation_Detail.ModerationBlockDetails
{
ReportCategory = category,
Duration = duration,
GameSessionId = gameSessionId,
IsHostKick = isHostKick,
Message = message,
PlayerIdReporter = playerIdReporter,
IsBan = isBan,
ModerationSetUnixTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
BannedByPlayerId = bannedByPlayerId
};
player.player_Extra.ModerationBlockDetails = newBlock;
col.Update(player);
// Every moderation action is logged with WHO did it to WHOM and why. Without this
// there is no way to tell a real moderator action from a bug calling this path -
// and a 1-second isBan looks identical to "the game keeps closing itself" from the
// player's side, because the block clears before anyone can inspect it.
Console.WriteLine(
$"[moderation] target={playerid} by={bannedByPlayerId} " +
$"isBan={isBan} isHostKick={isHostKick} duration={duration}s " +
$"category={category} reporter={playerIdReporter?.ToString() ?? "-"} " +
$"msg=\"{(message ?? "").Replace("\n", " ")}\"");
// A ban has to actually REMOVE them from the room.
//
// The client drops to its dorm and then resyncs against the presence heartbeat
// - and the heartbeat still said "you are in room X", so it put the banned
// player straight back into the room they were just banned from. Clearing the
// room instance means the resync has nowhere to send them.
if (isBan == true)
{
try
{
HeartbeatDB.UpdatePlayerHeartbeat(playerid, null, online: false);
Console.WriteLine($"[ban] cleared room instance for banned player {playerid}");
}
catch (Exception ex)
{
Console.WriteLine($"[ban] could not clear heartbeat for {playerid}: {ex.Message}");
}
}
try
{
Notifications.SendToPlayer(playerid, JsonConvert.SerializeObject(WebsocketEvents.CreateBan(newBlock)));
}
catch { }
return JsonConvert.SerializeObject(newBlock);
}
public static bool HasActiveModerationBlock(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return false;
var block = player.player_Extra.ModerationBlockDetails;
if (block == null || block.Duration <= 0 || block.ModerationSetUnixTime <= 0)
return false;
if (block.Duration == int.MaxValue) // Permanent ban
return true;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int elapsed = (int)(now - block.ModerationSetUnixTime);
int remaining = block.Duration - elapsed;
if (remaining <= 0)
{
return false;
}
return true;
}
public static bool CheckDevFlag(ulong id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.id == id);
if (player != null && (player.dev_Flag & DevFlag.dev) == DevFlag.dev)
{
return true;
}
return false;
}
public static bool CheckModFlag(ulong id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.id == id);
if (player != null && (player.dev_Flag & DevFlag.mod) == DevFlag.mod)
{
return true;
}
return false;
}
public static void set_player_avatar(string avatar, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
// Was: guarded `player_Setting1.player != null` but then wrote to
// player_Setting1.player_Extra - the wrong field. A row with a null
// player_Extra threw NullReferenceException while saving the avatar.
if (player_Setting1?.player_Extra != null)
{
player_Setting1.player_Extra.avatar = avatar;
col.Update(player_Setting1);
return;
}
Console.WriteLine($"[set_player_avatar] no player_Extra for {playerid} - avatar not saved");
}
public static (bool Cheered, bool Favorited) GetInteraction(ulong playerid, ulong roomid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return (false, false);
bool cheered = player.player_Extra.CheeredRooms?.Contains(roomid) ?? false;
bool favorited = player.player_Extra.FavoritedRooms?.Contains(roomid) ?? false;
return (cheered, favorited);
}
public static bool ToggleCheer(ulong playerid, ulong roomid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null) return false;
if (player.player_Extra.CheeredRooms == null)
player.player_Extra.CheeredRooms = new List<ulong>();
bool nowCheered;
if (player.player_Extra.CheeredRooms.Contains(roomid))
{
player.player_Extra.CheeredRooms.Remove(roomid);
nowCheered = false;
RoomDB.UpdateRoomCheerCount(roomid, -1);
}
else
{
player.player_Extra.CheeredRooms.Add(roomid);
nowCheered = true;
RoomDB.UpdateRoomCheerCount(roomid, +1);
}
col.Update(player);
return nowCheered;
}
public static bool ToggleFavorite(ulong playerid, ulong roomid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null) return false;
if (player.player_Extra.FavoritedRooms == null)
player.player_Extra.FavoritedRooms = new List<ulong>();
bool nowFavorited;
if (player.player_Extra.FavoritedRooms.Contains(roomid))
{
player.player_Extra.FavoritedRooms.Remove(roomid);
nowFavorited = false;
RoomDB.UpdateRoomFavoriteCount(roomid, -1);
}
else
{
player.player_Extra.FavoritedRooms.Add(roomid);
nowFavorited = true;
RoomDB.UpdateRoomFavoriteCount(roomid, +1);
}
col.Update(player);
return nowFavorited;
}
public static List<account_data> Search(string text)
{
// GET /Accounts/account/search with no `name` query parameter passes null
// here, and the first thing the loop does is text.StartsWith("@") - an
// unhandled NullReferenceException that Kestrel turns into a 500.
// Verified live: probing the route with no argument returned HTTP 500.
// An empty search matches nothing, so return an empty list.
if (string.IsNullOrWhiteSpace(text))
return new List<account_data>();
// Match on USERNAME first (the reference server prefix-matches username, and the
// People search is by @handle), and also on DISPLAY NAME so searching the shown name
// works too. Case-insensitive Contains, which is a superset of the reference's prefix
// match. A leading "@" is stripped so "@eli" and "eli" behave the same.
//
// The previous version only checked username when the query STARTED WITH "@", and
// otherwise matched display name ONLY - so searching a plain "eli" (which is a
// USERNAME) never checked usernames and returned []. It also left `found=true` when a
// row had a null DisplayName, which quietly matched every such account.
string q = text.Trim().TrimStart('@').ToLowerInvariant();
if (q.Length == 0)
return new List<account_data>();
var col = PlayerDBFile.GetCollection<player_data>("players");
List<account_data> list = new List<account_data>();
foreach (player_data room in col.FindAll())
{
var p = room?.player;
if (p == null)
continue;
bool matchUsername = !string.IsNullOrEmpty(p.Username)
&& p.Username.ToLowerInvariant().Contains(q);
bool matchDisplay = !string.IsNullOrEmpty(p.DisplayName)
&& p.DisplayName.ToLowerInvariant().Contains(q);
if (!matchUsername && !matchDisplay)
continue;
var room_data = GetAccountBulk(room.playerid);
if (room_data is null)
continue;
list.Add(room_data);
}
return list;
}
public static account_data GetAccountBulk(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
return new account_data
{
accountId = player.playerid,
createdAt = player.player.created_at,
displayName = player.player.DisplayName,
username = player.player.Username,
identityFlags = 0,
isJunior = player.player.IsJunior,
personalPronouns = 0,
platforms = PrimaryPlatformValue(player),
profileImage = player.player.profileImage,
};
}
public static bool SetPlayerUsername(string username, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
// Case-INSENSITIVE: "kyle" next to "Kyle" is the whole point of a spoofed @name.
var existingplayer = col.FindAll().FirstOrDefault(x => x.player?.Username != null
&& string.Equals(x.player.Username, username, StringComparison.OrdinalIgnoreCase) && x.playerid != playerid);
if (existingplayer != null)
{
return false;
}
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.Username = username;
col.Update(player_Setting1);
RoomDB.RenameDormForPlayer(playerid);
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
return true;
}
}
return false;
}
/// <summary>
/// Every platform id linked to this account for a platform, in the order stored.
/// An account may hold several - a player with two Steam accounts signs in on
/// either and lands here.
/// </summary>
public static List<ulong> GetPlatformIds(ulong playerid, Platforms platform)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var row = col.FindOne(x => x.playerid == playerid);
return row?.player?.PlatformIds?
.Where(p => p.Platform == platform)
.Select(p => p.PlatformId)
.ToList()
?? new List<ulong>();
}
/// <summary>First platform id for this platform, or 0. Convenience for display.</summary>
public static ulong GetPlatformId(ulong playerid, Platforms platform) =>
GetPlatformIds(playerid, platform).FirstOrDefault();
/// <summary>
/// Every account carrying this platform id, ignoring <paramref name="ignorePlayerId"/>.
///
/// Informational only - the admin panel reports shared ids rather than refusing
/// them. Sharing IS allowed by design, but it is worth knowing about: GetLogins
/// resolves a sign-in by (platform, platformId), so when several accounts hold the
/// same id, that lookup returns them all and the caller picks.
/// </summary>
public static List<ulong> FindPlayersByPlatformId(Platforms platform, ulong platformId, ulong ignorePlayerId = 0)
{
if (platformId == 0) return new List<ulong>();
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll()
.Where(x => x.playerid != ignorePlayerId &&
x.player?.PlatformIds != null &&
x.player.PlatformIds.Any(p => p.Platform == platform && p.PlatformId == platformId))
.Select(x => x.playerid)
.ToList();
}
/// <summary>
/// Replaces every link for this platform with <paramref name="platformIds"/>.
/// An empty list unlinks the platform entirely. Duplicates within the list are
/// collapsed; the same id living on OTHER accounts is left alone - that is
/// deliberate, several accounts may share one Steam id.
/// </summary>
public static bool SetPlatformIds(ulong playerid, Platforms platform, IEnumerable<ulong> platformIds)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var row = col.FindOne(x => x.playerid == playerid);
if (row?.player == null) return false;
row.player.PlatformIds ??= new List<mPlatformID>();
row.player.PlatformIds.RemoveAll(p => p.Platform == platform);
foreach (var id in platformIds.Where(i => i != 0).Distinct())
row.player.PlatformIds.Add(new mPlatformID { Platform = platform, PlatformId = id });
col.Update(row);
return true;
}
/// <summary>Single-id form of <see cref="SetPlatformIds"/>; 0 unlinks.</summary>
public static bool SetPlatformId(ulong playerid, Platforms platform, ulong platformId) =>
SetPlatformIds(playerid, platform, platformId == 0 ? Array.Empty<ulong>() : new[] { platformId });
/// <summary>Sets (or with 0, clears) the linked Discord account.</summary>
public static bool SetDiscordId(ulong playerid, ulong discordId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var row = col.FindOne(x => x.playerid == playerid);
if (row?.player == null) return false;
row.player_Extra ??= new player_extra();
row.player_Extra.DiscordId = discordId;
col.Update(row);
return true;
}
/// <summary>
/// Finds whoever already has this Discord id linked, ignoring
/// <paramref name="ignorePlayerId"/>. Returns 0 when nobody does.
/// </summary>
public static ulong FindPlayerByDiscordId(ulong discordId, ulong ignorePlayerId = 0)
{
if (discordId == 0) return 0;
var col = PlayerDBFile.GetCollection<player_data>("players");
var owner = col.FindAll().FirstOrDefault(x =>
x.playerid != ignorePlayerId &&
x.player_Extra != null &&
x.player_Extra.DiscordId == discordId);
return owner?.playerid ?? 0;
}
/// <summary>
/// Link a Discord user to a Campus account (website Settings > Discord tab + the /link bot
/// command). Sets BOTH the id and the display tag. This is ONLY an association for the bot
/// and the site - it grants NO dev/admin/moderation privilege of any kind. Pass discordId 0
/// to unlink.
/// </summary>
public static bool SetDiscordLink(ulong playerid, ulong discordId, string? discordUsername)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.DiscordId = discordId;
record.player_Extra.DiscordUsername = discordId == 0 ? null : discordUsername;
col.Update(record);
Console.WriteLine($"[discord-link] account {playerid} {(discordId == 0 ? "UNLINKED" : $"linked to discord {discordId} ({discordUsername})")}");
return true;
}
/// <summary>Whether a moderator has locked this player's @name / display name (admin panel).</summary>
public static bool IsNameLocked(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return record?.player_Extra?.NameLocked == true;
}
/// <summary>Lock or unlock a player's names. Admin edits are never gated by this.</summary>
public static bool SetNameLock(ulong playerid, bool locked)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.NameLocked = locked;
col.Update(record);
Console.WriteLine($"[names] account {playerid} (@{record.player.Username}) name lock {(locked ? "ON" : "off")}");
return true;
}
public static bool SetPlayerDev(DevFlag dev_Flag, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == playerid);
if (playerRecord != null)
{
if (playerRecord.player != null)
{
playerRecord.dev_Flag = dev_Flag;
col.Update(playerRecord);
return true;
}
}
return false;
}
public static bool SetAllDev()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
foreach (var playerRecord in col.FindAll())
{
if (playerRecord.player != null)
{
playerRecord.dev_Flag = DevFlag.dev;
col.Update(playerRecord);
return true;
}
}
return false;
}
public static account_login<account_data> GetAccountPassword(string username, string password, bool isAdminPanel)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return new account_login<account_data>
{
success = false,
error = "Your username or password is empty, please enter a username and password.",
};
}
var player = col.FindOne(x => x.player.Username == username);
if (player == null)
{
return new account_login<account_data>
{
success = false,
error = "The account you are trying to login to does not exist.",
};
}
if (string.IsNullOrEmpty(player.password))
{
return new account_login<account_data>
{
success = false,
error = "The password or username you entered don't match the account you are trying to login to."
};
}
if (string.IsNullOrEmpty(password))
{
return new account_login<account_data>
{
success = false,
error = "The password or username you entered don't match the account you are trying to login to.",
};
}
if (isAdminPanel)
{
var devCheckResult = PlayerDB.CheckDevFlag(player.playerid);
if (!devCheckResult)
{
return new account_login<account_data>
{
success = false,
error = "You are not a developer",
};
}
}
bool isValid = ClientSecurity.VerifyPassword(password, player.password);
if (!isValid)
{
string oldHash = ClientSecurity.GetHashString(password);
if (string.Equals(player.password, oldHash, StringComparison.OrdinalIgnoreCase))
{
player.password = ClientSecurity.HashPassword(password);
col.Update(player);
}
else
{
return new account_login<account_data>
{
success = false,
error = "The password or username you entered don't match the account you are trying to login to."
};
}
}
return new account_login<account_data>
{
success = true,
error = "",
accid = player.playerid,
};
}
public static void SetPlayerPassword(string pass, ulong playerid, bool log)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == playerid);
if (playerRecord != null && playerRecord.player != null)
{
playerRecord.password = ClientSecurity.HashPassword(pass);
col.Update(playerRecord);
if (log)
{
Console.WriteLine($"[Password Change] PlayerID: {playerid} has changed their password.");
}
}
}
public static bool SetPlayerBio(string bio, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player_Extra.Bio = bio;
col.Update(player_Setting1);
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
return true;
}
}
return false;
}
public static async Task<bool> SetPlayerLevel(int level, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.Level = level;
try
{
await Notifications.SendToAll(JsonConvert.SerializeObject(WebsocketEvents.createResponse_levelupdate(playerid, level, Progress.CumulativeXpForLevel(level, 0))));
}
catch
{
}
col.Update(player_Setting1);
return true;
}
}
return false;
}
public static player_data? GetPlayerByDiscordId(ulong discordId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player_Extra.DiscordId == discordId);
return player;
}
public static void SetDisplayName(string username, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.DisplayName = username;
col.Update(player_Setting1);
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
return;
}
}
}
public static bool SetRoleFlag(ulong playerid, DevFlag flag, bool enable)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
if (enable) record.dev_Flag |= flag;
else record.dev_Flag &= ~flag;
col.Update(record);
return true;
}
/// <summary>
/// Sets XP and/or Level directly (admin panel). Pass null to leave one unchanged.
/// Returns the values actually stored, or null if the player does not exist.
/// </summary>
public static (int XP, int Level)? SetProgression(ulong playerid, int? xp, int? level)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return null;
if (xp.HasValue) record.player.XP = xp.Value < 0 ? 0 : xp.Value;
if (level.HasValue) record.player.Level = level.Value < 0 ? 0 : level.Value;
col.Update(record);
return (record.player.XP, record.player.Level);
}
/// <summary>
/// Grants XP to a player and applies any level-ups it now pays for, using the real
/// progression curve (Progress.LEVEL_REQUIRED_XP). XP is progress INTO the current level;
/// a grant that fills the level carries the remainder forward. Returns the resulting
/// (Level, XP) and how many levels were gained, or null if the player has no row.
/// This is what a game reward calls - it was the missing piece: nothing granted XP, so
/// players never levelled up. Mirrors RecFlare's addXp.
/// </summary>
public static (int Level, int XP, int LevelsGained)? AddXp(ulong playerid, int amount)
{
if (amount <= 0)
{
var cur = Progress.GetPlayerLevel(playerid);
return (cur.Level, cur.XP, 0);
}
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return null;
int startLevel = record.player.Level < 1 ? 1 : record.player.Level;
int newXp = (record.player.XP < 0 ? 0 : record.player.XP) + amount;
var (level, xp) = Progress.ApplyLevelUps(startLevel, newXp);
int levelsGained = level - startLevel;
record.player.Level = level;
record.player.XP = xp;
col.Update(record);
// Push the new level/XP so the crown + progress bar update in real time (no relog) -
// AddXp used to be silent, so a level earned in-game only showed after a relog. A level
// CHANGE broadcasts (everyone's view of this player's crown updates); an XP-only gain
// pings just the owner so their bar advances. Same PlayerProgressionLevelUpdate event
// the admin level set + the progression GET use.
try
{
// Report CUMULATIVE XP (client derives the crown level from XP - see
// Progress.CumulativeXpForLevel), not the raw into-level XP.
int reportXp = Progress.CumulativeXpForLevel(level, xp);
string msg = JsonConvert.SerializeObject(WebsocketEvents.createResponse_levelupdate(playerid, level, reportXp));
if (levelsGained > 0) Notifications.FireAndForget(Notifications.SendToAll(msg), "levelup broadcast");
else Notifications.FireAndForget(Notifications.SendToPlayer(playerid, msg), "xp update");
}
catch { }
// Every level crossed pays its box (reference: grantLevelUpGifts). After the level is
// stored, so a failed roll costs a prize and not the level.
if (levelsGained > 0)
{
try { Campus_live_rewrite.api.server.LevelRewards.GrantLevelUps(playerid, startLevel, level); }
catch (Exception ex) { Console.WriteLine($"[levelrewards] {playerid}: {ex.Message}"); }
}
return (level, xp, levelsGained);
}
/// <summary>
/// Claims a once-per-day reward. Returns false if it was already claimed today,
/// in which case nothing is written.
/// </summary>
public static bool TryClaimDailyReward(ulong playerid, string rewardKey)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.DailyRewardClaims ??= new Dictionary<string, string>();
string today = DateTime.UtcNow.ToString("yyyy-MM-dd");
if (record.player_Extra.DailyRewardClaims.TryGetValue(rewardKey, out var last) && last == today)
return false;
record.player_Extra.DailyRewardClaims[rewardKey] = today;
col.Update(record);
return true;
}
// ---- Gift boxes -------------------------------------------------------
// ---- Avatar item ownership ------------------------------------------------
//
// The 2023 server treats AvatarItems.json as a CATALOG - the list of every item
// that exists - and keeps what each player owns on the player record, seeded from
// DefaultAvatarItems.json. The 2025 server had no ownership list at all and simply
// served the whole catalog from /api/avatar/v4/items, so everybody owned
// everything. These helpers restore the 2023 model.
/// <summary>
/// Canonical key for comparing two descriptors. Trailing empty colour slots are
/// dropped, so "guid,,," and "guid," compare equal, while "guid,colour,,"
/// stays distinct from "guid,,," - every colour of an item shares a base guid, so
/// keying on the guid alone would merge Knight Cape (Red) into Knight Cape (Green).
/// </summary>
public static string NormalizeAvatarItemDesc(string? desc)
{
if (string.IsNullOrWhiteSpace(desc)) return "";
var parts = desc.Trim().Split(',').Select(p => p.Trim()).ToList();
while (parts.Count > 1 && string.IsNullOrEmpty(parts[^1])) parts.RemoveAt(parts.Count - 1);
// Compare on the EXPANDED form of every id.
//
// Rec Room writes ids two ways - a full UUID, or a 22-char base64 "short guid" -
// and avatar_items.json used to mix both. The catalog has since been rewritten to
// full UUIDs throughout (see Data/APIS/items/README-avatar-items.md), but stored
// ownership rows, DefaultAvatarItems.json and the storefronts still carry whichever
// spelling they were written with. Expanding here means "guid,8XajS2adOEOqzqnLy6-zcQ"
// and "guid,4ba376f1-9d66-4338-aace-a9cbcbafb371" are the same item, so nobody
// loses an item they own just because the catalog changed spelling.
for (int i = 0; i < parts.Count; i++)
{
var expanded = Campus_live_rewrite.api.server.Classes.ItemGuids.NormalizeGuid(parts[i]);
if (expanded != null) parts[i] = expanded;
}
return string.Join(",", parts);
}
/// <summary>
/// The starter wardrobe every non-developer account begins with. Loaded once.
/// </summary>
private static List<string>? _defaultAvatarItems;
private static readonly object _defaultAvatarItemsLock = new();
public static List<string> DefaultAvatarItemDescs()
{
if (_defaultAvatarItems != null) return _defaultAvatarItems;
lock (_defaultAvatarItemsLock)
{
if (_defaultAvatarItems != null) return _defaultAvatarItems;
var list = new List<string>();
try
{
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "DefaultAvatarItems.json");
if (File.Exists(path))
{
// The file is a flat array of descriptor strings.
var arr = Newtonsoft.Json.JsonConvert.DeserializeObject<List<string>>(File.ReadAllText(path));
foreach (var d in arr ?? new List<string>())
if (!string.IsNullOrWhiteSpace(d)) list.Add(d.Trim());
}
else
{
Console.WriteLine($"[items] DefaultAvatarItems.json not found at {path} - new accounts will start empty");
}
}
catch (Exception ex)
{
Console.WriteLine($"[items] could not read DefaultAvatarItems.json: {ex.Message}");
}
_defaultAvatarItems = list;
Console.WriteLine($"[items] loaded {list.Count} default starter avatar item(s)");
return _defaultAvatarItems;
}
}
/// <summary>
/// Everything this account owns, as canonical keys. Grants the starter set on
/// first read so existing accounts need no migration.
/// </summary>
public static HashSet<string> OwnedAvatarItemDescs(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
record.player_Extra ??= new player_extra();
if (record.player_Extra.AvatarItems == null)
{
record.player_Extra.AvatarItems = new List<string>(DefaultAvatarItemDescs());
col.Update(record);
Console.WriteLine($"[items] granted {record.player_Extra.AvatarItems.Count} starter item(s) to {playerid}");
}
var set = new HashSet<string>(
record.player_Extra.AvatarItems.Select(NormalizeAvatarItemDesc).Where(d => d.Length > 0),
StringComparer.OrdinalIgnoreCase);
// Unioned on READ rather than written into the wardrobe row, so every existing account
// gets it with no migration and it cannot drift out of sync with the catalog.
set.UnionWith(Campus_live_rewrite.api.server.HairGrants.FreeHairKeys());
return set;
}
/// <summary>Adds an item to a player's wardrobe. Returns true if it was new.</summary>
/// <summary>The normalised keys of every avatar item the player owns (defaults included).</summary>
public static HashSet<string> GetOwnedAvatarItemKeys(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var items = record?.player_Extra?.AvatarItems ?? DefaultAvatarItemDescs();
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var d in items) { var k = NormalizeAvatarItemDesc(d); if (k.Length > 0) set.Add(k); }
set.UnionWith(Campus_live_rewrite.api.server.HairGrants.FreeHairKeys());
return set;
}
// Consumable descriptors come in short-guid and UUID spellings; own them by the UUID form.
public static string NormalizeConsumableDesc(string? desc)
{
string s = (desc ?? "").Trim();
if (s.Length == 0) return "";
return (Campus_live_rewrite.api.server.Classes.ItemGuids.NormalizeGuid(s) ?? s).ToLowerInvariant();
}
/// <summary>The player's consumable stock (desc key -> count), empty when they own none.</summary>
public static Dictionary<string, int> OwnedConsumables(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return new Dictionary<string, int>(record?.player_Extra?.Consumables ?? new Dictionary<string, int>(), StringComparer.OrdinalIgnoreCase);
}
public static void GrantConsumable(ulong playerid, string? desc, int count)
{
string ck = NormalizeConsumableDesc(desc);
if (ck.Length == 0 || count <= 0) return;
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return;
record.player_Extra ??= new player_extra();
record.player_Extra.Consumables ??= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
record.player_Extra.Consumables[ck] = (record.player_Extra.Consumables.TryGetValue(ck, out var have) ? have : 0) + count;
col.Update(record);
}
public static bool GrantAvatarItem(ulong playerid, string? desc)
{
string key = NormalizeAvatarItemDesc(desc);
if (key.Length == 0) return false;
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.AvatarItems ??= new List<string>(DefaultAvatarItemDescs());
if (record.player_Extra.AvatarItems.Any(d => NormalizeAvatarItemDesc(d).Equals(key, StringComparison.OrdinalIgnoreCase)))
return false;
record.player_Extra.AvatarItems.Add(desc!.Trim());
col.Update(record);
Console.WriteLine($"[items] granted '{desc!.Trim()}' to {playerid}");
return true;
}
/// <summary>Developers get the whole catalog - the dev locker.</summary>
public static bool HasDevLocker(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return record != null && (record.dev_Flag & DevFlag.dev) == DevFlag.dev;
}
/// <summary>
/// Marks CONTENTLESS pending gift boxes as opened, so they stop lingering forever. An empty
/// box (no gift drop, no avatar item, no consumable, no equipment) is filtered out of the
/// gifts list the client is sent - it can never be opened there and would sit in the DB
/// counting toward the gift badge as a "random gift" that won't clear. Marking it opened
/// retires it. Returns how many were retired.
/// </summary>
public static int MarkEmptyGiftBoxesOpened(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var boxes = record?.player_Extra?.PendingGiftBoxes;
if (boxes == null || boxes.Count == 0) return 0;
int cleaned = 0;
foreach (var g in boxes)
{
if (g.Opened) continue;
// Currency / XP boxes count as content (a token box has no item and is real).
if (!Campus_live_rewrite.api.server.GiftBoxes.HasContent(g)) { g.Opened = true; cleaned++; }
}
if (cleaned > 0) col.Update(record);
return cleaned;
}
public static bool AddPendingGiftBox(ulong playerid, int giftContext, string? message, ulong fromAccountId = 0)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.PendingGiftBoxes ??= new List<PendingGiftBox>();
record.player_Extra.PendingGiftBoxes.Add(new PendingGiftBox
{
GiftContext = giftContext,
Message = message,
FromAccountId = fromAccountId
});
col.Update(record);
return true;
}
/// <summary>Stores a fully-populated gift box (store purchases carry item data).</summary>
/// <summary>Backfill a numeric Id onto any pending gift box that still has NumericId 0 (older
/// reward/select boxes). Without it the gifts endpoint emits a guid Id the client rejects.</summary>
// ---- gift package ids: ONE sequence for the whole server ------------------------------
//
// A gift box id has to be unique across PLAYERS, not just within one player's list. The
// client networks a spawned box into the room by its package id, and "Open All" walks the
// boxes it can see: with per-player ids (reward boxes 1001+ per player, purchases from a
// counter that restarted at 5000 on every deploy) two players' boxes in the same room
// shared ids. Player.log, opening gifts in the Rec Center after another player had: the
// client posts consume for 5003 and 5002 (the other player's, already opened - "no matching
// unopened box"), throws "Gift is missing from the list", and only then reaches its own
// 5004 - which the other player ALSO had as 5004. The reference's received_gift table is
// AUTOINCREMENT for the same reason.
//
// Seeded once above every id already stored, so nothing pending collides with a new box.
private sealed class Counter { public int Id { get; set; } public int Value { get; set; } }
private static readonly object _giftIdLock = new();
public static int NextGiftPackageId()
{
lock (_giftIdLock)
{
var counters = PlayerDBFile.GetCollection<Counter>("counters");
var row = counters.FindById(1);
if (row == null)
{
int max = 100_000;
try
{
foreach (var p in PlayerDBFile.GetCollection<player_data>("players").FindAll())
foreach (var b in p.player_Extra?.PendingGiftBoxes ?? new List<PendingGiftBox>())
if (b.NumericId > max) max = b.NumericId;
}
catch { }
row = new Counter { Id = 1, Value = max };
Console.WriteLine($"[gifts] gift package id sequence seeded at {max}");
}
row.Value++;
counters.Upsert(row);
return row.Value;
}
}
/// <summary>True when a box with this id is (or was) some OTHER player's.</summary>
public static bool GiftBoxBelongsToSomeoneElse(ulong playerid, int numericId)
{
try
{
foreach (var p in PlayerDBFile.GetCollection<player_data>("players").Find(x => x.playerid != playerid))
if ((p.player_Extra?.PendingGiftBoxes ?? new List<PendingGiftBox>()).Any(b => b.NumericId == numericId))
return true;
}
catch { }
return false;
}
public static void EnsureGiftBoxNumericIds(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var boxes = record?.player_Extra?.PendingGiftBoxes;
if (boxes == null || boxes.Count == 0) return;
bool changed = false;
foreach (var b in boxes)
if (b.NumericId == 0) { b.NumericId = NextGiftPackageId(); changed = true; }
if (changed) col.Update(record);
}
// The guid is compared on its UUID form: Equipments.json spells every skin as a full UUID,
// but 112 of the 187 skin rows in the Watch store spell the SAME skins as 22-char short
// guids. Compared as text they never matched, so a bought skin was stored under a key
// that /equipment/v2/getUnlocked could never find - "Can't load item" on the box, and the
// skin never turned up in the locker.
public static string EquipmentSkinKey(string? prefab, string? guid)
{
string g = Campus_live_rewrite.api.server.Classes.ItemGuids.NormalizeGuid(guid) ?? (guid ?? "").Trim().ToLowerInvariant();
return $"{(prefab ?? "").Trim().ToLowerInvariant()}|{g}";
}
public static HashSet<string> OwnedEquipmentSkins(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return new HashSet<string>(record?.player_Extra?.EquipmentSkins ?? new List<string>(), StringComparer.OrdinalIgnoreCase);
}
public static bool OwnsCustomItem(ulong playerid, string caid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return record?.player_Extra?.CustomItems?.Any(c => string.Equals(c, caid, StringComparison.OrdinalIgnoreCase)) == true;
}
public static HashSet<string> OwnedCustomItems(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
return new HashSet<string>(record?.player_Extra?.CustomItems ?? new List<string>(), StringComparer.OrdinalIgnoreCase);
}
/// <summary>Put a UGC item in the player's wardrobe. Returns true if it was new.</summary>
public static bool GrantCustomItem(ulong playerid, string? caid)
{
if (string.IsNullOrWhiteSpace(caid)) return false;
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.CustomItems ??= new List<string>();
if (record.player_Extra.CustomItems.Any(c => string.Equals(c, caid, StringComparison.OrdinalIgnoreCase))) return false;
record.player_Extra.CustomItems.Add(caid);
col.Update(record);
Console.WriteLine($"[ugc] {playerid} now owns custom item {caid}");
return true;
}
/// <summary>Put a skin in the player's locker. Returns true if it was new.</summary>
public static bool GrantEquipmentSkin(ulong playerid, string? prefab, string? guid)
{
if (string.IsNullOrWhiteSpace(prefab) || string.IsNullOrWhiteSpace(guid)) return false;
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.EquipmentSkins ??= new List<string>();
string key = EquipmentSkinKey(prefab, guid);
if (record.player_Extra.EquipmentSkins.Any(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase))) return false;
record.player_Extra.EquipmentSkins.Add(key);
col.Update(record);
Console.WriteLine($"[skins] {playerid} now owns skin {prefab} {guid}");
return true;
}
public static bool AddPendingGiftBoxFull(ulong playerid, PendingGiftBox box)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.PendingGiftBoxes ??= new List<PendingGiftBox>();
// Every gift box MUST have a numeric Id. The gifts endpoint emits the guid Id when
// NumericId is 0, but the client's gift model expects a NUMBER there and rejects the whole
// gifts array as "Malformed Response" if it sees a guid - which silently killed every
// reward gift box (and made the reward watch open then instantly close). Purchases already
// set NumericId; reward/select boxes didn't, so assign one here - from the server-wide
// sequence (NextGiftPackageId), never a per-player count.
if (box.NumericId == 0) box.NumericId = NextGiftPackageId();
record.player_Extra.PendingGiftBoxes.Add(box);
// A purchased or gifted item has to enter the wardrobe too, now that owning it
// is no longer implied by the catalog being served to everyone. Done here, in
// the same write, so a bought item can never be left unowned.
if (!string.IsNullOrWhiteSpace(box.AvatarItemDesc))
{
string key = NormalizeAvatarItemDesc(box.AvatarItemDesc);
record.player_Extra.AvatarItems ??= new List<string>(DefaultAvatarItemDescs());
if (!record.player_Extra.AvatarItems.Any(d =>
NormalizeAvatarItemDesc(d).Equals(key, StringComparison.OrdinalIgnoreCase)))
{
record.player_Extra.AvatarItems.Add(box.AvatarItemDesc!.Trim());
Console.WriteLine($"[items] {playerid} now owns '{box.AvatarItemDesc!.Trim()}' ({box.FriendlyName})");
}
}
if (!string.IsNullOrWhiteSpace(box.CustomAvatarItemId))
{
record.player_Extra.CustomItems ??= new List<string>();
if (!record.player_Extra.CustomItems.Any(c => string.Equals(c, box.CustomAvatarItemId, StringComparison.OrdinalIgnoreCase)))
{
record.player_Extra.CustomItems.Add(box.CustomAvatarItemId!);
Console.WriteLine($"[ugc] {playerid} now owns custom item {box.CustomAvatarItemId} ({box.FriendlyName})");
}
}
// A consumable: counted into the player's own stock (was: everyone had every
// consumable with an infinite count, so nothing was ever really "got").
if (!string.IsNullOrWhiteSpace(box.ConsumableItemDesc))
{
record.player_Extra.Consumables ??= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
string ck = NormalizeConsumableDesc(box.ConsumableItemDesc);
int add = box.ConsumableCount > 0 ? box.ConsumableCount : 1;
record.player_Extra.Consumables[ck] = (record.player_Extra.Consumables.TryGetValue(ck, out var have) ? have : 0) + add;
Console.WriteLine($"[consumables] {playerid} +{add} {box.FriendlyName} -> {record.player_Extra.Consumables[ck]}");
}
// An equipment SKIN enters the locker the same way an avatar item enters the wardrobe -
// in this write, so a bought skin can never be left unowned (it used to be: skins were
// served to everyone, so nothing ever recorded a purchase).
if (!string.IsNullOrWhiteSpace(box.EquipmentPrefabName) && !string.IsNullOrWhiteSpace(box.EquipmentModificationGuid))
{
record.player_Extra.EquipmentSkins ??= new List<string>();
string skey = EquipmentSkinKey(box.EquipmentPrefabName, box.EquipmentModificationGuid);
if (!record.player_Extra.EquipmentSkins.Any(k => string.Equals(k, skey, StringComparison.OrdinalIgnoreCase)))
{
record.player_Extra.EquipmentSkins.Add(skey);
Console.WriteLine($"[skins] {playerid} now owns skin {box.EquipmentPrefabName} {box.EquipmentModificationGuid} ({box.FriendlyName})");
}
}
col.Update(record);
return true;
}
public static List<PendingGiftBox> GetPendingGiftBoxes(ulong playerid)
{
var acc = GetFullAccount(playerid);
var all = acc?.player_Extra?.PendingGiftBoxes;
if (all == null) return new List<PendingGiftBox>();
return all.Where(g => !g.Opened).ToList();
}
// ================= Daily objectives / weekly challenges (quests) =================
// Per-player progress only - the objective/challenge CATALOG (descriptions, targets,
// rewards) is client-side. See APIController objectives + challenge handlers.
public static (List<ObjectiveProgress> objectives, List<ObjectiveGroupProgress> groups)
GetObjectivesProgress(ulong playerid)
{
var ex = GetFullAccount(playerid)?.player_Extra;
return (ex?.Objectives ?? new List<ObjectiveProgress>(),
ex?.ObjectiveGroups ?? new List<ObjectiveGroupProgress>());
}
/// <summary>
/// Upsert one objective's progress on the (Group, Index) key. Returns true ONLY on the call
/// that first completes it while its reward is unclaimed - the once-only reward latch - so
/// the caller grants the reward exactly once. Hands back the parent group's state.
/// </summary>
public static bool UpdateObjective(ulong playerid, long group, int index,
double progress, double visualProgress, bool isCompleted,
out ObjectiveGroupProgress groupState)
{
groupState = new ObjectiveGroupProgress { Group = group };
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.Objectives ??= new List<ObjectiveProgress>();
record.player_Extra.ObjectiveGroups ??= new List<ObjectiveGroupProgress>();
var o = record.player_Extra.Objectives.FirstOrDefault(x => x.Group == group && x.Index == index);
if (o == null)
{
o = new ObjectiveProgress { Group = group, Index = index };
record.player_Extra.Objectives.Add(o);
}
o.Progress = progress;
o.VisualProgress = visualProgress;
bool justCompleted = isCompleted && !o.HasClaimedReward;
if (isCompleted) o.IsCompleted = true;
if (justCompleted) o.HasClaimedReward = true;
var g = record.player_Extra.ObjectiveGroups.FirstOrDefault(x => x.Group == group);
if (g == null)
{
g = new ObjectiveGroupProgress { Group = group };
record.player_Extra.ObjectiveGroups.Add(g);
}
groupState = g;
col.Update(record);
return justCompleted;
}
public static ObjectiveGroupProgress ClearObjectiveGroup(ulong playerid, long group)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var g = new ObjectiveGroupProgress { Group = group, IsCompleted = true, ClearedAt = DateTime.UtcNow.ToString("o") };
if (record == null) return g;
record.player_Extra ??= new player_extra();
record.player_Extra.ObjectiveGroups ??= new List<ObjectiveGroupProgress>();
var existing = record.player_Extra.ObjectiveGroups.FirstOrDefault(x => x.Group == group);
if (existing == null) { record.player_Extra.ObjectiveGroups.Add(g); }
else { existing.IsCompleted = true; existing.ClearedAt = g.ClearedAt; g = existing; }
col.Update(record);
return g;
}
public static HashSet<int> GetCompletedChallengeIds(ulong playerid, int mapId)
{
var set = new HashSet<int>();
var st = GetFullAccount(playerid)?.player_Extra?.ChallengeStatuses;
if (st != null)
foreach (var s in st)
if (s.ChallengeMapId == mapId && s.Complete) set.Add(s.ChallengeId);
return set;
}
/// <summary>Latch one challenge's completion within a rotation (false-&gt;true only). Returns the stored state.</summary>
public static bool RecordChallengeProgress(ulong playerid, int mapId, int challengeId, bool complete)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.ChallengeStatuses ??= new List<ChallengeStatus>();
var s = record.player_Extra.ChallengeStatuses
.FirstOrDefault(x => x.ChallengeMapId == mapId && x.ChallengeId == challengeId);
if (s == null)
{
s = new ChallengeStatus { ChallengeMapId = mapId, ChallengeId = challengeId };
record.player_Extra.ChallengeStatuses.Add(s);
}
s.Complete = s.Complete || complete;
s.UpdatedAt = DateTime.UtcNow.ToString("o");
col.Update(record);
return s.Complete;
}
/// <summary>At-most-once gift claim for a challenge map. Returns true only on the FIRST claim.</summary>
public static bool ClaimChallengeGift(ulong playerid, int mapId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record == null) return false;
record.player_Extra ??= new player_extra();
record.player_Extra.ClaimedChallengeMaps ??= new List<int>();
if (record.player_Extra.ClaimedChallengeMaps.Contains(mapId)) return false;
record.player_Extra.ClaimedChallengeMaps.Add(mapId);
col.Update(record);
return true;
}
/// <summary>
/// Opens (consumes) a gift box for a player. Returns the box that was opened, or null
/// when the player has no such unopened box.
///
/// The item was already granted at PURCHASE time (see AddPendingGiftBoxFull) - opening
/// is only the cosmetic reveal, so this just marks the box opened so it stops showing
/// as pending. Without this the client's POST /api/avatar/v2/gifts/consume 404'd, the
/// box was never marked opened, and GetPendingGiftBoxes handed it straight back - so a
/// box could be "opened" over and over and never went away.
///
/// The client sends back whatever id we emitted in the gifts list, which is NumericId
/// for purchases and the guid Id otherwise (see the Gifts endpoint), so match on both.
/// </summary>
/// <summary>Flip a box's PayOnOpen off. Returns true only for the call that flipped it.</summary>
public static bool ClearGiftBoxPayOnOpen(ulong playerid, PendingGiftBox box)
{
lock (_giftPayLock)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var stored = record?.player_Extra?.PendingGiftBoxes?.FirstOrDefault(g =>
(box.NumericId != 0 && g.NumericId == box.NumericId) || (box.NumericId == 0 && g.Id == box.Id));
if (stored == null || (!stored.PayOnOpen && !stored.XpOnOpen)) return false;
stored.PayOnOpen = false; stored.XpOnOpen = false;
box.PayOnOpen = false; box.XpOnOpen = false;
col.Update(record);
return true;
}
}
private static readonly object _giftPayLock = new();
/// <summary>Persist PayOnOpen=true on an already-stored box.</summary>
public static void SetGiftBoxPayOnOpen(ulong playerid, PendingGiftBox box)
{
lock (_giftPayLock)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var stored = record?.player_Extra?.PendingGiftBoxes?.FirstOrDefault(g =>
(box.NumericId != 0 && g.NumericId == box.NumericId) || (box.NumericId == 0 && g.Id == box.Id));
if (stored == null) return;
stored.PayOnOpen = true;
col.Update(record);
}
}
public static PendingGiftBox? ConsumeGiftBox(ulong playerid, string? id, int numericId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
var boxes = record?.player_Extra?.PendingGiftBoxes;
if (boxes == null) return null;
var box = boxes.FirstOrDefault(g => !g.Opened &&
((numericId != 0 && g.NumericId == numericId) ||
(!string.IsNullOrEmpty(id) && g.Id == id)));
if (box == null) return null;
box.Opened = true;
col.Update(record);
Console.WriteLine($"[gifts] {playerid} opened box {(numericId != 0 ? numericId.ToString() : id)} ({box.FriendlyName})");
return box;
}
public static bool SetPlayerSteamId(ulong steamId, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return false;
record.platform = Platforms.Steam;
record.player.PlatformIds ??= new List<mPlatformID>();
// Replace any existing Steam entry, add if missing.
var existing = record.player.PlatformIds.FirstOrDefault(p => p != null && p.Platform == Platforms.Steam);
if (existing != null) existing.PlatformId = steamId;
else record.player.PlatformIds.Add(new mPlatformID { Platform = Platforms.Steam, PlatformId = steamId });
col.Update(record);
return true;
}
/// <summary>The account using this email (case-insensitive), or 0.</summary>
public static ulong GetAccountIdByEmail(string email)
{
if (string.IsNullOrWhiteSpace(email)) return 0;
var col = PlayerDBFile.GetCollection<player_data>("players");
var hit = col.FindAll().FirstOrDefault(x => x.player?.Email != null &&
string.Equals(x.player.Email.Trim(), email.Trim(), StringComparison.OrdinalIgnoreCase));
return hit?.playerid ?? 0;
}
public static bool SetPlayerEmail(string? email, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var record = col.FindOne(x => x.playerid == playerid);
if (record?.player == null) return false;
record.player.Email = email;
col.Update(record);
return true;
}
public static bool SetLastLoginTime(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (player != null)
{
player.player.last_login_time = DateTime.UtcNow;
col.Update(player);
return true;
}
return false;
}
public static bool ResetCheerCreditIfNewDay(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (player?.player?.PlayerReputation == null)
return false;
DateTime lastLoginDate = player.player.last_login_time.Date;
DateTime today = DateTime.UtcNow.Date;
if (lastLoginDate < today)
{
player.player.PlayerReputation.CheerCredit = 20;
player.player.last_login_time = DateTime.UtcNow;
col.Update(player);
return true;
}
return false;
}
public static bool AddIpAddress(ulong playerid, string ip)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (player != null)
{
if (player.player_Extra.IpAddresses == null)
player.player_Extra.IpAddresses = new List<string>();
string encryptedIp = Utils.EncryptAes(ip);
if (!player.player_Extra.IpAddresses.Contains(encryptedIp))
player.player_Extra.IpAddresses.Add(encryptedIp);
col.Update(player);
return true;
}
return false;
}
public static List<string> GetDecryptedIpAddresses(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
List<string> result = new();
if (player?.player_Extra?.IpAddresses != null)
{
foreach (string encryptedIp in player.player_Extra.IpAddresses)
{
try
{
result.Add(Utils.DecryptAes(encryptedIp));
}
catch
{
}
}
}
return result;
}
public static void SetPlayerEmoji(string emoji, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.DisplayEmoji = emoji;
col.Update(player_Setting1);
return;
}
}
}
public static void SetPlayerPronounFlags(int pronounflag, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.pronounFlags = pronounflag;
col.Update(player_Setting1);
}
else
{
}
}
else
{
}
}
public static void AddSubscription(ulong fromPlayerId, ulong toPlayerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == fromPlayerId);
if (playerRecord == null || playerRecord.player_Extra == null)
throw new InvalidOperationException($"player_Extra for player {fromPlayerId} not found.");
// check if already subscribed
if (!playerRecord.player_Extra.SubbedTo.Contains(toPlayerId))
{
playerRecord.player_Extra.SubbedTo.Add(toPlayerId);
col.Update(playerRecord);
IncrementSubscriberCount(toPlayerId);
}
}
public static void IncrementSubscriberCount(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player != null && player.player_Extra != null)
{
player.player_Extra.sub_count++;
col.Update(player);
}
}
public static void RemoveSubscription(ulong fromPlayerId, ulong toPlayerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == fromPlayerId);
if (playerRecord == null || playerRecord.player_Extra == null)
return;
if (playerRecord.player_Extra.SubbedTo.Remove(toPlayerId))
{
col.Update(playerRecord);
DecrementSubscriberCount(toPlayerId);
}
}
public static void DecrementSubscriberCount(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player != null && player.player_Extra != null && player.player_Extra.sub_count > 0)
{
player.player_Extra.sub_count--;
col.Update(player);
}
}
/// <summary>Everyone subscribed to <paramref name="playerId"/> (the creator's subscribers).</summary>
public static List<ulong> GetSubscriberIds(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll()
.Where(x => x.player_Extra?.SubbedTo != null && x.player_Extra.SubbedTo.Contains(playerId))
.Select(x => x.playerid)
.ToList();
}
public static List<ulong> GetSubscribedToIds(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var playerRecord = col.FindOne(x => x.playerid == playerId);
if (playerRecord != null && playerRecord.player_Extra != null)
{
return playerRecord.player_Extra.SubbedTo;
}
return new List<ulong>();
}
public static void SetPlayerIdentityFlags(int IdentityFlag, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.identityFlags = IdentityFlag;
col.Update(player_Setting1);
}
else
{
}
}
else
{
}
}
public static void SetPlayerImageName(string imageName, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player == null)
return;
player.player.profileImage = imageName;
col.Update(player);
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
}
public static void SetPlayerBanner(string imageName, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player == null)
return;
player.player.bannerImage = imageName;
col.Update(player);
}
public static int GetAccountCountByPlatformId(ulong platformId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.Count(x =>
x.player.PlatformIds != null &&
x.player.PlatformIds
.Select(pid => pid.PlatformId)
.Any(id => id == platformId)
);
}
public static int GetAccountCountByDiscordId(ulong discordId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.Count(x =>
x.player_Extra != null &&
x.player_Extra.DiscordId == discordId
);
}
public static List<ulong> GetAccountIdsByDiscordId(ulong discordId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
return col
.Find(x =>
x.player_Extra != null &&
x.player_Extra.DiscordId == discordId
)
.Select(x => (ulong)x.playerid)
.ToList();
}
public static ulong GetPlayerCount()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var players = col.FindAll().ToList();
if (players.Count == 0)
return 0;
ulong minID = players.Min(p => p.playerid);
ulong maxID = players.Max(p => p.playerid);
ulong expectedCount = (ulong)(maxID - minID + 1);
return expectedCount;
}
public static List<object> Find_all_player_filtered(string? id, string? displayName, string? minLevel, string? jrState, string? platformId, string? deviceId, bool onlyBannedPlayers)
{
var col = PlayerDBFile.GetCollection<player_data>("players"); List<object> list = new List<object>();
foreach (player_data player in col.FindAll())
{
if (id != null && player.playerid.ToString() != id)
continue;
if (displayName != null && !player.player.DisplayName.Contains(displayName, StringComparison.OrdinalIgnoreCase))
continue;
if (minLevel != null && int.TryParse(minLevel, out int minLvl))
{
if (player.player.Level < minLvl)
continue;
}
if (jrState != null && jrState != "N/A")
{
bool expectedJunior = jrState == "true";
if (player.player.IsJunior != expectedJunior)
continue;
}
if (!string.IsNullOrEmpty(platformId) && (player.player.PlatformIds == null || !player.player.PlatformIds.Any(pid => pid.PlatformId.ToString() == platformId)))
{
continue;
}
if (onlyBannedPlayers)
{
var block = player.player_Extra.ModerationBlockDetails;
if (block == null || block.IsBan != true || !HasActiveModerationBlock(player.playerid))
continue;
}
if (deviceId != null && !string.Equals(player.deviceId.ToString(), deviceId, StringComparison.OrdinalIgnoreCase))
continue;
var room_data = GetAccountBulk_full(player.playerid);
if (room_data != null)
list.Add(room_data);
}
return list;
}
public static object? GetAccountBulk_full(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
List<object> list = new List<object>();
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
{
return null;
}
// The admin row template reads `player.PlatformID` (singular) — surface the first
// non-zero linked platform id so accounts created via /create-account show up as
// "Steam 76561…" instead of "Steam undefined".
var firstPlatformId = player?.player?.PlatformIds?
.FirstOrDefault(p => p != null && p.PlatformId != 0)?.PlatformId.ToString();
// PlatformID above is the first non-zero id of ANY platform, so on a Quest
// account it is an Oculus id even though the admin table labels the column
// "Steam". The Steam allowlist must never be fed that, so surface the real
// Steam id separately - empty when the account has none.
var steamId = player?.player?.PlatformIds?
.FirstOrDefault(p => p != null && p.Platform == Platforms.Steam && p.PlatformId != 0)
?.PlatformId.ToString() ?? "";
return new
{
AccountId = player?.playerid,
Level = player?.player?.Level,
xp = player?.player?.XP,
DisplayName = player?.player?.DisplayName,
DeviceId = player?.deviceId,
Username = player?.player?.Username,
IsJunior = player?.player?.IsJunior,
ProfileImage = player?.player?.profileImage,
LastLoginTime = player?.player?.last_login_time,
CreatedAt = player?.player?.created_at,
PlatformID = firstPlatformId ?? "",
SteamID = steamId,
PlatformIDs = player?.player?.PlatformIds,
// Linked Discord (website Settings > Discord tab). Empty/0 when not linked.
DiscordUsername = player?.player_Extra?.DiscordUsername ?? "",
DiscordId = (player?.player_Extra?.DiscordId ?? 0) == 0 ? "" : (player!.player_Extra!.DiscordId).ToString(),
dormroom_id = player?.player_Extra?.DormRoomID,
DevFlagRaw = (int)(player?.dev_Flag ?? DevFlag.none),
IsDev = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.dev) == DevFlag.dev,
IsMod = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.mod) == DevFlag.mod,
IsScreenshare = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.screenshare) == DevFlag.screenshare,
IsSupport = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.support) == DevFlag.support,
IsInfluencer = player?.player_Extra?.Influencer?.isInfluencer == true,
};
}
public static void DeletePlayer(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
col.Delete(playerid);
Console.WriteLine("Deleted player id " + playerid);
}
else
{
Console.WriteLine($"Player with ID {playerid} not found.");
}
}
public static int DeletePlayersWithNullLastLogin()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var minDate = DateTime.MinValue;
var playersToDelete = col
.Find(x => x.player.last_login_time == minDate)
.Select(x => x.playerid)
.ToList();
int deleted = 0;
foreach (var id in playersToDelete)
{
if (DeletePlayerFull(id))
deleted++;
}
Console.WriteLine($"Deleted {deleted} players with default LastLogin.");
return deleted;
}
public static bool DeletePlayerFull(ulong playerid)
{
try
{
DeletePlayer(playerid);
RoomDB.DeleteAllRoomsFromPlayerId(playerid);
HeartbeatDB.ClearPlayerHeartbeat(playerid);
SettingsDB.DeleteSettings(playerid);
LinkDB.DeleteLinksFromPlayerId(playerid);
ImageMetadataDB.DeleteImagesFromPlayerId(playerid);
return true;
}
catch
{
return false;
}
}
public static string SetRecentRooms(ulong playerid, ulong room_id)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
if (player != null)
{
if (player.player.recent_rooms == null)
{
player.player.recent_rooms = new List<ulong>();
}
player.player.recent_rooms.Remove(room_id);
player.player.recent_rooms.Insert(0, room_id);
col.Update(player);
}
return "";
}
public static string GetRecentRooms(ulong playerId, int skip = 0, int take = 20)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerId);
if (player == null || player.player.recent_rooms == null || !player.player.recent_rooms.Any())
{
return "[]";
}
var paginatedRoomIds = player.player.recent_rooms
.Skip(Math.Max(0, skip))
.Take(Math.Max(1, take))
.ToList();
var visitedRooms = new List<RoomDBClasses.RoomRoot>();
foreach (ulong roomId in paginatedRoomIds)
{
var room = RoomDB.GetRoom(roomId);
if (room != null)
{
visitedRooms.Add(room);
}
}
// Strip blobs the client cannot download - this listing is fetched moments
// before every join and the client can join straight from a cached entry.
Campus_live_rewrite.api.server.RoomBlobFilter.Strip(visitedRooms);
return JsonConvert.SerializeObject(visitedRooms, Formatting.None);
}
public static string ClearRecentRoomsForAllPlayers()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var allPlayers = col.FindAll();
foreach (var player in allPlayers)
{
if (player.player.recent_rooms != null && player.player.recent_rooms.Count > 0)
{
player.player.recent_rooms = new List<ulong>();
col.Update(player);
}
}
return "";
}
public static string GetFavoritedRooms(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Id == playerid);
// Was: `player == null || player.player_Extra.FavoritedRooms == null` -
// if player_Extra itself was null the second clause threw NRE before the
// check could help.
if (player?.player_Extra?.FavoritedRooms == null)
{
return "[]";
}
var favRooms = new List<RoomDBClasses.RoomRoot>();
foreach (ulong roomId in player.player_Extra.FavoritedRooms)
{
var room = RoomDB.GetRoom(roomId);
if (room != null)
{
favRooms.Add(room);
}
}
return JsonConvert.SerializeObject(favRooms);
}
/// <summary>
/// Records a device this account has logged in from. An account may accumulate
/// SEVERAL - a player is allowed to play on more than one machine, and each new
/// one is remembered the first time they sign in on it.
///
/// The restriction is on CREATION, not login: see CanCreateAccountOnDevice. A
/// device that already belongs to an account cannot be used to register another,
/// which is what stops alt farms, while a real player moving to a second device
/// just signs in and has it added here.
///
/// (This used to overwrite the list with a single id - one account, one device.
/// That made a legitimate second machine look like a device move on every login,
/// and it meant the creation check below had nothing reliable to test against.)
///
/// Blank ids are ignored rather than stored, so a login that sends no device id
/// cannot wipe the binding (and cannot create an unbannable empty-id account).
///
/// Device bans still work: AccountsOnDevice matches by CONTAINMENT, so banning a
/// device finds every account that has ever used it. Note the trade-off that comes
/// with allowing several devices - banning one does not stop a player who has
/// another registered.
/// </summary>
public static void SetPlayerDeviceId(string DeviceId, ulong playerid)
{
if (string.IsNullOrWhiteSpace(DeviceId)) return;
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player == null) return;
string id = DeviceId.Trim();
var current = player.deviceId ?? new List<string>();
// Already known for this account - nothing to write.
if (current.Contains(id)) return;
current.Add(id);
player.deviceId = current;
col.Update(player);
Console.WriteLine($"[device] account {playerid} added device '{id}' "
+ $"({current.Count} total)");
}
/// <summary>Drop device bindings (the account lock replacing an idle device). The flat
/// anti-alt deviceId list is left alone: that device stays "used" for alt purposes.</summary>
public static void UnbindDevices(ulong playerid, IEnumerable<string> deviceIds)
{
var ids = new HashSet<string>(deviceIds, StringComparer.Ordinal);
if (ids.Count == 0) return;
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.DeviceBindings == null) return;
int before = player.DeviceBindings.Count;
player.DeviceBindings.RemoveAll(b => b.DeviceId != null && ids.Contains(b.DeviceId));
if (player.DeviceBindings.Count != before) col.Update(player);
}
/// <summary>The account's device bindings (device id + the platform it signed in on).
/// Empty for legacy rows until the next login re-binds them.</summary>
public static List<mDeviceBind> GetDeviceBindings(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
return player?.DeviceBindings ?? new List<mDeviceBind>();
}
/// <summary>
/// Record that this account signed in from this device on this platform. Writes BOTH the
/// flat deviceId list (the anti-alt source of truth) and the platform-aware DeviceBindings
/// list (used by the account lock's one-PC-one-Quest cap). Idempotent per (device,platform).
/// </summary>
public static void BindDevice(string? deviceId, Platforms platform, ulong playerid)
{
if (string.IsNullOrWhiteSpace(deviceId)) return;
string id = deviceId.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player == null) return;
bool changed = false;
var flat = player.deviceId ?? new List<string>();
if (!flat.Contains(id)) { flat.Add(id); player.deviceId = flat; changed = true; }
var binds = player.DeviceBindings ?? new List<mDeviceBind>();
var mine = binds.FirstOrDefault(b => b.DeviceId == id && b.Platform == platform);
if (mine == null)
{
binds.Add(new mDeviceBind { DeviceId = id, Platform = platform, LastSeen = DateTime.UtcNow });
player.DeviceBindings = binds;
changed = true;
}
else
{
// Touch: the account lock lets a NEW device of the same class take over when this
// one has been idle (AccountLock), so every login has to stamp it.
mine.LastSeen = DateTime.UtcNow;
player.DeviceBindings = binds;
changed = true;
}
if (changed)
{
col.Update(player);
Console.WriteLine($"[device] account {playerid} bound device '{id}' on {platform} "
+ $"({binds.Select(b => b.DeviceId).Distinct().Count()} device(s))");
}
}
/// <summary>
/// Clear an account's device bindings so it can be signed in from fresh hardware - the
/// operator's "reset" when a player legitimately replaces their PC or headset. Clears both
/// the flat deviceId list and the platform-aware DeviceBindings.
/// </summary>
public static bool ClearDeviceBindings(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null) return false;
player.deviceId = new List<string>();
player.DeviceBindings = new List<mDeviceBind>();
col.Update(player);
Console.WriteLine($"[device] account {playerid} device bindings CLEARED");
return true;
}
/// <summary>
/// May a NEW account be created from this device?
///
/// False once any existing account has used it. This is the whole anti-alt rule:
/// one account per device at creation time, while logins from further devices are
/// unrestricted (SetPlayerDeviceId).
///
/// A blank device id returns TRUE - it carries no information, and refusing on it
/// would lock out every caller that does not send one. Callers that care must
/// require a non-empty id themselves before asking.
/// </summary>
public static bool CanCreateAccountOnDevice(string? deviceId, out ulong existingAccountId)
{
existingAccountId = 0;
if (string.IsNullOrWhiteSpace(deviceId)) return true;
var owners = AccountsOnDevice(deviceId).Where(o => !AccountLock.IsExempt(o)).ToList();
if (owners.Count == 0) return true;
// A device may hold one game-made account and one website account (see
// ConfigCampus.MaxAccountsPerDevice). Creating in-game takes the game-made slot: refused
// once the device is full, or once it already has a game-made account.
existingAccountId = owners[0];
if (owners.Count >= ConfigCampus.MaxAccountsPerDevice) return false;
var gameMade = owners.FirstOrDefault(o => OriginOf(o) == AccountOrigin.InGame);
if (gameMade != 0) { existingAccountId = gameMade; return false; }
return true;
}
public enum AccountOrigin { Unknown, InGame, Website }
/// <summary>
/// Where an account was made. Rows created since the flag exists say so explicitly; older
/// rows are Unknown, and an Unknown account fits EITHER of a device's two slots (it only
/// counts toward the cap), so nobody who existed before the rule is locked out by it.
/// </summary>
public static AccountOrigin OriginOf(ulong accountId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var p = col.FindOne(x => x.playerid == accountId);
if (p?.CreatedInGame == null) return AccountOrigin.Unknown;
return p.CreatedInGame.Value ? AccountOrigin.InGame : AccountOrigin.Website;
}
/// <summary>The account's single device id, or null if it has never logged in.</summary>
public static string? GetDeviceId(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
var list = player?.deviceId;
if (list == null || list.Count == 0) return null;
return string.IsNullOrWhiteSpace(list[0]) ? null : list[0];
}
/// <summary>
/// Cached-login picker entries for the accounts bound to this device on this platform
/// (see ConfigCampus.QuestDeviceCachedLogin). requirePassword=false: device ownership is the
/// credential, the cached_login grant re-checks the same binding before issuing a token.
/// </summary>
public static List<AccountLogin> GetDeviceLogins(string? deviceId, Platforms platform, ulong platformId)
{
var result = new List<AccountLogin>();
if (string.IsNullOrWhiteSpace(deviceId)) return result;
string id = deviceId.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
foreach (var p in col.FindAll())
{
if (p?.player == null || p.deviceId == null || !p.deviceId.Contains(id)) continue;
// Only a binding made from THIS platform class counts (a PC login on some shared
// device id must not turn into a headset cached login).
if (p.DeviceBindings == null || !p.DeviceBindings.Any(b => b.DeviceId == id && b.Platform == platform)) continue;
result.Add(new AccountLogin
{
accountId = p.playerid,
createdAt = p.player.created_at,
lastLoginTime = p.player.last_login_time,
platform = platform,
platformId = platformId.ToString(),
requirePassword = false
});
}
return result.OrderByDescending(a => a.lastLoginTime).ToList();
}
/// <summary>Is this account bound to this device on this platform?</summary>
public static bool IsDeviceBoundTo(ulong accountId, string? deviceId, Platforms platform)
{
if (string.IsNullOrWhiteSpace(deviceId)) return false;
string id = deviceId.Trim();
var binds = GetDeviceBindings(accountId);
return binds.Any(b => b.DeviceId == id && b.Platform == platform);
}
/// <summary>
/// Every account bound to this device id. Surfaced by the admin panel so a device
/// ban shows what else it takes out (alt accounts).
/// </summary>
public static List<ulong> AccountsOnDevice(string? deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId)) return new List<ulong>();
string id = deviceId.Trim();
var col = PlayerDBFile.GetCollection<player_data>("players");
return col.FindAll()
.Where(x => x.deviceId != null && x.deviceId.Contains(id))
.Select(x => x.playerid)
.ToList();
}
/// <summary>Clears an account-level ban. Device/IP bans are lifted separately (BanDB).</summary>
public static bool ClearModerationBlock(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player?.player_Extra == null) return false;
player.player_Extra.ModerationBlockDetails = null;
col.Update(player);
return true;
}
public static void SetPlayerDiscordId(ulong DiscordId, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
if (player.player != null)
{
player.player_Extra.DiscordId = DiscordId;
col.Update(player);
return;
}
}
}
public static string GetPlayerNameById(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
return player?.player.Username ?? "Unknown";
}
public static ulong GetPlayerIdByName(string name)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player.Username == name);
return player?.player.Id ?? 0;
}
public static string GetAvatarSaved(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
// Was: only `player` was null-checked, then player_Extra.SavedOutfits was
// dereferenced - NRE when player_Extra is null. And SerializeObject(null)
// returns the literal "null", not "[]", which the client cannot parse as a
// list. Both paths now yield a real empty array.
var saved = player?.player_Extra?.SavedOutfits;
if (saved == null || saved.Count == 0)
{
return "[]";
}
// The newer client's Saved Outfits page (SavedOutfitsWatchUIFlow) deserializes each
// outfit into the StoredOutfit envelope and THROWS "Expected LegacyData but was null"
// (DCJDHBOIBBC.MissingLegacyData) on any entry without a `LegacyData` object - which
// then blanks the whole page. Serializing the STRUCTURED avatar_data_saved (which has
// no LegacyData field) is exactly that failure. So project each outfit into the shape
// the client expects:
// - Preferred: round-trip the RAW document the client posted (SaveOutfit stores it in
// .Avatar); it already carries LegacyData/Selections/DataVersion verbatim.
// - Fallback (older saves / server-made presets with no raw body): synthesize the
// LegacyData envelope from the flat fields so LegacyData is never null.
var arr = new Newtonsoft.Json.Linq.JArray();
foreach (var o in saved)
{
Newtonsoft.Json.Linq.JObject? raw = null;
if (!string.IsNullOrWhiteSpace(o.Avatar))
{
try { raw = Newtonsoft.Json.Linq.JObject.Parse(o.Avatar); } catch { raw = null; }
}
if (raw != null && raw["LegacyData"] != null &&
raw["LegacyData"]!.Type != Newtonsoft.Json.Linq.JTokenType.Null)
{
// Slot/Name are the authority for slot overwrite - keep them in sync with the
// structured fields in case the stored raw body predated them.
raw["Slot"] = (long)o.Slot;
if (o.Name != null) raw["Name"] = o.Name;
arr.Add(raw);
continue;
}
arr.Add(new Newtonsoft.Json.Linq.JObject
{
["LegacyData"] = new Newtonsoft.Json.Linq.JObject
{
["SelectionsV1"] = o.OutfitSelections,
["SelectionsV2"] = o.OutfitSelectionsV2,
["FaceFeatures"] = o.FaceFeatures,
["SkinColor"] = o.SkinColor,
["HairColor"] = o.HairColor
},
["Selections"] = new Newtonsoft.Json.Linq.JArray(),
["DataVersion"] = 9,
["CustomizationSettings"] = (string?)null,
["ThumbnailFileName"] = o.ImageName ?? o.PreviewImageName,
["Name"] = o.Name,
["Accessibility"] = 0,
["Slot"] = (long)o.Slot
});
}
return arr.ToString(Newtonsoft.Json.Formatting.None);
}
public static string SetAvatarSaved(ulong playerid, List<avatar_data_saved> avatars)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
// Was: `player == null` only - player_Extra was dereferenced unchecked.
if (player?.player_Extra == null)
return "[]";
if (player.player_Extra.SavedOutfits == null)
player.player_Extra.SavedOutfits = new List<avatar_data_saved>();
var incomingSlots = avatars.Select(a => a.Slot).ToHashSet();
player.player_Extra.SavedOutfits.RemoveAll(a => incomingSlots.Contains(a.Slot));
player.player_Extra.SavedOutfits.AddRange(avatars);
col.Update(player);
return JsonConvert.SerializeObject(player.player_Extra.SavedOutfits);
}
public static void SetPlayerCheer(CheerCategoryEnum cheer = CheerCategoryEnum.None, ulong playerid = 0)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player.PlayerReputation != null)
{
player_Setting1.player.PlayerReputation.SelectedCheer = cheer;
col.Update(player_Setting1);
return;
}
}
}
public static CheerCategoryEnum? GetPlayerCheer(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player.PlayerReputation != null)
{
return player_Setting1.player.PlayerReputation.SelectedCheer;
}
}
return null;
}
public static List<ulong> GetInfluencerIds()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var result = col.Query().Where(x => x.player_Extra.Influencer.isInfluencer == true).Select(x => x.playerid).ToList();
return result;
}
public static player_data? GetInfluencerByCreatorCode(string creatorcode)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.player_Extra.Influencer.creatorCode == creatorcode && x.player_Extra.Influencer.isInfluencer == true);
return player;
}
public static bool IsInfluencer(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
return player?.player_Extra?.Influencer?.isInfluencer == true;
}
public static void SetIsInfluencer(ulong playerid, bool IsInfluencer)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return;
if (player.player_Extra == null)
player.player_Extra = new player_extra();
if (player.player_Extra.Influencer == null)
player.player_Extra.Influencer = new Influencer();
player.player_Extra.Influencer.isInfluencer = IsInfluencer;
col.Update(player);
}
public static void SetCreatorCode(ulong playerid, string creatorcode)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player == null)
return;
if (player.player_Extra == null)
player.player_Extra = new player_extra();
if (player.player_Extra.Influencer == null)
player.player_Extra.Influencer = new Influencer();
player.player_Extra.Influencer.creatorCode = creatorcode;
col.Update(player);
}
public static void SupportInfluencer(ulong requestplayerid, ulong SupportId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == requestplayerid);
if (player != null)
{
if (player.player_Extra.Influencer == null)
{
player.player_Extra.Influencer = new Influencer();
}
player.player_Extra.Influencer.SupportingInfluencer = SupportId;
col.Update(player);
return;
}
}
public static ulong? GetMyInfluencer(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
if (player.player_Extra.Influencer != null)
{
return player.player_Extra.Influencer.SupportingInfluencer;
}
}
return 0;
}
// message funcs
public static List<MessageData> GetMessages(ulong playerId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player == null)
return new List<MessageData>();
if (player.player_Extra.Messages == null)
player.player_Extra.Messages = new List<MessageData>();
return player.player_Extra.Messages;
}
public static MessageData? AddMessage(ulong toPlayerId, ulong fromPlayerId, WebsocketEvents.MessageType type, string? data, ulong? roomId = null, ulong? playerEventId = null)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == toPlayerId);
if (player == null)
return null;
if (player.player_Extra.Messages == null)
player.player_Extra.Messages = new List<MessageData>();
var msg = new MessageData()
{
Id = GenerateRandomMessageId(),
FromPlayerId = fromPlayerId,
SentTime = DateTime.UtcNow,
Type = type,
Data = data,
RoomId = roomId,
PlayerEventId = playerEventId
};
player.player_Extra.Messages.Add(msg);
col.Update(player);
// Nudge the recipient's open website tabs to re-fetch the inbox (cheers, coach
// messages, ...). No-op if they have no browser tab open.
Campus_live_rewrite.api.server.SiteNotifications.Notify(toPlayerId);
return msg;
}
public static void DeleteMessage(ulong playerId, ulong messageId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player == null)
return;
if (player.player_Extra.Messages == null || player.player_Extra.Messages.Count == 0)
return;
var message = player.player_Extra.Messages.FirstOrDefault(m => m.Id == messageId);
if (message == null)
return;
player.player_Extra.Messages.Remove(message);
// Save changes
col.Update(player);
}
public static ulong GenerateRandomMessageId()
{
lock (randLock)
{
byte[] buffer = new byte[8];
random.NextBytes(buffer);
return BitConverter.ToUInt64(buffer, 0);
}
}
// message funcs
public static int SetCheerCredit(int cheerCredit, ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
if (player_Setting1 != null)
{
if (player_Setting1.player != null)
{
player_Setting1.player.PlayerReputation.CheerCredit = cheerCredit;
col.Update(player_Setting1);
return cheerCredit;
}
}
return 0;
}
public static void Setup()
{
var col = PlayerDBFile.GetCollection<player_data>("players");
col.EnsureIndex(x => x.playerid, true);
col.EnsureIndex(x => x.platform);
col.EnsureIndex(x => x.player.PlatformIds);
col.EnsureIndex(x => x.player.Username);
col.EnsureIndex(x => x.deviceId);
col.EnsureIndex(x => x.authtoken);
col.EnsureIndex(x => x.id);
col.EnsureIndex(x => x.player_Extra.Messages);
col.EnsureIndex("infl", "playerExtra.Influencer.isInfluencer");
}
public static string? GetPlayerData(ulong playerId, ulong roomId)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player?.player_Extra?.MyRoomData?.PlayerData == null)
return null;
var entry = player.player_Extra.MyRoomData.PlayerData.FirstOrDefault(p => p.RoomId == roomId);
return entry?.Data;
}
public static bool SetPlayerData(ulong playerId, ulong roomId, string data)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerId);
if (player == null)
return false;
if (player.player_Extra == null)
player.player_Extra = new player_extra();
if (player.player_Extra.MyRoomData == null)
player.player_Extra.MyRoomData = new MyRoomData();
if (player.player_Extra.MyRoomData.PlayerData == null)
player.player_Extra.MyRoomData.PlayerData = new List<PlayerData>();
var existingEntry = player.player_Extra.MyRoomData.PlayerData.FirstOrDefault(p => p.RoomId == roomId);
if (existingEntry != null)
{
existingEntry.Data = data;
}
else
{
player.player_Extra.MyRoomData.PlayerData.Add(new PlayerData
{
RoomId = roomId,
Data = data
});
}
return col.Update(player);
}
public static PlayerPhotoTaggingSetting GetPlayerPhotoTaggingSetting(ulong playerid)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
if (player.player != null)
{
return player.player_Extra.PlayerPhotoTaggingSetting;
}
}
return PlayerPhotoTaggingSetting.Anyone;
}
public static bool SetPlayerPhotoTaggingSetting(ulong playerid, PlayerPhotoTaggingSetting playerPhotoTaggingSetting)
{
var col = PlayerDBFile.GetCollection<player_data>("players");
var player = col.FindOne(x => x.playerid == playerid);
if (player != null)
{
if (player.player != null)
{
player.player_Extra.PlayerPhotoTaggingSetting = playerPhotoTaggingSetting;
col.Update(player);
return true;
}
}
return false;
}
}
}
@@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Player reports - the ones raised from the in-game report UI.
///
/// The client files them with POST /api/PlayerReporting/v3/create (form-encoded; the reporter
/// is the bearer token, never a body field). That route did not exist here at all, so every
/// report a player made 404'd and was lost - which is why reporting "did nothing" and why the
/// admin panel had nothing to show. Reports now land in this store and the admin panel's
/// Reports section reads and acts on them.
///
/// A report is filed OPEN. A moderator resolves it, and resolving is the only thing that
/// changes its state - the ban / kick an action performs goes through the normal ban path so
/// there is one ban mechanism, not two.
/// </summary>
public static class ReportsDB
{
private static readonly LiteDatabase _db =
new(System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "DBs", "Reports.db"));
/// <summary>Merge this store's write-ahead log back into the data file (see DbMaintenance).</summary>
public static void Checkpoint() => _db.Checkpoint();
public class Report
{
[BsonId]
public long Id { get; set; }
public ulong ReporterPlayerId { get; set; }
public ulong ReportedPlayerId { get; set; }
/// <summary>Stored verbatim, unmapped - Moderation_Detail.ReportCategory names it for display.</summary>
public int ReportCategory { get; set; }
public string? Details { get; set; }
public double? HeightReporter { get; set; }
public double? HeightReported { get; set; }
/// <summary>Null when the report was raised outside a room (the client omits it).</summary>
public ulong? RoomId { get; set; }
public string? RoomInstanceType { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// ---- website reports -------------------------------------------------------
// Filed from the site's Report buttons (POST /api/site/report). Source is "website";
// Subject says what was reported (player / photo / comment), SubjectId which one,
// SubjectLink is the site page a moderator can open, Reason the label the reporter
// picked. In-game reports leave these null and keep using ReportCategory.
public string? Source { get; set; }
public string? Subject { get; set; }
public long SubjectId { get; set; }
public string? SubjectLink { get; set; }
public string? Reason { get; set; }
// ---- moderation state -------------------------------------------------------
public bool Resolved { get; set; }
public string? Action { get; set; } // what was done: dismissed / warned / banned / ...
public ulong ResolvedByPlayerId { get; set; }
public DateTime? ResolvedAt { get; set; }
public string? ModeratorNote { get; set; }
}
private static ILiteCollection<Report> Col
{
get
{
var c = _db.GetCollection<Report>("reports");
c.EnsureIndex(x => x.ReportedPlayerId);
c.EnsureIndex(x => x.CreatedAt);
c.EnsureIndex(x => x.Resolved);
return c;
}
}
public static Report Create(Report r)
{
r.CreatedAt = DateTime.UtcNow;
r.Resolved = false;
Col.Insert(r);
Console.WriteLine($"[report] #{r.Id}: {r.ReporterPlayerId} reported {r.ReportedPlayerId} " +
$"(category {r.ReportCategory}{(r.RoomId != null ? $", room {r.RoomId}" : "")})");
return r;
}
public static Report? Get(long id) => Col.FindById(id);
/// <summary>Newest first. openOnly leaves resolved reports out of the queue.</summary>
public static List<Report> List(bool openOnly, int skip, int take, ulong reportedPlayerId = 0)
{
var q = Col.FindAll().AsEnumerable();
if (openOnly) q = q.Where(x => !x.Resolved);
if (reportedPlayerId != 0) q = q.Where(x => x.ReportedPlayerId == reportedPlayerId);
return q.OrderByDescending(x => x.CreatedAt).Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, 500)).ToList();
}
public static int CountOpen() => Col.Count(x => !x.Resolved);
/// <summary>An OPEN report by this reporter about the same thing already exists.</summary>
public static bool HasOpenDuplicate(ulong reporter, string subject, long subjectId, ulong reported) =>
Col.Exists(x => !x.Resolved && x.ReporterPlayerId == reporter && x.Subject == subject
&& x.SubjectId == subjectId && x.ReportedPlayerId == reported);
/// <summary>How many reports this player has had filed against them, ever.</summary>
public static int CountAgainst(ulong playerId) => Col.Count(x => x.ReportedPlayerId == playerId);
public static bool Resolve(long id, ulong byPlayerId, string action, string? note)
{
var r = Col.FindById(id);
if (r == null) return false;
r.Resolved = true;
r.Action = action;
r.ResolvedByPlayerId = byPlayerId;
r.ResolvedAt = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(note)) r.ModeratorNote = note;
Col.Update(r);
Console.WriteLine($"[report] #{id} resolved by {byPlayerId}: {action}");
return true;
}
/// <summary>
/// Resolve every OPEN report against one player at once. Used when an action is taken on a
/// player rather than on a single report - banning someone should clear their queue, not
/// leave nine duplicates of the thing you just acted on.
/// </summary>
public static int ResolveAllAgainst(ulong reportedPlayerId, ulong byPlayerId, string action, string? note)
{
int n = 0;
foreach (var r in Col.Find(x => x.ReportedPlayerId == reportedPlayerId && !x.Resolved).ToList())
if (Resolve(r.Id, byPlayerId, action, note)) n++;
return n;
}
}
}
@@ -0,0 +1,109 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
// Comments pinned in a room's scene - the little notes players leave stuck to a spot in
// a room. Ported from RecFlare's `roomcomments` service, which the 2025 client talks to
// on the `/RoomComments` base URL (see WebController's service map). There were no
// handlers for it, so reading a room's notes and leaving one both 404'd.
//
// Stored as a collection INSIDE Rooms.db so it rides RoomDB's existing checkpoint
// (DbMaintenance) instead of adding another write-ahead log that never merges.
public static class RoomCommentsDB
{
public const int MaxCommentLength = 1000;
public const int DefaultCount = 100;
public const int MaxCount = 500;
// Matches RecFlare's RoomCommentEntry field-for-field (PascalCase, the client's shape).
public class RoomCommentEntry
{
[BsonId]
public int CommentId { get; set; } // autoincrement; also the read cursor (minId)
public long RoomId { get; set; }
public long SubRoomId { get; set; }
public ulong AccountId { get; set; } // who wrote it
public string CreatedAt { get; set; } = ""; // ISO-8601 UTC
public string Message { get; set; } = "";
public int Style { get; set; }
public bool Unread { get; set; } = true; // always true - read state, not per-viewer
public double PositionX { get; set; }
public double PositionY { get; set; }
public double PositionZ { get; set; }
}
private static readonly ILiteCollection<RoomCommentEntry> _col =
RoomDB.rooms_db.GetCollection<RoomCommentEntry>("room_comments");
static RoomCommentsDB()
{
_col.EnsureIndex(x => x.RoomId);
}
/// <summary>
/// A room's comments, NEWEST FIRST. minId is an EXCLUSIVE cursor (-1 = give me
/// everything), so a client holding up to id N polls with minId=N and gets only what
/// was written since. count is clamped 1..500. An unknown room simply has none.
/// </summary>
public static List<RoomCommentEntry> Get(long roomId, int? count, int? minId, int? subRoomId)
{
int take = count.HasValue ? Math.Clamp(count.Value, 1, MaxCount) : DefaultCount;
int cursor = minId ?? -1;
var q = _col.Query().Where(x => x.RoomId == roomId);
// subRoomId omitted -> the whole room; given -> narrow to that subroom.
if (subRoomId.HasValue)
{
int sr = subRoomId.Value;
q = q.Where(x => x.SubRoomId == sr);
}
return q.ToEnumerable()
.Where(x => x.CommentId > cursor) // exclusive
.OrderByDescending(x => x.CommentId) // newest first
.Take(take)
.ToList();
}
/// <summary>
/// Pins a comment in a subroom's scene. Author is the caller (never trusted from the
/// body). Message is truncated to MaxCommentLength; a blank message or missing subroom
/// is the caller's responsibility to reject (returns null on a blank message here as a
/// safety net). Returns the stored comment so the client can draw the bubble it placed.
/// </summary>
public static RoomCommentEntry? Create(long roomId, ulong accountId, long subRoomId,
string message, int style, double x, double y, double z)
{
message = (message ?? "").Trim();
if (message.Length > MaxCommentLength) message = message.Substring(0, MaxCommentLength);
if (message.Length == 0) return null;
int nextId = (_col.Query().OrderByDescending(c => c.CommentId).FirstOrDefault()?.CommentId ?? 0) + 1;
var entry = new RoomCommentEntry
{
CommentId = nextId,
RoomId = roomId,
SubRoomId = subRoomId,
AccountId = accountId,
CreatedAt = DateTime.UtcNow.ToString("o"),
Message = message,
Style = style,
Unread = true,
PositionX = x,
PositionY = y,
PositionZ = z
};
_col.Insert(entry);
Console.WriteLine($"[roomcomments] {accountId} pinned a comment in room {roomId}/{subRoomId} (id {nextId})");
return entry;
}
/// <summary>Deletes one comment - for admin moderation.</summary>
public static bool Delete(int commentId) => _col.Delete(commentId);
/// <summary>Every comment across all rooms, newest first - for the admin panel.</summary>
public static List<RoomCommentEntry> GetAll()
=> _col.Query().OrderByDescending(x => x.CommentId).ToList();
}
}
@@ -0,0 +1,2599 @@
using System.Diagnostics.Metrics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Discord;
using LiteDB;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Security.Cryptography;
using Campus_live_rewrite.api.server.Classes.db_class;
using static Campus_live_rewrite.api.server.Classes.db_class.RoomDBClasses;
using System.Linq;
namespace Campus_live_rewrite.api.dbs
{
public class RoomDB
{
public static LiteDatabase rooms_db = new LiteDatabase(Environment.CurrentDirectory + $"/Data/DBs/Rooms.db");
private static readonly object _roomLock = new();
public static readonly ILiteCollection<RoomRoot> col = rooms_db.GetCollection<RoomRoot>("Main_rooms");
public static readonly ILiteCollection<SubRoom> subroom_col = rooms_db.GetCollection<SubRoom>("Main_subrooms");
public static async Task ImportRooms(string path, ulong? ownerOverride = null)
{
string jsonData = await File.ReadAllTextAsync(path);
var hotRooms = JsonConvert.DeserializeObject<Resultslist<RoomDBClasses.RoomRoot>>(jsonData);
if (hotRooms?.Results == null)
return;
foreach (var room in hotRooms.Results)
{
// Re-own every imported room to the given account (the @Importer account), so all
// uploaded rooms land under one owner regardless of who created them originally.
if (ownerOverride.HasValue) room.CreatorAccountId = ownerOverride.Value;
AddRoom(room, isImportingFromJson: true);
}
}
// Rec Room exports carry JSON nulls for value-type fields (e.g. "PublishedAt": null); the
// server's RoomRoot types those as non-nullable DateTime, so a plain ToObject throws and the
// whole room import dies before any blob is placed. Ignoring nulls leaves such fields at
// their default instead of throwing.
private static readonly Newtonsoft.Json.JsonSerializer _lenientRoomSerializer =
Newtonsoft.Json.JsonSerializer.Create(new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
public class RoomExportResult
{
public int Rooms, Blobs, Images;
public readonly List<string> Names = new();
public readonly List<string> Errors = new();
}
/// <summary>
/// Import Rec Room's OFFICIAL room-export format (the one the game's export tool produces):
///
/// {Room}/RoomDetails.json # a SINGLE RoomRoot object (NOT {Results:[...]})
/// {Room}/super_room_data(.original).binpb # bytes for RoomRoot.DataBlob (a .meta)
/// {Room}/RoomImage.jpg # bytes for RoomRoot.ImageName
/// {Room}/SubRoom_{SubRoomId}_{Name}/persisted_room_data(.original).binpb
/// # bytes for that subroom's CurrentSave.DataBlob (a .room)
///
/// The OLD ImportRooms() only understood the server's own {Results:[...]} dump AND expected the
/// .room/.meta blobs to already exist under their content-hash names. A real export has neither:
/// RoomDetails.json is a bare object, and the blob bytes live in the .binpb files under DIFFERENT
/// names. So a dragged-in export imported 0 rooms (or a room with no build = bare scene).
///
/// The blob mapping is verified by hash: RoomRoot.DataBlob's recorded DataBlobHash matches
/// super_room_data.binpb, and each CurrentSave.DataBlobHash matches that subroom's
/// persisted_room_data.ORIGINAL.binpb (the pre-migration copy is the canonical blob the manifest
/// records; the plain .binpb is a migrated variant). We pick, per blob, the .binpb whose SHA-256
/// equals the recorded hash and write it under the DataBlob name into RoomBlobs, so the client's
/// integrity check passes and the room actually loads its build.
/// </summary>
public static async Task<RoomExportResult> ImportRoomExport(string dir, ulong? ownerOverride = null)
{
var res = new RoomExportResult();
string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
string imgDir = Path.Combine(Environment.CurrentDirectory, "Data", "Images");
Directory.CreateDirectory(blobDir);
Directory.CreateDirectory(imgDir);
var manifests = Directory.EnumerateFiles(dir, "RoomDetails.json", SearchOption.AllDirectories).ToList();
if (manifests.Count == 0) { res.Errors.Add("no RoomDetails.json found"); return res; }
foreach (var manifest in manifests)
{
try
{
string baseDir = Path.GetDirectoryName(manifest)!;
var tok = JToken.Parse(await File.ReadAllTextAsync(manifest));
var roomTokens =
tok is JObject o && o["Results"] is JArray ra ? ra.Cast<JToken>().ToList() :
tok is JArray arr ? arr.Cast<JToken>().ToList() :
new List<JToken> { tok };
foreach (var rt in roomTokens)
{
var room = rt.ToObject<RoomRoot>(_lenientRoomSerializer);
if (room == null) { res.Errors.Add($"{Path.GetFileName(baseDir)}: could not parse RoomDetails.json"); continue; }
// Downgrade the room's declared build version to the client's level so the
// client doesn't reject it with "playing on an older version of Rec Room than
// this room was saved with." Working base rooms declare no minimum; the subroom
// CurrentSave versions are zeroed separately in AddRoom. See ConfigCampus.
if (Campus_live_rewrite.api.server.ConfigCampus.ClampImportedRoomVersionToClient)
{
room.MinUgcSubVersion = null;
room.UgcSubVersion = null;
room.PersistenceVersion = 0;
}
// DataBlobHash is NOT modelled on RoomRoot/CurrentSave (commented out), so read
// the recorded hashes straight off the raw JSON to hash-match the .binpb files.
string? roomHash = (string?)rt["DataBlobHash"];
var subHashById = new Dictionary<ulong, string?>();
if (rt["SubRooms"] is JArray sra)
foreach (var st in sra)
if (ulong.TryParse((string?)st["SubRoomId"], out var sid))
subHashById[sid] = (string?)st["CurrentSave"]?["DataBlobHash"];
// Room-level blob (super room data) -> RoomBlobs/<DataBlob>.
if (!string.IsNullOrWhiteSpace(room.DataBlob))
{
var src = PickBlobByHash(baseDir, new[] { "super_room_data.original.binpb", "super_room_data.binpb" }, roomHash);
if (src != null) { File.Copy(src, Path.Combine(blobDir, room.DataBlob), true); res.Blobs++; }
else res.Errors.Add($"{room.Name}: super_room_data.binpb not found for DataBlob {room.DataBlob}");
}
// Each subroom's build blob -> RoomBlobs/<CurrentSave.DataBlob>.
foreach (var sub in room.SubRooms ?? new List<SubRoom>())
{
var cs = sub.CurrentSave;
if (cs == null || string.IsNullOrWhiteSpace(cs.DataBlob)) continue;
string? subDir = FindSubRoomDir(baseDir, sub.SubRoomId);
if (subDir == null) { res.Errors.Add($"{room.Name}/{sub.Name}: SubRoom_{sub.SubRoomId}_* folder missing"); continue; }
subHashById.TryGetValue(sub.SubRoomId, out var subHash);
var src = PickBlobByHash(subDir, new[] { "persisted_room_data.original.binpb", "persisted_room_data.binpb" }, subHash);
if (src != null) { File.Copy(src, Path.Combine(blobDir, cs.DataBlob), true); res.Blobs++; }
else res.Errors.Add($"{room.Name}/{sub.Name}: persisted_room_data.binpb not found for DataBlob {cs.DataBlob}");
}
// Room thumbnail -> Images/<ImageName>. Skip when the room has no custom image
// (ImageName is the shared DefaultRoomImage.jpg) so we don't clobber the global
// fallback every other room falls back to.
if (!string.IsNullOrWhiteSpace(room.ImageName) &&
!room.ImageName.StartsWith("Default", StringComparison.OrdinalIgnoreCase))
{
var img = Directory.EnumerateFiles(baseDir, "RoomImage.*", SearchOption.TopDirectoryOnly).FirstOrDefault();
if (img != null) { try { File.Copy(img, Path.Combine(imgDir, room.ImageName), true); res.Images++; } catch { } }
}
if (ownerOverride.HasValue) room.CreatorAccountId = ownerOverride.Value;
var id = AddRoom(room, isImportingFromJson: true);
if (id != 0) { res.Rooms++; res.Names.Add(room.Name); }
else res.Errors.Add($"{room.Name}: not added (a room with that name may already exist)");
}
}
catch (Exception ex) { res.Errors.Add($"{Path.GetFileName(Path.GetDirectoryName(manifest))}: {ex.Message}"); }
}
return res;
}
/// <summary>
/// Clamp the declared build version of every @Importer-owned room down to the client's level
/// (no minimum), so already-imported rooms stop tripping the client's "playing on an older
/// version of Rec Room" gate. Idempotent; only touches imported rooms (base rooms are owned
/// by other accounts and already declare no minimum). Returns how many rooms were changed.
/// </summary>
public static int ClampImportedRoomsToClient(ulong importerId)
{
if (!Campus_live_rewrite.api.server.ConfigCampus.ClampImportedRoomVersionToClient || importerId == 0) return 0;
int n = 0;
lock (_roomLock)
{
foreach (var room in col.Find(r => r.CreatorAccountId == importerId).ToList())
{
bool ch = false;
if (room.MinUgcSubVersion != null) { room.MinUgcSubVersion = null; ch = true; }
if (room.UgcSubVersion != null) { room.UgcSubVersion = null; ch = true; }
if (room.PersistenceVersion != null && room.PersistenceVersion != 0) { room.PersistenceVersion = 0; ch = true; }
if (ch) { col.Update(room); n++; }
}
}
return n;
}
/// <summary>
/// Wrap a loose .room blob (a single subroom's build data, with no RoomDetails.json) in a
/// minimal, joinable @Importer-owned room so it can actually be played. The blob must already
/// be present in Data/CDN/RoomBlobs under `roomFile`. The room-level blob points at the shared
/// default_room.META (empty room variables). Scene id is taken from the blob when present, else
/// the standard MakerPen sandbox scene. Returns the new RoomId, or 0 if a room with that name
/// already exists / creation failed.
/// </summary>
public static ulong CreateRoomFromBlob(string roomFile, string roomName, ulong ownerId, string? sceneId = null, int persistenceVersion = 0)
{
const string SandboxScene = "a75f7547-79eb-47c6-8986-6767abcb4f92";
string scene = string.IsNullOrWhiteSpace(sceneId) ? SandboxScene : sceneId!;
var room = new RoomRoot
{
Name = roomName,
Description = "Imported from a loose .room blob.",
CreatorAccountId = ownerId,
Accessibility = RoomAccessibility.Public,
State = RoomState.Active,
PublishState = 0,
CreatedAt = DateTime.UtcNow,
PublishedAt = DateTime.UtcNow,
DataBlob = "default_room.META", // shared empty room-level (super) blob
ImageName = "DefaultRoomImage.jpg",
MaxPlayers = 20,
MaxPlayerCalculationMode = 0,
AgeRating = 0,
MinLevel = 0,
CloningAllowed = false,
SupportsScreens = true,
SupportsWalkVR = true,
SupportsTeleportVR = true,
SupportsVRLow = true,
SupportsQuest2 = true,
SupportsMobile = true,
SupportsJuniors = false,
IsRRO = false,
UgcVersion = 1,
UgcSubVersion = null, // no minimum -> passes the client version gate
MinUgcSubVersion = null,
PersistenceVersion = persistenceVersion, // the blob's real save-format version
LoadScreens = new List<LoadScreen> { new LoadScreen { ImageName = "", Title = roomName, Subtitle = "" } },
Stats = new Stats(),
Tags = new List<TagClass>(),
Roles = new List<RoleClass>(),
PromoImages = new List<string>(),
PromoExternalContent = new List<string>(),
RestrictedCircuitsAllowListNames = new List<string>(),
SubRooms = new List<SubRoom>
{
new SubRoom
{
Name = roomName,
UnitySceneId = scene,
IsSandbox = true,
MaxPlayers = 20,
Accessibility = RoomAccessibility.Public,
ShouldAutoStageSaves = true,
AllSaves = new List<CurrentSave>(),
CurrentSave = new CurrentSave
{
DataBlob = roomFile,
Description = "Imported from a loose .room blob.",
CreatedAt = DateTime.UtcNow,
SavedByAccountId = ownerId,
PersistenceVersion = 0,
OMVersion = 0,
UgcSubVersion = 0,
ReferencedUnityAssetIds = new List<string>()
}
}
}
};
return AddRoom(room, isImportingFromJson: true);
}
/// <summary>True if any room (room-level DataBlob) or subroom (CurrentSave/AllSaves DataBlob) already points at this blob.</summary>
public static bool AnyRoomUsesBlob(string blobName)
{
try
{
if (col.Exists(r => r.DataBlob == blobName)) return true;
foreach (var s in subroom_col.FindAll())
{
if (s.CurrentSave != null && string.Equals(s.CurrentSave.DataBlob, blobName, StringComparison.OrdinalIgnoreCase)) return true;
if (s.AllSaves != null && s.AllSaves.Any(x => string.Equals(x.DataBlob, blobName, StringComparison.OrdinalIgnoreCase))) return true;
}
}
catch { }
return false;
}
/// <summary>
/// Remove @Importer rooms that were wrapped around a loose .room blob whose save version is
/// newer than the client can load - they only freeze the game and drop the player to the dorm.
/// The blob file stays hosted. Only touches loose-blob rooms (our marker), never real imports.
/// </summary>
public static int RemoveTooNewLooseRooms(ulong importerId)
{
if (importerId == 0) return 0;
int max = Campus_live_rewrite.api.server.ConfigCampus.ClientMaxRoomPersistenceVersion;
string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
int removed = 0;
lock (_roomLock)
{
foreach (var room in col.Find(r => r.CreatorAccountId == importerId).ToList())
{
var subs = subroom_col.Find(s => s.RoomId == room.RoomId).ToList();
bool loose = subs.Count == 1 && subs[0].CurrentSave != null &&
(subs[0].CurrentSave.Description ?? "").Contains("loose .room", StringComparison.OrdinalIgnoreCase);
if (!loose) continue;
int ver = BlobPersistenceVersion(Path.Combine(blobDir, subs[0].CurrentSave!.DataBlob ?? ""));
if (ver > max)
{
subroom_col.DeleteMany(x => x.RoomId == room.RoomId);
col.DeleteMany(x => x.RoomId == room.RoomId);
removed++;
Console.WriteLine($"[room-version] removed too-new loose room '{room.Name}' (id {room.RoomId}, save v{ver} > client max {max})");
}
}
}
return removed;
}
/// <summary>The .room blob's persistence version = field 1 (a varint) at the very start. 0 if unreadable.</summary>
public static int BlobPersistenceVersion(string path)
{
try
{
using var fs = File.OpenRead(path);
int first = fs.ReadByte();
if (first != 0x08) return 0; // expect field 1, wiretype 0 (varint)
int result = 0, shift = 0, b;
while ((b = fs.ReadByte()) >= 0)
{
result |= (b & 0x7f) << shift; shift += 7;
if ((b & 0x80) == 0) break;
if (shift > 28) break;
}
return result;
}
catch { return 0; }
}
public enum LooseWrapStatus { Created, TooNew, AlreadyUsed, NameExists, Error }
/// <summary>
/// Host a single loose .room blob in RoomBlobs and, when it's old enough for the client and not
/// already linked to a room, wrap it in a joinable @Importer room. Reusable by the admin upload
/// AND the staging scan. Returns what happened.
/// </summary>
public static (LooseWrapStatus status, ulong roomId, int version, string roomName, string? error) WrapLooseRoom(string onDiskPath, ulong ownerId)
{
string bn = Path.GetFileName(onDiskPath);
try
{
string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
Directory.CreateDirectory(blobDir);
string dest = Path.Combine(blobDir, bn);
if (!File.Exists(dest) || new FileInfo(dest).Length != new FileInfo(onDiskPath).Length)
File.Copy(onDiskPath, dest, true);
if (AnyRoomUsesBlob(bn)) return (LooseWrapStatus.AlreadyUsed, 0, 0, bn, null);
int ver = BlobPersistenceVersion(dest);
if (ver > Campus_live_rewrite.api.server.ConfigCampus.ClientMaxRoomPersistenceVersion)
return (LooseWrapStatus.TooNew, 0, ver, bn, null);
string roomName = Path.GetFileNameWithoutExtension(bn);
string? scene = SceneIdFromBlob(dest);
ulong id = CreateRoomFromBlob(bn, roomName, ownerId, scene, ver);
return id != 0 ? (LooseWrapStatus.Created, id, ver, roomName, null)
: (LooseWrapStatus.NameExists, 0, ver, roomName, null);
}
catch (Exception ex) { return (LooseWrapStatus.Error, 0, 0, bn, ex.Message); }
}
/// <summary>Best-effort: pull the first GUID (the subroom's UnitySceneId) out of a .room blob's header.</summary>
public static string? SceneIdFromBlob(string path)
{
try
{
byte[] head = new byte[Math.Min(4096, (int)new FileInfo(path).Length)];
using (var fs = File.OpenRead(path)) fs.Read(head, 0, head.Length);
string ascii = System.Text.Encoding.ASCII.GetString(head);
var m = System.Text.RegularExpressions.Regex.Match(ascii, "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
return m.Success ? m.Value : null;
}
catch { return null; }
}
/// <summary>True if the directory looks like a Rec Room room export (has RoomDetails.json + a room blob).</summary>
public static bool LooksLikeRoomExport(string dir)
{
try
{
return Directory.EnumerateFiles(dir, "RoomDetails.json", SearchOption.AllDirectories).Any()
&& Directory.EnumerateFiles(dir, "*room_data*.binpb", SearchOption.AllDirectories).Any();
}
catch { return false; }
}
// Pick, among candidate filenames in dir, the one whose SHA-256 matches expectedHashB64.
// Falls back to the first existing candidate (candidates are listed best-first) when no hash
// is recorded or none match - better to serve a plausible blob than nothing.
private static string? PickBlobByHash(string dir, string[] candidateNames, string? expectedHashB64)
{
var existing = candidateNames.Select(n => Path.Combine(dir, n)).Where(File.Exists).ToList();
if (existing.Count == 0) return null;
if (!string.IsNullOrWhiteSpace(expectedHashB64))
{
foreach (var p in existing)
{
try
{
using var fs = File.OpenRead(p);
var hash = Convert.ToBase64String(SHA256.Create().ComputeHash(fs));
if (string.Equals(hash, expectedHashB64, StringComparison.Ordinal)) return p;
}
catch { }
}
}
return existing[0];
}
private static string? FindSubRoomDir(string baseDir, ulong subRoomId)
{
try
{
string prefix = $"SubRoom_{subRoomId}_";
return Directory.EnumerateDirectories(baseDir, "SubRoom_*", SearchOption.AllDirectories)
.FirstOrDefault(d => Path.GetFileName(d).StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}
catch { return null; }
}
public static void ExportRooms()
{
var allRooms = col.FindAll().ToList();
var allSubrooms = subroom_col.FindAll().ToList();
// Option 1: Export rooms with their subrooms attached
foreach (var room in allRooms)
{
room.SubRooms = allSubrooms.Where(s => s.RoomId == room.RoomId).ToList();
}
// Build final result
var exportData = new Resultslist<RoomDBClasses.RoomRoot>
{
Results = allRooms, // No mapping, just raw rooms
TotalResults = allRooms.Count
};
string outputPath = Path.Combine(Environment.CurrentDirectory, "Data/exported_rooms.json");
string json = JsonConvert.SerializeObject(exportData, Formatting.Indented);
File.WriteAllText(outputPath, json);
Console.WriteLine($"Exported {allRooms.Count} rooms with subrooms → {outputPath}");
}
public static void FixDuplicateDorms()
{
// Get all dorm rooms
var dormRooms = col.Find(x => x.IsDorm).ToList();
// Group dorms by creator
var groupedDorms = dormRooms
.GroupBy(r => r.CreatorAccountId)
.Where(g => g.Count() > 1);
foreach (var group in groupedDorms)
{
// Load player data to get DormRoomId
var player = PlayerDB.GetFullAccount(group.FirstOrDefault().CreatorAccountId);
if (player == null)
continue;
// Try to keep the dorm that matches player.DormRoomId
var dormToKeep = group.FirstOrDefault(r => r.RoomId == player.player_Extra.DormRoomID);
// Fallback: keep oldest dorm if DormRoomId is invalid/missing
if (dormToKeep == null)
{
dormToKeep = group
.OrderBy(r => r.CreatedAt)
.First();
}
// Delete all other dorms
var roomsToDelete = group
.Where(r => r.RoomId != dormToKeep.RoomId)
.ToList();
foreach (var room in roomsToDelete)
{
subroom_col.DeleteMany(x => x.RoomId == room.RoomId);
col.DeleteMany(x => x.RoomId == room.RoomId);
Console.WriteLine(
$"Deleted duplicate dorm room: {room.Name} (ID: {room.RoomId}) for creator {room.CreatorAccountId}"
);
}
Console.WriteLine(
$"Kept dorm room: {dormToKeep.Name} (ID: {dormToKeep.RoomId}) for creator {group.Key}"
);
}
}
public static string get_subroom_from_id(ulong room_id, ulong subroom_id)
{
try
{
var subroom = subroom_col.FindOne(x => x.RoomId == room_id && x.SubRoomId == subroom_id);
return subroom?.Name ?? "Unknown Room";
}
catch (Exception ex)
{
Console.WriteLine($"Error in get_subroom_from_id: {ex.Message}");
return "Unknown Room";
}
}
public static ulong AddRoom(RoomDBClasses.RoomRoot room, bool isImportingFromJson = false)
{
try
{
if (room == null)
throw new ArgumentNullException(nameof(room));
lock (_roomLock)
{
// Skip duplicate check if DormRoom
if (room.Name != "DormRoom")
{
var existingRoom = col.FindOne(x => x.Name == room.Name);
if (existingRoom != null)
return 0;
}
var highest = col.Query().OrderByDescending(x => x.RoomId).Limit(1).FirstOrDefault();
room.RoomId = highest != null ? highest.RoomId + 1 : 1;
if (room.RoomId == 0)
{
throw new Exception($"Imported room '{room.Name}' has RoomId = 0");
}
//room.DataBlobHash = null;
if (string.IsNullOrEmpty(room.RankedEntityId))
room.RankedEntityId = room.RoomId.ToString();
if (room.UgcVersion == null)
{
room.UgcVersion = 1;
}
if (room.PersistenceVersion == null)
{
room.PersistenceVersion = 0;
}
if (room.NeedsSnapshotId == false)
{
room.CurrentSnapshotId = null;
}
// Ensure required fields exist
if (room.LoadScreens == null) throw new Exception("room.LoadScreens is null!");
if (room.Stats == null) throw new Exception("room.Stats is null!");
// --- Insert the room directly, no mapping ---
col.Insert(room);
// --- Insert subrooms directly, no mapping ---
if (room.SubRooms != null && room.SubRooms.Count > 0)
{
// ----------------------------
// Get next SubRoomId safely
// ----------------------------
var lastSubroom = subroom_col.Query()
.OrderByDescending(x => x.SubRoomId)
.Limit(1)
.FirstOrDefault();
ulong nextSubId = lastSubroom?.SubRoomId + 1 ?? 1;
// ----------------------------
// Get next SubRoomDataSaveId globally
// ----------------------------
ulong nextSaveId = 1;
var allExistingSubrooms = subroom_col.FindAll().ToList();
var maxSaveId = allExistingSubrooms
.SelectMany(x =>
{
var saves = new List<RoomDBClasses.CurrentSave>();
if (x.CurrentSave != null)
saves.Add(x.CurrentSave);
if (x.AllSaves != null)
saves.AddRange(x.AllSaves);
return saves;
})
.Select(x => x.SubRoomDataSaveId)
.DefaultIfEmpty(0UL)
.Max();
nextSaveId = maxSaveId + 1;
// ----------------------------
// Insert SubRooms
// ----------------------------
foreach (var sub in room.SubRooms)
{
try
{
ulong subId = nextSubId++;
if (isImportingFromJson && subId == 0)
throw new Exception($"Imported subroom '{sub.Name}' has SubRoomId = 0");
sub.SubRoomId = subId;
sub.RoomId = room.RoomId;
// Fix CurrentSave
if (sub.CurrentSave != null)
{
sub.CurrentSave.SubRoomDataSaveId = nextSaveId++;
sub.CurrentSave.SubRoomId = sub.SubRoomId;
sub.CurrentSave.UgcSubVersion = 0;
sub.CurrentSave.PersistenceVersion = 0;
}
// ...and AllSaves the same way.
//
// Only CurrentSave was re-keyed. When a room is CLONED the
// subroom objects come straight off the source room, so every
// AllSaves entry kept the SOURCE subroom's SubRoomId and save
// id. GetRoomSaveById looks a save up by SubRoomDataSaveId, so
// the clone's saves pointed at another room's ids - the save
// either could not be found or resolved to the wrong subroom.
//
// Harmless on this server today because no template room has a
// save to copy, but it breaks the moment one does.
if (sub.AllSaves != null)
{
foreach (var save in sub.AllSaves)
{
save.SubRoomDataSaveId = nextSaveId++;
save.SubRoomId = sub.SubRoomId;
}
}
subroom_col.Insert(sub);
Console.WriteLine($"Added subroom: {sub.Name} ({sub.SubRoomId})");
}
catch (Exception ex)
{
Console.WriteLine($"Error adding subroom '{sub.Name}' for room '{room.Name}': {ex.Message}");
}
}
}
Console.WriteLine($"added room: {room.Name} ({room.RoomId})");
return room.RoomId;
}
}
catch (Exception ex)
{
Console.WriteLine($"Error adding room '{room.Name}': {ex.Message}");
return 0;
}
}
public static RoomDBClasses.RoomRoot? GetRoom(ulong room_id)
{
// Find the main room
var room = col.FindOne(x => x.RoomId == room_id);
if (room == null) return null;
// Fetch subrooms and attach them directly
var subrooms = subroom_col.Find(x => x.RoomId == room_id).ToList();
// Attach subrooms directly, no new object mapping
room.SubRooms = subrooms;
// Return the database object as-is
return room;
}
public static bool DoesPlayerDormExist(ulong playerId)
{
var playercol = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
var player = playercol.FindOne(x => x.player.Id == playerId);
if (player?.player_Extra == null)
return false;
ulong dormId = (ulong)player.player_Extra.DormRoomID;
if (dormId == 0 || dormId == 1)
return false;
var dormRoom = RoomDB.GetRoom(dormId);
return dormRoom != null;
}
// The dorm template the client asks for by name. Rec Room added multi-dorm
// ("My Dorms" -> Create new Dorm) after this server was written, so no room
// called CustomDormRoomTemplate was ever seeded - the lookup 404'd and the
// client reported "Failed to find ^CustomDormRoomTemplate from server".
// Room 1 IS the dorm template (Name "DormRoom", IsDorm, one "Home" subroom) and
// is already what clone_dorm_room copies, so point the new name at it.
private const ulong DormTemplateRoomId = 1;
public static RoomDBClasses.RoomRoot? GetRoomByName(string roomName)
{
if (!string.IsNullOrEmpty(roomName))
{
// The client prefixes template/base room lookups with '^' ("^Park",
// "^CustomDormRoomTemplate") but the rooms are stored unprefixed, so an
// exact match never hit. Strip it before comparing.
roomName = roomName.TrimStart('^');
if (roomName.Equals("CustomDormRoomTemplate", StringComparison.OrdinalIgnoreCase)
|| roomName.Equals("DormRoomTemplate", StringComparison.OrdinalIgnoreCase))
{
var dormTemplate = GetRoom(DormTemplateRoomId);
if (dormTemplate != null)
{
dormTemplate.DataBlob ??= "";
return dormTemplate;
}
}
foreach (RoomDBClasses.RoomRoot subroom in col.FindAll())
{
if (roomName.Equals(subroom.Name, StringComparison.OrdinalIgnoreCase))
{
var room = GetRoom(subroom.RoomId);
if (room.DataBlob is null)
{
room.DataBlob = "";
}
return room;
}
}
}
return null;
}
// "<Player>'s DormRoom" instead of the literal "DormRoom" every dorm used to
// get. With every dorm sharing one name the client had nothing distinguishing
// to show and fell back to just the accessibility tag - a bare "[PRIVATE]".
public static string DormRoomName(ulong player_id)
{
string owner = PlayerDB.GetPlayerNameById(player_id);
return string.IsNullOrWhiteSpace(owner) || owner == "Unknown"
? "DormRoom"
: $"{owner}'s DormRoom";
}
/// <summary>
/// Keep the player's dorm ROOM named after their current @name ("kyle's DormRoom"). Called
/// after a username change; the room's stored Name is what the room list, the in-game
/// "go to dorm" tile and the website would otherwise keep showing under the old handle.
/// The live dorm INSTANCE name (`@kyle's Dorm`) is rebuilt from the username on every
/// dorm matchmake, so it follows on the next visit by itself.
/// </summary>
public static void RenameDormForPlayer(ulong player_id)
{
try
{
ulong dormId = PlayerDB.GetDormId(player_id);
if (dormId == 0) return;
var dorm = col.FindOne(x => x.RoomId == dormId);
if (dorm == null || !dorm.IsDorm) return;
string wanted = DormRoomName(player_id);
if (dorm.Name == wanted) return;
dorm.Name = wanted;
col.Update(dorm);
Console.WriteLine($"[dorm] renamed dorm {dormId} of {player_id} -> \"{wanted}\"");
}
catch (Exception ex) { Console.WriteLine($"[dorm] rename for {player_id} failed: {ex.Message}"); }
}
/// <summary>Startup pass: bring every existing dorm's name in line with its owner's current @name.</summary>
public static void RenameAllDormsToOwners()
{
int fixedCount = 0;
foreach (var dorm in col.Find(x => x.IsDorm).ToList())
{
if (dorm.CreatorAccountId == 0) continue;
string wanted = DormRoomName(dorm.CreatorAccountId);
if (wanted == "DormRoom" || dorm.Name == wanted) continue;
dorm.Name = wanted; col.Update(dorm); fixedCount++;
}
if (fixedCount > 0) Console.WriteLine($"[dorm] startup: renamed {fixedCount} dorm(s) to their owners' current @names");
}
public static ulong clone_dorm_room(ulong player_id)
{
RoomDBClasses.RoomRoot room_data = GetRoom(1);
var deep = JsonConvert.SerializeObject(room_data);
var room_data2 = JsonConvert.DeserializeObject<RoomDBClasses.RoomRoot>(deep);
if (room_data2 == null)
{
throw new Exception("Base dorm room not found.");
}
if (room_data2.SubRooms == null || room_data2.SubRooms.Count == 0)
{
throw new Exception("Base dorm room has no subrooms.");
}
if (room_data2.Stats == null)
{
throw new Exception("Base dorm room stats are null.");
}
room_data2.CreatorAccountId = player_id;
room_data2.CloningAllowed = false;
// Was: the literal "DormRoom" for every player, so the client had nothing to
// show but the accessibility tag - hence a bare "[PRIVATE]". Name it after
// the owner so it reads "<Player>'s DormRoom [Private]" instead.
room_data2.Name = DormRoomName(player_id);
room_data2.CreatedAt = DateTime.Now;
room_data2.Accessibility = RoomDBClasses.RoomAccessibility.Private;
room_data2.IsRRO = false;
room_data2.IsDorm = true;
room_data2.Stats.CheerCount = 0;
room_data2.Stats.FavoriteCount = 0;
room_data2.Stats.VisitorCount = 0;
room_data2.Stats.VisitCount = 0;
room_data2.Roles = new List<RoomDBClasses.RoleClass>
{
new RoomDBClasses.RoleClass
{
AccountId = (int)player_id,
InvitedRole = 0,
Role = RoomDBClasses.Role_data.Creator
}
};
ulong id = AddRoom(room_data2);
return id;
}
public static void GiveCoOwnerToAllRooms(ulong playerId)
{
var allRooms = col.FindAll().ToList();
foreach (var room in allRooms)
{
if (room.CreatorAccountId == playerId)
continue;
if (room.Roles == null)
room.Roles = new List<RoleClass>();
var existingRole = room.Roles.FirstOrDefault(r => (ulong)r.AccountId == playerId);
if (existingRole != null)
{
if (existingRole.Role != RoomDBClasses.Role_data.Creator)
{
existingRole.Role = RoomDBClasses.Role_data.CoOwner;
}
}
else
{
room.Roles.Add(new RoleClass
{
AccountId = (int)playerId,
InvitedRole = 0,
Role = RoomDBClasses.Role_data.CoOwner
});
}
col.Update(room);
}
Console.WriteLine($"Updated all rooms to grant Co-Owner to player {playerId}.");
}
public static RoomDBClasses.RoomRoot? IncreaseRoomVisit(ulong roomid)
{
var room = col.FindOne(x => x.RoomId == roomid);
if (room != null)
{
if (room.Stats == null)
room.Stats = new Stats();
if (room.Stats.VisitCount == 0)
room.Stats.VisitCount = 1;
else
room.Stats.VisitCount++;
col.Update(room);
return room;
}
return null;
}
/// <summary>
/// The hot-rooms candidate set: every non-dorm room, WITHOUT subrooms. The list is
/// filtered, sorted and paged by the caller, which then attaches subrooms to just the
/// page it returns (AttachSubRooms) - loading them for all ~2,500 candidates to serve 30
/// was what made this endpoint take over a minute.
/// </summary>
public static List<RoomDBClasses.RoomRoot> GetHotRooms()
{
return col.Query()
.Where(r => !r.IsDorm)
.ToList();
}
/// <summary>
/// Populate SubRooms on the given rooms. One indexed lookup per room (RoomId is indexed),
/// so call it on the rooms you are actually returning, not on the whole table.
/// </summary>
public static void AttachSubRooms(IEnumerable<RoomDBClasses.RoomRoot> rooms)
{
foreach (var room in rooms)
room.SubRooms = subroom_col.Find(s => s.RoomId == room.RoomId).ToList();
}
/// <summary>
/// Every non-dorm room with subrooms attached. Subrooms are read in ONE pass and grouped in
/// memory rather than one query per room. Admin/maintenance use - the client-facing lists
/// should page first and use AttachSubRooms on the page.
/// </summary>
public static List<RoomDBClasses.RoomRoot> GetAllRooms()
{
var mainRooms = col.Query()
.Where(r => !r.IsDorm)
.ToList();
var byRoom = subroom_col.FindAll()
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var room in mainRooms)
room.SubRooms = byRoom.TryGetValue(room.RoomId, out var subs) ? subs : new List<SubRoom>();
return mainRooms;
}
public static void UpdateRoomCheerCount(ulong roomid, int delta)
{
var room = col.FindOne(x => x.RoomId == roomid);
if (room != null)
{
room.Stats.CheerCount = Math.Max(0, room.Stats.CheerCount + delta);
col.Update(room);
}
}
public static void UpdateRoomFavoriteCount(ulong roomid, int delta)
{
var room = col.FindOne(x => x.RoomId == roomid);
if (room != null)
{
room.Stats.FavoriteCount = Math.Max(0, room.Stats.FavoriteCount + delta);
col.Update(room);
}
}
/// <summary>
/// Attaches each room's LIVE subrooms from the subroom collection.
///
/// Rooms carry an embedded SubRooms array, but it is a STALE SNAPSHOT taken when
/// the room was created/cloned - saves are written to the subroom collection, not
/// back into it. GetRoom() has always overwritten it with live data; the list
/// queries did not, so a listing reported the original subroom with
/// CurrentSave = null even for rooms that had been saved many times.
///
/// That is what made the client treat saved rooms as "never been saved": it hid
/// them from Create -> My Rooms, and prompted "Do you want to save?" on exit
/// because as far as the listing was concerned no save existed.
/// </summary>
// ---- Room access control ----------------------------------------------
//
// RoomAccessibility: Private = 0, Public = 1, Unlisted = 2, Dev_only = 3,
// Dev_Unlisted = 4. Private is the DEFAULT for a freshly created room, which is
// why almost every player room is private.
//
// There is no separate invite system on this server - room ROLES are the invite.
// Give someone Host/Moderator/CoOwner/Creator on a room and they can see and join
// it; without a role a private room is invisible and unjoinable to them.
/// <summary>The player's role on this room, or null if they have none.</summary>
public static RoomDBClasses.Role_data? PlayerRoleOn(RoomDBClasses.RoomRoot? room, ulong playerId)
{
if (room == null) return null;
if (room.CreatorAccountId == playerId) return RoomDBClasses.Role_data.Creator;
var role = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == playerId);
return role?.Role;
}
/// <summary>
/// True when the player holds a role that counts as being part of the room -
/// owner, co-owner, moderator or host. Explicitly false for Banned and None.
/// </summary>
public static bool PlayerIsMemberOf(RoomDBClasses.RoomRoot? room, ulong playerId)
{
var role = PlayerRoleOn(room, playerId);
if (role == null) return false;
switch (role.Value)
{
case RoomDBClasses.Role_data.Creator:
case RoomDBClasses.Role_data.CoOwner:
case RoomDBClasses.Role_data.TemporaryCoOwner:
case RoomDBClasses.Role_data.Contributor:
case RoomDBClasses.Role_data.Moderator:
case RoomDBClasses.Role_data.Host:
return true;
default:
return false; // None / Banned
}
}
/// <summary>Banned from this specific room (an elapsed timed ban is lifted on the way).</summary>
public static bool PlayerIsBannedFrom(RoomDBClasses.RoomRoot? room, ulong playerId)
=> ActiveRoomBan(room, playerId) != null;
/// <summary>
/// The ban currently keeping this player out of the room, or null. A ban is the Banned ROLE
/// on the room (which is what every join gate reads) plus a RoomBans record carrying who /
/// when / why / until. A timed ban whose BanEndTime has passed is lifted here - the role
/// removed and the record marked Elapsed - so a "15 minutes" restriction really ends.
/// </summary>
public static RoomDBClasses.RoomBanDetails? ActiveRoomBan(RoomDBClasses.RoomRoot? room, ulong playerId)
{
if (room == null) return null;
var role = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == playerId);
if (role == null || role.Role != RoomDBClasses.Role_data.Banned) return null;
var ban = room.RoomBans?.FirstOrDefault(b => b.AccountId == (long)playerId && b.Status == 0);
if (ban != null && ban.BanEndTime.HasValue && ban.BanEndTime.Value <= DateTime.UtcNow)
{
// Elapsed: lift it on the stored document (not on whatever copy we were handed).
var stored = col.FindOne(x => x.RoomId == room.RoomId);
if (stored != null)
{
stored.Roles?.RemoveAll(r => (ulong)r.AccountId == playerId && r.Role == RoomDBClasses.Role_data.Banned);
foreach (var b in stored.RoomBans ?? new()) if (b.AccountId == (long)playerId && b.Status == 0) b.Status = 1;
if (stored.RoomBanDetails?.AccountId == (long)playerId) stored.RoomBanDetails = null;
col.Update(stored);
}
room.Roles!.Remove(role);
ban.Status = 1;
Console.WriteLine($"[roomban] room {room.RoomId}: ban on {playerId} elapsed, lifted");
return null;
}
// A Banned role with no record (older data): still a ban, with what we know.
return ban ?? new RoomDBClasses.RoomBanDetails { AccountId = (long)playerId, BanStartTime = DateTime.UtcNow, Reason = "", Status = 0 };
}
/// <summary>
/// Ban a player from a room: the Banned role (what the join gates refuse on) plus a record.
/// duration null = until someone unbans them. The room's creator can never be banned.
/// Any earlier active ban on the same player is closed as Lifted first.
/// </summary>
public static RoomDBClasses.RoomBanDetails? BanFromRoom(ulong roomId, ulong playerId, ulong by, string? reason, TimeSpan? duration)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null || room.CreatorAccountId == playerId) return null;
room.Roles ??= new List<RoomDBClasses.RoleClass>();
var existing = room.Roles.FirstOrDefault(r => (ulong)r.AccountId == playerId);
if (existing != null) { existing.Role = RoomDBClasses.Role_data.Banned; existing.InvitedRole = 0; existing.LastChangedByAccountId = (long)by; }
else room.Roles.Add(new RoomDBClasses.RoleClass { AccountId = (long)playerId, Role = RoomDBClasses.Role_data.Banned, InvitedRole = 0, LastChangedByAccountId = (long)by });
room.RoomBans ??= new List<RoomDBClasses.RoomBanDetails>();
foreach (var old in room.RoomBans) if (old.AccountId == (long)playerId && old.Status == 0) old.Status = 2;
var ban = new RoomDBClasses.RoomBanDetails
{
AccountId = (long)playerId,
BannedByAccountId = (long)by,
BanStartTime = DateTime.UtcNow,
BanEndTime = duration.HasValue ? DateTime.UtcNow + duration.Value : null,
Reason = reason ?? "",
Status = 0
};
room.RoomBans.Add(ban);
room.RoomBanDetails = ban;
col.Update(room);
Console.WriteLine($"[roomban] room {roomId} '{room.Name}': {by} banned {playerId} " +
$"{(duration.HasValue ? $"for {duration.Value.TotalMinutes:0} min" : "until unbanned")}" +
$"{(string.IsNullOrEmpty(reason) ? "" : $" - {reason}")}");
return ban;
}
/// <summary>Lift a room ban. Returns the closed record, or null if they were not banned.</summary>
public static RoomDBClasses.RoomBanDetails? UnbanFromRoom(ulong roomId, ulong playerId, ulong by)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return null;
var role = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == playerId && r.Role == RoomDBClasses.Role_data.Banned);
var ban = room.RoomBans?.FirstOrDefault(b => b.AccountId == (long)playerId && b.Status == 0);
if (role == null && ban == null) return null;
if (role != null) room.Roles!.Remove(role);
if (ban != null) { ban.Status = 2; ban.UnbannedByAccountId = (long)by; }
if (room.RoomBanDetails?.AccountId == (long)playerId) room.RoomBanDetails = null;
col.Update(room);
Console.WriteLine($"[roomban] room {roomId}: {by} unbanned {playerId}");
return ban ?? new RoomDBClasses.RoomBanDetails { AccountId = (long)playerId, BannedByAccountId = 0, BanStartTime = DateTime.UtcNow, Status = 2, UnbannedByAccountId = (long)by, Reason = "" };
}
/// <summary>
/// One-off repair: room kicks used to be stored as room bans ("Kicked by X" / "Vote
/// kicked ..."), so every kick was a permanent ban from the map. Lift those; a real ban
/// (issued from the ban route, any other reason) is left alone. Returns how many.
/// </summary>
public static int LiftKickBans()
{
int lifted = 0;
foreach (var room in col.FindAll().Where(r => r.RoomBans != null && r.RoomBans.Count > 0).ToList())
{
bool changed = false;
foreach (var b in room.RoomBans!.Where(b => b.Status == 0).ToList())
{
string reason = b.Reason ?? "";
if (!reason.StartsWith("Kicked by ", StringComparison.OrdinalIgnoreCase) && !reason.StartsWith("Vote kicked", StringComparison.OrdinalIgnoreCase)) continue;
b.Status = 2;
room.Roles?.RemoveAll(r => r.AccountId == b.AccountId && r.Role == RoomDBClasses.Role_data.Banned);
if (room.RoomBanDetails?.AccountId == b.AccountId) room.RoomBanDetails = null;
Console.WriteLine($"[roomban] room {room.RoomId} '{room.Name}': lifted ban on {b.AccountId} - it was a kick ({reason})");
changed = true; lifted++;
}
if (changed) col.Update(room);
}
return lifted;
}
/// <summary>The room's active bans, newest first.</summary>
public static List<RoomDBClasses.RoomBanDetails> ActiveRoomBans(RoomDBClasses.RoomRoot room)
{
var list = new List<RoomDBClasses.RoomBanDetails>();
foreach (var r in (room.Roles ?? new()).Where(r => r.Role == RoomDBClasses.Role_data.Banned).ToList())
{
var b = ActiveRoomBan(room, (ulong)r.AccountId);
if (b != null) list.Add(b);
}
return list.OrderByDescending(b => b.BanStartTime).ToList();
}
/// <summary>Creator, co-owner, moderator or host on the room, or a staff account.</summary>
public static bool CanModerateRoom(RoomDBClasses.RoomRoot? room, ulong accountId)
{
if (room == null) return false;
if (room.CreatorAccountId == accountId) return true;
if (PlayerDB.CheckDevFlag(accountId) || PlayerDB.CheckModFlag(accountId)) return true;
var role = PlayerRoleOn(room, accountId);
return role is RoomDBClasses.Role_data.CoOwner or RoomDBClasses.Role_data.TemporaryCoOwner
or RoomDBClasses.Role_data.Moderator or RoomDBClasses.Role_data.Host;
}
/// <summary>
/// Is the room published?
///
/// PublishState is the client's own field (RoomRoot.PublishState) and the save
/// request carries AutoPublish. Neither was ever read by this server - every room
/// in the DB sits at PublishState = 0, including the stock RRO rooms.
///
/// So "published" is treated as: PublishState says so, OR the room is Public /
/// Unlisted. That second clause matters - without it every existing room (Rec
/// Center, Park, all 54 of account 1's) would read as unpublished and vanish from
/// everyone's listings the moment this shipped.
/// </summary>
public static bool RoomHasTag(RoomDBClasses.RoomRoot room, string tag) =>
room.Tags != null && room.Tags.Any(t => string.Equals(t.Tag, tag, StringComparison.OrdinalIgnoreCase));
// Rooms 2.0 keep their REAL visibility on the "Home" subroom; the room-level Accessibility
// is a stale default (Private) for them. Search / hot / CanPlayerSeeRoom all read the
// ROOM-level field, so a published 2.0 room was invisible everywhere. This returns the
// room's effective accessibility - its Home subroom's (or the first subroom's) - which the
// room-level field is kept mirrored to (ModifySubroom + the startup SyncAllRoomAccessibility).
public static RoomDBClasses.RoomAccessibility PrimaryAccessibility(RoomDBClasses.RoomRoot room)
{
var subs = room.SubRooms;
if (subs == null || subs.Count == 0) return room.Accessibility;
var home = subs.FirstOrDefault(s => string.Equals(s.Name, "Home", StringComparison.OrdinalIgnoreCase)) ?? subs[0];
return home.Accessibility;
}
// The room-level Accessibility a room SHOULD carry: its Home subroom's, which is what the
// owner actually set in-game. Rooms are NOT auto-published, so the accessibility the owner
// chose (Public / Unlisted / Private) IS the room's published state - see IsRoomPublished.
public static RoomDBClasses.RoomAccessibility EffectiveRoomAccessibility(RoomDBClasses.RoomRoot room)
=> PrimaryAccessibility(room);
// Idempotent migration: give every non-RRO room its effective accessibility, so existing
// PUBLISHED Rooms 2.0 become Public/searchable and unpublished ones match their subroom.
// RRO rooms already carry a correct room-level Accessibility, so leave them.
/// <summary>
/// Idempotent: player-made rooms cloned before the clone fix carry the template's
/// IsDeveloperOwned=true and RankedEntityId (the client hides developer-owned rooms from a
/// player's created list). Rec Room Originals and base templates are left alone.
/// </summary>
public static int FixPlayerRoomFlags()
{
int n = 0;
foreach (var room in col.FindAll().ToList())
{
if (room == null || room.IsRRO || room.IsDorm) continue;
if (RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal")) continue;
if (room.CreatorAccountId <= 1) continue; // system / owner-seeded rooms
bool changed = false;
if (room.IsDeveloperOwned) { room.IsDeveloperOwned = false; changed = true; }
if (room.RankedEntityId != room.RoomId.ToString()) { room.RankedEntityId = room.RoomId.ToString(); changed = true; }
if (changed) { col.Update(room); n++; }
}
if (n > 0) Console.WriteLine($"[rooms] cleared template flags (IsDeveloperOwned / RankedEntityId) on {n} player room(s)");
return n;
}
public static int SyncAllRoomAccessibility()
{
int n = 0;
// Subrooms live in their own collection and are NOT on the rows FindAll hands back, so
// EffectiveRoomAccessibility (the Home subroom's setting) used to see no subrooms and
// answer the room's own field - this sync never changed anything. One pass over the
// subroom collection, grouped by room, instead of a query per room.
var subsByRoom = subroom_col.FindAll().GroupBy(s => s.RoomId).ToDictionary(g => g.Key, g => g.ToList());
foreach (var room in col.FindAll().ToList())
{
if (room == null || room.IsRRO) continue;
if (subsByRoom.TryGetValue(room.RoomId, out var subs)) room.SubRooms = subs;
if (room.SubRooms == null || room.SubRooms.Count == 0) continue;
var eff = EffectiveRoomAccessibility(room);
if (room.Accessibility != eff)
{
room.Accessibility = eff;
col.Update(room); n++; // the embedded subroom copy is refreshed from the subroom collection too
Console.WriteLine($"[rooms] {room.RoomId} '{room.Name}': room-level accessibility -> {eff} (from its Home subroom)");
}
}
return n;
}
public static bool IsRoomPublished(RoomDBClasses.RoomRoot? room)
{
if (room == null) return false;
// Legacy Rec Room Originals (Rec Center, Park, the base rooms) are always listed.
if (room.IsRRO || RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal"))
return true;
// For a user room (Rooms 2.0 / beta) the ACCESSIBILITY the owner set IS the published
// state: Public or Unlisted = visible to non-members; Private = a draft, members-only.
// Rooms are NOT auto-published on save, so a freshly created (Private) room stays in the
// owner's list only until they deliberately set it Public/Unlisted.
var acc = EffectiveRoomAccessibility(room);
return acc == RoomDBClasses.RoomAccessibility.Public
|| acc == RoomDBClasses.RoomAccessibility.Unlisted;
}
/// <summary>
/// Does the room appear in LISTS for people who hold no role on it (another player's
/// profile, search, hot)? Only a PUBLIC room does - Unlisted is exactly that (reachable by
/// invite or link, never listed) and Private is a draft. Rec Room Originals are always listed.
/// </summary>
/// <summary>A Rec Room Original - the stock rooms, not a player's creation.</summary>
public static bool IsRecRoomOriginal(RoomDBClasses.RoomRoot? room) =>
room != null && (room.IsRRO || RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal"));
public static bool IsRoomListed(RoomDBClasses.RoomRoot? room)
{
if (room == null) return false;
if (room.IsRRO || RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal"))
return true;
return EffectiveRoomAccessibility(room) == RoomDBClasses.RoomAccessibility.Public;
}
/// <summary>
/// May this player SEE the room (listings, search, someone's portfolio)?
///
/// Published rooms are visible to everyone, private or not - a published private
/// room advertises itself, it just does not let strangers into your session.
/// An UNPUBLISHED room is invisible to anyone without a role on it.
/// </summary>
public static bool CanPlayerSeeRoom(RoomDBClasses.RoomRoot? room, ulong playerId)
{
if (room == null) return false;
if (PlayerIsBannedFrom(room, playerId)) return false;
if (PlayerIsMemberOf(room, playerId)) return true; // own/co-own/mod/host
return IsRoomListed(room); // others only see what is PUBLIC; unlisted/private stay off their lists
}
/// <summary>
/// May this player ENTER the room at all - in an instance of their own if need be?
///
/// Published rooms may be entered by anyone: a published PRIVATE room hands the
/// visitor their own fresh session (see CanPlayerShareInstance), which is what lets
/// them play it with their own friends without getting into the owner's session.
/// Unpublished rooms are members-only.
/// </summary>
public static bool CanPlayerEnterRoom(RoomDBClasses.RoomRoot? room, ulong playerId)
{
if (room == null) return false;
if (PlayerIsBannedFrom(room, playerId)) return false;
if (PlayerIsMemberOf(room, playerId)) return true;
// A dorm is never open, published or not.
if (room.IsDorm) return false;
return IsRoomPublished(room);
}
/// <summary>
/// May this player be dropped into an instance SOMEONE ELSE is already in?
///
/// Only for Public rooms, or when they hold a role on the room. This is the line
/// between "published private" and "public": both are visible and enterable, but a
/// published private room gives each visitor their own session instead of pooling
/// strangers into the owner's.
/// </summary>
public static bool CanPlayerShareInstance(RoomDBClasses.RoomRoot? room, ulong playerId)
{
if (room == null) return false;
if (PlayerIsBannedFrom(room, playerId)) return false;
if (PlayerIsMemberOf(room, playerId)) return true;
return room.Accessibility == RoomDBClasses.RoomAccessibility.Public;
}
/// <summary>Sets the room's publish state (0 = unpublished). Returns false if missing.</summary>
public static bool SetRoomPublishState(ulong roomId, int publishState)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return false;
room.PublishState = publishState;
if (publishState > 0) room.PublishedAt = DateTime.UtcNow;
// Publishing makes a room Public for everyone; unpublishing drops it back to its
// subroom's accessibility (and it becomes members-only via CanPlayerSeeRoom).
room.Accessibility = EffectiveRoomAccessibility(room);
col.Update(room);
return true;
}
public static List<RoomDBClasses.RoomRoot> AttachSubRooms(List<RoomDBClasses.RoomRoot> rooms)
{
if (rooms == null || rooms.Count == 0) return rooms ?? new List<RoomDBClasses.RoomRoot>();
foreach (var room in rooms)
{
if (room == null) continue;
room.SubRooms = subroom_col.Find(s => s.RoomId == room.RoomId).ToList();
}
return rooms;
}
/// <summary>
/// Every room the player should see under "My Rooms": ones they created, plus any
/// they hold a role on. A room stays listed until the player's role is removed or
/// the room is deleted - it is never dropped for being unsaved or unpublished.
/// </summary>
public static List<RoomDBClasses.RoomRoot> GetRoomsForPlayer(ulong playerId)
{
// A role of Banned is still "a role", so a room the player had been BANNED from
// was listed among their own rooms. Creator always counts; otherwise the role has
// to be one that actually makes them part of the room.
var rooms = col.FindAll()
.Where(r => r != null
&& (r.CreatorAccountId == playerId
|| PlayerIsMemberOf(r, playerId)))
.ToList();
return AttachSubRooms(rooms);
}
public static List<RoomDBClasses.RoomRoot> GetRoomsByCreator(ulong playerId)
{
var rooms = col.Find(x => x.CreatorAccountId == playerId).ToList();
return AttachSubRooms(rooms);
}
/// <summary>
/// A single PAGE of a creator's rooms, ordered newest-first, attaching subrooms ONLY to the
/// page returned. GetRoomsByCreator loads EVERY room the creator owns and runs a subroom query
/// per room - fine for a normal player, but @Importer owns thousands, so a profile view was
/// loading everything at once and overloading the server. This caps it at the DB level.
/// </summary>
public static List<RoomDBClasses.RoomRoot> GetRoomsByCreatorPaged(ulong playerId, int skip, int take, bool attachSubrooms = true)
{
if (skip < 0) skip = 0;
if (take <= 0) take = 200;
var page = col.Query()
.Where(x => x.CreatorAccountId == playerId)
.OrderByDescending(x => x.RoomId)
.Skip(skip)
.Limit(take)
.ToList();
return attachSubrooms ? AttachSubRooms(page) : page;
}
/// <summary>Count of a creator's rooms - cheap (no room objects loaded).</summary>
public static int CountRoomsByCreator(ulong playerId) => col.Count(x => x.CreatorAccountId == playerId);
/// <summary>
/// Rooms the player has a role on (Creator/CoOwner/etc.) but did not create -
/// what the client's "contributed to" listing expects.
/// </summary>
/// <summary>
/// Every room this player has a hand in - the ones they CREATED plus the ones they hold a
/// role on. This is what the profile's room list is drawn from.
///
/// It used to exclude the player's own rooms outright (`CreatorAccountId != playerId`),
/// which is why a profile read "32 Rooms" above a list holding exactly one: the single
/// room they had been invited to co-own. A creator IS a contributor to their own room -
/// the owner is simply the first role on it - so they belong in this list ahead of
/// everyone else's. (The Room Showcase is a separate, deliberately-picked handful; it is
/// NOT a place to dump these. See RoomController.RoomShowcaseForPlayer.)
///
/// Own rooms come first, newest first; then the rooms they were invited to. Capped, because
/// this attaches subrooms to every room it returns.
/// </summary>
public static List<RoomDBClasses.RoomRoot> GetRoomsContributedTo(ulong playerId)
{
var all = col.FindAll().ToList();
var mine = all.Where(r => r.CreatorAccountId == playerId)
.OrderByDescending(r => r.CreatedAt);
var invited = all.Where(r => r.CreatorAccountId != playerId
&& r.Roles != null
&& r.Roles.Any(x => (ulong)x.AccountId == playerId));
return AttachSubRooms(mine.Concat(invited)
.Take(Campus_live_rewrite.api.server.ConfigCampus.ProfileRoomsCap)
.ToList());
}
public static List<RoomDBClasses.RoomRoot> Search(string p0, bool isBase = false)
{
if (string.IsNullOrWhiteSpace(p0))
return new List<RoomDBClasses.RoomRoot>();
var terms = p0.ToLowerInvariant()
.Split(new char[] { ' ', '+' }, StringSplitOptions.RemoveEmptyEntries);
// Start with the basic "Not a Dorm" filter
var query = col.Query().Where(r => !r.IsDorm);
// Only enforce Public accessibility if isBase is false
if (!isBase)
{
query = query.Where(r => r.Accessibility == RoomDBClasses.RoomAccessibility.Public);
}
foreach (var term in terms)
{
if (term.StartsWith("#"))
{
string tag = term.Substring(1);
// Using BsonExpression ensures LiteDB looks directly into the array
// This looks for any element in the 'Tags' array where the 'Tag' field matches
query = query.Where("$.Tags[*].Tag ANY = @0", tag);
}
else
{
string nameTerm = term;
query = query.Where(r => r.Name.ToLower().Contains(nameTerm));
}
}
return AttachSubRooms(query.ToList());
}
public static ulong GetAllRoomsCount()
{
return (ulong)col.Count();
}
/// <summary>All non-dorm rooms WITHOUT attaching subrooms - for listings (admin table) that
/// only need top-level fields. Avoids one subroom query per room (brutal at thousands of rooms).</summary>
public static List<RoomDBClasses.RoomRoot> GetAllRoomsLite()
{
return col.Query().Where(r => !r.IsDorm).ToList();
}
public static void DeleteAllRoomsFromPlayerId(ulong creatorId)
{
var roomsToDelete = col.Find(x => x.CreatorAccountId == creatorId).ToList();
foreach (var room in roomsToDelete)
{
subroom_col.DeleteMany(x => x.RoomId == room.RoomId);
col.DeleteMany(x => x.RoomId == room.RoomId);
}
Console.WriteLine($"Deleted {roomsToDelete.Count} rooms and their subrooms for creator {creatorId}.");
}
public static RoomDBClasses.RoomRoot? SetRoomName(ulong roomId, string roomName)
{
var room = col.FindOne(x => x.RoomId == roomId);
var existingRoom = col.FindOne(x => x.Name == roomName && x.RoomId != roomId);
if (existingRoom != null)
{
return null;
}
if (room != null)
{
try
{
room.Name = roomName;
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
public static RoomDBClasses.RoomRoot? es()
{
var room = col.FindOne(x => x.RoomId == 53);
if (room != null)
{
try
{
room.CurrentSnapshotId = "98cfdcad-b680-4b33-e94c-08dd76d7c12b";
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
public static RoomDBClasses.RoomRoot? SetRoomDescription(ulong roomId, string NewDescription)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
try
{
room.Description = NewDescription;
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
public static RoomDBClasses.RoomRoot? SetRoomImageName(ulong roomId, string imageName)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
try
{
room.ImageName = imageName;
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
public static RoomDBClasses.RoomRoot? SetRoomAccessibility(ulong roomId, RoomDBClasses.RoomAccessibility roomAccessibility)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
try
{
room.Accessibility = roomAccessibility;
// The effective accessibility is read from the Home subroom (PrimaryAccessibility),
// so the subrooms must follow or "publish" changed nothing anyone could see.
foreach (var sr in subroom_col.Find(x => x.RoomId == roomId).ToList())
{
sr.Accessibility = roomAccessibility;
subroom_col.Update(sr);
}
bool published = roomAccessibility == RoomDBClasses.RoomAccessibility.Public
|| roomAccessibility == RoomDBClasses.RoomAccessibility.Unlisted;
room.PublishState = published ? 1 : 0;
if (published && (room.PublishedAt == default || room.PublishedAt < room.CreatedAt)) room.PublishedAt = DateTime.UtcNow;
col.Update(room);
Console.WriteLine($"[rooms] room {roomId} ({room.Name}) accessibility -> {roomAccessibility} ({(published ? "published" : "unpublished")})");
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
public static RoomDBClasses.RoomRoot? SetRoomCloning(ulong roomId, bool cloningEnabled)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
try
{
room.CloningAllowed = cloningEnabled;
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
// supportsScreens=True&supportsWalkVR=False&supportsTeleportVR=True&supportsJuniors=True
public static RoomDBClasses.RoomRoot? SetRoomRestrictions(ulong roomId, bool supportsScreens, bool supportsWalkVR, bool supportsTeleportVR, bool supportsJuniors)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
try
{
room.SupportsScreens = supportsScreens;
room.SupportsWalkVR = supportsWalkVR;
room.SupportsTeleportVR = supportsTeleportVR;
room.SupportsJuniors = supportsJuniors;
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
public static bool UserCanEditRoom(ulong roomId, ulong accountId)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null)
return false;
if (room.CreatorAccountId == accountId)
return true;
return IsCoOwner(room, accountId);
}
public static RoomDBClasses.RoomRoot? SetRoomLoadscreen(ulong roomid, string? imageName, string? title, string? subtitle)
{
var room = col.FindOne(x => x.RoomId == roomid);
if (room == null)
return null;
room.LoadScreens = new List<LoadScreen>
{
new LoadScreen
{
ImageName = imageName ?? "",
Title = title ?? "",
Subtitle = subtitle ?? ""
}
};
col.Update(room);
return room;
}
public static RoomDBClasses.RoomRoot SetRoomWarningMask(ulong roomId, WarningMaskType warningMask)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
try
{
room.WarningMask = warningMask;
col.Update(room);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
return room;
}
/// <summary>
/// The reference's room tag edit (rooms.app.ts / applyRoomTagEdit). Two request shapes reach
/// PUT /rooms/{id}/tags and the FIELDS say which:
/// - a lone `tag=<name>`: TOGGLE that owner tag (the five "main" tags pvp/quest/game/
/// hangout/art act as radio buttons);
/// - anything else is the whole-state save from room settings
/// (`autoTag=limitsv2&tag=roleplay&tag=social&primaryGenreTag=roleplay`): `tag` is the
/// COMPLETE set of owner (Type 0) tags, `autoTag` ADDS derived Type 1 tags (limitsv2,
/// beta - the ones Beta rooms and Limits V2 need to be recognised), `primaryGenreTag`
/// flags the genre.
/// The previous implementation toggled every posted tag, so a settings save with three
/// tags flipped them, and `autoTag` wiped the list to just "beta" as an owner tag.
/// </summary>
public static RoomDBClasses.RoomRoot? ApplyRoomTagEdit(ulong roomId, string? toggle, List<string>? tags, List<string> autoTags, string? primaryGenre)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return null;
var next = (room.Tags ?? new List<TagClass>()).Where(t => t != null && !string.IsNullOrWhiteSpace(t.Tag)).ToList();
string Key(TagClass t) => t.Tag.Trim().ToLowerInvariant();
var mainTags = new HashSet<string> { "pvp", "quest", "game", "hangout", "art" };
if (tags != null)
{
var posted = tags.Select(t => t.Trim().ToLowerInvariant()).Where(t => t.Length > 0).Distinct().ToList();
var kept = next.Where(t => t.Type != 0 && !posted.Contains(Key(t))).ToList();
foreach (var tag in posted)
kept.Add(next.FirstOrDefault(t => Key(t) == tag) ?? new TagClass { Tag = tag, Type = 0 });
next = kept;
}
else if (!string.IsNullOrWhiteSpace(toggle))
{
string lower = toggle.Trim().ToLowerInvariant();
var existing = next.FirstOrDefault(t => Key(t) == lower);
if (existing != null) next.Remove(existing);
else
{
if (mainTags.Contains(lower)) next = next.Where(t => !mainTags.Contains(Key(t))).ToList();
next.Add(new TagClass { Tag = lower, Type = 0 });
}
}
foreach (var auto in autoTags)
{
string lower = auto.Trim().ToLowerInvariant();
if (lower.Length == 0) continue;
var existing = next.FirstOrDefault(t => Key(t) == lower);
if (existing != null) existing.Type = 1;
else next.Add(new TagClass { Tag = lower, Type = 1 });
}
if (!string.IsNullOrWhiteSpace(primaryGenre))
{
string lower = primaryGenre.Trim().ToLowerInvariant();
foreach (var t in next) t.IsPrimaryGenre = null;
var chosen = next.FirstOrDefault(t => Key(t) == lower);
if (chosen != null) chosen.IsPrimaryGenre = true;
else next.Add(new TagClass { Tag = lower, Type = 0, IsPrimaryGenre = true });
}
room.Tags = next;
col.Update(room);
return room;
}
public static RoomDBClasses.RoomRoot? SetRoomTags(List<string> tags, ulong roomId, int type = 0)
{
col.EnsureIndex(x => x.RoomId);
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null)
return null;
if (tags == null || tags.Count == 0)
return room;
// Normalize input tags: trim, lowercase, remove empty, distinct
var newTags = tags
.Select(t => t.Trim().ToLowerInvariant())
.Where(t => !string.IsNullOrWhiteSpace(t))
.Distinct()
.ToList();
if (room.Tags == null)
room.Tags = new List<TagClass>();
foreach (var newTag in newTags)
{
var existingTag = room.Tags.FirstOrDefault(t =>
t.Tag.Equals(newTag, StringComparison.OrdinalIgnoreCase));
if (existingTag != null)
{
// TagClass exists -> remove it
room.Tags.Remove(existingTag);
}
else
{
// TagClass does not exist -> add it
room.Tags.Add(new TagClass
{
Tag = newTag,
Type = type
});
}
}
col.Update(room);
return room;
}
public static bool DoesRoomExist(ulong roomId)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
return true;
}
else
{
return false;
}
}
public static ulong? CloneRoom(ulong roomId, string RoomName, ulong playerId)
{
// ✅ Deep clone to detach from DB references
RoomDBClasses.RoomRoot? original = GetRoom(roomId);
RoomDBClasses.RoomRoot? room_data = JsonConvert.DeserializeObject<RoomDBClasses.RoomRoot>(JsonConvert.SerializeObject(original));
if (room_data?.Name == "MakerRoom")
goto clone_room_data;
if (room_data?.Name == "TheBackDoor")
return null;
if (room_data?.Name == "DormRoom")
return null;
if (room_data.CloningAllowed &&
(room_data.Accessibility == RoomDBClasses.RoomAccessibility.Public ||
room_data.Accessibility == RoomDBClasses.RoomAccessibility.Unlisted))
goto clone_room_data;
if (RoomName == "MakerRoom")
goto clone_room_data;
if (!room_data.CloningAllowed && room_data.CreatorAccountId != playerId && !room_data.IsDorm)
return null;
var existingRoom = col.FindOne(x => x.Name == room_data.Name && x.RoomId != room_data.RoomId);
if (existingRoom != null)
return null;
clone_room_data:
room_data.RoomId = 0;
room_data.CreatorAccountId = playerId;
room_data.CloningAllowed = false;
room_data.Name = RoomName;
room_data.ImageName = "DefaultRoomImage.jpg";
room_data.CreatedAt = DateTime.Now;
room_data.Accessibility = RoomDBClasses.RoomAccessibility.Private;
room_data.PublishState = 0;
// A player's room is not one of Rec Room's own. The template's flags came across on the
// copy: IsDeveloperOwned=true is what kept every player-made room out of the profile's
// created-rooms list (the client hides developer-owned rooms there), and the template's
// RankedEntityId meant dozens of rooms shared one ranking key.
room_data.IsDeveloperOwned = false;
room_data.IsRecRoomApproved = false;
room_data.PublishedAt = default;
room_data.RankedEntityId = "";
// The subrooms carry the accessibility the owner actually sets (PrimaryAccessibility
// reads the Home subroom), and a template's are Public - so a fresh room was "published"
// the moment it was created and every profile listed it. A new room is a private draft
// until its owner publishes it from room settings.
if (room_data.SubRooms != null)
foreach (var sr in room_data.SubRooms) sr.Accessibility = RoomDBClasses.RoomAccessibility.Private;
room_data.IsRRO = false;
room_data.IsDorm = false;
room_data.Stats.CheerCount = 0;
room_data.Stats.FavoriteCount = 0;
room_data.Stats.VisitorCount = 0;
room_data.Stats.VisitCount = 0;
room_data.Tags = new List<TagClass>();
room_data.Roles = new List<RoleClass>
{
new RoleClass
{
AccountId = (int)playerId,
InvitedRole = 0,
Role = RoomDBClasses.Role_data.Creator
}
};
ulong NewRoomId = RoomDB.AddRoom(room_data);
Console.WriteLine($"[clone_room] New room ID returned from add_room: {NewRoomId}");
if (NewRoomId != 0)
{
var made = col.FindOne(x => x.RoomId == NewRoomId);
if (made != null && made.RankedEntityId != NewRoomId.ToString())
{
made.RankedEntityId = NewRoomId.ToString();
col.Update(made);
}
}
return NewRoomId;
}
public static object? GetRoomSaveById(
ulong roomId,
ulong subroomId,
ulong subRoomDataSaveId, int target, int version)
{
var subroom = subroom_col.FindOne(x =>
x.RoomId == roomId &&
x.SubRoomId == subroomId);
if (subroom == null)
return null;
var save =
(subroom.CurrentSave != null &&
subroom.CurrentSave.SubRoomDataSaveId == subRoomDataSaveId)
? subroom.CurrentSave
: subroom.AllSaves?
.FirstOrDefault(x => x.SubRoomDataSaveId == subRoomDataSaveId);
if (save == null)
return null;
return MapSave(save, subroomId, target, version);
}
private class UnityAssetInfo
{
public string UnityAssetId { get; set; }
public int Target { get; set; }
public int Version { get; set; }
public string Filename { get; set; }
public string Hash { get; set; }
}
private static object MapSave(RoomDBClasses.CurrentSave save, ulong subroomid, int target, int version)
{
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "BakedBulk", $"{save.UnityAssetId}_assets.json");
UnityAssetInfo matchingAsset = null;
if (!string.IsNullOrEmpty(save.UnityAssetId) && System.IO.File.Exists(path))
{
var unityAssets = JsonConvert.DeserializeObject<List<UnityAssetInfo>>(System.IO.File.ReadAllText(path));
matchingAsset = unityAssets.FirstOrDefault(u => u.Target == target && u.Version == version);
}
var result = new Dictionary<string, object>
{
["UnitySubAssets"] = new List<object>(),
["ReferencedUnityAssets"] = new List<object>(),
["SubRoomDataSaveId"] = save.SubRoomDataSaveId,
["SubRoomId"] = subroomid,
["DataBlob"] = save.DataBlob,
["ReferencedUnityAssetIds"] = save.ReferencedUnityAssetIds ?? new List<string>(),
["PersistenceVersion"] = save.PersistenceVersion,
["OMVersion"] = save.OMVersion,
["UgcSubVersion"] = save.UgcSubVersion,
["SavedByAccountId"] = save.SavedByAccountId,
["SavedOnPlatform"] = save.SavedOnPlatform,
["SavedOnDeviceClass"] = save.SavedOnDeviceClass,
["Description"] = save.Description,
["Tags"] = new List<object>(),
["ModerationState"] = save.ModerationState,
["CreatedAt"] = save.CreatedAt
};
// Only include UnityAssetId if it exists
if (!string.IsNullOrEmpty(save.UnityAssetId))
{
result["UnityAssetId"] = save.UnityAssetId;
}
// Only include UnityAsset if matching asset exists
if (matchingAsset != null)
{
result["UnityAsset"] = matchingAsset.Filename;
}
return result;
}
/// <summary>
/// Sets a subroom's UnitySceneId - the scene the CLIENT loads for that subroom.
///
/// This is what decides which map you actually walk into, and it is not the same
/// thing as the .room save blob:
///
/// - a Rec Room Original's geometry ships INSIDE the client and is selected by
/// this id. Such rooms have no save at all and never needed one.
/// - a player-built room loads a template scene by this id, then applies its
/// save blob on top.
///
/// Imported room data carries the id the ORIGINAL service used, which does not
/// necessarily mean the same scene in this client build. Rooms whose id resolves to
/// a template the client happens to have - a75f7547-79eb-47c6-8986-6767abcb4f92 is
/// "Basement" here - load that blank template instead of their real map, which looks
/// exactly like a missing save but has nothing to do with blobs.
///
/// Changing this only rewrites one field; nothing is downloaded and no file moves, so
/// a wrong value costs nothing but another attempt.
/// </summary>
public static RoomDBClasses.SubRoom? GetSubRoom(ulong roomId, ulong subRoomId) =>
subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
public static bool SetSubRoomPermissions(ulong roomId, ulong subRoomId, List<Campus_live_rewrite.api.server.RoomPermissions.Entry> table)
{
var sub = GetSubRoom(roomId, subRoomId);
if (sub == null) return false;
sub.Permissions = table;
subroom_col.Update(sub);
return true;
}
public static bool SetSubRoomScene(ulong roomId, ulong subRoomId, string sceneId)
{
var sub = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (sub == null) return false;
string before = sub.UnitySceneId ?? "(none)";
sub.UnitySceneId = sceneId;
subroom_col.Update(sub);
Console.WriteLine($"[rooms] room {roomId} subroom {subRoomId} scene {before} -> {sceneId}");
return true;
}
public static RoomDBClasses.RoomRoot? SetRoomSubroomRoomFile(
ulong roomId,
ulong subRoomId,
string roomFile,
string roomDesc,
ulong playerId,
Platforms currentplatform,
int persistenceVersion = 0,
int omVersion = 0,
int ugcSubVersion = 0,
string? roomLevelBlob = null)
=> SetRoomSubroomRoomFile(roomId, subRoomId, roomFile, roomDesc, playerId, currentplatform, persistenceVersion, omVersion, ugcSubVersion, roomLevelBlob, true, out _);
/// <summary>
/// Record a save. PUBLISHED and UNPUBLISHED saves are kept apart (reference rooms
/// POST /subrooms/{id}/data): a save lands in the subroom's history and, unless the
/// client asked to AutoPublish, is only STAGED (StagedSubRoomDataSaveId) - players keep
/// loading the last published CurrentSave until the owner publishes it
/// (publish_save). Dorms, and a subroom that has never been published, publish at once.
/// </summary>
public static RoomDBClasses.RoomRoot? SetRoomSubroomRoomFile(
ulong roomId,
ulong subRoomId,
string roomFile,
string roomDesc,
ulong playerId,
Platforms currentplatform,
int persistenceVersion,
int omVersion,
int ugcSubVersion,
string? roomLevelBlob,
bool autoPublish,
out CurrentSave? created)
{
created = null;
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null)
return null;
var subroom = subroom_col.FindOne(x =>
x.RoomId == roomId &&
x.SubRoomId == subRoomId);
if (subroom == null)
return room;
subroom.AllSaves ??= new List<CurrentSave>();
// The history holds EVERY save, the published one included (older rows kept the
// published save only on CurrentSave - fold it in once).
if (subroom.CurrentSave != null && !subroom.AllSaves.Any(s => s.SubRoomDataSaveId == subroom.CurrentSave.SubRoomDataSaveId))
subroom.AllSaves.Add(subroom.CurrentSave);
long maxId = subroom.AllSaves.Count > 0 ? subroom.AllSaves.Max(s => (long)s.SubRoomDataSaveId) : 0;
if (subroom.CurrentSave != null) maxId = Math.Max(maxId, (long)subroom.CurrentSave.SubRoomDataSaveId);
// 💡 Create fresh save object
var newSave = new CurrentSave
{
SubRoomDataSaveId = (ulong)(maxId + 1),
SubRoomId = subroom.SubRoomId,
DataBlob = roomFile,
Description = roomDesc ?? "",
SavedByAccountId = playerId,
SavedOnPlatform = currentplatform,
SavedOnDeviceClass = DeviceClasses.Screen,
CreatedAt = DateTime.UtcNow,
// These arrive on every save request and used to be dropped, so every
// save was stored as 0/0/0. The client compares the versions it wrote
// against what the room reports back; 0 never matched, so the room looked
// modified the moment it loaded and leaving always prompted
// "do you want to save?" even with no changes made.
PersistenceVersion = persistenceVersion,
OMVersion = omVersion,
UgcSubVersion = ugcSubVersion
};
subroom.AllSaves.Add(newSave);
created = newSave;
bool publishNow = autoPublish || room.IsDorm || subroom.CurrentSave == null;
if (publishNow)
{
subroom.CurrentSave = newSave;
subroom.StagedSubRoomDataSaveId = null;
}
else
{
subroom.StagedSubRoomDataSaveId = (long)newSave.SubRoomDataSaveId;
}
subroom.CreatorAccountId ??= (long)playerId; // client NREs on a null creator
subroom_col.Update(subroom);
Console.WriteLine($"[rooms] save {newSave.SubRoomDataSaveId} on room {roomId} subroom {subRoomId} by {playerId}: {(publishNow ? "PUBLISHED" : "staged (unpublished)")}");
// Mirror onto the embedded copy the room document carries, so a room read that does
// not re-attach subrooms still shows the staged / published state.
var emb = room.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId);
if (emb != null)
{
emb.AllSaves = subroom.AllSaves;
emb.CurrentSave = subroom.CurrentSave;
emb.StagedSubRoomDataSaveId = subroom.StagedSubRoomDataSaveId;
col.Update(room);
}
// The save request also carries a ROOM-level blob (RoomData.Filename), which
// was never read - so RoomRoot.DataBlob kept pointing at whatever stale .meta
// it shipped with and never picked up the freshly uploaded one.
bool roomChanged = false;
if (!string.IsNullOrWhiteSpace(roomLevelBlob) && room.DataBlob != roomLevelBlob)
{
room.DataBlob = roomLevelBlob;
roomChanged = true;
}
if (ugcSubVersion > 0 && room.UgcSubVersion != ugcSubVersion)
{
room.UgcSubVersion = ugcSubVersion;
roomChanged = true;
}
if (roomChanged)
col.Update(room);
return room;
}
public static RoomDBClasses.RoomRoot? d(
ulong roomId,
ulong subRoomId,
string roomFile)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null)
return null;
var subroom = subroom_col.FindOne(x =>
x.RoomId == roomId &&
x.SubRoomId == subRoomId);
subroom.CurrentSave.DataBlob = "80c450a999404dbcb792d6d3d2f62a56.room";
subroom_col.Update(subroom);
return room;
}
public static RoomDBClasses.RoomRoot? CreateSubroomForRoom(ulong roomId, string newSubroomName)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null)
{
Console.WriteLine("Room not found.");
return null;
}
var roomSubrooms = subroom_col.Find(x => x.RoomId == roomId).ToList();
ulong nextSubRoomId = roomSubrooms.Count == 0 ? 1 : roomSubrooms.Max(sr => sr.SubRoomId) + 1;
var lastSubroom = roomSubrooms
.OrderByDescending(sr => sr.SubRoomId)
.FirstOrDefault();
var newSubroom = new SubRoom
{
RoomId = roomId,
Name = newSubroomName,
SubRoomId = nextSubRoomId,
AllSaves = new List<CurrentSave>(),
CurrentSave = null,
Accessibility = RoomDBClasses.RoomAccessibility.Public,
UnitySceneId = lastSubroom.UnitySceneId,
MaxPlayers = lastSubroom.MaxPlayers,
};
subroom_col.Insert(newSubroom);
roomSubrooms.Add(newSubroom);
col.Update(room);
return room;
}
/// <summary>A copy of a subroom under a fresh SubRoomId: same scene, settings and saves.</summary>
public static SubRoom? CloneSubroom(ulong roomId, ulong subRoomId)
{
var room = col.FindOne(x => x.RoomId == roomId);
var src = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (room == null || src == null) return null;
var all = subroom_col.Find(x => x.RoomId == roomId).ToList();
ulong nextId = all.Count == 0 ? 1 : all.Max(sr => sr.SubRoomId) + 1;
var copy = new SubRoom
{
RoomId = roomId,
SubRoomId = nextId,
Name = (src.Name ?? "Subroom") + " (copy)",
Accessibility = src.Accessibility,
CreatorAccountId = src.CreatorAccountId,
IsSandbox = src.IsSandbox,
MaxPlayers = src.MaxPlayers,
ShouldAutoStageSaves = src.ShouldAutoStageSaves,
UnitySceneId = src.UnitySceneId,
CurrentSave = src.CurrentSave,
AllSaves = new List<CurrentSave>(src.AllSaves ?? new List<CurrentSave>())
};
subroom_col.Insert(copy);
room.SubRooms ??= new List<SubRoom>();
room.SubRooms.Add(copy);
col.Update(room);
return copy;
}
/// <summary>Put the room's embedded subroom list in the given order (ids not named keep their relative order at the end).</summary>
public static void ReorderSubrooms(ulong roomId, List<ulong> order)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room?.SubRooms == null) return;
var rank = order.Select((id, i) => (id, i)).ToDictionary(t => t.id, t => t.i);
room.SubRooms = room.SubRooms.OrderBy(s => rank.TryGetValue(s.SubRoomId, out var r) ? r : int.MaxValue).ToList();
col.Update(room);
}
/// <summary>Make one of a subroom's saved versions the live one (restore). False when unknown.</summary>
public static bool PublishSubroomSave(ulong roomId, ulong subRoomId, ulong saveId)
{
var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (subroom == null) return false;
var save = (subroom.AllSaves ?? new List<CurrentSave>()).FirstOrDefault(s => s.SubRoomDataSaveId == saveId)
?? (subroom.CurrentSave?.SubRoomDataSaveId == saveId ? subroom.CurrentSave : null);
if (save == null) return false;
subroom.CurrentSave = save;
if (subroom.StagedSubRoomDataSaveId == (long)saveId) subroom.StagedSubRoomDataSaveId = null;
subroom_col.Update(subroom);
var room = col.FindOne(x => x.RoomId == roomId);
var emb = room?.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId);
if (room != null && emb != null) { emb.CurrentSave = save; emb.StagedSubRoomDataSaveId = subroom.StagedSubRoomDataSaveId; col.Update(room); }
return true;
}
public static bool DeleteSubroom(ulong roomId, ulong subRoomId)
{
var subroom = subroom_col.FindOne(x =>
x.RoomId == roomId &&
x.SubRoomId == subRoomId);
if (subroom == null)
return false;
subroom_col.Delete(subroom.Id);
return true;
}
public static bool ModifySubroom(
ulong roomId,
ulong subRoomId,
string name,
RoomDBClasses.RoomAccessibility accessibility,
int maxPlayers)
{
var subroom = subroom_col.FindOne(x =>
x.RoomId == roomId &&
x.SubRoomId == subRoomId);
if (subroom == null)
return false;
subroom.Name = name;
subroom.Accessibility = accessibility;
subroom.MaxPlayers = maxPlayers;
subroom_col.Update(subroom);
// Mirror the change onto the room's embedded subroom copy AND the room-level
// Accessibility (what Search / hot / visibility read), so setting a Rooms 2.0 room to
// Public in-game actually makes it appear in listings/search without a stale field.
var room = col.FindOne(x => x.RoomId == roomId);
if (room != null)
{
var emb = room.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId);
if (emb != null) { emb.Name = name; emb.Accessibility = accessibility; emb.MaxPlayers = maxPlayers; }
// Published rooms stay Public no matter what the subroom is set to; unpublished
// rooms mirror the subroom.
room.Accessibility = EffectiveRoomAccessibility(room);
col.Update(room);
}
return true;
}
public static void DeleteRoom(ulong roomId)
{
subroom_col.DeleteMany(x => x.RoomId == roomId);
col.DeleteMany(x => x.RoomId == roomId);
}
public static RoomDBClasses.SubroomSaveResults? GetRoomSaves(ulong roomId, ulong subroomId, int skip = 0, int take = 20)
{
var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subroomId);
if (subroom == null)
return null;
var saves = new List<RoomDBClasses.CurrentSave>();
if (subroom.CurrentSave != null)
saves.Add(subroom.CurrentSave);
if (subroom.AllSaves != null && subroom.AllSaves.Count > 0)
saves.AddRange(subroom.AllSaves);
saves = saves
.GroupBy(x => x.SubRoomDataSaveId)
.Select(g => g.First())
.OrderByDescending(x => x.CreatedAt)
.ToList();
var paged = saves
.Skip(skip)
.Take(take)
.ToList();
return new SubroomSaveResults
{
Results = paged,
TotalCount = saves.Count
};
}
/// <summary>A room's role list (by id). Empty if the room or its Roles are unset.</summary>
public static List<RoomDBClasses.RoleClass> GetRoomRoles(ulong roomId)
{
var room = col.FindOne(x => x.RoomId == roomId);
return room?.Roles ?? new List<RoomDBClasses.RoleClass>();
}
/// <summary>Can this player manage a room's roles? Creator, a co-owner, or a dev/admin.</summary>
public static bool PlayerCanManageRoles(ulong roomId, ulong callerId)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return false;
if (room.CreatorAccountId == callerId) return true;
if (PlayerDB.CheckDevFlag(callerId)) return true;
return IsCoOwner(room, callerId);
}
/// <summary>Co-owner or temporary co-owner (both edit like the creator does).</summary>
public static bool IsCoOwner(RoomDBClasses.RoomRoot room, ulong accountId) =>
room.Roles?.Any(r => (ulong)r.AccountId == accountId
&& (r.Role == RoomDBClasses.Role_data.Creator
|| r.Role == RoomDBClasses.Role_data.CoOwner
|| r.Role == RoomDBClasses.Role_data.TemporaryCoOwner)) ?? false;
/// <summary>
/// Does the player hold at least <paramref name="role"/> (a Role_data value: 10 Host,
/// 20 Moderator, 30 CoOwner) in the room? The creator always passes; a banned entry never
/// does (reference: verifyRole).
/// </summary>
public static bool PlayerHasRoleAtLeast(ulong roomId, ulong accountId, int role)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return false;
if (room.CreatorAccountId == accountId) return true;
var mine = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == accountId);
if (mine == null || mine.Role == RoomDBClasses.Role_data.Banned || mine.Role == RoomDBClasses.Role_data.None) return false;
return (int)mine.Role >= role;
}
/// <summary>Add/update/remove a player's role on a room (by id). None removes it. Returns success.</summary>
/// <summary>Creator, co-owner (temporary too) or contributor: may save the room's contents.</summary>
public static bool CanEditContent(RoomDBClasses.RoomRoot? room, ulong accountId)
{
if (room == null) return false;
if (room.CreatorAccountId == accountId || IsCoOwner(room, accountId)) return true;
return room.Roles?.Any(r => (ulong)r.AccountId == accountId && r.Role == RoomDBClasses.Role_data.Contributor) ?? false;
}
/// <summary>
/// Record a PENDING role invite: InvitedRole = the offered role, Role untouched. Nothing is
/// granted until the invitee accepts (AcceptRoleInvite). A non-zero InvitedRole is what the
/// client renders as "invited, not yet accepted".
/// </summary>
public static bool InviteRoomRole(ulong roomId, long accountId, RoomDBClasses.Role_data role, long invitedBy)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return false;
room.Roles ??= new List<RoomDBClasses.RoleClass>();
var existing = room.Roles.FirstOrDefault(r => r.AccountId == accountId);
if (existing != null) { existing.InvitedRole = (int)role; existing.LastChangedByAccountId = invitedBy; }
else room.Roles.Add(new RoomDBClasses.RoleClass { AccountId = accountId, Role = RoomDBClasses.Role_data.None, InvitedRole = (int)role, LastChangedByAccountId = invitedBy });
col.Update(room);
return true;
}
/// <summary>The role this player has been invited to on the room, or None.</summary>
public static RoomDBClasses.Role_data PendingRoleInvite(ulong roomId, long accountId)
{
var room = col.FindOne(x => x.RoomId == roomId);
var e = room?.Roles?.FirstOrDefault(r => r.AccountId == accountId);
if (e == null || e.InvitedRole == 0) return RoomDBClasses.Role_data.None;
return Enum.IsDefined(typeof(RoomDBClasses.Role_data), (byte)e.InvitedRole) ? (RoomDBClasses.Role_data)(byte)e.InvitedRole : RoomDBClasses.Role_data.None;
}
/// <summary>Accept (grant the invited role) or decline (drop the invite) a pending invite.</summary>
public static bool ResolveRoleInvite(ulong roomId, long accountId, bool accept)
{
var room = col.FindOne(x => x.RoomId == roomId);
var e = room?.Roles?.FirstOrDefault(r => r.AccountId == accountId);
if (room == null || e == null || e.InvitedRole == 0) return false;
if (accept)
{
e.Role = Enum.IsDefined(typeof(RoomDBClasses.Role_data), (byte)e.InvitedRole) ? (RoomDBClasses.Role_data)(byte)e.InvitedRole : RoomDBClasses.Role_data.None;
e.InvitedRole = 0;
if (e.Role == RoomDBClasses.Role_data.None) room.Roles!.Remove(e);
}
else
{
e.InvitedRole = 0;
if (e.Role == RoomDBClasses.Role_data.None) room.Roles!.Remove(e);
}
col.Update(room);
return true;
}
public static bool SetRoomRole(ulong roomId, long accountId, RoomDBClasses.Role_data role, long changedBy)
{
var room = col.FindOne(x => x.RoomId == roomId);
if (room == null) return false;
room.Roles ??= new List<RoomDBClasses.RoleClass>();
var existing = room.Roles.FirstOrDefault(r => r.AccountId == accountId);
if (role == RoomDBClasses.Role_data.None)
{
if (existing != null) room.Roles.Remove(existing);
}
// Granted directly (no pending-invite step here), so InvitedRole stays 0: a non-zero
// InvitedRole is what the client reads as "invited, not yet accepted".
else if (existing != null)
{
existing.Role = role; existing.InvitedRole = 0; existing.LastChangedByAccountId = changedBy;
}
else
{
room.Roles.Add(new RoomDBClasses.RoleClass { AccountId = accountId, Role = role, InvitedRole = 0, LastChangedByAccountId = changedBy });
}
col.Update(room);
return true;
}
public static bool AddRoleToRoom(string name, int accountId, RoomDBClasses.Role_data roleData, int invitedRole = 0)
{
var room = col.FindOne(x => x.Name == name);
if (room == null)
return false;
room.Roles ??= new List<RoleClass>();
room.Roles.Add(new RoleClass
{
AccountId = accountId,
Role = roleData,
InvitedRole = invitedRole
});
col.Update(room);
return true;
}
public static bool RemoveRoleFromRoom(string name, int accountId)
{
var room = col.FindOne(x => x.Name == name);
if (room == null)
return false;
if (room.Roles == null || room.Roles.Count == 0)
return false;
room.Roles.RemoveAll(r => r.AccountId == accountId);
col.Update(room);
return true;
}
public static RoomDBClasses.RoomRoot SetRoomCreatorId(ulong playerid, string roomname)
{
col.EnsureIndex(x => x.Name);
var room = col.FindOne(x => x.Name == roomname);
if (room != null)
{
room.CreatorAccountId = playerid;
var ownerRole = room.Roles.FirstOrDefault(r => r.Role == Role_data.Creator);
if (ownerRole != null)
{
ownerRole.AccountId = (long)playerid;
}
else
{
}
Console.WriteLine($"Setting the creator id to {playerid}...");
col.Update(room);
Console.WriteLine($"Success!");
return room;
}
return null;
}
public static void Setup()
{
col.EnsureIndex(x => x.CreatorAccountId);
col.EnsureIndex(x => x.RoomId);
col.EnsureIndex(x => x.IsDorm);
subroom_col.EnsureIndex(x => x.SubRoomId);
// Every "subrooms of room X" lookup filters on RoomId. Without this index each one was a
// full scan of the subroom table, and the hot-rooms list did one per room - 2,500 scans
// per request, 30-70 s each on the VPS.
subroom_col.EnsureIndex(x => x.RoomId);
col.EnsureIndex(x => x.Name);
col.EnsureIndex("TagIndex", "$.Tags[*].Tag");
}
}
}
@@ -0,0 +1,222 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// The room economy a creator sets up in Room Settings: room KEYS (a one-off pass sold for
/// tokens or for a room currency), room CURRENCIES (with the purchase offers that sell them
/// for tokens), and room CONSUMABLES (stackable items sold for tokens or a room currency).
/// Plus who owns what: keys per player, currency balances per player, consumable counts per
/// player. Every route that used to answer "[]" or an ack reads and writes this.
///
/// DTO shapes follow the wire-shape notes (roomkeys-v1-room, roomcurrencies-createcurrency,
/// roomcurrencies-purchaseoffersbatch, roomconsumables-by-room).
/// </summary>
public static class RoomEconDB
{
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "RoomEcon.db")));
public class RoomKey
{
[BsonId] public long RoomKeyId { get; set; }
public Guid ReplicationId { get; set; } = Guid.NewGuid();
public long RoomId { get; set; }
public ulong CreatorId { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public int Price { get; set; }
public Guid? PurchaseCurrencyId { get; set; }
public string? ImageName { get; set; }
public int Type { get; set; }
public bool Deleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class RoomCurrency
{
[BsonId] public Guid CurrencyId { get; set; } = Guid.NewGuid();
public long RoomId { get; set; }
public ulong CreatorId { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public long Limit { get; set; } = 1_000_000;
public int Shape { get; set; }
public int Color { get; set; } = -1;
public string? ImageName { get; set; }
public bool Deleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
}
public class CurrencyOffer
{
[BsonId] public Guid CurrencyPurchaseOfferId { get; set; } = Guid.NewGuid();
public Guid CurrencyId { get; set; }
public long RoomId { get; set; }
public int Order { get; set; }
public string Name { get; set; } = "";
public long CurrencyAmount { get; set; }
public long Price { get; set; }
public bool Deleted { get; set; }
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
}
public class RoomConsumable
{
[BsonId] public Guid RoomConsumableId { get; set; } = Guid.NewGuid();
public long RoomId { get; set; }
public ulong CreatorId { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public string? ImageName { get; set; }
public long Price { get; set; }
public Guid? PurchaseCurrencyId { get; set; }
public int MaximumCountPerPurchase { get; set; } = 10;
public bool Deleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
}
public class KeyOwnership
{
[BsonId] public string Key { get; set; } = ""; // "{player}|{roomKeyId}"
public ulong PlayerId { get; set; }
public long RoomKeyId { get; set; }
public long RoomId { get; set; }
public DateTime At { get; set; } = DateTime.UtcNow;
}
public class CurrencyBalance
{
[BsonId] public string Key { get; set; } = ""; // "{player}|{currencyId}"
public ulong PlayerId { get; set; }
public Guid CurrencyId { get; set; }
public long RoomId { get; set; }
public long Balance { get; set; }
}
public class ConsumableOwnership
{
[BsonId] public string Key { get; set; } = ""; // "{player}|{consumableId}"
public ulong PlayerId { get; set; }
public Guid RoomConsumableId { get; set; }
public long RoomId { get; set; }
public int Count { get; set; }
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
}
private static ILiteCollection<RoomKey> Keys() { var c = _db.Value.GetCollection<RoomKey>("keys"); c.EnsureIndex(x => x.RoomId); return c; }
private static ILiteCollection<RoomCurrency> Currencies() { var c = _db.Value.GetCollection<RoomCurrency>("currencies"); c.EnsureIndex(x => x.RoomId); return c; }
private static ILiteCollection<CurrencyOffer> Offers() { var c = _db.Value.GetCollection<CurrencyOffer>("offers"); c.EnsureIndex(x => x.CurrencyId); return c; }
private static ILiteCollection<RoomConsumable> Consumables() { var c = _db.Value.GetCollection<RoomConsumable>("consumables"); c.EnsureIndex(x => x.RoomId); return c; }
private static ILiteCollection<KeyOwnership> KeysOwned() { var c = _db.Value.GetCollection<KeyOwnership>("keys_owned"); c.EnsureIndex(x => x.PlayerId); c.EnsureIndex(x => x.RoomKeyId); return c; }
private static ILiteCollection<CurrencyBalance> Balances() { var c = _db.Value.GetCollection<CurrencyBalance>("balances"); c.EnsureIndex(x => x.PlayerId); return c; }
private static ILiteCollection<ConsumableOwnership> ConsOwned() { var c = _db.Value.GetCollection<ConsumableOwnership>("consumables_owned"); c.EnsureIndex(x => x.PlayerId); c.EnsureIndex(x => x.RoomConsumableId); return c; }
// ---- keys ----------------------------------------------------------------------------
public static RoomKey CreateKey(RoomKey k)
{
var col = Keys();
if (k.RoomKeyId == 0)
{
var last = col.Query().OrderByDescending(x => x.RoomKeyId).Limit(1).FirstOrDefault();
k.RoomKeyId = Math.Max(900_000, (last?.RoomKeyId ?? 0) + 1);
}
col.Insert(k);
return k;
}
public static RoomKey? GetKey(long id) { var k = Keys().FindById(id); return k == null || k.Deleted ? null : k; }
public static List<RoomKey> KeysForRoom(long roomId) => Keys().Query().Where(x => x.RoomId == roomId).ToList().Where(x => !x.Deleted).OrderBy(x => x.CreatedAt).ToList();
public static RoomKey? UpdateKey(long id, Action<RoomKey> edit) { var col = Keys(); var k = col.FindById(id); if (k == null) return null; edit(k); col.Update(k); return k; }
public static bool OwnsKey(ulong player, long keyId) => KeysOwned().FindById($"{player}|{keyId}") != null;
public static bool GrantKey(ulong player, RoomKey k)
{
var col = KeysOwned();
string key = $"{player}|{k.RoomKeyId}";
if (col.FindById(key) != null) return false;
col.Insert(new KeyOwnership { Key = key, PlayerId = player, RoomKeyId = k.RoomKeyId, RoomId = k.RoomId });
return true;
}
public static bool RevokeKey(ulong player, long keyId) => KeysOwned().Delete($"{player}|{keyId}");
public static List<RoomKey> KeysOwnedBy(ulong player)
{
var ids = KeysOwned().Find(x => x.PlayerId == player).Select(x => x.RoomKeyId).ToList();
return ids.Select(GetKey).Where(k => k != null).Select(k => k!).ToList();
}
public static int KeyOwnerCount(long keyId) => KeysOwned().Count(x => x.RoomKeyId == keyId);
// ---- currencies ----------------------------------------------------------------------
public static RoomCurrency CreateCurrency(RoomCurrency c) { Currencies().Insert(c); return c; }
public static RoomCurrency? GetCurrency(Guid id) { var c = Currencies().FindById(id); return c == null || c.Deleted ? null : c; }
public static List<RoomCurrency> CurrenciesForRoom(long roomId) => Currencies().Query().Where(x => x.RoomId == roomId).ToList().Where(x => !x.Deleted).OrderBy(x => x.CreatedAt).ToList();
public static RoomCurrency? UpdateCurrency(Guid id, Action<RoomCurrency> edit) { var col = Currencies(); var c = col.FindById(id); if (c == null) return null; edit(c); c.ModifiedAt = DateTime.UtcNow; col.Update(c); return c; }
public static CurrencyOffer CreateOffer(CurrencyOffer o) { Offers().Insert(o); return o; }
public static CurrencyOffer? GetOffer(Guid id) { var o = Offers().FindById(id); return o == null || o.Deleted ? null : o; }
public static List<CurrencyOffer> OffersFor(Guid currencyId) => Offers().Query().Where(x => x.CurrencyId == currencyId).ToList().Where(x => !x.Deleted).OrderBy(x => x.Order).ThenBy(x => x.Price).ToList();
public static CurrencyOffer? UpdateOffer(Guid id, Action<CurrencyOffer> edit) { var col = Offers(); var o = col.FindById(id); if (o == null) return null; edit(o); o.ModifiedAt = DateTime.UtcNow; col.Update(o); return o; }
public static long GetCurrencyBalance(ulong player, Guid currencyId) => Balances().FindById($"{player}|{currencyId}")?.Balance ?? 0;
public static List<CurrencyBalance> BalancesForRoom(ulong player, long roomId) => Balances().Find(x => x.PlayerId == player && x.RoomId == roomId).ToList();
/// <summary>Add (or take, negative) room currency. Returns the new balance, or null when it would go negative / over the limit.</summary>
public static long? AddCurrency(ulong player, RoomCurrency cur, long delta)
{
var col = Balances();
string key = $"{player}|{cur.CurrencyId}";
var b = col.FindById(key) ?? new CurrencyBalance { Key = key, PlayerId = player, CurrencyId = cur.CurrencyId, RoomId = cur.RoomId, Balance = 0 };
long next = b.Balance + delta;
if (next < 0) return null;
if (cur.Limit > 0 && next > cur.Limit) next = cur.Limit;
b.Balance = next;
col.Upsert(b);
return next;
}
// ---- consumables ---------------------------------------------------------------------
public static RoomConsumable CreateConsumable(RoomConsumable c) { Consumables().Insert(c); return c; }
public static RoomConsumable? GetConsumable(Guid id) { var c = Consumables().FindById(id); return c == null || c.Deleted ? null : c; }
public static List<RoomConsumable> ConsumablesForRoom(long roomId) => Consumables().Query().Where(x => x.RoomId == roomId).ToList().Where(x => !x.Deleted).OrderBy(x => x.CreatedAt).ToList();
public static RoomConsumable? UpdateConsumable(Guid id, Action<RoomConsumable> edit) { var col = Consumables(); var c = col.FindById(id); if (c == null) return null; edit(c); c.ModifiedAt = DateTime.UtcNow; col.Update(c); return c; }
public static int ConsumableCount(ulong player, Guid id) => ConsOwned().FindById($"{player}|{id}")?.Count ?? 0;
public static List<ConsumableOwnership> ConsumablesOwnedInRoom(ulong player, long roomId) => ConsOwned().Find(x => x.PlayerId == player && x.RoomId == roomId).Where(x => x.Count > 0).ToList();
/// <summary>Add (or consume, negative) copies. Returns the new count, or null when there were not enough.</summary>
public static int? AddConsumable(ulong player, RoomConsumable c, int delta)
{
var col = ConsOwned();
string key = $"{player}|{c.RoomConsumableId}";
var o = col.FindById(key) ?? new ConsumableOwnership { Key = key, PlayerId = player, RoomConsumableId = c.RoomConsumableId, RoomId = c.RoomId, Count = 0 };
int next = o.Count + delta;
if (next < 0) return null;
o.Count = next; o.ModifiedAt = DateTime.UtcNow;
col.Upsert(o);
return next;
}
public static int ConsumableSoldCount(Guid id) => ConsOwned().Find(x => x.RoomConsumableId == id).Sum(x => x.Count);
// ---- DTOs ----------------------------------------------------------------------------
public static object KeyDto(RoomKey k) => new
{
k.RoomKeyId, k.ReplicationId, k.RoomId, k.Name, k.Description, k.Price, k.PurchaseCurrencyId,
CreatedAt = k.CreatedAt.ToString("o"), ImageName = k.ImageName ?? "", k.Type
};
public static object CurrencyDto(RoomCurrency c) => new
{
c.CurrencyId, RoomId = (long?)c.RoomId, c.Name, c.Description, CurrencyType = 300, c.Limit, c.Shape, c.Color,
ImageName = c.ImageName ?? "", CreatedAt = c.CreatedAt.ToString("o"), ModifiedAt = c.ModifiedAt.ToString("o")
};
public static object OfferDto(CurrencyOffer o) => new
{
o.CurrencyPurchaseOfferId, o.CurrencyId, o.Order, o.Name, o.CurrencyAmount, o.Price, ModifiedAt = o.ModifiedAt.ToString("o")
};
public static object ConsumableDto(RoomConsumable c) => new
{
c.RoomConsumableId, c.RoomId, c.Name, c.Description, ImageName = c.ImageName ?? "", c.Price, c.PurchaseCurrencyId,
ModifiedAt = c.ModifiedAt.ToString("o"), c.MaximumCountPerPurchase, CreatedAt = c.CreatedAt.ToString("o")
};
}
}
@@ -0,0 +1,224 @@
using LiteDB;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.dbs
{
public class SettingsDB
{
private static readonly string _dbPath = Path.Combine(Environment.CurrentDirectory, "Data", "DBs", "Settings.db");
private static readonly LiteDatabase _db = new LiteDatabase(_dbPath);
// Merge this store's write-ahead log back into the data file.
// See DbMaintenance for why this must be called explicitly.
public static void Checkpoint() => _db.Checkpoint();
private static ILiteCollection<PlayerSetting> PlayerSettings => _db.GetCollection<PlayerSetting>("players_settings");
public static List<Setting> LoadSettings(ulong playerId)
{
return PlayerSettings.FindById(playerId)?.Settings ?? new List<Setting>();
}
public static void SaveSettings(List<Setting> settings, ulong playerId)
{
if (playerId == 0) return;
var player = new PlayerSetting
{
PlayerId = playerId,
Settings = settings
};
PlayerSettings.Upsert(player); // ✅ SAFE
}
public static void SetPlayerSetting(string key, string value, ulong playerId)
{
if (string.IsNullOrWhiteSpace(key) || playerId == 0)
return;
if (key is "SplitTestAssignedSegments" or "Growth.LastEmailPromptTime")
return;
var col = PlayerSettings;
var player = col.FindById(playerId) ?? new PlayerSetting
{
PlayerId = playerId,
Settings = CreateDefaultSettings()
};
var setting = player.Settings.FirstOrDefault(s => s.Key == key);
if (setting != null)
setting.Value = value;
else
player.Settings.Add(new Setting { Key = key, Value = value });
col.Upsert(player); // 🔥 KEY LINE
}
/// <summary>Every step of the new-user / Orientation flow, marked done.</summary>
private static readonly (string Key, string Value)[] OrientationDone =
{
("Recroom.AccountCreation.HasStarted", "true"),
("Recroom.AccountCreation.AcceptedCodeOfConduct", "true"),
("Recroom.AccountCreation.HasChosenUsername", "true"),
("Recroom.AccountCreation.HasCreatedPassword", "true"),
("Recroom.AccountCreation.HasImportedFriends", "true"),
("Recroom.AccountCreation.FinishedAvatarCustomization", "true"),
("Recroom.AccountCreation.HasFinished", "true"),
// Every bit set = every tutorial/orientation step complete.
("TUTORIAL_COMPLETE_MASK", "4294967295"),
("Recroom.OOBE", "1"),
};
/// <summary>
/// Mark this player as having finished Orientation. One upsert, not nine - SetPlayerSetting
/// reads and writes the whole settings document per call, which on a full backfill is a
/// read-modify-write per key per account.
/// </summary>
public static bool MarkOrientationComplete(ulong playerId)
{
if (playerId == 0) return false;
var col = PlayerSettings;
var player = col.FindById(playerId) ?? new PlayerSetting
{
PlayerId = playerId,
Settings = CreateDefaultSettings()
};
player.Settings ??= CreateDefaultSettings();
bool changed = false;
foreach (var (key, value) in OrientationDone)
{
var setting = player.Settings.FirstOrDefault(s => s.Key == key);
if (setting == null) { player.Settings.Add(new Setting { Key = key, Value = value }); changed = true; }
else if (setting.Value != value) { setting.Value = value; changed = true; }
}
if (changed) col.Upsert(player);
return changed;
}
/// <summary>
/// Backfill: mark Orientation complete for every account that already has a settings row.
/// Accounts with no row inherit CreateDefaultSettings(), which already says complete.
/// Returns how many rows were actually changed.
/// </summary>
public static int SkipOrientationForAllPlayers()
{
int n = 0;
foreach (var id in PlayerSettings.FindAll().Select(p => p.PlayerId).ToList())
{
try { if (MarkOrientationComplete(id)) n++; }
catch (Exception ex) { Console.WriteLine($"[orientation] {id}: skip failed - {ex.Message}"); }
}
return n;
}
public static void DeletePlayerKey(string key, ulong playerId)
{
if (string.IsNullOrWhiteSpace(key) || playerId == 0)
return;
var col = PlayerSettings;
var player = col.FindById(playerId);
if (player == null || player.Settings == null)
return;
player.Settings.RemoveAll(s => s.Key == key);
col.Upsert(player);
}
public static string? GetPlayerSettingValue(string key, ulong playerId)
{
if (string.IsNullOrWhiteSpace(key) || playerId == 0)
return null;
// ignored keys
if (key == "SplitTestAssignedSegments")
return null;
if (key == "Growth.LastEmailPromptTime")
return null;
var settings = LoadSettings(playerId);
var setting = settings.Find(s => s.Key == key);
return setting?.Value;
}
public static void SetPlayerSettingFromJson(string jsonData, ulong playerId)
{
if (string.IsNullOrWhiteSpace(jsonData) || playerId == 0)
return;
Setting? setting;
try
{
setting = JsonConvert.DeserializeObject<Setting>(jsonData);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to parse setting JSON: {ex.Message}");
return;
}
if (setting == null || string.IsNullOrEmpty(setting.Key) || setting.Key == "SplitTestAssignedSegments")
return;
SetPlayerSetting(setting.Key, setting.Value ?? "", playerId);
}
public static void DeleteSettings(ulong playerId)
{
if (playerId == 0) return;
var col = PlayerSettings;
col.DeleteMany(x => x.PlayerId == playerId);
}
public static List<Setting> CreateDefaultSettings() => new List<Setting>
{
new Setting { Key = "MOD_BLOCKED_TIME", Value = "0" },
new Setting { Key = "MOD_BLOCKED_DURATION", Value = "0" },
new Setting { Key = "PlayerSessionCount", Value = "0" },
new Setting { Key = "ShowRoomCenter", Value = "1" },
new Setting { Key = "QualitySettings", Value = "1" },
new Setting { Key = "Recroom.OOBE", Value = "1" },
new Setting { Key = "VoiceFilter", Value = "0" },
new Setting { Key = "VIGNETTED_TELEPORT_ENABLED", Value = "0" },
new Setting { Key = "CONTINUOUS_ROTATION_MODE", Value = "0" },
new Setting { Key = "ROTATION_INCREMENT", Value = "0" },
new Setting { Key = "ROTATE_IN_PLACE_ENABLED", Value = "0" },
new Setting { Key = "TeleportBuffer", Value = "0" },
new Setting { Key = "VoiceChat", Value = "1" },
new Setting { Key = "PersonalBubble", Value = "0" },
new Setting { Key = "ShowNames", Value = "1" },
new Setting { Key = "H.264 plugin", Value = "1" },
// Mark account creation as fully done so the client doesn't get stuck in first-boot
// flows that expect Steam / Meta OAuth pages we don't serve.
new Setting { Key = "Recroom.AccountCreation.HasStarted", Value = "true" },
new Setting { Key = "Recroom.AccountCreation.HasFinished", Value = "true" },
new Setting { Key = "Recroom.AccountCreation.HasChosenUsername", Value = "true" },
new Setting { Key = "Recroom.AccountCreation.HasCreatedPassword", Value = "true" },
// The client re-enters the flow on ANY step that reads false, so the three that were
// missing here have to be stated too - not left to whatever the client defaults to.
new Setting { Key = "Recroom.AccountCreation.AcceptedCodeOfConduct", Value = "true" },
new Setting { Key = "Recroom.AccountCreation.HasImportedFriends", Value = "true" },
new Setting { Key = "Recroom.AccountCreation.FinishedAvatarCustomization", Value = "true" },
// Every bit set = every tutorial/orientation step marked complete. Prevents the
// client from trying to load SocialOrientation, which currently loops → logout.
new Setting { Key = "TUTORIAL_COMPLETE_MASK", Value = "4294967295" },
new Setting { Key = "BACKPACK_FAVORITE_TOOL", Value = "-1" }
};
}
}
@@ -0,0 +1,59 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Website-only per-account preferences: the nickname a player gives a friend and which
/// friends are pinned to their home page. Neither exists in the game, so they live here
/// (Data/DBs/SitePrefs.db) instead of on the relationship row, keyed by the OWNER's account.
/// </summary>
public static class SitePrefsDB
{
private static readonly Lazy<LiteDatabase> _db = new(() =>
new LiteDatabase(Path.Combine(Environment.CurrentDirectory, "Data", "DBs", "SitePrefs.db")));
public class Prefs
{
[BsonId] public ulong AccountId { get; set; }
public Dictionary<string, string> Nicknames { get; set; } = new(); // other account id -> nickname
public List<ulong> Pinned { get; set; } = new();
}
private static ILiteCollection<Prefs> Col() => _db.Value.GetCollection<Prefs>("prefs");
public static Prefs Get(ulong accountId) => Col().FindById(accountId) ?? new Prefs { AccountId = accountId };
public static string? NicknameFor(ulong owner, ulong other) =>
Get(owner).Nicknames.TryGetValue(other.ToString(), out var n) && !string.IsNullOrWhiteSpace(n) ? n : null;
public static bool IsPinned(ulong owner, ulong other) => Get(owner).Pinned.Contains(other);
public static string? SetNickname(ulong owner, ulong other, string? nickname)
{
var p = Get(owner);
nickname = (nickname ?? "").Trim();
if (nickname.Length > 24) nickname = nickname[..24];
if (nickname.Length == 0) p.Nicknames.Remove(other.ToString()); else p.Nicknames[other.ToString()] = nickname;
Col().Upsert(p);
return nickname.Length == 0 ? null : nickname;
}
public static bool SetPinned(ulong owner, ulong other, bool on)
{
var p = Get(owner);
p.Pinned.Remove(other);
if (on) { p.Pinned.Insert(0, other); if (p.Pinned.Count > 12) p.Pinned = p.Pinned.Take(12).ToList(); }
Col().Upsert(p);
return on;
}
/// <summary>Drop a friend's nickname and pin when the friendship ends.</summary>
public static void Forget(ulong owner, ulong other)
{
var p = Col().FindById(owner);
if (p == null) return;
bool changed = p.Nicknames.Remove(other.ToString()) | p.Pinned.Remove(other);
if (changed) Col().Update(p);
}
}
}
@@ -0,0 +1,97 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Website support tickets. A signed-in player opens a ticket (from the Support page bot's
/// "talk to a real person" flow); staff (dev / mod / owner) see EVERY ticket and can reply,
/// close and reopen. Stored in its own LiteDB file; checkpointed by DbMaintenance.
/// </summary>
public static class SupportDB
{
private static readonly LiteDatabase DB =
new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/Support.db");
public static void Checkpoint() => DB.Checkpoint();
private static ILiteCollection<Ticket> Tickets => DB.GetCollection<Ticket>("tickets");
static SupportDB()
{
Tickets.EnsureIndex(x => x.AccountId);
Tickets.EnsureIndex(x => x.Status);
}
public sealed class TicketMessage
{
public ulong FromAccountId { get; set; }
public string FromName { get; set; } = "";
public bool Staff { get; set; }
public string Body { get; set; } = "";
public DateTime At { get; set; } = DateTime.UtcNow;
}
public sealed class Ticket
{
public int Id { get; set; } // LiteDB auto-increment PK
public ulong AccountId { get; set; }
public string Username { get; set; } = "";
public string Subject { get; set; } = "";
public string Status { get; set; } = "open"; // "open" | "closed"
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public List<TicketMessage> Messages { get; set; } = new();
}
public static Ticket Create(ulong accountId, string username, string subject, string body)
{
var t = new Ticket
{
AccountId = accountId,
Username = username,
Subject = string.IsNullOrWhiteSpace(subject) ? "Support request" : subject,
Status = "open",
};
t.Messages.Add(new TicketMessage { FromAccountId = accountId, FromName = username, Staff = false, Body = body });
Tickets.Insert(t); // Id auto-assigned
return t;
}
public static Ticket? Get(int id) => Tickets.FindById(id);
public static List<Ticket> ForAccount(ulong accountId) =>
Tickets.Find(x => x.AccountId == accountId).OrderByDescending(x => x.UpdatedAt).ToList();
public static List<Ticket> All(string? status)
{
var rows = status == "open" ? Tickets.Find(x => x.Status == "open")
: status == "closed" ? Tickets.Find(x => x.Status == "closed")
: Tickets.FindAll();
return rows.OrderByDescending(x => x.UpdatedAt).ToList();
}
public static bool AddMessage(int id, ulong fromId, string fromName, bool staff, string body)
{
var t = Tickets.FindById(id);
if (t == null) return false;
t.Messages.Add(new TicketMessage { FromAccountId = fromId, FromName = fromName, Staff = staff, Body = body });
t.UpdatedAt = DateTime.UtcNow;
// A reply on a closed ticket reopens it, so nobody's follow-up is lost in a closed thread.
if (t.Status == "closed") t.Status = "open";
Tickets.Update(t);
return true;
}
public static bool SetStatus(int id, string status)
{
var t = Tickets.FindById(id);
if (t == null) return false;
t.Status = status;
t.UpdatedAt = DateTime.UtcNow;
Tickets.Update(t);
return true;
}
public static int OpenCount() => Tickets.Count(x => x.Status == "open");
}
}
@@ -0,0 +1,94 @@
using LiteDB;
namespace Campus_live_rewrite.api.dbs
{
/// <summary>
/// Item wishlists (POST /api/itemWishlists/v1/wishlist/add|remove). The client's add 405'd
/// (only the reads existed); the row is recorded here so the add answers and the panel can
/// read it back. The wishlist DTO the client renders is not pinned yet, so the reads stay
/// empty until it is (see the wishlist routes) - nothing is lost, the rows are here.
/// </summary>
public static class WishlistDB
{
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "Wishlists.db")));
public class Row
{
[BsonId] public string Key { get; set; } = ""; // "{account}|{storefront}|{item}"
public ulong AccountId { get; set; }
public int StorefrontType { get; set; }
public int PurchasableItemId { get; set; }
// A UGC (custom avatar item) wish: PurchasableItemId is -1 and this is the item's GUID.
public string? CustomAvatarItemId { get; set; }
public string RawBody { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
private static ILiteCollection<Row> Col()
{
var c = _db.Value.GetCollection<Row>("items");
c.EnsureIndex(x => x.AccountId);
return c;
}
private static string KeyFor(ulong account, int item, string? custom) =>
string.IsNullOrWhiteSpace(custom) ? $"{account}|{item}" : $"{account}|ugc|{custom.ToLowerInvariant()}";
/// <summary>Record a wish; returns the row (existing or new) so the add can answer it.</summary>
public static Row Add(ulong account, int storefront, int item, string? custom, string raw)
{
var key = KeyFor(account, item, custom);
try
{
var existing = Col().FindById(key);
if (existing != null) return existing;
var row = new Row { Key = key, AccountId = account, StorefrontType = storefront, PurchasableItemId = item, CustomAvatarItemId = string.IsNullOrWhiteSpace(custom) ? null : custom, RawBody = raw };
Col().Upsert(row);
return row;
}
catch (Exception ex)
{
Console.WriteLine($"[wishlist] add failed: {ex.Message}");
return new Row { Key = key, AccountId = account, StorefrontType = storefront, PurchasableItemId = item, CustomAvatarItemId = custom };
}
}
public static void Remove(ulong account, int item, string? custom = null)
{
try
{
if (!string.IsNullOrWhiteSpace(custom))
{
string c = custom.ToLowerInvariant();
Col().DeleteMany(x => x.AccountId == account && x.CustomAvatarItemId != null && x.CustomAvatarItemId.ToLower() == c);
}
else Col().DeleteMany(x => x.AccountId == account && x.PurchasableItemId == item && x.CustomAvatarItemId == null);
}
catch (Exception ex) { Console.WriteLine($"[wishlist] remove failed: {ex.Message}"); }
}
/// <summary>The wire entry (real RecNet: WishlistItemId guid, AccountId, PurchasableItemId, CreatedAt;
/// the 2025 client adds CustomAvatarItemId for UGC wishes). The guid is derived from the key so it is stable.</summary>
public static Guid WireId(Row r)
{
using var md5 = System.Security.Cryptography.MD5.Create();
return new Guid(md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes("wish|" + r.Key)));
}
public static object ToWire(Row r)
{
Guid id = WireId(r);
return new
{
WishlistItemId = id,
AccountId = r.AccountId,
PurchasableItemId = r.PurchasableItemId,
CustomAvatarItemId = r.CustomAvatarItemId,
CreatedAt = r.CreatedAt
};
}
public static List<object> WireFor(ulong account) => For(account).Select(ToWire).ToList();
public static List<Row> For(ulong account) => Col().Find(x => x.AccountId == account).OrderBy(x => x.CreatedAt).ToList();
}
}
@@ -0,0 +1,235 @@
using System.Collections.Generic;
using System.Linq;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The anti-alt account lock. Accounts are made either in-game (the client's first launch) or
/// on the website. The FIRST time an account signs in from the game it is bound to that Steam
/// (or other platform) id AND to that device. After that:
/// - the account can only be signed in from that same Steam id / device class,
/// - that Steam id and that device may host at most ConfigCampus.MaxAccountsPerDevice
/// accounts: one made in-game plus one made on the website (CanShareSlot), so a player
/// can play either way and switch between them from the client's account picker.
///
/// A short allowlist (ConfigCampus.LockExemptUsernames - the owner's own test accounts) is
/// exempt so they can share one Steam/device. The exemption is pinned to account IDS at
/// startup, so renaming some other account to an exempt username later grants it nothing.
/// </summary>
public static class AccountLock
{
private static HashSet<ulong> _exempt = new HashSet<ulong>();
/// <summary>
/// Resolve ConfigCampus.LockExemptUsernames to the account ids they currently name, once
/// at startup. Call after the admin-seed (Program.cs) so the seeded accounts exist.
/// </summary>
public static void ResolveExemptions()
{
var set = new HashSet<ulong>();
foreach (var name in ConfigCampus.LockExemptUsernames ?? System.Array.Empty<string>())
{
ulong id = PlayerDB.GetAccountIdByUsernameCI(name);
if (id != 0)
{
set.Add(id);
System.Console.WriteLine($"[account-lock] exemption: '{name}' -> account {id}");
}
else
{
System.Console.WriteLine($"[account-lock] exemption: '{name}' not found (skipped)");
}
}
_exempt = set;
System.Console.WriteLine($"[account-lock] lock is {(ConfigCampus.EnforceAccountLock ? "ENABLED" : "disabled")}, "
+ $"{_exempt.Count} exempt account(s)");
}
public static bool IsExempt(ulong accountId) => _exempt.Contains(accountId);
/// <summary>
/// A device's class for the lock. The account cap is "one PC and one Quest headset", so
/// every login collapses to one of two buckets: Oculus (Meta Quest) is the VR/headset
/// class, everything else (Steam, Standalone, ...) is the PC class. platform_id is NOT
/// consulted - it is client-supplied and spoofable.
/// </summary>
private static string DeviceClass(Platforms p) =>
p == Platforms.Oculus ? "Quest" : "PC";
/// <summary>
/// Check the account-device lock for a login and, if it passes, bind the device (with its
/// platform) to the account. Returns false with a player-facing message when the login must
/// be refused. The rule: an account may sign in from at most ConfigCampus.MaxDevicesPerAccount
/// devices AND at most one per class (one PC, one Quest) - and a device may host only one
/// (non-exempt) account. The spoofable platform_id no longer gates anything; it is recorded
/// for reference only. Exempt accounts (Coach/Kyle) bypass enforcement.
/// </summary>
public static bool TryEnforceAndBind(ulong accountId, Platforms platform, ulong platformId,
string? deviceId, out string error)
{
error = "";
// The Quest no-Meta build sends the sentinel id 1 for EVERY headset, so it identifies
// nobody (see PlatformIdentity). Treat it as "no platform id": recording it would make
// the cached-login picker offer this account to every other Quest player, and
// clash-checking it would refuse every headset after the first as "already linked to a
// different Campus account". The per-device half of the lock below is unaffected - it
// keys on the device id - so one-PC-one-Quest still holds for headset players.
if (PlatformIdentity.IsSentinel(platform, platformId))
platformId = 0;
// One account per Steam id. If this account isn't already bound to this Steam id but a
// DIFFERENT non-exempt account is, refuse - otherwise a second website account logging in
// on the same Steam records a duplicate binding, and the cached-login picker can then land
// on a random account (the "logs into a random account" bug). Existing bindings pass, so
// this only blocks a NEW account claiming an already-taken Steam. Exempt owner/test
// accounts (they deliberately share one Steam) skip this entirely.
if (platformId != 0 && ConfigCampus.EnforceAccountLock && !IsExempt(accountId))
{
bool alreadyMine = PlayerDB.GetAccountPlatformId(accountId, platform) == platformId;
if (!alreadyMine)
{
PlayerDB.GetLogins(platform, platformId, out var steamAccounts);
var steamOthers = (steamAccounts ?? new List<AccountLogin>())
.Select(a => a.accountId)
.Where(a => a != 0 && a != accountId && !IsExempt(a))
.Distinct().ToList();
if (!CanShareSlot(accountId, steamOthers, out string slotWhy))
{
error = $"This Steam account already has {slotWhy}. Log in with that one - each Steam "
+ "account may have one Campus account made in-game and one made on the website.";
System.Console.WriteLine($"[account-lock] refused: Steam {platformId} already has {slotWhy} "
+ $"({string.Join(",", steamOthers)}), login tried for {accountId}");
return false;
}
}
}
// Record the platform id for reference (profile/support) and so the cached-login lookup
// can find this account by its Steam id next launch.
if (platformId != 0)
PlayerDB.AddPlatformId(accountId, platform, platformId);
// Owner/test accounts, or the lock switched off: bind and allow, no enforcement.
if (!ConfigCampus.EnforceAccountLock || IsExempt(accountId))
{
PlayerDB.BindDevice(deviceId, platform, accountId);
return true;
}
// No usable device id: nothing to key the per-device lock on. Allow the login rather
// than lock out a legitimate client that didn't send one - this is the known soft spot
// (a modified client can also spoof the id), documented as such.
if (string.IsNullOrWhiteSpace(deviceId))
return true;
deviceId = deviceId.Trim();
var bindings = PlayerDB.GetDeviceBindings(accountId);
bool knownDevice = bindings.Any(b => b.DeviceId == deviceId);
if (!knownDevice)
{
string cls = DeviceClass(platform);
// (1) Already a DIFFERENT device of this same class on the account? (a second PC,
// or a second headset). If that device has not signed in for
// ConfigCampus.DeviceReplaceAfterMinutes (a reinstall, a new PC, a re-imaged
// headset, an id that changed) the new one TAKES ITS PLACE; only a device that
// is still in use holds the slot. Before this a player who changed PCs was
// locked out of their own account for good ("requires a VPN" reports - they
// were actually hitting this).
var sameClass = bindings.Where(b => b.DeviceId != deviceId && DeviceClass(b.Platform) == cls).ToList();
if (sameClass.Count > 0)
{
var idle = TimeSpan.FromMinutes(ConfigCampus.DeviceReplaceAfterMinutes);
var stale = sameClass.Where(b => b.LastSeen == null || DateTime.UtcNow - b.LastSeen.Value >= idle).ToList();
if (stale.Count == sameClass.Count)
{
PlayerDB.UnbindDevices(accountId, stale.Select(b => b.DeviceId!).Where(d => d != null));
bindings = PlayerDB.GetDeviceBindings(accountId);
System.Console.WriteLine($"[account-lock] account {accountId}: {cls} device(s) {string.Join(",", stale.Select(b => b.DeviceId))} " +
$"idle for {ConfigCampus.DeviceReplaceAfterMinutes}+ min, replaced by '{deviceId}'");
sameClass.Clear();
}
}
if (sameClass.Count > 0)
{
error = cls == "Quest"
? "This account is already tied to a different Quest headset. Each account "
+ "is limited to one PC and one Quest headset."
: "This account is already tied to a different PC. Each account is limited "
+ "to one PC and one Quest headset.";
System.Console.WriteLine($"[account-lock] refused: account {accountId} already has a "
+ $"{cls}-class device, login tried from a new {cls} device '{deviceId}'");
return false;
}
// (2) Overall device cap.
int distinct = bindings.Select(b => b.DeviceId).Distinct().Count();
if (distinct >= ConfigCampus.MaxDevicesPerAccount)
{
error = "This account already uses its allowed devices (one PC and one Quest headset).";
System.Console.WriteLine($"[account-lock] refused: account {accountId} at device cap "
+ $"({distinct}/{ConfigCampus.MaxDevicesPerAccount}), new device '{deviceId}'");
return false;
}
}
// (3) Anti-alt: the other (non-exempt) accounts already on this device. A device may
// host one game-made account and one website account (CanShareSlot); anything
// beyond that is refused. Gates NEW bindings only: an account this device is
// already bound to was admitted when that binding was made (or predates the
// rule), and re-checking it on every launch would lock a player out of an account
// the picker itself just offered.
if (!knownDevice)
{
var devOthers = PlayerDB.AccountsOnDevice(deviceId)
.Where(o => o != accountId && !IsExempt(o)).Distinct().ToList();
if (!CanShareSlot(accountId, devOthers, out string devWhy))
{
error = $"This device already has {devWhy}. Log in with that one - each device may have "
+ "one Campus account made in-game and one made on the website.";
System.Console.WriteLine($"[account-lock] refused: device '{deviceId}' already has {devWhy} "
+ $"({string.Join(",", devOthers)}), login tried for {accountId}");
return false;
}
}
// Passed - bind the device (with platform) so the cap holds from now on.
PlayerDB.BindDevice(deviceId, platform, accountId);
return true;
}
/// <summary>
/// May <paramref name="accountId"/> join the accounts already on a device / Steam id
/// (<paramref name="others"/>, non-exempt, excluding itself)? The rule (see
/// ConfigCampus.MaxAccountsPerDevice): at most that many accounts, and among them at most
/// one made in-game and at most one made on the website. An account of Unknown origin
/// (older than the flag) only counts toward the cap. <paramref name="why"/> names what is
/// already there, for the player-facing message.
/// </summary>
public static bool CanShareSlot(ulong accountId, List<ulong> others, out string why)
{
why = "";
if (others.Count == 0) return true;
int cap = System.Math.Max(1, ConfigCampus.MaxAccountsPerDevice);
if (others.Count >= cap)
{
why = others.Count == 1 ? "a Campus account" : "its Campus accounts";
return false;
}
if (!ConfigCampus.DeviceSlotsByOrigin) return true;
var mine = PlayerDB.OriginOf(accountId);
if (mine == PlayerDB.AccountOrigin.Unknown) return true;
if (others.Any(o => PlayerDB.OriginOf(o) == mine))
{
why = mine == PlayerDB.AccountOrigin.InGame ? "an account made in-game" : "an account made on the website";
return false;
}
return true;
}
}
}
@@ -0,0 +1,74 @@
using System.Security.Principal;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes.db_class;
public static class AuthHelper
{
public static (player_info? account, ContentResult? error) ValidateToken(HttpContext httpContext)
{
// Accept the JWT from EITHER place, so the same game endpoints serve both callers:
// - IN-GAME: the client sends it in the Authorization HEADER.
// - WEB: the admin panel is a browser and holds it in the Authorization COOKIE (set at
// admin login). Without this fallback, panel calls to game endpoints like
// /Accounts/account/me would 401 even after passing the patch gate.
// The patch gate already ensures only the game client or a logged-in admin reaches here.
string token = httpContext.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(token))
token = httpContext.Request.Cookies["Authorization"] ?? "";
// Reject WEBSITE tokens on game endpoints: a gameauth.ddns.net site session must not be
// usable in-game. Admin tokens are intentionally allowed here - the admin panel is a
// browser that legitimately calls game endpoints (e.g. /Accounts/account/me) with its
// cookie. Game and legacy (no-surface) tokens pass.
string playerData = ClientSecurity.DecodeToken(token, rejectSurfaces: new[] { "website" });
if (string.IsNullOrEmpty(playerData) || playerData.StartsWith("error_"))
{
return (null, new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 401
});
}
player_info account = JsonConvert.DeserializeObject<player_info>(playerData);
return (account, null);
}
public static (player_info? account, ContentResult? error) ValidateTokenDev(HttpContext httpContext)
{
string? temp2 = httpContext.Request.Cookies["Authorization"];
// Admin endpoints accept ONLY admin (or legacy/launcher no-surface) tokens: reject both
// website tokens and plain in-game tokens so neither can reach the admin panel. The
// dev-flag check below still applies on top.
string playerData = ClientSecurity.DecodeToken(temp2, rejectSurfaces: new[] { "website", "game" });
if (string.IsNullOrEmpty(playerData) || playerData.StartsWith("error_"))
{
return (null, new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
});
}
player_info account = JsonConvert.DeserializeObject<player_info>(playerData);
bool flag = PlayerDB.CheckDevFlag(account.Id);
if (!flag)
return (null, new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
});
return (account, null);
}
}
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// A searchable in-memory index of Data/APIS/items/avatar_items.json - every avatar item that
/// exists, ~3.6k of them.
///
/// The admin Gifts search only ever looked at the STOREFRONT catalogs
/// (Data/APIS/GiftDropStores), which between them list a few thousand PURCHASABLE drops. Most
/// of the avatar catalog is not sold in any storefront, so those items simply could not be
/// found - searching "thief hat" returned the three that happened to be on the Watch store and
/// nothing else. This index is what makes the rest giftable.
///
/// Loaded once, lazily, and re-read if the file changes on disk (a Data deploy replaces it).
/// The file is ~1.5 MB; parsing it per keystroke would be absurd once the search box is live.
/// </summary>
public static class AvatarCatalog
{
public sealed class Entry
{
public string Desc = ""; // AvatarItemDesc, RAW - the client parses this
public string Name = "";
public int AvatarItemId;
public int AvatarItemType;
public int Rarity;
public string? Thumbnail;
}
private static List<Entry>? _items;
private static DateTime _stamp;
private static readonly object _lock = new();
private static string Path_ =>
Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "avatar_items.json");
public static IReadOnlyList<Entry> All()
{
try
{
string path = Path_;
if (!File.Exists(path))
{
if (_items == null) Console.WriteLine($"[catalog] {path} not found - gift search falls back to storefronts only");
return _items ?? (IReadOnlyList<Entry>)Array.Empty<Entry>();
}
var stamp = File.GetLastWriteTimeUtc(path);
if (_items != null && stamp == _stamp) return _items;
lock (_lock)
{
if (_items != null && stamp == _stamp) return _items;
var list = new List<Entry>();
foreach (var e in JArray.Parse(File.ReadAllText(path)))
{
string desc = (string?)e["AvatarItemDesc"] ?? "";
if (string.IsNullOrWhiteSpace(desc)) continue;
list.Add(new Entry
{
Desc = desc,
Name = (string?)e["FriendlyName"] ?? "",
AvatarItemId = (int?)e["AvatarItemId"] ?? 0,
AvatarItemType = (int?)e["AvatarItemType"] ?? 0,
Rarity = (int?)e["Rarity"] ?? 0,
Thumbnail = (string?)e["ThumbnailImage"]
});
}
_items = list;
_stamp = stamp;
Console.WriteLine($"[catalog] indexed {list.Count} avatar item(s) for the admin gift search");
return _items;
}
}
catch (Exception ex)
{
Console.WriteLine($"[catalog] index failed: {ex.Message}");
return _items ?? (IReadOnlyList<Entry>)Array.Empty<Entry>();
}
}
/// <summary>
/// Name or descriptor match, case-insensitive. Items whose name STARTS with the query come
/// first - typing "thief" should lead with "Thief Hat", not with everything that merely
/// contains the word - then the rest alphabetically so the order is stable between
/// keystrokes rather than jumping around as results stream in.
/// </summary>
public static List<Entry> Search(string query, int max)
{
if (string.IsNullOrWhiteSpace(query)) return new List<Entry>();
string q = query.Trim().ToLowerInvariant();
return All()
.Where(e => e.Name.ToLowerInvariant().Contains(q) || e.Desc.ToLowerInvariant().Contains(q))
.OrderBy(e => e.Name.ToLowerInvariant().StartsWith(q) ? 0 : 1)
.ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
.Take(Math.Clamp(max, 1, 500))
.ToList();
}
/// <summary>How many items match, ignoring the page cap - so the UI can say "showing 60 of 214".</summary>
public static int CountMatches(string query)
{
if (string.IsNullOrWhiteSpace(query)) return 0;
string q = query.Trim().ToLowerInvariant();
return All().Count(e => e.Name.ToLowerInvariant().Contains(q) || e.Desc.ToLowerInvariant().Contains(q));
}
/// <summary>Exact descriptor lookup (the id the gift form sends back for a catalog item).</summary>
public static Entry? ByDesc(string? desc)
{
if (string.IsNullOrWhiteSpace(desc)) return null;
string key = dbs.PlayerDB.NormalizeAvatarItemDesc(desc);
return All().FirstOrDefault(e =>
string.Equals(dbs.PlayerDB.NormalizeAvatarItemDesc(e.Desc), key, StringComparison.OrdinalIgnoreCase));
}
}
}
@@ -0,0 +1,85 @@
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Anti-farm throttle for the one currency-minting endpoint, POST /api/storefronts/v2/balance.
///
/// It answers Allow or Deny per award attempt, keyed on the player's LIVE room instance
/// (decided server-side from the heartbeat, never the request body):
///
/// * RATE LIMIT -> Deny. At most one award per player per BalanceRateLimitSeconds. A second
/// award that arrives sooner - e.g. the client glitching and firing twice within seconds -
/// simply doesn't go through. No currency, no ban: the player is not punished for a glitch.
///
/// * LONG SESSION -> Deny. A player parked in ONE instance past BalanceMaxInstanceMinutes, or
/// already awarded BalanceMaxAwardsPerInstance times in it, is refused further awards. The
/// counters reset the moment they move to a different room instance (legit players move;
/// farmers park).
///
/// All in-memory and self-pruning. No bans are issued here.
/// </summary>
public static class BalanceGuard
{
public enum Decision { Allow, Deny }
private sealed class State
{
public long InstanceId;
public DateTime FirstSeenUtc;
public DateTime LastAwardUtc;
public int Awards;
}
private static readonly object _lock = new();
private static readonly Dictionary<ulong, State> _state = new();
/// <summary>
/// Decide what to do with an award attempt BEFORE any currency is granted. On Allow the
/// attempt is recorded (advances the rate-limit clock and the per-instance total).
/// </summary>
public static (Decision decision, string reason) Evaluate(ulong playerId, long roomInstanceId)
{
if (!ConfigCampus.BalanceGuardEnabled)
return (Decision.Allow, "");
var now = DateTime.UtcNow;
lock (_lock)
{
// Fresh session whenever the player is in a different instance than last time
// (or we have never seen them) - this is what resets the long-session counters.
if (!_state.TryGetValue(playerId, out var st) || st.InstanceId != roomInstanceId)
{
st = new State { InstanceId = roomInstanceId, FirstSeenUtc = now, LastAwardUtc = DateTime.MinValue };
_state[playerId] = st;
}
// RATE LIMIT -> deny (the glitch-guard). Not an award, so nothing is recorded.
double sinceLast = (now - st.LastAwardUtc).TotalSeconds;
if (sinceLast < Math.Max(1, ConfigCampus.BalanceRateLimitSeconds))
{
return (Decision.Deny,
$"rate-limited: {sinceLast:F0}s since last award (< {ConfigCampus.BalanceRateLimitSeconds}s) in instance {roomInstanceId}");
}
// LONG SESSION -> deny.
double mins = (now - st.FirstSeenUtc).TotalMinutes;
if (mins >= ConfigCampus.BalanceMaxInstanceMinutes ||
st.Awards >= ConfigCampus.BalanceMaxAwardsPerInstance)
{
return (Decision.Deny,
$"parked in instance {roomInstanceId} for {mins:F0} min / {st.Awards} awards - denying");
}
// Allow: record the award.
st.LastAwardUtc = now;
st.Awards++;
return (Decision.Allow, "");
}
}
/// <summary>Forget a player's live session (e.g. on disconnect).</summary>
public static void ClearSession(ulong playerId)
{
lock (_lock) { _state.Remove(playerId); }
}
}
}
@@ -0,0 +1,119 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The sha256 (base64) of every avatar asset bundle THIS server actually serves, stamped onto a
/// custom-item record's saves (UnityAssetHash / UnityAsset2Hash) as it goes out.
///
/// Why: the client checks the bundle it downloads against the hash in the item definition and
/// refuses the item when they differ (the wardrobe tile shows, equipping dies in a
/// NullReferenceException right after the download). The bundles on disk are re-stamped for the
/// 2021.3 client (ConvertBundles2021.py) and so have different hashes from the catalog capture;
/// ImportRealStore.py rewrites the JSON to match, but a catalog file and a bundle folder that
/// were deployed at different times disagree again - which is exactly what happened live
/// (UGC.json carrying the Unity 6 hashes over 2021.3 files: every first-party item failed to
/// load). Hashing the file we serve makes the definition follow the file, always.
///
/// Bundles live under Data/CDN/AvatarBlobs, possibly in sub-folders; the index is built once by
/// name (first hit wins), hashes are computed lazily per file and cached, and a save whose bundle
/// we don't have is left untouched.
/// </summary>
public static class BundleHashes
{
private static readonly string Root = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "AvatarBlobs");
// Files in sub-folders (the avatar-pack dumps, AvatarBlobs/<n>/...), by bare name. The FLAT
// file at the root always wins over these - that is where the converted bundles live on
// the VPS - so the index is only the fallback for a name that has no flat copy.
private static readonly Lazy<Dictionary<string, string>> _packs = new(() =>
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
if (Directory.Exists(Root))
foreach (var dir in Directory.EnumerateDirectories(Root))
foreach (var f in Directory.EnumerateFiles(dir, "*.assetbundle", SearchOption.AllDirectories))
map.TryAdd(Path.GetFileName(f), f);
}
catch (Exception ex) { Console.WriteLine($"[bundlehash] pack index failed: {ex.Message}"); }
Console.WriteLine($"[bundlehash] {map.Count} packed avatar bundles indexed");
return map;
});
/// <summary>
/// The file the CDN serves for this bundle name: the flat copy under AvatarBlobs, else the
/// packed one. Null when neither exists. Shared with the /cdn/avatar route so the hash we
/// stamp is the hash of the bytes we send.
/// </summary>
public static string? Resolve(string? fileName)
{
if (string.IsNullOrWhiteSpace(fileName) || fileName.Contains('/') || fileName.Contains(Path.DirectorySeparatorChar)) return null;
string flat = Path.Combine(Root, fileName);
if (File.Exists(flat)) return flat;
return _packs.Value.TryGetValue(fileName, out var p) ? p : null;
}
private static readonly ConcurrentDictionary<string, string?> _hashes = new(StringComparer.OrdinalIgnoreCase);
/// <summary>base64 sha256 of the bundle file with this name, or null when we don't hold it.</summary>
public static string? Of(string? fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return null;
return _hashes.GetOrAdd(fileName, name =>
{
var path = Resolve(name);
if (path == null) return null;
try
{
using var fs = File.OpenRead(path);
return Convert.ToBase64String(SHA256.HashData(fs));
}
catch (Exception ex) { Console.WriteLine($"[bundlehash] {name}: {ex.Message}"); return null; }
});
}
/// <summary>
/// Hash every flat bundle in the background so the first big catalog page doesn't pay for
/// thousands of file reads. Lazy lookups keep working while this runs.
/// </summary>
public static void WarmUp()
{
Task.Run(() =>
{
try
{
if (!Directory.Exists(Root)) return;
int n = 0; var sw = System.Diagnostics.Stopwatch.StartNew();
foreach (var f in Directory.EnumerateFiles(Root, "*.assetbundle", SearchOption.TopDirectoryOnly)) { Of(Path.GetFileName(f)); n++; }
Console.WriteLine($"[bundlehash] warmed {n} flat bundle hashes in {sw.Elapsed.TotalSeconds:0}s");
}
catch (Exception ex) { Console.WriteLine($"[bundlehash] warm-up failed: {ex.Message}"); }
});
}
/// <summary>Rewrite the record's save hashes to match the files on disk. Returns the same object.</summary>
public static JObject Stamp(JObject rec)
{
try
{
if (rec["CurrentSaves"] is JArray saves)
foreach (var s in saves.OfType<JObject>())
{
Fix(s, "UnityAsset", "UnityAssetHash");
Fix(s, "UnityAsset2", "UnityAsset2Hash");
}
}
catch { }
return rec;
}
private static void Fix(JObject save, string fileKey, string hashKey)
{
var name = save[fileKey]?.Type == JTokenType.String ? (string?)save[fileKey] : null;
var real = Of(name);
if (real != null && (string?)save[hashKey] != real) save[hashKey] = real;
}
}
}
@@ -0,0 +1,38 @@
using Newtonsoft.Json;
using LiteDB;
namespace Campus_live_rewrite.api.server.Classes
{
public class ActionLink
{
[BsonId]
[JsonIgnore]
public string LinkId { get; set; } = Utils.GenerateShortCode(6);
public ulong creatorPlayerId { get; set; }
public string? data { get; set; }
public bool isValid { get; set; }
[JsonIgnore]
public LinkType CodeType { get; set; }
[JsonIgnore]
public int ValidHours { get; set; }
[JsonIgnore]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[JsonIgnore]
public DateTime ExpiresAt => CreatedAt.AddHours(ValidHours);
[JsonIgnore]
public int ExtraDataId { get; set; }
}
public enum LinkType
{
Unknown = -1,
Friend, // 0
Referral, // 1
Meetup, // 2
Club, // 3
PlayerEvent, // 4
Room, // 5
Influencer, // 6
Photo // 7
}
}
@@ -0,0 +1,12 @@
namespace Campus_live_rewrite.api.server.Classes
{
public class Amplitude2023
{
public string? AmplitudeKey { get; set; }
public bool? UseRudderStack { get; set; }
public string? RudderStackKey { get; set; }
public bool? UseStatSig { get; set; }
public string? StatSigKey { get; set; }
public int? StatSigEnvironment { get; set; }
}
}
@@ -0,0 +1,86 @@
using System.Collections.Generic;
namespace Campus_live_rewrite.api.server.Classes
{
public class Config
{
public string MessageOfTheDay { get; set; }
public string CdnBaseUri { get; set; }
public string ShareBaseUrl { get; set; }
public List<LevelProgressionMap> LevelProgressionMaps { get; set; }
public MatchmakingParams MatchmakingParams { get; set; }
public List<List<DailyObjective>> DailyObjectives { get; set; }
public ServerMaintenance ServerMaintainence { get; set; }
public AutoMicMutingConfig AutoMicMutingConfig { get; set; }
public StorefrontConfig StorefrontConfig { get; set; }
public RoomKeyConfig RoomKeyConfig { get; set; }
public RoomCurrencyConfig RoomCurrencyConfig { get; set; }
public List<ConfigTableEntry> ConfigTable { get; set; }
public PhotonConfig PhotonConfig { get; set; }
}
public class LevelProgressionMap
{
public int Level { get; set; }
public int RequiredXp { get; set; }
public int? GiftDropId { get; set; }
}
public class MatchmakingParams
{
public double PreferFullRoomsFrequency { get; set; }
public double PreferEmptyRoomsFrequency { get; set; }
}
public class DailyObjective
{
public int Type { get; set; }
public int Score { get; set; }
public int Xp { get; set; }
}
public class ServerMaintenance
{
public int StartsInMinutes { get; set; }
}
public class AutoMicMutingConfig
{
public double MicSpamVolumeThreshold { get; set; }
public double MicVolumeSampleInterval { get; set; }
public double MicVolumeSampleRollingWindowLength { get; set; }
public double MicSpamSamplePercentageForWarning { get; set; }
public double MicSpamSamplePercentageForWarningToEnd { get; set; }
public double MicSpamSamplePercentageForForceMute { get; set; }
public double MicSpamSamplePercentageForForceMuteToEnd { get; set; }
public double MicSpamWarningStateVolumeMultiplier { get; set; }
}
public class StorefrontConfig
{
public int MinPlayerLevelForGifting { get; set; }
}
public class RoomKeyConfig
{
public int MaxKeysPerRoom { get; set; }
}
public class RoomCurrencyConfig
{
public double AwardCurrencyCooldownSeconds { get; set; }
}
public class ConfigTableEntry
{
public string Key { get; set; }
public string Value { get; set; }
}
public class PhotonConfig
{
public string CloudRegion { get; set; }
public bool CrcCheckEnabled { get; set; }
public bool EnableServerTracingAfterDisconnect { get; set; }
}
}
@@ -0,0 +1,27 @@
namespace Campus_live_rewrite.api.server.Classes
{
/// <summary>
/// Currency identifiers, taken from the client's own enum (il2cpp dump, 2026-08-24).
///
/// These are NOT arbitrary - the value is the path segment the client uses when
/// asking for a balance, e.g. /api/storefronts/v4/balance/2 is RecCenterTokens and
/// /api/storefronts/v4/balance/500 is RoomieCredits. Both appear in every session log.
///
/// Kept as a real enum so reward/balance code stops passing bare ints around.
/// NOTE: this is the BALANCES enum. Game rewards use a different one.
/// </summary>
public enum CurrencyType
{
Invalid = 0,
LaserTagTickets = 1,
RecCenterTokens = 2,
LostSkullsGold = 100,
DraculaSilver = 101,
RecRoyale_Season1 = 200,
RoomCurrency = 300,
RoomInventoryItem = 301,
ProgressionEvent = 400,
RoomieCredits = 500,
PaintballPaint = 600,
}
}
@@ -0,0 +1,21 @@
using System.Runtime.CompilerServices;
namespace Campus_live_rewrite.api.server.Classes
{
public class CustomAvatarItems
{
public List<object>? Results { get; set; }
public int TotalResults { get; set; }
}
public class CreateCustomAvatarItemResponse
{
// {"name":"eeeeeeeeeeeeeeeeeeeee","Description":"eawwwwwwwwwwweweawewaewa","Price":1000,"BaseAvatarItemId":2184,"BaseAvatarItemColor":"#F55C1A","Accessibility":1}
public string? Name { get; set; } // Custom shirt name
public string? Description { get; set; } // Custom shirt description
public int Price { get; set; } // This range from 1000 to 10,000
public int BaseAvatarItemId { get; set; } // the avatar id from the avatar db
public string? BaseAvatarItemColor { get; set; } // Hex color code default is #F55C1A
public int Accessibility { get; set; } // enum 0 for me only, 1 for everyone
}
}
@@ -0,0 +1,125 @@
namespace Campus_live_rewrite.api.server.Classes
{
/// <summary>
/// Which currency a game mode pays out in.
///
/// The client asks /api/storefronts/v2/balance to add currency and NAMES the currency
/// itself. Trusting that is how a completed Golden Trophy match paid out 1000 Dracula
/// Silver - Crescendo's currency. The room the player is actually in decides now; the
/// client's CurrencyType is only a fallback when the room is unknown.
///
/// Room names come from the live server (Room_server/rooms/search).
/// </summary>
public static class GameCurrency
{
/// <summary>
/// The one balance bucket this server keeps per currency, on the wire as "Platform"
/// (BalancePlatform.NonPurchasedNotUsableInP2P = -2, the reference's ALL_PLATFORMS). The
/// client holds a balance per (CurrencyType, Platform) and SUMS them, so every balance
/// frame - the GET, a purchase response, a balance add, the 61 push - has to name this
/// same bucket; one that says 0 (SteamPurchased) makes a phantom bucket that is added to
/// the real one for the rest of the session.
/// </summary>
public const int AllPlatformsBucket = -2;
/// <summary>Currency for a room name, or null when the room has no special currency.</summary>
public static CurrencyType? ForRoomName(string? roomName)
{
if (string.IsNullOrWhiteSpace(roomName)) return null;
// Room instance names arrive as "^RoomName".
string n = roomName.TrimStart('^').Trim();
if (n.Equals("Crescendo", StringComparison.OrdinalIgnoreCase))
return CurrencyType.DraculaSilver;
if (n.Equals("IsleOfLostSkulls", StringComparison.OrdinalIgnoreCase))
return CurrencyType.LostSkullsGold;
if (n.StartsWith("RecRoyale", StringComparison.OrdinalIgnoreCase))
return CurrencyType.RecRoyale_Season1;
if (n.StartsWith("Paintball", StringComparison.OrdinalIgnoreCase))
return CurrencyType.PaintballPaint;
if (n.Equals("LaserTag", StringComparison.OrdinalIgnoreCase))
return CurrencyType.LaserTagTickets;
// GoldenTrophy and everything else pay in Rec Center Tokens - which is what the
// Golden Trophy storefront (StorefrontType 102) actually prices its items in.
return CurrencyType.RecCenterTokens;
}
/// <summary>
/// STRICT balance-room whitelist: the ONLY rooms in which POST v2/balance may mint
/// currency, and which currency each mints. Anything not listed returns null and mints
/// nothing. Rec Center Tokens are deliberately absent - they are server/admin only.
///
/// Crescendo -> DraculaSilver (101)
/// IsleOfLostSkulls -> LostSkullsGold (100)
/// LaserTag -> LaserTagTickets (1)
/// </summary>
public static CurrencyType? ForBalanceRoom(string? roomName)
{
if (string.IsNullOrWhiteSpace(roomName)) return null;
// Room instance names arrive as "^RoomName".
string n = roomName.TrimStart('^').Trim();
if (n.Equals("Crescendo", StringComparison.OrdinalIgnoreCase))
return CurrencyType.DraculaSilver;
if (n.Equals("IsleOfLostSkulls", StringComparison.OrdinalIgnoreCase))
return CurrencyType.LostSkullsGold;
if (n.Equals("LaserTag", StringComparison.OrdinalIgnoreCase))
return CurrencyType.LaserTagTickets;
return null;
}
/// <summary>
/// Whether a currency may be minted by a PLAYER-facing endpoint (v2/balance, game rewards).
/// Only the three room currencies qualify. Rec Center Tokens (and every other bucket) are
/// NOT player-grantable - they can only be granted server-side / from the admin panel.
/// </summary>
public static bool IsPlayerGrantable(int currencyType)
=> currencyType == (int)CurrencyType.DraculaSilver
|| currencyType == (int)CurrencyType.LostSkullsGold
|| currencyType == (int)CurrencyType.LaserTagTickets;
/// <summary>
/// Most a single award may grant, per currency. 0 = no cap.
///
/// Crescendo pays per CHECKPOINT, and a checkpoint is worth 0..30,000 Silver -
/// five checkpoints, so 150,000 is a full game. The cap is enforced per request so
/// a bad (or forged) amount cannot mint a fortune in one call.
/// </summary>
/// <summary>
/// Whether a currency is an account balance a purchase can debit. Invalid (0) never is;
/// everything else the enum defines is a real balance bucket. Used by bulkpurchase to
/// reject a bag posted against a currency there is no wallet to spend from (400).
/// </summary>
public static bool IsSpendable(int currencyType)
=> currencyType != (int)CurrencyType.Invalid
&& Enum.IsDefined(typeof(CurrencyType), currencyType);
public static long MaxPerAward(CurrencyType currency)
{
switch (currency)
{
case CurrencyType.DraculaSilver: return 30000; // per Crescendo checkpoint
default: return 0; // uncapped
}
}
/// <summary>Clamps an award to 0..MaxPerAward for that currency.</summary>
public static long ClampAward(CurrencyType currency, long amount)
{
if (amount < 0) return 0;
long max = MaxPerAward(currency);
return (max > 0 && amount > max) ? max : amount;
}
}
}
@@ -0,0 +1,106 @@
namespace Campus_live_rewrite.api.server.Classes
{
/// <summary>
/// RewardType values the client sends to /api/gamerewards/v1/request and
/// /api/avatar/v3/gifts/generate (observed in real client traffic, 2026-08).
/// Sent as the STRING name in the form body, e.g. "RewardType=PostGameActivity".
/// This is a partial list - extend as more are observed.
/// </summary>
public enum GameRewardType
{
Unknown = 0,
PostGameActivity, // end-of-game gift box
ReferralReward, // referral / gift generate
FirstActivityOfDay, // observed live: "rewardType=FirstActivityOfDay&Message=First Game of the Day"
}
/// <summary>
/// What each reward type pays, and whether it is limited to once per day.
///
/// The real grant table was never captured, so these amounts are our own - but the
/// DAILY LIMIT is not cosmetic: FirstActivityOfDay is named for a once-a-day event
/// and the client will happily request it again on the next activity. Without a gate
/// it pays out every single time and is trivially farmed.
/// </summary>
public static class GameRewardRules
{
public static GameRewardType Parse(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return GameRewardType.Unknown;
return Enum.TryParse<GameRewardType>(raw, ignoreCase: true, out var t)
? t
: GameRewardType.Unknown;
}
/// <summary>Amount awarded, in the currency returned by CurrencyFor.</summary>
public static long AmountFor(GameRewardType type)
{
switch (type)
{
case GameRewardType.FirstActivityOfDay: return 100;
case GameRewardType.PostGameActivity: return 25;
case GameRewardType.ReferralReward: return 250;
default: return 10;
}
}
public static CurrencyType CurrencyFor(GameRewardType type) => CurrencyType.RecCenterTokens;
/// <summary>True when this reward may only be claimed once per calendar day (UTC).</summary>
public static bool IsOncePerDay(GameRewardType type)
=> type == GameRewardType.FirstActivityOfDay;
}
/// <summary>
/// GiftContext ids seen in client traffic. These identify WHICH gift box the reward
/// is for; the id is game/activity specific. Kept for reference - the reward request
/// carries the raw int, not this enum.
/// 4003 Golden 4503 Jumbotron 4013 Crimson 4103 Isle 4206 Crescendo
/// 5001 Charades 10001 Bowling 1200 RecRoyale 12000 RecRally 12100 Showdown
/// 12200 MyLittleMonster 12300 RunTheBlock 11007 StuntRunner
/// 3010/3011 DiscGolf 2010-2016 Paintball maps
/// </summary>
public static class GiftContextIds
{
public const int GoldenGiftBox = 4003;
public const int JumbotronGiftBox = 4503;
public const int CrimsonGiftBox = 4013;
public const int IsleGiftBox = 4103;
public const int CrescendoGiftBox = 4206;
/// <summary>
/// Which currency a gift box pays out in.
///
/// Each box belongs to a game, and those games have their own currency: the Isle
/// boxes pay Lost Skulls Gold, Crescendo pays Dracula Silver, Paintball pays
/// Paint, and so on. Anything without a game-specific currency falls back to
/// Rec Center Tokens.
///
/// This is a reasonable mapping rather than a captured one - rec.net's real
/// payout table was never observed. It is easy to change: the amount is chosen
/// in the admin panel, and this only decides WHICH currency it lands in.
/// </summary>
public static CurrencyType CurrencyForGiftBox(int giftContext)
{
switch (giftContext)
{
case 4103: // Isle
return CurrencyType.LostSkullsGold;
case 4206: // Crescendo
return CurrencyType.DraculaSilver;
case 1200: // Rec Royale
return CurrencyType.RecRoyale_Season1;
case 2010: case 2011: case 2012: // Paintball maps
case 2013: case 2014: case 2015:
case 2016:
return CurrencyType.PaintballPaint;
default:
return CurrencyType.RecCenterTokens;
}
}
}
}
@@ -0,0 +1,46 @@
namespace Campus_live_rewrite.api.server.Classes
{
public class Hile
{
public enum HileTypes
{
Obscured = 0,
Time = 1,
Inject = 2,
GiftCount = 3,
Engine = 4,
UnknownDll = 5,
ImageSignature = 6,
AvatarHack = 7,
NetworkCertificatePublicKey = 100,
NetworkCertificateIssuer = 101,
NetworkCertificateMissing = 102,
NetworkCertificateMismatch = 103,
AutosaveChecksumMismatch = 150,
AutosaveSubRoomIdMismatch = 151,
AutosaveChecksumException = 152,
Photon_MissingHash = 200,
Photon_CorruptHash = 201,
Photon_DifferentHash = 203,
AppData_Runtime_LengthMismatch = 300,
AppData_Runtime_LastWriteTimeMismatch = 301,
AppData_Runtime_FileModified = 302,
AppData_Boot_InvalidSignature = 310,
AppData_Boot_UnableToVerifySignatures = 311,
Config_MissingHash = 320,
Config_DifferentHash = 321,
Photon_InstantiateTool = 400,
Memory_Hash_Mismatch = 500,
Driver_Invalid_Signature = 600
}
}
}
@@ -0,0 +1,187 @@
using System.Text.Json;
namespace Campus_live_rewrite.api.server.Classes
{
// Which avatar items the CLIENT BUILD can actually render.
//
// The client carries its own item database. Hand it an item whose id isn't in there and
// it has no asset, so it cannot work out the item's outfit type either - which is the
// "None does not have an associated browsable avatar item filter" warning in Player.log.
// The item then belongs to no wardrobe category and never appears while browsing, even
// though it is present in the /api/avatar/v4/items response and findable by search.
//
// No server change makes such an item render. The only thing the server can usefully do
// is stop listing items the client cannot draw.
//
// Data/APIS/items/ClientKnownGuids.json holds the ids, in the same shape the 2023 server
// used: { "_comment": "...", "Guids": [ ... ] }.
//
// IF THE FILE IS ABSENT, NOTHING IS FILTERED. That is the default: the list shipped
// alongside this was extracted from the 2023 build, and the 2025 client is a newer build
// that almost certainly knows items 2023 never had. Filtering on the older list would
// hide items that currently work. Turn it on deliberately, and compare.
public static class ItemGuids
{
private static HashSet<string>? _known;
private static readonly object _lock = new();
public static HashSet<string> KnownGuids()
{
if (_known != null) return _known;
lock (_lock)
{
if (_known != null) return _known;
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "ClientKnownGuids.json");
if (File.Exists(path))
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.TryGetProperty("Guids", out var arr) && arr.ValueKind == JsonValueKind.Array)
foreach (var g in arr.EnumerateArray())
{
var s = g.GetString();
if (!string.IsNullOrWhiteSpace(s))
{
var n = NormalizeGuid(s);
if (n != null) set.Add(n);
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[items] could not read ClientKnownGuids.json ({ex.Message}) - nothing will be filtered");
}
_known = set;
Console.WriteLine($"[items] client-known GUIDs loaded: {set.Count}" +
(set.Count == 0 ? " (no filter file - nothing will be filtered)" : ""));
return _known;
}
}
/// <summary>
/// Rec Room writes item ids two ways: a normal UUID, or a 22-character base64
/// "short GUID". Both normalise to a plain lowercase UUID so they compare equal.
/// </summary>
public static string? NormalizeGuid(string? s)
{
s = (s ?? "").Trim();
if (s.Length == 0) return null;
if (Guid.TryParse(s, out var g)) return g.ToString().ToLowerInvariant();
if (s.Length == 22)
{
try
{
var bytes = Convert.FromBase64String(s.Replace('-', '+').Replace('_', '/') + "==");
if (bytes.Length == 16) return new Guid(bytes).ToString().ToLowerInvariant();
}
catch { }
}
return null;
}
/// <summary>
/// Whether the client can render this item. Always true when no filter file is
/// present, so an absent list can never cost anyone their wardrobe.
/// </summary>
// ---- HiddenItemsStorefronts --------------------------------------------------------
//
// An ALLOWLIST: while the flag is on, a store lists an avatar item only if its base guid is
// in Data/APIS/items/ShownItems.json ({ "Guids": [...] }) - or, when that file does not
// exist, only if it is in the starter wardrobe (DefaultAvatarItems.json), which every avatar
// on the server wears and which is known to render.
//
// ShownItems.json is generated from the reference server's legacy item catalog
// (recflare apps/econ/static/db/avatar-items.json, 3,061 items / 977 base guids) - the
// list its 2025 Watch store (sf3-2025.json) sells to this very client build, with nothing
// but AvatarItemDesc on each row. Regenerate it from that file if the reference updates.
//
// History: the 2023 embedded-item list was the gate first, then a hand-grown "seen to
// render" list, because items on the 2023 list still came up "Can't load item". That was
// not the embedded set: the captured store rows carried AvatarItemInfo.DownloadableAvatarItemId,
// which sends the client to download a CAI bundle instead of drawing the legacy item, and
// that download always fails on this client. StoreGenerator.ShapeRow now strips it, so a
// legacy item on this list renders the way it does on the reference.
// ShownItems.json is re-read when it changes.
private static HashSet<string>? _shown;
private static string _shownStamp = "";
private static HashSet<string> ShownGuids()
{
string shownPath = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "ShownItems.json");
string defaultsPath = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "DefaultAvatarItems.json");
string stamp = (File.Exists(shownPath) ? File.GetLastWriteTimeUtc(shownPath).Ticks.ToString() : "-")
+ "|" + (File.Exists(defaultsPath) ? File.GetLastWriteTimeUtc(defaultsPath).Ticks.ToString() : "-");
if (_shown != null && stamp == _shownStamp) return _shown;
lock (_lock)
{
if (_shown != null && stamp == _shownStamp) return _shown;
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string source = "nothing";
try
{
if (File.Exists(shownPath))
{
using var doc = JsonDocument.Parse(File.ReadAllText(shownPath));
if (doc.RootElement.TryGetProperty("Guids", out var arr) && arr.ValueKind == JsonValueKind.Array)
foreach (var g in arr.EnumerateArray())
{
var n = NormalizeGuid((g.GetString() ?? "").Split(',')[0]);
if (n != null) set.Add(n);
}
source = "ShownItems.json";
}
else if (File.Exists(defaultsPath))
{
var arr = Newtonsoft.Json.JsonConvert.DeserializeObject<List<string>>(File.ReadAllText(defaultsPath)) ?? new();
foreach (var d in arr)
{
var n = NormalizeGuid((d ?? "").Split(',')[0]);
if (n != null) set.Add(n);
}
source = "DefaultAvatarItems.json (no ShownItems.json yet)";
}
}
catch (Exception ex) { Console.WriteLine($"[items] could not read the shown-items list: {ex.Message}"); }
Console.WriteLine($"[items] HiddenItemsStorefronts: stores list only the {set.Count} item id(s) in {source}");
_shown = set;
_shownStamp = stamp;
return _shown;
}
}
/// <summary>
/// May a STORE list this avatar item? False only while HiddenItemsStorefronts is on and
/// the item's base guid is not in the known-embedded set. Items with no descriptor
/// (consumables, boxes, equipment skins) are never hidden - their art is in the client.
/// </summary>
public static bool StoreCanList(string? avatarItemDesc)
{
if (!ConfigCampus.HiddenItemsStorefronts) return true;
if (string.IsNullOrWhiteSpace(avatarItemDesc)) return true; // consumables, boxes, skins: no bundle
var shown = ShownGuids();
var baseGuid = NormalizeGuid(avatarItemDesc.Split(',').FirstOrDefault());
// An unparseable descriptor is hidden, not waved through - this is an allowlist.
return baseGuid != null && shown.Contains(baseGuid);
}
public static bool ClientCanRender(string? avatarItemDesc)
{
var known = KnownGuids();
if (known.Count == 0) return true;
var baseGuid = NormalizeGuid((avatarItemDesc ?? "").Split(',').FirstOrDefault());
return baseGuid == null || known.Contains(baseGuid);
}
}
}
@@ -0,0 +1,7 @@
namespace Campus_live_rewrite.api.server.Classes
{
public class LockedItems
{
public List<string> AvatarItemDescriptions { get; set; } = new List<string>();
}
}
@@ -0,0 +1,8 @@
namespace Campus_live_rewrite.api.server.Classes
{
public class MyObjectives
{
public List<object> Objectives { get; set; } = new List<object>();
public List<object> ObjectiveGroups { get; set; } = new List<object>();
}
}
@@ -0,0 +1,42 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Campus_live_rewrite.api.server.Classes
{
public class NS
{
public string Accounts { get; set; } = "";
public string API { get; set; } = "";
public string Auth { get; set; } = "";
public string BugReporting { get; set; } = "";
public string Cards { get; set; } = "";
public string CDN { get; set; } = "";
public string Chat { get; set; } = "";
public string Clubs { get; set; } = "";
public string CMS { get; set; } = "";
public string Commerce { get; set; } = "";
public string Data { get; set; } = "";
public string DataCollection { get; set; } = "";
public string Discovery { get; set; } = "";
public string Econ { get; set; } = "";
public string GameLogs { get; set; } = "";
public string Geo { get; set; } = "";
public string Images { get; set; } = "";
public string Leaderboard { get; set; } = "";
public string Link { get; set; } = "";
public string Lists { get; set; } = "";
public string Matchmaking { get; set; } = "";
public string Moderation { get; set; } = "";
public string Notifications { get; set; } = "";
public string PlatformNotifications { get; set; } = "";
public string PlayerSettings { get; set; } = "";
public string RoomComments { get; set; } = "";
public string Rooms { get; set; } = "";
public string Storage { get; set; } = "";
public string Strings { get; set; } = "";
public string StringsCDN { get; set; } = "";
public string Studio { get; set; } = "";
public string Thorn { get; set; } = "";
public string Videos { get; set; } = "";
public string WWW { get; set; } = "";
}
}
@@ -0,0 +1,137 @@
using System;
using Campus_live_rewrite.api.dbs;
namespace Campus_live_rewrite.api.server.Classes
{
public class Progress
{
public int Level { get; set; }
public ulong PlayerId { get; set; }
public int XP { get; set; }
// ---- The real Rec Room progression curve, ported from RecFlare -------------------------
// LEVEL_REQUIRED_XP[level] is the XP needed to LEAVE that level (indexed BY level). A
// player's stored XP is the progress INTO their current level - NOT a lifetime total -
// and every LEVEL_REQUIRED_XP[level] XP earned lifts them one level, carrying the
// remainder forward. This replaces the old made-up "1 + xp/1000" rule; the previous author
// left a note that the real curve should replace exactly that one method when it turned up.
//
// This is also what the client renders its progress bar against (see ConfigV2's
// LevelProgressionMaps, which now sends this same table), so client and server agree on
// what level an XP total is - the disagreement is what tripped the Level/XP anti-cheat
// ("obscured") check and dropped players to their dorm.
public static readonly int[] LEVEL_REQUIRED_XP = new int[]
{
0, 10, 10, 10, 20, 20, 20, 20, 20, 20, 20, 45, 45, 45, 45, 45, 45, 45, 45, 45, 45,
115, 115, 115, 115, 115, 115, 115, 115, 115, 115, 360, 360, 360, 360, 360, 360, 360,
360, 360, 360, 1080, 1080, 1080, 1080, 1080, 1080, 1080, 1080, 1080, 1080,
// 51..98: unreachable by play (20,000,000 XP each) - they exist only so the client's
// table (ConfigV2 LevelProgressionMaps sends this) can render level 99 for the one
// account that holds it (ConfigCampus.Level99Username). Everyone else is capped at
// PLAYER_MAX_LEVEL below.
20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000,
20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000,
20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000,
20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000,
20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000, 20000000
};
public static readonly int MAX_LEVEL = LEVEL_REQUIRED_XP.Length - 1; // 99 (100 entries, 0..99)
/// <summary>The level any ordinary account can reach. Only the Level99Username account goes past it.</summary>
public const int PLAYER_MAX_LEVEL = 50;
public static bool MayExceedPlayerCap(ulong playerId)
{
var acc = PlayerDB.GetAccountById(playerId);
return acc != null && string.Equals(acc.Username, ConfigCampus.Level99Username, StringComparison.OrdinalIgnoreCase);
}
// Synthetic GiftDropId for level N's reward tile = BASE + N; Gameconfigs.json's
// Lifecycle.PSF.Progression.RewardMap maps each to its image.
public const int LEVEL_REWARD_GIFTDROP_BASE = 2_000_000;
// XP a single game reward pays out. Tunable - see ConfigCampus.GameRewardXp (was a
// constant 5, the reference's value, which made the top levels take ~216 games each).
public static int GAME_REWARD_XP => ConfigCampus.GameRewardXp;
/// <summary>
/// Spend XP on any levels it now pays for. Takes a (level, xp) pair where xp is progress
/// into the current level, and returns the pair after applying every level-up the xp
/// covers - each level costs LEVEL_REQUIRED_XP[level], and the remainder carries forward.
/// Mirrors RecFlare's applyLevelUps.
/// </summary>
public static (int level, int xp) ApplyLevelUps(int level, int xp)
{
if (level < 1) level = 1;
if (xp < 0) xp = 0;
// Play stops at the player cap; 51+ is not earned.
while (level < PLAYER_MAX_LEVEL)
{
int cost = LEVEL_REQUIRED_XP[level];
if (cost <= 0 || xp < cost) break;
xp -= cost;
level += 1;
}
// At the cap there is nothing left to buy; park any overflow at 0 so the pair stays
// consistent. A level above the cap (the Coach's 99) is left exactly as stored.
if (level == PLAYER_MAX_LEVEL) xp = 0;
if (level > MAX_LEVEL) { level = MAX_LEVEL; xp = 0; }
return (level, xp);
}
// The CUMULATIVE (lifetime) XP for a given (level, xp-into-level) pair. The client derives
// the crown/nameplate LEVEL NUMBER from the XP value against LEVEL_REQUIRED_XP (proven: a
// 160-XP account shows "level 10", and 150 = the cost to reach level 10). Our stored XP is
// progress INTO the current level, so reporting it raw made the client recompute a low
// level (e.g. Kyle at Level 50 / into-level XP 0 -> the client read XP 0 -> level 1). So we
// REPORT cumulative XP; internal storage stays per-level.
public static int CumulativeXpForLevel(int level, int xpIntoLevel)
{
if (level < 1) level = 1;
if (level > MAX_LEVEL) level = MAX_LEVEL;
long total = 0;
for (int l = 1; l < level && l < LEVEL_REQUIRED_XP.Length; l++) total += LEVEL_REQUIRED_XP[l];
if (xpIntoLevel > 0) total += xpIntoLevel;
return (int)total;
}
public static Progress GetPlayerLevel(ulong player_id)
{
var account = PlayerDB.GetFullAccount(player_id);
if (account == null || account.player == null)
{
return new Progress { Level = 1, PlayerId = player_id, XP = 0 };
}
int level = account.player.Level;
int xp = account.player.XP;
// Normalise to a consistent (level, xp) pair before reporting it. Every legacy account
// has XP 0 (the 2023 server only incremented Level, never tracked XP), and XP 0 is
// consistent at any level in this per-level model - so nothing jumps. A stored pair
// that somehow carries leftover XP is settled through the ladder here.
if (ConfigCampus.EnforceLevelXpConsistency)
{
var (cLevel, cXp) = ApplyLevelUps(level < 1 ? 1 : level, xp);
if (cLevel != level || cXp != xp)
{
Console.WriteLine($"[progression] {account.player.Id}: stored Level {level}/XP {xp} " +
$"normalised to Level {cLevel}/XP {cXp}");
level = cLevel; xp = cXp;
}
}
else if (level < 1)
{
level = 1;
}
// Level 99 is the Coach's alone; anyone else past the player cap is shown at the cap.
if (level > PLAYER_MAX_LEVEL && !MayExceedPlayerCap(account.player.Id)) { level = PLAYER_MAX_LEVEL; xp = 0; }
if (level > PLAYER_MAX_LEVEL) { level = MAX_LEVEL; xp = 0; }
// Report CUMULATIVE XP (see CumulativeXpForLevel) so the client computes the SAME level
// for the crown/nameplate that we stored - not a low level recomputed from into-level XP.
return new Progress { Level = level, PlayerId = account.player.Id, XP = CumulativeXpForLevel(level, xp) };
}
}
}
@@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
namespace Campus_live_rewrite.api.server.Classes
{
public class RRPlus
{
public class SubscriptionResponse
{
[JsonPropertyOrder(1)]
public Subscription subscription { get; set; }
[JsonPropertyOrder(2)]
public ulong? platformAccountSubscribedPlayerId { get; set; }
}
public class Subscription
{
[JsonPropertyOrder(1)] public int subscriptionId { get; set; }
[JsonPropertyOrder(2)] public ulong recNetPlayerId { get; set; }
[JsonPropertyOrder(3)] public int platformType { get; set; }
[JsonPropertyOrder(4)] public string platformId { get; set; }
[JsonPropertyOrder(5)] public string platformPurchaseId { get; set; }
[JsonPropertyOrder(6)] public object? @event { get; set; }
[JsonPropertyOrder(7)] public int type { get; set; }
[JsonPropertyOrder(8)] public int level { get; set; }
[JsonPropertyOrder(9)] public int period { get; set; }
[JsonPropertyOrder(10)] public DateTime expirationDate { get; set; }
[JsonPropertyOrder(11)] public bool isAutoRenewing { get; set; }
[JsonPropertyOrder(12)] public DateTime createdAt { get; set; }
[JsonPropertyOrder(13)] public DateTime modifiedAt { get; set; }
}
public enum SubscriptionLevel
{
Gold,
Platinum,
}
public enum SubscriptionPeriod
{
Month,
Year,
}
}
}
@@ -0,0 +1,22 @@
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server.Classes
{
public class Rep
{
public ulong AccountId { get; set; }
public bool IsCheerful { get; set; }
public int Noteriety { get; set; }
public CheerCategoryEnum SelectedCheer { get; set; }
public int CheerCredit { get; set; }
public int CheerGeneral { get; set; }
public int CheerHelpful { get; set; }
public int CheerCreative { get; set; }
public int CheerGreatHost { get; set; }
public int CheerSportsman { get; set; }
// RecFlare's ReputationDto carries these two; the profile reads them off the reputation
// object, so they must be present (a missing field can make the client's parser throw).
public int SubscriberCount { get; set; }
public int SubscribedCount { get; set; }
}
}
@@ -0,0 +1,308 @@
using System.Text;
using System.Text.RegularExpressions;
namespace Campus_live_rewrite.api.server.Classes
{
public class Sanitize
{
public string Context { get; set; } = "RoomChat";
public int Intent { get; set; } = 1;
public bool PreRemoveBlockedCharacters { get; set; } = true;
public string ReplacementChar { get; set; } = "*";
public string Value { get; set; } = "";
public int ruleset { get; set; } = 0;
private const char MaskChar = '*';
// Words that should never be censored
private static readonly HashSet<string> Whitelist = new(StringComparer.OrdinalIgnoreCase)
{
"password",
"pass",
"class",
"assistant",
"account"
};
// Bad words in NORMALIZED form (see Normalize): lowercase letters only, leet
// characters folded, runs of 3+ identical letters collapsed to 2. Both sides of
// the comparison go through the same normaliser, so a plain set lookup catches
// "F.U.C.K" and "b1tch" with no pattern matching at all.
//
// This replaced 1749 compiled regexes of the form ^f.{0,1}u.{0,1}c.{0,1}k$, run
// against every token of every message. The .{0,1} gaps were meant to jump
// separator characters - but Normalize already strips every non-letter before the
// match, so all they did was let a 3-letter entry match a 4-or-5-letter innocent
// word: "fag" matched FLAG, "gay" matched GRAY, "cum" matched CHUM, "hoe" matched
// HOSE. Exact comparison on the normalised token is both correct and far cheaper.
private static readonly HashSet<string> Swears = new(StringComparer.Ordinal);
static Sanitize()
{
// Absolute path. This runs in a static constructor, so a miss here throws
// TypeInitializationException on EVERY caller - room chat, DM threads, room
// names, usernames, display names, bios. A missing word list must degrade to
// "nothing is filtered", never take those endpoints down.
string path = Path.Combine(Environment.CurrentDirectory, "Data", "badwords.txt");
try
{
if (!File.Exists(path))
{
Console.WriteLine($"[Sanitize] word list not found at {path} - nothing will be filtered");
return;
}
int masked = 0;
foreach (var line in File.ReadAllLines(path))
{
// A masked entry ("f***", "****..just", "h*ll") is somebody's already-censored
// OUTPUT, not a word: normalised it becomes "just", "hll", "f" - which is how
// "just" got censored in chat. Skipped, never learnt.
if (line.Contains('*')) { masked++; continue; }
var normalized = Normalize(line);
if (normalized.Length >= 3) Swears.Add(normalized);
}
if (masked > 0) Console.WriteLine($"[Sanitize] skipped {masked} masked (*) entries in the word list");
Console.WriteLine($"[Sanitize] loaded {Swears.Count} words from {path}");
}
catch (Exception ex)
{
Console.WriteLine($"[Sanitize] could not load word list ({ex.Message}) - nothing will be filtered");
}
}
/* ----------------------------------------------------
* PUBLIC API
* --------------------------------------------------*/
public static bool ContainsSwears(string message)
{
if (string.IsNullOrWhiteSpace(message))
return false;
if (ConfigCampus.AllowSwears)
return false;
if (SplitKeepingWhitespace(message).Any(chunk => !IsWhitespace(chunk) && ChunkIsSwear(chunk))) return true;
return FindConcealed(message).Count > 0;
}
public static string SanitizeMessage(string message, char mask = MaskChar)
{
if (string.IsNullOrWhiteSpace(message))
return message;
var sb = new StringBuilder(message.Length);
// Two passes, because they catch different evasions.
//
// 1. The whole whitespace-delimited chunk, punctuation stripped. This is what
// catches "f.u.c.k", "f-u-c-k", "f*ck" and zero-width characters wedged
// between letters - splitting on \W+ first would turn those into
// single-letter tokens that are too short to test, which is exactly how
// they used to get through.
//
// 2. Failing that, the individual words inside the chunk, so "hey,bitch"
// (one chunk, normalising to the harmless "heybitch") still gets caught on
// the word "bitch".
// 3. The CONCEALED forms neither of those see, run first over the whole message so
// the masked characters are already in place when the token passes look:
// letters spread over several tokens ("s h i t", "sh it") and a swear
// repeated or lightly suffixed inside one token ("shitshit", "shitty").
// See FindConcealed for the rules that keep "this hit" and "cockpit" alone.
var concealed = FindConcealed(message);
if (concealed.Count > 0)
{
var chars = message.ToCharArray();
foreach (var (start, end) in concealed)
for (int i = start; i < end; i++)
if (char.IsLetterOrDigit(chars[i])) chars[i] = mask;
Console.WriteLine($"[Sanitize] Censored concealed word(s) in message: '{message}'");
message = new string(chars);
}
foreach (var chunk in SplitKeepingWhitespace(message))
{
if (IsWhitespace(chunk))
{
sb.Append(chunk);
continue;
}
if (ChunkIsSwear(chunk))
{
sb.Append(new string(mask, chunk.Length));
Console.WriteLine($"[Sanitize] Censored word in message: '{chunk}'");
continue;
}
foreach (var token in Regex.Split(chunk, @"(\W+)"))
{
if (IsWord(token) && ChunkIsSwear(token))
{
sb.Append(new string(mask, token.Length));
Console.WriteLine($"[Sanitize] Censored word in message: '{token}'");
}
else
{
sb.Append(token);
}
}
}
return sb.ToString();
}
/* ----------------------------------------------------
* INTERNAL HELPERS
* --------------------------------------------------*/
private static string[] SplitKeepingWhitespace(string input) =>
Regex.Split(input, @"(\s+)");
// Swears of 4+ letters, the only ones worth hunting inside other text: the 3-letter
// entries ("ass", "cum") are exactly the ones that live inside innocent words.
private static readonly Lazy<string[]> LongSwears = new(() =>
Swears.Where(w => w.Length >= 4).OrderByDescending(w => w.Length).ToArray());
/// <summary>
/// Character spans [start, end) of swears that the token passes cannot see:
///
/// spread - the letters of one swear spread across several whitespace-separated
/// tokens ("s h i t", "sh it", "s.h.i t"). A match counts only when it
/// starts at the start of a token and ends at the end of one, covering
/// whole tokens - so "this hit" (thi|shit spans two partial tokens) is
/// not a match, "s h i t" is.
/// compound - one token that IS a swear repeated or lightly suffixed: "shitshit",
/// "shitty", "fucked". The rest of the token after the swear(s) must be
/// at most 2 letters, and the swear must start the token - so "cockpit"
/// (3 letters after) and "scunthorpe" (swear in the middle) are left alone.
///
/// Whole-token exact matches are the caller's job (ChunkIsSwear); this only adds the
/// forms it misses.
/// </summary>
private static List<(int start, int end)> FindConcealed(string message)
{
var spans = new List<(int, int)>();
if (string.IsNullOrWhiteSpace(message) || ConfigCampus.AllowSwears || Swears.Count == 0) return spans;
// Normalised letter stream with, for every letter, its index in the original.
var letters = new StringBuilder(message.Length);
var origIdx = new List<int>(message.Length);
var tokenStarts = new HashSet<int>(); // stream positions that begin a token
var tokenEnds = new HashSet<int>(); // stream positions just past a token's last letter
bool inToken = false;
for (int i = 0; i < message.Length; i++)
{
char c = message[i];
if (char.IsWhiteSpace(c))
{
if (inToken) { tokenEnds.Add(letters.Length); inToken = false; }
continue;
}
string n = Normalize(c.ToString());
if (n.Length == 0) continue; // punctuation / digit that folds to nothing
if (!inToken) { tokenStarts.Add(letters.Length); inToken = true; }
letters.Append(n[0]);
origIdx.Add(i);
}
if (inToken) tokenEnds.Add(letters.Length);
string stream = letters.ToString();
if (stream.Length < 4) return spans;
// Per-token bounds in stream coordinates, for the compound rule.
var tokens = tokenStarts.OrderBy(x => x).Zip(tokenEnds.OrderBy(x => x), (a, b) => (a, b)).ToList();
foreach (var swear in LongSwears.Value)
{
int from = 0;
while ((from = stream.IndexOf(swear, from, StringComparison.Ordinal)) >= 0)
{
int end = from + swear.Length;
bool spread = tokenStarts.Contains(from) && tokenEnds.Contains(end)
&& tokens.Count(t => t.a >= from && t.b <= end) >= 2;
bool compound = false;
if (!spread)
{
var tok = tokens.FirstOrDefault(t => t.a <= from && end <= t.b);
if (tok != default && tok.a == from)
{
// Strip every leading repeat of the swear, then allow a tiny tail.
int p = from;
while (p + swear.Length <= tok.b && string.CompareOrdinal(stream, p, swear, 0, swear.Length) == 0) p += swear.Length;
int tail = tok.b - p;
// The tail has to be an actual suffix ("s", "y", "ed", "er"...). Any
// two letters used to count, so "hello" was hell+o and censored.
string tailText = tail > 0 ? stream.Substring(p, tail) : "";
bool suffix = tail == 0 || CompoundSuffixes.Contains(tailText);
if (tail <= 2 && suffix && (p > from + swear.Length || tail > 0)) // repeated, or one swear + short suffix
{
compound = true; end = tok.b;
string whole = stream.Substring(tok.a, tok.b - tok.a);
if (Whitelist.Contains(whole) || Swears.Contains(whole)) compound = false; // exact forms are the token pass's
}
}
}
if (spread || compound)
{
if (!spans.Any(x => x.Item1 <= origIdx[from] && origIdx[end - 1] < x.Item2))
spans.Add((origIdx[from], origIdx[end - 1] + 1));
from = end;
}
else from++;
}
}
return spans;
}
private static readonly HashSet<string> CompoundSuffixes = new(StringComparer.Ordinal)
{ "s", "y", "z", "ed", "er", "es", "in", "ie", "ty", "rs", "ah" };
private static bool IsWhitespace(string s) =>
s.Length > 0 && char.IsWhiteSpace(s[0]);
private static bool IsWord(string input) =>
input.Any(char.IsLetterOrDigit);
private static bool ChunkIsSwear(string raw)
{
var normalized = Normalize(raw);
if (normalized.Length < 3) return false;
if (Whitelist.Contains(normalized)) return false;
if (Swears.Contains(normalized)) return true;
// "fuuuuck". Normalize leaves runs at 2 ("fuuck") so that "ass" does not
// become "as", but that means a stretched word no longer matches its entry.
// Squeezing every run to a single letter gives "fuck", which does. Only the
// TOKEN is squeezed - the word list keeps its 2-letter runs, so this cannot
// make the innocent "as" match the entry "ass".
var squeezed = Regex.Replace(normalized, @"(.)\1+", "$1");
return squeezed.Length >= 3
&& !Whitelist.Contains(squeezed)
&& Swears.Contains(squeezed);
}
private static string Normalize(string input)
{
input = input.ToLowerInvariant();
input = Regex.Replace(input, @"[0@]", "o");
input = Regex.Replace(input, @"[1!|]", "i");
input = Regex.Replace(input, @"[$5]", "s");
input = Regex.Replace(input, @"3", "e");
input = Regex.Replace(input, @"7", "t");
// Remove non letters
input = Regex.Replace(input, @"[^a-z]", "");
// Collapse repeats (fuuuuuck → fuuck)
input = Regex.Replace(input, @"(.)\1{2,}", "$1$1");
return input;
}
}
}
@@ -0,0 +1,24 @@
using Newtonsoft.Json;
namespace Campus_live_rewrite.api.server.Classes
{
public class SteamAuthResponse
{
public SteamResponse? response { get; set; }
}
public class SteamResponse
{
[JsonProperty("params")]
public SteamParams? paramsData { get; set; }
}
public class SteamParams
{
public string? result { get; set; }
public string? steamid { get; set; }
public string? ownersteamid { get; set; }
public bool vacbanned { get; set; }
public bool publisherbanned { get; set; }
}
}
@@ -0,0 +1,56 @@
namespace Campus_live_rewrite.api.server.Classes
{
/// <summary>
/// Storefront ids, taken from the client's own enum. /api/storefronts/v3/giftdropstore/{id}
/// uses these, and buyItem sends "StorefrontType": N in its body.
///
/// Recovered by the 2023 Campus server (D:\Puck\Campus-Server) by reading client metadata -
/// reproduced here so the 2025 server names storefronts in logs instead of printing bare ids.
/// </summary>
public enum StorefrontType
{
None = 0,
LaserTag = 1,
RecCenter = 2,
Watch = 3, // the main token store
Quest_LostSkulls = 100, // Isle
Quest_Dracula = 101, // Crescendo
Quest_GoldenTrophy = 102, // Crimson
Quest_CrimsonCauldron = 103,
RecRoyale = 200,
Cafe = 300,
Paintball = 400,
Paintball_River = 401,
Paintball_Homestead = 402,
Paintball_Quarry = 403,
Paintball_ClearCut = 404,
Paintball_Spillway = 405,
Paintball_SunsetDriveIn = 406,
Bowling = 500,
StuntRunner = 600,
DormMirror = 700,
InventionStore = 800,
RoomKeys = 900,
Player_Profile = 1000,
Room_Save = 1100,
RoomCurrency = 1200,
Wishlist = 1300,
RoomConsumable = 1400,
RecRally = 1500,
// The Dorm Mirror customize screen asks for 2000-2004 when it opens. There are no
// pop-up shops, so an empty store is the correct answer for these.
PopUpShop_1 = 2000,
PopUpShop_2 = 2001,
PopUpShop_3 = 2002,
PopUpShop_4 = 2003,
PopUpShop_5 = 2004,
CustomAvatarItems = 2100,
}
}
@@ -0,0 +1,9 @@
namespace Campus_live_rewrite.api.server.Classes
{
public class Sub
{
public ulong creatorAccountId { get; set; }
public ulong clubId { get; set; }
public int membershipType { get; set; }
}
}
@@ -0,0 +1,9 @@
namespace Campus_live_rewrite.api.server.Classes
{
public class SuccessResponse
{
public bool success { get; set; }
public object? value { get; set; }
}
}
@@ -0,0 +1,26 @@
namespace Campus_live_rewrite.api.server.Classes
{
public class VersionCheck
{
public VersionStatus ValidVersion { get; set; }
public VersionStatus VersionStatus { get; set; }
public UpdateNoti UpdateNotificationStage { get; set; }
public bool IsVersionIslanded { get; set; }
public bool IsCrossPlayDisabled { get; set; }
}
public enum VersionStatus
{
ValidForPlay,
UpdateRequired
}
public enum UpdateNoti
{
None,
Silent,
Warn,
Prompt,
Require
}
}
@@ -0,0 +1,111 @@
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class ChatDBClasses
{
public class ChatThread
{
public List<ChatMessage> Messages { get; set; }
public long ChatThreadId { get; set; }
public List<ulong> PlayerIds { get; set; }
public long LastReadMessageId { get; set; }
public string ChatThreadName { get; set; }
public DateTime? SnoozedUntil { get; set; }
public bool IsFavorited { get; set; }
// 0 = a normal DM / group thread, 1 = a PARTY thread (the party's chat; the client
// finds it through /chat/thread/party and shows it in-room).
public int ChatThreadType { get; set; } = 0;
// Per-member read pointer / snooze / favorite. These are PER PLAYER in the real
// service; the three thread-level fields above are the pre-existing shared ones and
// are only read when a player has no entry here yet.
public Dictionary<string, ChatMemberState> MemberStates { get; set; } = new();
// Who created the party thread (party leader) - only meaningful for ChatThreadType 1.
public ulong CreatorPlayerId { get; set; }
// Nullable on purpose: a thread stored before these existed deserialises with null
// (an initialiser would stamp "now" on every old thread and sort them above new ones).
public DateTime? CreatedAt { get; set; }
public DateTime? LastActivityAt { get; set; }
}
public class ChatMemberState
{
public long LastReadMessageId { get; set; }
public DateTime? SnoozedUntil { get; set; }
public bool IsFavorited { get; set; }
}
public class ChatMessage
{
public long ChatMessageId { get; set; }
public long ChatThreadId { get; set; }
public int SenderPlayerId { get; set; }
public DateTime TimeSent { get; set; }
public string Contents { get; set; }
public int ModerationState { get; set; } // todo find the enum
}
public class MessageJson
{
public MessageContentType Type { get; set; }
public int Version { get; set; } // default ver is 2
public string Data { get; set; }
}
public struct SendMessageResponse
{
public ChatResults ChatResult { get; set; }
public ChatThread ChatThread { get; set; }
}
public class ChatPrivacySettings
{
public ulong playerId { get; set; }
public ChatPrivacy directMessagePrivacySetting { get; set; } = ChatPrivacy.Friends;
public ChatPrivacy groupChatPrivacySetting { get; set; } = ChatPrivacy.Friends;
}
public enum ChatResults
{
Success,
InvalidArguments,
ThreadNotFound,
MembershipNotFound,
PlayerAlreadyOnThread,
CannotMessagePlayer,
InvalidCharacters,
RecentlyLeftThread,
ThreadTooLarge
}
public enum ModerationState : byte
{
Active,
Junior_Pending = 11,
Moderation_Pending = 100,
Moderation_Closed,
Moderation_Banned,
MarkedForDelete = 255
}
public enum QueryMode
{
Latest,
NewerThan,
OlderThan
}
public enum MessageContentType
{
Text,
PartyInvite,
Photo
}
public enum ChatPrivacy
{
Friends,
Favorites,
NoOne
}
}
}
@@ -0,0 +1,6 @@
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class ClubDBClasses
{
}
}
@@ -0,0 +1,134 @@
using LiteDB;
using Microsoft.AspNetCore.Http.HttpResults;
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class EventDBClasses
{
public class CreateEventRequest
{
// {"RoomId":708,"SubRoomId":1444,"ClubId":null,"name":"awdadwdawdadwdawdadwd","Description":"awdadwdawdadwdawdadwdawdadwdawdadwdawdadwdawdadwdawdadwdawdadwdawdadwd","Tags":["grandopening"],"ImageName":"J9W8A0FUGWXG4CMBKFDJ46CUZHJOHP.png","StartTime":"2025-12-13T14:20:41.6244242Z","EndTime":"2025-12-13T14:50:41.6244242Z","Accessibility":1,"IsMultiInstance":false,"SupportMultiInstanceRoomChat":false,"DefaultBroadcastPermissions":0,"CanRequestBroadcastPermissions":0}
public ulong RoomId { get; set; }
public ulong? SubRoomId { get; set; }
public ulong? ClubId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public List<string>? Tags { get; set; }
public string? ImageName { get; set; }
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public PlayerEventAccessibility Accessibility { get; set; }
public bool IsMultiInstance { get; set; } // this is just multi instance room
public bool SupportMultiInstanceRoomChat { get; set; } // this let player talk betweeen room instance using room chat
public int DefaultBroadcastPermissions { get; set; } // idk what this is used for
public int CanRequestBroadcastPermissions { get; set; } // idk what this is used for
}
public class FullPlayerEvent
{
public List<FullTag>? Tags { get; set; }
public List<FullEventResponses>? Responses { get; set; }
[BsonId]
public ulong PlayerEventId { get; set; }
public ulong? CreatorPlayerId { get; set; }
public string? ImageName { get; set; }
public ulong RoomId { get; set; }
public ulong? SubRoomId { get; set; }
public ulong? ClubId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public int AttendeeCount { get; set; }
public int State { get; set; }
public PlayerEventAccessibility Accessibility { get; set; }
public bool IsMultiInstance { get; set; } // this is just multi instance room
public bool SupportMultiInstanceRoomChat { get; set; } // this let player talk betweeen room instance using room chat
public BroadcastPermissionsEnum DefaultBroadcastPermissions { get; set; } // idk what this is used for
public BroadcastPermissionsEnum CanRequestBroadcastPermissions { get; set; } // idk what this is used for
public ulong BroadcastingRoomInstanceId { get; set; }
public DateTime? RecurrenceSchedule { get; set; }
}
public class FullTag
{
public string? Tag { get; set; }
public TagType Type { get; set; }
}
public class FullEventResponses
{
public ulong PlayerEventResponseId { get; set; }
public ulong PlayerEventId { get; set; }
public ulong PlayerId { get; set; }
public DateTime CreatedAt { get; set; }
public PlayerEventResponseType Type { get; set; } = PlayerEventResponseType.None;
}
public enum PlayerEventResponseType
{
None = -1,
Yes,
Interested,
No,
Pending
}
public enum BroadcastPermissionsEnum
{
None,
RoomOwners = 256,
All = 2147483647
}
public enum PlayerEventAccessibility
{
Private,
Public,
Unlisted
}
public enum TagType
{
Unknown1 = 0,
Unknown2 = 1,
Unknown3 = 2
}
public enum EventResults
{
Success,
HasModeratorClosedEvent,
DoesNotExist,
PlayerDoesNotExist,
RoomDoesNotExist,
StatusUnchanged,
PrivateEvent,
SomethingWentWrong,
DoesNotOwnRoom,
ResponseDoesNotExist,
PlayerAlreadyInvited,
EventDatesInvalid,
EventTooLong,
EventTooShort,
InappropriateName,
InappropriateDescription,
SomeInvitesFailed,
CannotInviteJunior,
EventCountLimitReached,
DoesNotOwnEvent,
UnregisteredOrJuniorNotAllowed,
InvalidClubPermissions,
ImageDoesNotExist,
SubRoomDoesNotExist,
DoesNotOwnSubRoom,
ModifyTagsFailed,
RoomCapacityTooLow,
BroadcastEventNotMultiInstance,
PlayerNotAllowedToCreateMultiInstanceEvents,
PlayerBannedFromEventCreation,
EventIsModerationClosed,
EventIsModerationPendingReview
}
}
}
@@ -0,0 +1,61 @@
using System;
using LiteDB;
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class YourFriendData
{
[BsonId]
public ObjectId? Id { get; set; }
public ulong PlayerID { get; set; }
public RelationshipDetail Relationship { get; set; }
}
public class FriendData
{
[BsonId]
public ObjectId? Id { get; set; }
public string? FriendId { get; set; }
public RelationshipType Relationship { get; set; }
public FriendInfo FriendFlags { get; set; }
public ReciprocalStatus Status { get; set; }
}
public class RelationshipDetail
{
public ulong Id { get; set; }
public ulong PlayerID { get; set; }
public ulong OtherPlayerID { get; set; }
public RelationshipType RelationshipType { get; set; }
public ReciprocalStatus Muted { get; set; }
public ReciprocalStatus Ignored { get; set; }
public ReciprocalStatus Favorited { get; set; }
public int VoiceVolume { get; set; } = 100;
}
public enum RelationshipType
{
None,
FriendRequestSent,
FriendRequestReceived,
Friend,
}
[Flags]
public enum FriendInfo
{
None = 0,
Favorited = 1,
Muted = 2,
Ignored = 4,
Blocked = 5
}
public enum ReciprocalStatus
{
None,
Local,
Remote,
Mutual
}
}
@@ -0,0 +1,101 @@
using System.Text.Json.Serialization;
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class LeaderboardDBClasses
{
public class GetNearbyScoresRequest
{
// {"PlayerId":2,"StatChannel":2,"RoomId":29,"FilterType":1,"SortAscending":false,"WindowSize":10}
public ulong PlayerId { get; set; }
public int StatChannel { get; set; }
public ulong RoomId { get; set; }
public FilterTypeEnum FilterType { get; set; }
public bool SortAscending { get; set; }
public int WindowSize { get; set; }
}
public class GetRanksRequest
{
// {"PlayerId":2,"StatChannel":2,"RoomId":29,"FilterType":0,"SortAscending":false,"RankStart":0,"RankEnd":9}
public ulong PlayerId { get; set; }
public int StatChannel { get; set; }
public ulong RoomId { get; set; }
public FilterTypeEnum FilterType { get; set; }
public bool SortAscending { get; set; }
public int RankStart { get; set; } // skip
public int RankEnd { get; set; } // take
}
public class CheckAndSetStatRequest
{
// {"StatChannel":2,"RoomId":29,"StatValue":1,"CurrentStatValue":null}
public int StatChannel { get; set; }
public ulong RoomId { get; set; }
public int StatValue { get; set; }
public int? CurrentStatValue { get; set; }
}
public class Entry
{
[Newtonsoft.Json.JsonIgnore] // serialized with Newtonsoft (JsonConvert); STJ's JsonIgnore is ignored there and leaked Id/StatChannel/RoomId
public int Id { get; set; } // LiteDB PK
public int PlayerId { get; set; }
[Newtonsoft.Json.JsonIgnore] // serialized with Newtonsoft (JsonConvert); STJ's JsonIgnore is ignored there and leaked Id/StatChannel/RoomId
public int StatChannel { get; set; }
[Newtonsoft.Json.JsonIgnore] // serialized with Newtonsoft (JsonConvert); STJ's JsonIgnore is ignored there and leaked Id/StatChannel/RoomId
public long RoomId { get; set; }
public int Score { get; set; }
public int Rank { get; set; }
// LeaderboardGuard bookkeeping. Not on the wire.
[Newtonsoft.Json.JsonIgnore] public DateTime UpdatedAt { get; set; }
[Newtonsoft.Json.JsonIgnore] public DateTime DayGainDate { get; set; }
[Newtonsoft.Json.JsonIgnore] public long DayGain { get; set; }
}
public class SingleLeaderboard
{
public List<Entry> Rows { get; set; }
}
public class FullLeaderboard
{
public List<Entry> GlobalOverall { get; set; }
public List<Entry> GlobalPeriodic { get; set; }
public List<Entry> FriendsOverall { get; set; }
public List<Entry> FriendsPeriodic { get; set; }
public DateTime NextResetUTC { get; set; }
}
public enum RequestResult
{
Success,
InvalidStat,
RedisConnectionError
}
public enum FilterType
{
Global,
Friends
}
public enum Timeframe
{
AllTime,
TwoWeeks
}
public enum FilterTypeEnum
{
NearbyScoresGlobal,
NearbyScoresFriends,
Champions,
NUM_VALUES // wat
}
}
}
@@ -0,0 +1,552 @@
using System;
using System.Text.Json.Serialization;
using LiteDB;
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class account_data
{
[JsonPropertyOrder(1)]
public ulong accountId { get; set; }
[JsonPropertyOrder(2)]
public string? username { get; set; }
[JsonPropertyOrder(3)]
public string? displayName { get; set; }
[JsonPropertyOrder(4)]
public string? profileImage { get; set; }
[JsonPropertyOrder(5)]
public bool? isJunior { get; set; }
[JsonPropertyOrder(6)]
public long platforms { get; set; }
[JsonPropertyOrder(7)]
public long personalPronouns { get; set; }
[JsonPropertyOrder(8)]
public long identityFlags { get; set; }
[JsonPropertyOrder(9)]
public DateTime createdAt { get; set; }
[JsonPropertyOrder(10)]
public bool isMetaPlatformBlocked { get; set; } = false;
}
public class setting_data
{
public ulong accountId { get; set; }
public string Key { get; set; }
public string Val { get; set; }
}
public class player_data
{
public ulong id { get; set; }
public ulong playerid { get; set; }
public Platforms platform { get; set; }
public List<string>? deviceId { get; set; }
// Device bindings WITH the platform each was seen on. The flat deviceId list above stays
// the source of truth for the anti-alt "one account per device" check; this parallel list
// adds the platform so the account lock can enforce "one PC + one Quest headset" (see
// AccountLock.TryEnforceAndBind). Null on legacy rows; populated lazily on next login.
public List<mDeviceBind>? DeviceBindings { get; set; }
// Where the account was made: true = in-game (the client's first-launch create_account),
// false = the website. Null on rows older than the flag - see PlayerDB.OriginOf for how
// those are classified. Drives the one-game-account-plus-one-website-account-per-device
// rule (ConfigCampus.MaxAccountsPerDevice / AccountLock.CanShareSlot).
public bool? CreatedInGame { get; set; }
public string? authtoken { get; set; }
public string? password { get; set; }
public player_info? player { get; set; }
public player_extra? player_Extra { get; set; }
public DevFlag dev_Flag { get; set; }
}
public enum Platforms
{
All = -1,
Steam,
Oculus,
PlayStation,
Xbox,
RecNet,
IOS,
GooglePlay,
Standalone
}
public class player_extra
{
// Equipment skins this player owns, as "PrefabName|ModificationGuid". Null = none (the
// tools' default looks). Devs ignore this and get the whole catalog (the dev locker).
public List<string>? EquipmentSkins { get; set; }
public ulong? DormRoomID { get; set; }
public List<string>? IpAddresses { get; set; }
public ulong DiscordId { get; set; }
// The linked Discord user's tag/username, stored purely so the website Settings > Discord
// tab can show "Linked as <name>" instead of a bare numeric id. Set by the /link bot flow.
public string? DiscordUsername { get; set; }
// Name lock (admin panel > Edit Player). While true the player cannot change their own
// @name or display name - in-game, on the website, or through the first-run onboarding
// path. Admin edits still go through. Absent/false on every existing record.
public bool NameLocked { get; set; }
public string? Bio { get; set; }
public string? avatar { get; set; }
public List<avatar_data_saved>? SavedOutfits { get; set; }
public Moderation_Detail.ModerationBlockDetails? ModerationBlockDetails { get; set; }
public List<ulong>? CheeredRooms { get; set; }
public List<ulong>? FavoritedRooms { get; set; }
// Last time each once-per-day game reward was claimed, keyed by reward type name.
// Stored as a UTC date string (yyyy-MM-dd) so "already claimed today" is a plain
// string compare and cannot drift with timezones.
public Dictionary<string, string> DailyRewardClaims { get; set; } = new();
// Gift boxes waiting to be opened. Served by /api/avatar/v2/gifts.
public List<PendingGiftBox> PendingGiftBoxes { get; set; } = new();
// Avatar items this account actually OWNS, as raw AvatarItemDesc strings.
//
// Until this existed /api/avatar/v4/items handed the entire 3061-item catalog to
// everyone, so every account owned every item. Non-developers now start from
// Data/APIS/items/DefaultAvatarItems.json and earn the rest; developers keep the
// full catalog (the dev locker). See PlayerDB.OwnedAvatarItemDescs.
//
// null means "never granted the starter set" - distinct from an empty list, which
// would mean a player who somehow owns nothing. The grant is lazy on first read so
// existing accounts do not need a migration pass.
public List<string>? AvatarItems { get; set; }
/// <summary>Custom avatar items (UGC) this player bought or was gifted - the ids.</summary>
public List<string>? CustomItems { get; set; }
// Consumables the player owns: ConsumableItemDesc -> count left. Filled by gift boxes /
// store buys; /api/consumables/v2/getUnlocked lists only these (devs keep the whole file).
public Dictionary<string, int>? Consumables { get; set; }
// Per-currency balances, keyed by CurrencyType (see api/server/Classes/CurrencyType.cs):
// 1=LaserTagTickets, 2=RecCenterTokens, 100=LostSkullsGold, 101=DraculaSilver,
// 300=RoomCurrency, 500=RoomieCredits, etc. Value is the amount held.
// Persisted with the rest of player_extra, so balances survive restarts.
public Dictionary<int, long> Balances { get; set; } = new();
// ---- Quests / rewards progress (see APIController objectives + challenge, RewardsDB) ----
// Daily objectives: the client owns the objective CATALOG (what Group/Index means, its
// target and reward); the server only stores per-(Group,Index) progress numbers and a
// once-only reward latch. Weekly challenges: only completion flags per (map,challenge),
// plus which challenge maps have already paid their gift.
public List<ObjectiveProgress> Objectives { get; set; } = new();
public List<ObjectiveGroupProgress> ObjectiveGroups { get; set; } = new();
public List<ChallengeStatus> ChallengeStatuses { get; set; } = new();
public List<int> ClaimedChallengeMaps { get; set; } = new();
public int sub_count { get; set; } = 0;
public List<ulong> SubbedTo { get; set; } = new();
public bool IsRecentRoomHistoryVisible { get; set; } = true;
public Influencer? Influencer { get; set; }
public CustomAvatarItems CustomAvatarItems { get; set; } = new CustomAvatarItems();
public MyRoomData MyRoomData { get; set; } = new MyRoomData();
public List<MessageData>? Messages { get; set; }
public PlayerPhotoTaggingSetting PlayerPhotoTaggingSetting { get; set; } = PlayerPhotoTaggingSetting.Anyone;
}
public enum PlayerPhotoTaggingSetting
{
Anyone,
Friends,
NoOne
}
public class SetPlayerPhotoTaggingSettingRequest
{
public PlayerPhotoTaggingSetting Setting { get; set; }
}
public class MyRoomData
{
public List<string> owned_room_keys { get; set; } = new List<string>();
public List<PlayerData> PlayerData { get; set; } = new List<PlayerData>();
public List<My_Room_currencies> Roomcurrencies { get; set; } = new List<My_Room_currencies>();
}
public class PlayerData
{
public ulong RoomId { get; set; }
public string Data { get; set; }
}
public class My_Room_currencies
{
public long RoomId { get; set; }
public string Id { get; set; }
public long Value { get; set; }
}
public class SubscribedToEntry
{
public ulong PlayerId { get; set; }
}
public class avatar_data_saved
{
public ulong Slot { get; set; }
public string? Avatar { get; set; }
public string? ImageName { get; set; }
public List<CustomAvatarItemEntry>? CustomAvatarItems { get; set; }
public string? FaceFeatures { get; set; }
public string? HairColor { get; set; }
public string? Name { get; set; }
public string? SkinColor { get; set; }
public string? OutfitSelections { get; set; }
public string? OutfitSelectionsV2 { get; set; }
public string? PreviewImageName { get; set; }
}
public class CustomAvatarItemEntry
{
public string? CustomAvatarItemId { get; set; }
public int BodyPart { get; set; }
}
public class avatar_data
{
public string? OutfitSelections { get; set; }
public string? FaceFeatures { get; set; }
public string? SkinColor { get; set; }
public string? HairColor { get; set; }
}
/// <summary>
/// A gift box granted to a player but not yet opened.
/// GiftContext identifies which box (4003 Golden, 4503 Jumbotron, ... see
/// api/server/Classes/GameRewardType.cs -> GiftContextIds).
/// </summary>
public class PendingGiftBox
{
public string Id { get; set; } = System.Guid.NewGuid().ToString();
/// <summary>How many of a consumable the box holds (the store row's ItemCount; 1 when unknown).</summary>
public int ConsumableCount { get; set; }
public int GiftContext { get; set; }
public string? Message { get; set; }
public ulong FromAccountId { get; set; }
public System.DateTime CreatedAt { get; set; } = System.DateTime.UtcNow;
public bool Opened { get; set; }
// Int32-safe numeric id. The client's gift package field is a 32-bit int: a
// 13-digit millisecond timestamp overflows it, the package fails to deserialize,
// and the box renders the "Update your game" placeholder while the item never
// lands live. (Learned by the 2023 server - D:\Puck\Campus-Server.)
public int NumericId { get; set; }
// What the box actually contains, when it came from a store purchase.
//
// AvatarItemDesc is stored RAW - exactly as the store file writes it, e.g.
// "4ee2133d-...,,,". The client parses this as guid,combination,combination,
// combination; hand it a bare guid with the empty fields stripped and it cannot
// read it, so the item never reaches the locker. 746 of the 968 Watch items use
// that trailing-comma form, which is why Laser Tag purchases always worked and
// Watch ones did not.
public string? AvatarItemDesc { get; set; }
public string? ConsumableItemDesc { get; set; }
public int AvatarItemType { get; set; }
public int GiftDropId { get; set; }
public string? FriendlyName { get; set; }
public int Rarity { get; set; }
public string? EquipmentPrefabName { get; set; }
public string? EquipmentModificationGuid { get; set; }
/// <summary>A UGC item (custom avatar item id) the box carries - a bought/gifted custom clothing item.</summary>
public string? CustomAvatarItemId { get; set; }
public bool Anonymous { get; set; }
// What a NON-item box pays: tokens (a duplicate reward converted, a level-up's token
// tier) and/or XP. These are DISPLAY copies - the balance and the XP are banked when the
// box is minted, not when it is opened - but the box has to carry them or the gifts list
// and the open animation read "0 Tokens" / no XP, which is what players saw.
public int CurrencyType { get; set; }
public long Currency { get; set; }
public int Xp { get; set; }
// True = the Currency above is credited when the box is OPENED (consume), so the number
// moves as the box pops rather than silently beforehand. False = already credited.
public bool PayOnOpen { get; set; }
// True = the Xp above is banked when the box is opened (an admin XP box). Game-reward boxes
// carry Xp for display only (already banked at pick), so they leave this false.
public bool XpOnOpen { get; set; }
// Image the box / picker shows for a drop that has no item of its own (the token coin).
// A plain file name under Data/Images, served by /img/<name> like a consumable's.
public string? ThumbnailImageName { get; set; }
}
/// <summary>Per-player progress for one daily objective, keyed by (Group, Index).</summary>
public class ObjectiveProgress
{
public long Group { get; set; }
public int Index { get; set; }
public double Progress { get; set; }
public double VisualProgress { get; set; }
public bool IsCompleted { get; set; }
// Latches true the first time the objective completes and never unlatches, so a replayed
// or double-fired completion only ever pays its reward once.
public bool HasClaimedReward { get; set; }
}
/// <summary>Per-player state of one objective group.</summary>
public class ObjectiveGroupProgress
{
public long Group { get; set; }
public bool IsCompleted { get; set; }
public string? ClearedAt { get; set; }
}
/// <summary>Per-player completion of one weekly challenge within a rotation (map).</summary>
public class ChallengeStatus
{
public int ChallengeMapId { get; set; }
public int ChallengeId { get; set; }
public bool Complete { get; set; }
public string? UpdatedAt { get; set; }
}
public class player_info
{
public ulong Id { get; set; }
public string? Username { get; set; }
public string? DisplayName { get; set; }
public string? DisplayEmoji { get; set; }
public string? Email { get; set; }
// ISO-8601 date the player entered at website signup. Used to drive account_data_me.birthday
// (the client requires a valid date) and to decide junior status at creation. Null on legacy
// accounts, which fall back to the old fixed placeholder date.
public string? Birthday { get; set; }
public int XP { get; set; }
public int Level { get; set; }
public int pronounFlags { get; set; }
public int identityFlags { get; set; }
public bool IsJunior { get; set; }
public bool AvoidJuniors { get; set; }
public mPlayerReputation? PlayerReputation { get; set; }
public List<mPlatformID>? PlatformIds { get; set; }
public DateTime created_at { get; set; }
public DateTime last_login_time { get; set; }
public List<ulong> recent_rooms { get; set; } = new List<ulong>();
public string? bannerImage { get; set; }
public string? profileImage { get; set; }
}
public class AccountLogin
{
public DateTime createdAt { get; set; }
public DateTime lastLoginTime { get; set; }
public ulong accountId { get; set; }
public Platforms platform { get; set; }
public string? platformId { get; set; }
// false = platform ownership is the credential -> the client cached-logs-in with no prompt.
// true = no account/link behind this entry -> the client shows the username/password login
// screen for it (the reference's FAKE cached-login trick). See AuthController.CachedLogins.
public bool requirePassword { get; set; } = false;
}
public class mPlatformID
{
public Platforms Platform { get; set; }
public ulong PlatformId { get; set; }
}
// One device the account has signed in from, plus the platform it signed in on. Used by the
// account lock to cap an account at one PC device and one Quest (Oculus) device.
public class mDeviceBind
{
public string? DeviceId { get; set; }
public Platforms Platform { get; set; }
/// <summary>Last login from this device (UTC). Null on rows written before it was tracked.</summary>
public DateTime? LastSeen { get; set; }
}
public class mPlayerReputation
{
public bool IsCheerful { get; set; }
public int Noteriety { get; set; }
public int CheerGeneral { get; set; }
public int CheerHelpful { get; set; }
public int CheerGreatHost { get; set; }
public int CheerSportsman { get; set; }
public int CheerCreative { get; set; }
public int CheerCredit { get; set; }
public int SubscriberCount { get; set; }
public int SubscribedCount { get; set; }
public CheerCategoryEnum SelectedCheer { get; set; }
}
public class DeleteMessagesRequest
{
public List<ulong> MessageIds { get; set; }
}
// POST /api/messages/v1/sendMultiple body: one message to several recipients.
// JSON e.g. {"ToPlayerIds":[205],"Type":20,"Data":""}.
public class SendMultipleMessagesRequest
{
public List<ulong>? ToPlayerIds { get; set; }
public int Type { get; set; }
public string? Data { get; set; }
}
public enum CheerCategoryEnum
{
None = -1,
General = 0,
Helpful = 10,
Sportmanship = 20,
GreatHost = 30,
Creative = 40,
RecRoomDeveloper = 9000
}
// The PUBLIC account DTO - the backbone of a player's profile.
//
// Every string here coalesces null -> "" via its setter, so the client NEVER receives a
// null (or a missing key) for a string field. RecFlare's account DTO does exactly this on
// purpose - it reads these as strings, and a null makes its parser throw, which is why a
// profile with no banner / emoji / (rarely) display name would fail to load entirely even
// though the account exists. Fixing it on the DTO covers every path that builds one
// (account/{id}, account/bulk, account/me, admin) at once.
public class account_data_web
{
private string _displayName = "";
private string _displayEmoji = "";
private string _profileImage = "DefaultProfileImage.jpg";
private string _bannerImage = "";
private string _username = "";
public ulong accountId { get; set; }
public DateTime createdAt { get; set; }
public string displayName { get => _displayName; set => _displayName = value ?? ""; }
public string displayEmoji { get => _displayEmoji; set => _displayEmoji = value ?? ""; }
public long identityFlags { get; set; }
public bool isJunior { get; set; }
public long personalPronouns { get; set; }
public long platforms { get; set; }
// Unset profile image falls back to "DefaultProfileImage.jpg" (RecFlare's default), not ""
// - the client renders whatever name it is handed, and an empty name is a broken image.
public string profileImage { get => _profileImage; set => _profileImage = string.IsNullOrEmpty(value) ? "DefaultProfileImage.jpg" : value; }
public string bannerImage { get => _bannerImage; set => _bannerImage = value ?? ""; }
public string username { get => _username; set => _username = value ?? ""; }
}
// The /account/me (self) DTO. Matches RecFlare's toSelfAccountDto: the public account fields
// PLUS email, birthday, availableUsernameChanges. Two rules the reference's tests spell out
// and that a strict client parser enforces:
// * string fields are "" when unset, never null - a null makes the client's parser throw
// (which can leave a screen that reads the full account, like the profile, blank);
// * the client reads `availableUsernameChanges` (capital U) and `birthday`. The birthday
// just has to be PRESENT and a valid date - "the game client needs this to be set" - so a
// fixed old date stands in until real birthdays are stored.
public class account_data_me
{
private string _bannerImage = "";
private string _email = "";
private string _displayName = "";
private string _displayEmoji = "";
private string _profileImage = "DefaultProfileImage.jpg";
private string _username = "";
public ulong accountId { get; set; }
public int availableUsernameChanges { get; set; } = 3;
public string bannerImage { get => _bannerImage; set => _bannerImage = value ?? ""; }
public DateTime createdAt { get; set; }
public string birthday { get; set; } = "1904-01-01T00:00:00.000Z";
public string email { get => _email; set => _email = value ?? ""; }
public string displayName { get => _displayName; set => _displayName = value ?? ""; }
public string displayEmoji { get => _displayEmoji; set => _displayEmoji = value ?? ""; }
public long identityFlags { get; set; }
public bool hasBirthday { get; set; } = true;
public bool isJunior { get; set; }
public long personalPronouns { get; set; }
public long platforms { get; set; }
public string profileImage { get => _profileImage; set => _profileImage = string.IsNullOrEmpty(value) ? "DefaultProfileImage.jpg" : value; }
public bool TreatAsJunior { get; set; } = false;
public string username { get => _username; set => _username = value ?? ""; }
}
public class CustomAvatarItems
{
public bool isCreationAllowedForAccount { get; set; } = true;
public bool isCreationEnabled { get; set; } = true;
public bool isRenderingEnabled { get; set; } = true;
public List<string> ownedItems { get; set; } = new List<string>();
}
public class Influencer
{
public string creatorCode { get; set; } = "";
public bool isInfluencer { get; set; } = false;
public ulong SupportingInfluencer { get; set; }
}
public class MessageData
{
/*
[
{
"Id": 15909836337,
"FromPlayerId": 64495891,
"SentTime": "2025-05-27T11:39:25.12Z",
"Type": 200,
"Data": "58129955",
"RoomId": null,
"PlayerEventId": null
}
]
*/
public ulong Id { get; set; }
public ulong FromPlayerId { get; set; }
// The recipient - the reference's game-invite Message includes ToPlayerId; ours omitted it.
// Harmless on chat/other messages (0 when unset), present on invites so the client's
// invite-message parser has the field it expects.
public ulong ToPlayerId { get; set; }
public DateTime SentTime { get; set; }
public WebsocketEvents.MessageType Type { get; set; }
public string? Data { get; set; }
public ulong? RoomId { get; set; }
public ulong? PlayerEventId { get; set; }
}
public enum DeviceClasses
{
Unknown,
VR,
Screen,
Mobile,
VRLow,
Quest2
}
public class account_login<T>
{
public bool success { get; set; }
public string? error { get; set; }
[JsonIgnore]
public ulong accid { get; set; }
}
[Flags]
public enum DevFlag
{
none = 0,
screenshare = 1,
dev = 2,
mod = 4,
owner = 8,
junior = 16,
keepsake = 32,
newly_created_account = 64,
restricted_account = 128,
support = 256, // website Support staff: can see & manage all support tickets (SiteController.IsStaff)
unused_3 = 512,
unused_4 = 1024,
unused_5 = 2048,
unused_6 = 4096,
unused_7 = 8192,
}
}
@@ -0,0 +1,283 @@
using LiteDB;
using Newtonsoft.Json;
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class RoomDBClasses
{
public class Resultslist<T>
{
public List<T>? Results { get; set; }
public long TotalResults { get; set; }
}
[Flags]
public enum WarningMaskType
{
None = 0,
Scary = 1,
Mature = 2,
FlashingLights = 4,
IntenseMotion = 8,
Violence = 16,
Custom = 32,
Reports = 64
}
public class RoomRoot
{
// The UI shows FriendlyName; `Name` is the internal slug (devin1s_MakerRoom_0601).
// It is a real key on the client's 44-key Room DTO and was simply absent from ours
// (wire-shapes: rooms-contributedby). Computed, never stored.
[LiteDB.BsonIgnore]
public string FriendlyName => Name ?? "";
// Live player count, shown on room cards. Nullable on the wire and filled only by the
// list endpoints that can afford one presence pass; null = "unknown", which the client
// renders as no count rather than zero.
[LiteDB.BsonIgnore]
public int? CCU { get; set; }
public RoomDBClasses.RoomAccessibility Accessibility { get; set; }
public int AgeRating { get; set; }
public bool AutoLocalizeRoom { get; set; }
public DateTime? BecameRRStudioRoomAt { get; set; }
public bool CloningAllowed { get; set; }
public DateTime CreatedAt { get; set; }
public ulong CreatorAccountId { get; set; }
public string? CurrentSnapshotId { get; set; }
public bool NeedsSnapshotId { get; set; }
public string CustomWarning { get; set; }
public string DataBlob { get; set; }
// public string DataBlobHash { get; set; }
public string Description { get; set; }
public bool DisableMicAutoMute { get; set; }
public bool DisableRoomComments { get; set; }
public bool EncryptVoiceChat { get; set; }
public bool ExcludeFromLists { get; set; }
public bool ExcludeFromSearch { get; set; }
public string ImageName { get; set; }
public bool IsDeveloperOwned { get; set; }
public bool IsDorm { get; set; }
public bool IsJuniorCreated { get; set; }
public bool IsPlacePlay { get; set; }
public bool IsRRO { get; set; }
public bool IsRecRoomApproved { get; set; }
public bool LoadScreenLocked { get; set; }
public List<LoadScreen> LoadScreens { get; set; }
public LocalizationContext LocalizationContext { get; set; }
public int MaxPlayerCalculationMode { get; set; }
public int MaxPlayers { get; set; }
public int MinLevel { get; set; }
public int? MinUgcSubVersion { get; set; }
public string Name { get; set; }
public int? PersistenceVersion { get; set; }
public List<string> PromoExternalContent { get; set; }
public List<string> PromoImages { get; set; }
public int PublishState { get; set; }
public DateTime PublishedAt { get; set; }
public string RankedEntityId { get; set; }
public object RankingContext { get; set; }
public List<string> RestrictedCircuitsAllowListNames { get; set; }
public List<RoleClass> Roles { get; set; }
public RoomBanDetails RoomBanDetails { get; set; }
// Every ban ever issued on this room (active, elapsed and lifted). RoomBanDetails above
// is the single "current" record older code reads; this is the real list. Not part of
// the room DTO the client gets.
[Newtonsoft.Json.JsonIgnore]
public List<RoomBanDetails>? RoomBans { get; set; }
[BsonId]
public ulong RoomId { get; set; }
public RoomState State { get; set; }
public Stats Stats { get; set; }
public List<SubRoom> SubRooms { get; set; }
public bool SupportsJuniors { get; set; }
public bool SupportsLevelVoting { get; set; }
public bool SupportsMobile { get; set; }
public bool SupportsQuest2 { get; set; }
public bool SupportsScreens { get; set; }
public bool SupportsTeleportVR { get; set; }
public bool SupportsVRLow { get; set; }
public bool SupportsWalkVR { get; set; }
public List<TagClass> Tags { get; set; }
public bool ToxmodEnabled { get; set; }
public int? UgcSubVersion { get; set; }
public int? UgcVersion { get; set; }
public WarningMaskType WarningMask { get; set; }
}
public class LoadScreen
{
public string ImageName { get; set; }
public string Subtitle { get; set; }
public string Title { get; set; }
}
public class LocalizationContext
{
public List<string> LocalizedFields { get; set; }
public string Scope { get; set; }
public string TargetLocale { get; set; }
}
public class RoleClass
{
public long AccountId { get; set; }
public int InvitedRole { get; set; }
public long? LastChangedByAccountId { get; set; }
public RoomDBClasses.Role_data Role { get; set; }
}
public class RoomBanDetails
{
public long AccountId { get; set; }
public DateTime? BanEndTime { get; set; }
public DateTime BanStartTime { get; set; }
public long BannedByAccountId { get; set; }
public string Reason { get; set; }
public int Status { get; set; }
public long? UnbannedByAccountId { get; set; }
}
public class Stats
{
public int CheerCount { get; set; }
public int FavoriteCount { get; set; }
public int VisitCount { get; set; }
public int VisitorCount { get; set; }
}
public class SubRoom
{
[BsonId]
[Newtonsoft.Json.JsonIgnore]
[System.Text.Json.Serialization.JsonIgnore]
public ObjectId? Id { get; set; }
public CurrentSave? CurrentSave { get; set; }
[JsonIgnore]
public List<CurrentSave> AllSaves { get; set; } = new();
public RoomAccessibility Accessibility { get; set; }
public long? CreatorAccountId { get; set; }
public bool IsSandbox { get; set; }
public int LastModeratedSaveModerationState { get; set; }
public int MaxPlayers { get; set; }
public string Name { get; set; }
public ulong RoomId { get; set; }
public bool ShouldAutoStageSaves { get; set; }
public long? StagedSubRoomDataSaveId { get; set; }
public ulong SubRoomId { get; set; }
public string UnitySceneId { get; set; }
// The permission overrides a manager saved for THIS subroom (RoomPermissions). Not on
// the wire - the client reads them merged into GET /photon_access_token, never here.
[Newtonsoft.Json.JsonIgnore]
[System.Text.Json.Serialization.JsonIgnore]
public List<Campus_live_rewrite.api.server.RoomPermissions.Entry>? Permissions { get; set; }
}
public class CurrentSave
{
public DateTime CreatedAt { get; set; }
public string DataBlob { get; set; }
//public string DataBlobHash { get; set; }
public string Description { get; set; }
public int ModerationState { get; set; }
public int OMVersion { get; set; }
public int PersistenceVersion { get; set; }
public List<string> ReferencedUnityAssetIds { get; set; }
public ulong? SavedByAccountId { get; set; }
public DeviceClasses? SavedOnDeviceClass { get; set; }
public Platforms? SavedOnPlatform { get; set; }
public ulong SubRoomDataSaveId { get; set; }
public ulong SubRoomId { get; set; }
public int UgcSubVersion { get; set; }
public string UnityAssetId { get; set; } // optional, may be null
}
public class TagClass
{
public string Tag { get; set; }
public int Type { get; set; } // 0 owner-set, 1 client-derived (autoTag: limitsv2 / beta), 2 server-derived (rro)
// Present ONLY on the room's primary genre tag (never false) - the client keys on presence.
[Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public bool? IsPrimaryGenre { get; set; }
}
public class SubroomSaveResults
{
public List<CurrentSave> Results { get; set; } = new();
public int TotalCount { get; set; }
}
public class RoomFileData
{
public RoomData? RoomData { get; set; }
public SubRoomData? SubRoomData { get; set; }
public string? InventionUsage { get; set; }
public int PersistenceVersion { get; set; }
public int OMVersion { get; set; }
public int UgcSubVersion { get; set; }
public string? Description { get; set; }
public bool AutoPublish { get; set; }
}
public class RoomData
{
public string? Filename { get; set; }
public string? Hash { get; set; }
public string? OwnershipProof { get; set; }
}
public class SubRoomData
{
public string? Filename { get; set; }
public string? Hash { get; set; }
public string? OwnershipProof { get; set; }
}
public enum RoomAccessibility
{
Private,
Public,
Unlisted,
Dev_only,
Dev_Unlisted,
}
public enum RoomState
{
Active,
PendingJunior = 11,
Moderation_PendingReview = 100,
Moderation_Closed,
MarkedForDelete = 1000
}
public class Tags
{
public string? Tag { get; set; }
public int Type { get; set; }
}
public class LoadScreens
{
public string? ImageName { get; set; }
public string? Title { get; set; }
public string? Subtitle { get; set; }
}
public enum Role_data : byte
{
None,
Banned,
Host = 10,
Moderator = 20,
Contributor = 25, // the client's value (was missing -> a contributor invite landed as CoOwner)
CoOwner = 30,
TemporaryCoOwner,
Creator = 255
}
}
}
@@ -0,0 +1,17 @@
using LiteDB;
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class PlayerSetting
{
[BsonId]
public ulong PlayerId { get; set; }
public List<Setting> Settings { get; set; } = new List<Setting>();
}
public class Setting
{
public string? Key { get; set; }
public string? Value { get; set; }
}
}
@@ -0,0 +1,391 @@
using System.Security.Cryptography;
using System.Text;
using JWT;
using JWT.Algorithms;
using JWT.Exceptions;
using JWT.Serializers;
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server;
public class ClientSecurity
{
public static string key = ConfigCampus.Key;
// ---- in-game token kill-switch -----------------------------------------------------------
// A game token minted before ConfigCampus.GameTokenValidFrom is dead (see DecodeToken). The
// epoch is persisted so a restart doesn't un-revoke; load it at boot, set it from the admin
// "Expire all game tokens" button.
private static readonly string GameTokenEpochFile =
System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "game_token_valid_from.txt");
public static void LoadGameTokenRevocation()
{
try
{
if (System.IO.File.Exists(GameTokenEpochFile) &&
long.TryParse(System.IO.File.ReadAllText(GameTokenEpochFile).Trim(), out var v))
ConfigCampus.GameTokenValidFrom = v;
}
catch (Exception ex) { Console.WriteLine($"[token-revoke] load skipped: {ex.Message}"); }
Console.WriteLine($"[token-revoke] in-game tokens valid from epoch {ConfigCampus.GameTokenValidFrom}");
}
/// <summary>Revoke EVERY in-game bearer token issued before now. Returns the new epoch.</summary>
public static long RevokeAllGameTokens()
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
ConfigCampus.GameTokenValidFrom = now;
try { System.IO.File.WriteAllText(GameTokenEpochFile, now.ToString()); }
catch (Exception ex) { Console.WriteLine($"[token-revoke] persist FAILED: {ex.Message}"); }
Console.WriteLine($"[token-revoke] ALL in-game tokens revoked - valid-from set to {now}");
return now;
}
public static string CreateToken(ulong userId, List<string> roles, string request_url, Platforms platform)
{
var now = DateTimeOffset.UtcNow;
var payload = new Dictionary<string, object>
{
// MUST be a string. As a raw ulong this serializes to `"sub":2` (a JSON
// number); the client casts the sub claim to String while building the
// local Player, and an Int64 there throws InvalidCastException inside
// Player.Awake(). Verified fix - that exception went to zero and stayed
// there. The Photon token builders below already used .ToString().
{ "sub", userId.ToString() },
{ "role", roles },
{ "iat", now.ToUnixTimeSeconds() },
{ "iss", $"{request_url}" },
{ "client_id", $"recnet" },
// 12 h (ConfigCampus.GameTokenHours). The client re-authenticates from its cached
// login when a token expires, so a bounded lifetime costs the player nothing and stops
// a leaked token being usable indefinitely.
{ "exp", now.AddHours(ConfigCampus.GameTokenHours).ToUnixTimeSeconds() },
{ "platform", platform },
// Surface stamp: this is an IN-GAME token. Validators reject it on the website
// (SiteController requires "website") so a game token can't be used as a site
// session. See DecodeToken's surface enforcement + AuthHelper / SiteController.
{ "surface", "game" }
};
IJwtAlgorithm algorithm = new HMACSHA256Algorithm();
IJsonSerializer serializer = new JsonNetSerializer();
IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder);
return encoder.Encode(payload, key);
}
public static string Createphotonacesstoken(ulong userId)
{
var now = DateTimeOffset.UtcNow;
var heartbeat = HeartbeatDB.GetPlayerHeartbeat(userId);
// Null-safe: if the player isn't in a room yet, heartbeat.roomInstance is null.
// Dereferencing `.photonRoomIdYeee` on null throws BEFORE the `??` fallback runs.
string photonRoomId = heartbeat?.roomInstance?.photonRoomIdYeee ?? $"solo-{userId}";
var payload = new Dictionary<string, object>
{
{ "sub", userId.ToString() },
{ "scope", "makerpen" },
{ "aud", photonRoomId },
};
IJwtAlgorithm algorithm = new HMACSHA256Algorithm();
IJsonSerializer serializer = new JsonNetSerializer();
IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder);
return encoder.Encode(payload, key);
}
/// <summary>
/// Photon auth token. The room it is scoped to (the "aud" claim) MUST match the
/// photonRoomId handed back by connection-info - if the token says one room and the
/// client connects to another, Photon custom auth rejects it and the load dies.
/// Pass photonRoomOverride when answering about a specific instance.
/// </summary>
public static string Createphotonauthtoken(ulong userId, string? photonRoomOverride = null)
{
var now = DateTimeOffset.UtcNow;
var heartbeat = HeartbeatDB.GetPlayerHeartbeat(userId);
// Null-safe (see comment on Createphotonacesstoken).
string photonRoomId = photonRoomOverride
?? heartbeat?.roomInstance?.photonRoomIdYeee
?? $"solo-{userId}";
var payload = new Dictionary<string, object>
{
{ "sub", userId.ToString() },
{ "rn.platid", "76561199000000000" },
{ "rn.plat", "0" },
{ "rn.deviceclass", "2" },
{ "rn.env", "prod" },
{ "exp", "1754316353" },
{ "aud", photonRoomId },
};
IJwtAlgorithm algorithm = new HMACSHA256Algorithm();
IJsonSerializer serializer = new JsonNetSerializer();
IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder);
return encoder.Encode(payload, key);
}
public static string RefreshToken(string temp_val, ulong accid, int platform, ulong platform_id, ulong ticketid, string request_url, Platforms currentplatform)
{
var account = PlayerDB.getfullaccount_token(accid, ticketid, 0);
if (account == null)
return "";
List<string> roles = new List<string> { "gameclient" };
if ((account.dev_Flag & DevFlag.dev) == DevFlag.dev)
{
roles.Add("developer");
roles.Add("moderator");
roles.Add("screenshare");
}
if (account.player.Id == 314)
{
roles.Add("developer");
roles.Add("moderator");
roles.Add("screenshare");
}
return CreateToken(accid, roles, request_url, currentplatform);
}
// ttl defaults to 30 days (the game client's long-lived token). The ADMIN web login passes a
// short ttl (1 hour) so the admin session expires and must be re-minted every hour.
//
// `surface` stamps which surface the token belongs to ("website" / "admin"); leave it null
// for the launcher / Discord-OAuth callers, whose token is handed to the game client or set
// as the admin cookie and must keep working on both (validators only REJECT specific
// surfaces, they don't require one unless the surface is website). See DecodeToken.
public static string RefreshToken_Web(ulong userId, TimeSpan? ttl = null, string? surface = null)
{
var account = PlayerDB.GetFullAccount(userId);
if (account == null)
return "";
List<string> roles = new List<string> { "gameclient" };
if ((account.dev_Flag & DevFlag.dev) == DevFlag.dev)
{
roles.Add("developer");
roles.Add("moderator");
roles.Add("screenshare");
}
var now = DateTimeOffset.UtcNow;
var payload = new Dictionary<string, object>
{
// String, same reason as CreateToken above - a refreshed token has to
// carry the same claim shape as the original.
{ "sub", userId.ToString() },
{ "role", roles },
{ "iat", now.ToUnixTimeSeconds() },
{ "exp", now.Add(ttl ?? TimeSpan.FromDays(30)).ToUnixTimeSeconds() },
{ "iss", "https://gameauth.ddns.net" }
};
if (!string.IsNullOrEmpty(surface))
payload["surface"] = surface;
string key = ClientSecurity.key;
IJwtAlgorithm algorithm = new HMACSHA256Algorithm();
IJsonSerializer serializer = new JsonNetSerializer();
IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder);
string tokenString = encoder.Encode(payload, key);
return tokenString;
}
public static string HashPassword(string password)
{
using (var rng = new RNGCryptoServiceProvider())
{
byte[] salt = new byte[16];
rng.GetBytes(salt);
using (var pbkdf2 = new Rfc2898DeriveBytes(password, salt, 100_000, System.Security.Cryptography.HashAlgorithmName.SHA256))
{
byte[] hash = pbkdf2.GetBytes(32);
byte[] hashBytes = new byte[48];
Array.Copy(salt, 0, hashBytes, 0, 16);
Array.Copy(hash, 0, hashBytes, 16, 32);
return Convert.ToBase64String(hashBytes);
}
}
}
public static bool VerifyPassword(string enteredPassword, string storedHash)
{
// Crash-safe: a malformed/legacy stored value (not base64, or not the 48-byte
// salt+hash layout) must fail cleanly, not throw and 500 the login. The legacy-SHA256
// fallback in GetAccountPassword then gets its chance to upgrade the row.
if (string.IsNullOrEmpty(storedHash)) return false;
byte[] hashBytes;
try { hashBytes = Convert.FromBase64String(storedHash); }
catch { return false; }
if (hashBytes.Length != 48) return false;
byte[] salt = new byte[16];
Array.Copy(hashBytes, 0, salt, 0, 16);
using (var pbkdf2 = new Rfc2898DeriveBytes(enteredPassword, salt, 100_000, System.Security.Cryptography.HashAlgorithmName.SHA256))
{
byte[] hash = pbkdf2.GetBytes(32);
byte[] stored = new byte[32];
Array.Copy(hashBytes, 16, stored, 0, 32);
// Constant-time compare: a byte-by-byte early return leaks, via timing, how much of
// the hash matched, which is a foothold for recovering it. FixedTimeEquals doesn't.
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(hash, stored);
}
}
// requireSurface: the token's "surface" claim MUST equal this (used by the website, which
// accepts ONLY website-minted tokens). rejectSurfaces: the token is refused if its
// surface is one of these (used by game/admin to keep website tokens out). A token with
// no surface claim (legacy, or the launcher/Discord tokens) passes rejectSurfaces but
// fails requireSurface - so only the website is strict, which is the surface that was
// getting cross-used. Both default to no-op so the ~dozens of existing single-arg callers
// are unaffected.
public static string DecodeToken(string token, string? requireSurface = null, string[]? rejectSurfaces = null)
{
if (string.IsNullOrEmpty(token))
return "error_token_not_found";
string tokenString = token.Replace("Bearer ", "");
try
{
IJsonSerializer serializer = new JsonNetSerializer();
IDateTimeProvider provider = new UtcDateTimeProvider();
IJwtValidator validator = new JwtValidator(serializer, provider);
IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
IJwtAlgorithm algorithm = new HMACSHA256Algorithm();
IJwtDecoder decoder = new JwtDecoder(serializer, validator, urlEncoder, algorithm);
var jsonPayload = decoder.Decode(tokenString, key, verify: true);
var payload = JsonConvert.DeserializeObject<Dictionary<string, object>>(jsonPayload);
// Surface isolation: keep a token minted for one surface from being accepted on
// another. A WEBSITE token (surface="website") is valid ONLY where the caller
// explicitly requires "website" (SiteController). EVERY other decode path - the
// website validator aside - refuses it, including the many game endpoints that call
// DecodeToken directly instead of through AuthHelper. Game/admin tokens and legacy
// no-surface tokens pass unless a caller adds them to rejectSurfaces (admin also
// rejects "game").
{
string? surface = payload.TryGetValue("surface", out var sfObj) ? sfObj?.ToString() : null;
if (requireSurface != null)
{
if (surface != requireSurface)
return "error_token_wrong_surface";
}
else
{
// No specific surface required => a game / admin / generic context. Website
// tokens are never welcome here.
if (surface == "website")
return "error_token_wrong_surface";
if (rejectSurfaces != null && surface != null && rejectSurfaces.Contains(surface))
return "error_token_wrong_surface";
}
}
// In-game token kill-switch: a game token minted before the revoke epoch is dead, so
// the admin "Expire all game tokens" button logs every player out. Scoped to game
// tokens (surface "game") so it never touches website/admin sessions. A game token
// with no iat is treated as pre-epoch while a revoke is active.
{
string? surfaceForRevoke = payload.TryGetValue("surface", out var sf2) ? sf2?.ToString() : null;
if (surfaceForRevoke == "game" && ConfigCampus.GameTokenValidFrom > 0)
{
long iat = payload.TryGetValue("iat", out var iatObj) ? Convert.ToInt64(iatObj) : 0;
if (iat < ConfigCampus.GameTokenValidFrom)
return "error_token_revoked";
}
}
if (payload.TryGetValue("exp", out var expObj))
{
var expUnix = Convert.ToInt64(expObj);
var nowUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (nowUnix > expUnix)
{
return "error_token_expired";
}
}
else
{
return "error_token_no_expiration";
}
if (payload.TryGetValue("sub", out var subObj))
{
if (ulong.TryParse(subObj.ToString(), out ulong userId))
{
var playerData = PlayerDB.GetAccountById(userId);
if (playerData != null)
{
Console.WriteLine($"player username: {playerData.Username}");
return JsonConvert.SerializeObject(playerData);
}
else
{
Console.WriteLine($"player not found: {userId}");
return "error_token_user_not_found";
}
}
else
{
return "error_token_invalid_sub";
}
}
else
{
return "error_token_no_sub";
}
}
catch (SignatureVerificationException)
{
return "error_token_tampered";
}
catch (Exception ex)
{
Console.WriteLine($"Error decoding token: {ex.Message}");
return "error_token";
}
}
public static byte[] GetHash(string inputString)
{
using (HashAlgorithm algorithm = SHA256.Create())
return algorithm.ComputeHash(Encoding.UTF8.GetBytes(inputString));
}
public static string GetHashString(string inputString)
{
StringBuilder sb = new StringBuilder();
foreach (byte b in GetHash(inputString))
sb.Append(b.ToString("X2"));
return sb.ToString();
}
private static Random random = new Random();
public static string RandomString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
return new string(Enumerable.Repeat(chars, length)
.Select(s => s[random.Next(s.Length)]).ToArray());
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,188 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The store search bar (GET /api/customAvatarItems/v1/search?searchQuery=...), ranked.
///
/// The real RecNet answers this from Elasticsearch, so a player types "eyepatches" and gets the
/// eyepatches. Here it was a case-insensitive substring test on Name-or-Description, which is
/// wrong in both directions: "eyepatches" matched NOTHING (no item spells the plural), and "eye"
/// matched a belt whose description mentions eyes - in date order, so the belt could come first.
///
/// Now: the query and each item are tokenised on word boundaries and lightly stemmed
/// (eyepatches -> eyepatch, hoodies -> hoodie, glasses -> glass), every query token must be
/// found in the item - in its name (strong), in its name with the spaces removed (so
/// "eyepatch" finds "Eye Patch" and "top hat" finds "TopHat"), or in its description (weak) -
/// and the hits are scored: an exact name first, then a name that starts with the query, then
/// the phrase inside the name, then word matches, then description-only matches. Ties go to
/// the newest item, which is also the order of an empty query.
///
/// `ordering` is the client's PBFFECJMOHO: SearchScoreDescending 0, HotScoreDescending 1,
/// Newest 2, Oldest 3, PriceAscending 4, PriceDescending 5, FeaturedFirst 6. Nothing records a
/// hot score, so 1 ranks like 0.
///
/// Each catalog record is indexed once, lazily, in a ConditionalWeakTable keyed on the record
/// itself - so a reload of the catalog drops the old index with the old records, and the 24k
/// item scan per search is a handful of string compares per item rather than re-tokenising
/// every description on every keystroke (the client searches on every keystroke).
/// </summary>
public static class CustomItemSearch
{
private sealed class Indexed
{
public string Name = ""; // lower-cased, whitespace collapsed
public string Squashed = ""; // Name with everything but letters and digits removed
public string[] NameTokens = Array.Empty<string>();
public string[] DescTokens = Array.Empty<string>();
public string Created = "";
public int Price;
public bool Featured;
}
private static readonly ConditionalWeakTable<CustomItemsCache.Rec, Indexed> _index = new();
private static Indexed Index(CustomItemsCache.Rec item) => _index.GetValue(item, Build);
private static Indexed Build(CustomItemsCache.Rec item)
{
return new Indexed
{
Name = Collapse(item.Name),
Squashed = Squash(item.Name),
NameTokens = Tokens(item.Name),
DescTokens = Tokens(item.Description),
Created = item.Created,
Price = item.Price,
Featured = item.Featured
};
}
// ---- text helpers ----------------------------------------------------------------------
private static string Collapse(string s) =>
string.Join(' ', s.ToLowerInvariant().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
private static string Squash(string s)
{
var sb = new StringBuilder(s.Length);
foreach (char c in s.ToLowerInvariant())
if (char.IsLetterOrDigit(c)) sb.Append(c);
return sb.ToString();
}
private static string[] Tokens(string s)
{
var list = new List<string>();
var sb = new StringBuilder();
foreach (char c in s.ToLowerInvariant())
{
if (char.IsLetterOrDigit(c)) { sb.Append(c); continue; }
if (sb.Length > 0) { list.Add(Stem(sb.ToString())); sb.Clear(); }
}
if (sb.Length > 0) list.Add(Stem(sb.ToString()));
return list.ToArray();
}
// A light English plural stem, enough for what a store is searched for: eyepatches ->
// eyepatch, glasses -> glass, shoes -> shoe, hoodies -> hoodie, bunnies -> bunny. A word
// ending in "ss" (dress, glass) is left alone, and short words are never touched.
private static string Stem(string w)
{
if (w.Length <= 3) return w;
if (w.EndsWith("ies") && w.Length > 4) return w[..^3] + "y";
if (w.EndsWith("ss")) return w;
if (w.EndsWith("es") && w.Length > 4)
{
string stemless = w[..^2];
if (stemless.EndsWith("s") || stemless.EndsWith("x") || stemless.EndsWith("z") || stemless.EndsWith("ch") || stemless.EndsWith("sh"))
return stemless;
return w[..^1]; // shoes -> shoe, hoodies handled above
}
if (w.EndsWith("s")) return w[..^1];
return w;
}
// ---- scoring ---------------------------------------------------------------------------
/// <summary>0 = no match. Higher is better.</summary>
private static double Score(Indexed ix, string q, string qSquashed, string[] qTokens)
{
double score = 0;
// Every query word has to be found somewhere - in the name (strongly), in the squashed
// name (a word boundary the player didn't type), or in the description (weakly).
foreach (var t in qTokens)
{
double best = 0;
foreach (var n in ix.NameTokens)
{
if (n == t) { best = Math.Max(best, 3); break; }
if (t.Length >= 2 && n.StartsWith(t)) best = Math.Max(best, 2);
}
// The word boundary the player didn't type: "eyepatch" for "Eye Patch", "top hat"
// for "TopHat". Judged on the WHOLE query with its spaces removed, not the token,
// so a short token can't hit the middle of an unrelated word ("die" in "hoodie").
if (best < 1.5 && (qTokens.Length > 1 || qSquashed.Length >= 4) && ix.Squashed.Contains(qSquashed)) best = Math.Max(best, 1.5);
if (best == 0)
{
foreach (var d in ix.DescTokens)
{
if (d == t) { best = 1; break; }
if (t.Length >= 3 && d.StartsWith(t)) best = Math.Max(best, 0.75);
}
}
if (best == 0) return 0;
score += best;
}
// Whole-query bonuses: the exact name, the name starting with what was typed, the
// phrase inside the name, the phrase inside the name ignoring spaces.
if (ix.Name == q) score += 100;
else if (ix.Name.StartsWith(q)) score += 50;
else if (ix.Name.Contains(q)) score += 25;
else if (qSquashed.Length >= 3 && ix.Squashed.Contains(qSquashed)) score += 20;
return score;
}
/// <summary>
/// Filter <paramref name="items"/> to those matching <paramref name="query"/> (everything,
/// when it is blank) and order them by <paramref name="ordering"/>.
/// </summary>
public static IEnumerable<CustomItemsCache.Rec> Search(IEnumerable<CustomItemsCache.Rec> items, string? query, int ordering)
{
string q = Collapse(query ?? "");
string[] qTokens = Tokens(q);
string qSquashed = Squash(q);
bool searching = qTokens.Length > 0;
IEnumerable<(CustomItemsCache.Rec item, Indexed ix, double score)> hits;
if (searching)
{
hits = items.Select(it => { var ix = Index(it); return (it, ix, Score(ix, q, qSquashed, qTokens)); })
.Where(h => h.Item3 > 0);
}
else
{
hits = items.Select(it => (it, Index(it), 0.0));
}
IOrderedEnumerable<(CustomItemsCache.Rec item, Indexed ix, double score)> ordered = ordering switch
{
2 => hits.OrderByDescending(h => h.ix.Created),
3 => hits.OrderBy(h => h.ix.Created),
4 => hits.OrderBy(h => h.ix.Price).ThenByDescending(h => h.score),
5 => hits.OrderByDescending(h => h.ix.Price).ThenByDescending(h => h.score),
6 => hits.OrderByDescending(h => h.ix.Featured).ThenByDescending(h => h.score).ThenByDescending(h => h.ix.Created),
_ => hits.OrderByDescending(h => h.score).ThenByDescending(h => h.ix.Created),
};
return ordered.Select(h => h.item);
}
}
}
@@ -0,0 +1,499 @@
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// In-memory index of the UGC custom-avatar-item catalog (Data/APIS/items/UGC.json, ~24k items,
/// ~87MB), the imported overlay (ImportedUGC.json - the dev locker) and the items players made
/// here (CreatedUGC.json). Loaded once at startup.
///
/// MEMORY. Each record is kept as its compact UTF-8 JSON (the whole catalog is ~45 MB that
/// way) plus a small typed header - <see cref="Rec"/> - with the handful of fields the routes
/// filter and sort on. A <see cref="JObject"/> is only built when a record is actually being
/// served (a search page, a /bulk lookup, a locker page), and thrown away after. Holding the
/// catalog as JObjects cost ~640 MB for the raw file alone (measured), and loading it as one
/// parsed tree put the peak well over a gigabyte; the server sat at 2.3 GB RSS. The file is
/// also read item by item (JsonTextReader), never as one parsed tree, so the peak is one item.
///
/// Dates are kept as the strings they arrived as (DateParseHandling.None) - the catalog's own
/// spelling is what the client got before, and ISO strings sort correctly as text.
/// </summary>
public static class CustomItemsCache
{
/// <summary>One catalog record: its JSON bytes and the fields the server reasons about.</summary>
public sealed class Rec
{
public string Id = "";
public byte[] Json = Array.Empty<byte>();
public ulong Creator;
public int Accessibility;
public int OutfitType = -1;
public int? BaseAvatarItemId;
public bool Featured;
public string Created = "";
public int Price;
public string Name = "";
public string Description = "";
/// <summary>Has asset-bundle saves (a modelled item) as opposed to a PNG shirt design.</summary>
public bool BundleBacked;
/// <summary>The record as the client receives it. A fresh object every call.</summary>
public JObject ToJObject() => BundleHashes.Stamp(ParseRecord(Json));
public static Rec From(JObject o)
{
// Clothing floor (owner rule): a custom item that is for sale costs at least
// StoreGenerator.DefaultPrice. Applied to the record itself so the price the client
// displays and the price the buy charges are the same number.
int rawPrice = (int?)o["Price"] ?? 0;
if (rawPrice > 0 && rawPrice < StoreGenerator.DefaultPrice) o["Price"] = StoreGenerator.DefaultPrice;
var r = new Rec
{
Id = (string?)o["CustomAvatarItemId"] ?? "",
Creator = (ulong?)o["CreatorAccountId"] ?? 0,
Accessibility = (int?)o["Accessibility"] ?? 0,
OutfitType = (int?)o["OutfitType"] ?? -1,
BaseAvatarItemId = (int?)o["BaseAvatarItemId"],
Featured = (bool?)o["IsFeatured"] == true,
Created = (string?)o["CreatedAt"] ?? "",
Price = (int?)o["Price"] ?? 0,
Name = (string?)o["Name"] ?? "",
Description = (string?)o["Description"] ?? "",
BundleBacked = o["CurrentSaves"] is JArray saves && saves.Count > 0,
};
r.Json = Encoding.UTF8.GetBytes(o.ToString(Formatting.None));
return r;
}
}
private static readonly JsonSerializerSettings NoDateParsing = new() { DateParseHandling = DateParseHandling.None };
public static JObject ParseRecord(byte[] json)
{
using var sr = new StreamReader(new MemoryStream(json, writable: false), Encoding.UTF8);
using var jr = new JsonTextReader(sr) { DateParseHandling = DateParseHandling.None };
return JObject.Load(jr);
}
private static Dictionary<string, Rec> _byId = new(StringComparer.OrdinalIgnoreCase);
private static List<Rec> _all = new();
private static Dictionary<ulong, List<Rec>> _byCreator = new();
// IMPORTED UGC = items whose real .assetbundle/.png assets have been hosted on THIS server
// (via the admin importer). These are what a dev "owns" in the UGC dev-locker. Kept as a
// distinct set so devs don't get the whole catalog dumped into their wardrobe.
private static readonly List<Rec> _imported = new();
private static readonly HashSet<string> _importedIds = new(StringComparer.OrdinalIgnoreCase);
private static readonly object _lock = new();
public static string ImportedPath =>
Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "ImportedUGC.json");
// FIRST-PARTY (Coach) custom items from the reference export, priced from a real Watch
// storefront - ImportRealStore.py writes it. Loaded over UGC.json like part of the base
// catalog (its record wins for an id both hold, which is how the prices land); NOT the
// dev locker, so nobody's wardrobe grows by 2700 items.
public static string FirstPartyPath =>
Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "FirstPartyUGC.json");
// PLAYER-CREATED items (the in-game costume maker, POST /api/customAvatarItems/v1): their own
// file so a deploy can never clobber them, indexed like the rest but NOT marked imported.
public static string CreatedPath =>
Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "CreatedUGC.json");
private static readonly List<Rec> _created = new();
public static int Count => _all.Count;
public static int ImportedCount { get { lock (_lock) return _imported.Count; } }
/// <summary>Every record (catalog + imported + created), as compact records.</summary>
public static IReadOnlyList<Rec> AllRecs => _all;
/// <summary>The imported (dev locker) records.</summary>
public static List<Rec> ImportedRecs { get { lock (_lock) return new List<Rec>(_imported); } }
public static HashSet<string> ImportedIds { get { lock (_lock) return new HashSet<string>(_importedIds, StringComparer.OrdinalIgnoreCase); } }
/// <summary>Walk a {"Results":[...]} file one object at a time - never the whole tree.</summary>
private static IEnumerable<JObject> ReadResults(string path)
{
using var sr = new StreamReader(path, Encoding.UTF8);
using var jr = new JsonTextReader(sr) { DateParseHandling = DateParseHandling.None };
// Find the Results array, then yield each object in it.
while (jr.Read())
{
if (jr.TokenType == JsonToken.PropertyName && string.Equals((string?)jr.Value, "Results", StringComparison.OrdinalIgnoreCase))
{
if (!jr.Read() || jr.TokenType != JsonToken.StartArray) yield break;
while (jr.Read() && jr.TokenType != JsonToken.EndArray)
{
if (jr.TokenType == JsonToken.StartObject)
yield return JObject.Load(jr);
}
yield break;
}
}
}
public static void Load()
{
_droppedItems = 0; _droppedSaves = 0;
try
{
string ugc = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "UGC.json");
string legacy = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "customitems.json");
string path = File.Exists(ugc) ? ugc : legacy;
var byId = new Dictionary<string, Rec>(StringComparer.OrdinalIgnoreCase);
var all = new List<Rec>();
if (File.Exists(path))
{
foreach (var o in ReadResults(path))
{
string? id = (string?)o["CustomAvatarItemId"];
if (string.IsNullOrEmpty(id)) continue;
if (!PassesCutoff(o)) continue;
SanitizeForClient(o);
var rec = Rec.From(o);
if (byId.TryGetValue(id, out var existing)) all.Remove(existing);
byId[id] = rec;
all.Add(rec);
// NOT indexed by creator: the catalog's CreatorAccountId values are the ORIGINAL
// RecNet creators' ids, which are not accounts here. Only CreatedUGC (made on
// this server) maps to local accounts; see AddOrUpdate.
}
Console.WriteLine($"[ugc] loaded {all.Count} custom avatar items from {Path.GetFileName(path)}");
}
else Console.WriteLine("[ugc] no UGC.json / customitems.json found - base custom items empty");
_byId = byId; _all = all; _byCreator = new Dictionary<ulong, List<Rec>>();
lock (_lock) { _imported.Clear(); _importedIds.Clear(); }
// Merge the imported overlay LAST so its records win (they carry hosted-asset refs).
if (File.Exists(ImportedPath))
{
try
{
int n = 0;
foreach (var o in ReadResults(ImportedPath)) { AddOrUpdate(o, persist: false); n++; }
Console.WriteLine($"[ugc] merged {n} imported UGC item(s) from ImportedUGC.json (dev locker)");
}
catch (Exception iex) { Console.WriteLine($"[ugc] ImportedUGC.json load failed: {iex.Message}"); }
}
// First-party items LAST: the same 2830 Coach items the dev locker was imported
// from, now carrying the real store's price (ImportRealStore.py). A record that is
// already in the locker stays in it - only the record is swapped.
if (File.Exists(FirstPartyPath))
{
try
{
int added = 0, replaced = 0;
foreach (var o in ReadResults(FirstPartyPath))
{
string? id = (string?)o["CustomAvatarItemId"];
if (string.IsNullOrEmpty(id)) continue;
if (!PassesCutoff(o)) continue;
SanitizeForClient(o);
var rec = Rec.From(o);
lock (_lock)
{
if (_byId.TryGetValue(id, out var existing))
{
_all.Remove(existing);
if (_imported.Remove(existing)) _imported.Add(rec);
replaced++;
}
else added++;
_byId[id] = rec;
_all.Add(rec);
}
}
Console.WriteLine($"[ugc] FirstPartyUGC.json: {added} first-party item(s) added, {replaced} replaced (priced)");
}
catch (Exception fex) { Console.WriteLine($"[ugc] FirstPartyUGC.json load failed: {fex.Message}"); }
}
lock (_lock) _created.Clear();
if (File.Exists(CreatedPath))
{
try
{
int n = 0;
foreach (var o in ReadResults(CreatedPath))
{
var rec = AddOrUpdate(o, persist: false, markImported: false);
if (rec != null) { lock (_lock) _created.Add(rec); n++; }
}
Console.WriteLine($"[ugc] loaded {n} player-created item(s) from CreatedUGC.json");
}
catch (Exception cex) { Console.WriteLine($"[ugc] CreatedUGC.json load failed: {cex.Message}"); }
}
if (ConfigCampus.CustomItemCreatedCutoffUtc != null)
Console.WriteLine($"[ugc] cutoff {ConfigCampus.CustomItemCreatedCutoffUtc:u}: dropped {_droppedItems} item(s) and {_droppedSaves} newer save(s) the clients can't read; {_all.Count} item(s) served");
long bytes = 0; foreach (var r in _all) bytes += r.Json.Length;
Console.WriteLine($"[ugc] catalog held as {bytes / (1024 * 1024)} MB of compact JSON ({_all.Count} record(s))");
GC.Collect();
}
catch (Exception ex)
{
Console.WriteLine($"[ugc] load failed: {ex.Message}");
}
}
/// <summary>
/// Upsert one custom item into every index and mark it IMPORTED (dev-locker eligible).
/// Thread-safe; used both at Load (persist:false) and live from the admin importer
/// (persist:true appends it to ImportedUGC.json so it survives a restart). Returns the
/// stored record, or null when the item was refused by the cutoff.
/// </summary>
public static Rec? AddOrUpdate(JObject item, bool persist = true, bool markImported = true)
{
string? id = (string?)item["CustomAvatarItemId"];
if (string.IsNullOrEmpty(id)) return null;
if (markImported && !PassesCutoff(item)) return null; // a player's own creation is always current
SanitizeForClient(item);
var rec = Rec.From(item);
lock (_lock)
{
if (_byId.TryGetValue(id, out var existing))
{
_all.Remove(existing);
if (_byCreator.TryGetValue(existing.Creator, out var oldList)) oldList.Remove(existing);
_imported.Remove(existing);
_created.Remove(existing);
}
_byId[id] = rec;
_all.Add(rec);
if (rec.Creator != 0 && !markImported) // imported catalog records keep their RecNet creator id; only local creations are attributed
{
if (!_byCreator.TryGetValue(rec.Creator, out var list)) _byCreator[rec.Creator] = list = new List<Rec>();
list.Add(rec);
}
if (markImported)
{
_importedIds.Add(id);
_imported.Add(rec);
}
}
if (persist && markImported) PersistImported();
return rec;
}
/// <summary>Register a player-created item (costume maker) and persist it to CreatedUGC.json.</summary>
public static void AddCreated(JObject item)
{
var rec = AddOrUpdate(item, persist: false, markImported: false);
if (rec == null) return;
lock (_lock)
{
_created.RemoveAll(x => string.Equals(x.Id, rec.Id, StringComparison.OrdinalIgnoreCase));
_created.Add(rec);
}
PersistCreated();
}
/// <summary>Store an edited created item (PUT) and re-persist. The record is replaced, not patched in place.</summary>
public static void CreatedChanged(JObject item) => AddCreated(item);
/// <summary>Remove an item from every index (DELETE). Returns the record, or null.</summary>
public static JObject? Remove(string? id)
{
if (string.IsNullOrEmpty(id)) return null;
Rec? rec;
lock (_lock)
{
if (!_byId.TryGetValue(id!, out rec)) return null;
_byId.Remove(id!);
_all.Remove(rec);
if (_byCreator.TryGetValue(rec.Creator, out var list)) list.Remove(rec);
_importedIds.Remove(id!);
_imported.Remove(rec);
_created.Remove(rec);
}
PersistCreated();
return rec.ToJObject();
}
// {"Results":[<record bytes>,...],"TotalResults":N}, streamed - no tree is built to write it.
private static void WriteResults(string path, List<Rec> recs)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
string tmp = path + ".tmp";
using (var fs = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None))
{
var head = Encoding.UTF8.GetBytes("{\"Results\":[");
fs.Write(head, 0, head.Length);
for (int i = 0; i < recs.Count; i++)
{
if (i > 0) fs.WriteByte((byte)',');
fs.Write(recs[i].Json, 0, recs[i].Json.Length);
}
var tail = Encoding.UTF8.GetBytes($"],\"TotalResults\":{recs.Count}}}");
fs.Write(tail, 0, tail.Length);
}
File.Move(tmp, path, overwrite: true);
}
private static void PersistCreated()
{
try
{
List<Rec> list;
lock (_lock) list = new List<Rec>(_created);
WriteResults(CreatedPath, list);
}
catch (Exception ex) { Console.WriteLine($"[ugc] persist created failed: {ex.Message}"); }
}
/// <summary>Write the imported overlay to disk once (use after a batch of AddOrUpdate(persist:false)).</summary>
public static void FlushImported() => PersistImported();
private static void PersistImported()
{
try
{
List<Rec> list;
lock (_lock) list = new List<Rec>(_imported);
WriteResults(ImportedPath, list);
}
catch (Exception ex) { Console.WriteLine($"[ugc] persist imported failed: {ex.Message}"); }
}
// Keys of a save's AdditionalConfiguration envelope the 2021.3 client (PC + Quest
// 20250718.01) can read: {"v":1,"d":{...}}. Newer exports add top-level keys ("ls":1 on
// every 2024+ save); the client's SerializedAvatarItemData converter reads v and d and then
// expects the object to END, so an extra key throws "The converter read too much or not
// enough. Path: $ ... BytePositionInLine: 53" inside ApplyDownloadableAvatarItem and the
// item never gets as far as its bundle. Unknown keys INSIDE d are only warned about
// ("unhandled property ... (et)"), so d is passed through untouched.
private static readonly HashSet<string> ConfigEnvelopeKeys = new(StringComparer.Ordinal) { "v", "d" };
private static int _droppedItems, _droppedSaves;
/// <summary>
/// Apply ConfigCampus.CustomItemCreatedCutoffUtc: false when the ITEM was created after the
/// cutoff (caller skips it). Saves created after the cutoff are removed from the record; an
/// item left with no saves at all is dropped too (nothing renderable would remain).
/// </summary>
private static bool PassesCutoff(JObject item)
{
var cutoff = ConfigCampus.CustomItemCreatedCutoffUtc;
if (cutoff == null) return true;
if (After(item["CreatedAt"], cutoff.Value)) { _droppedItems++; return false; }
if (item["CurrentSaves"] is JArray saves && saves.Count > 0)
{
var late = saves.Where(s => After(s["CreatedAt"], cutoff.Value)).ToList();
foreach (var s in late) { s.Remove(); _droppedSaves++; }
if (saves.Count == 0) { _droppedItems++; return false; }
}
return true;
}
private static bool After(JToken? createdAt, DateTime cutoffUtc)
{
string? s = (string?)createdAt;
return !string.IsNullOrEmpty(s)
&& DateTime.TryParse(s, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt)
&& dt > cutoffUtc;
}
// Keys the client's converter accepts INSIDE d.d (the per-slot attach/render block). This is
// every key the 2024-era catalogs use; a key it doesn't know is first warned about ("Found
// an unhandled property when parsing SerializedAvatarItemData | propertyName(et)") and then
// the SAME "read too much or not enough" exception follows - and because the client applies
// the whole owned list in one pass, one bad item (a sword exported in 2025-08 carrying
// "et") aborts registration of every item after it, which surfaces as bundles that download
// but "can't load". Unknown nested keys are stripped and logged.
private static readonly HashSet<string> ConfigNestedKnownKeys = new(StringComparer.Ordinal)
{ "ap", "fp", "af", "uu", "hh", "fr", "uv", "c", "r", "ho", "hp", "l", "dc", "p", "hr", "ha", "hs", "t" };
private static int _strippedNested;
/// <summary>Strip the parts of an item record the clients' converters can't parse (see ConfigEnvelopeKeys).</summary>
public static void SanitizeForClient(JObject item)
{
if (item["CurrentSaves"] is not JArray saves) return;
foreach (var t in saves)
{
if (t is not JObject save) continue;
string? ac = (string?)save["AdditionalConfiguration"];
if (string.IsNullOrWhiteSpace(ac)) continue;
try
{
var cfg = JObject.Parse(ac);
bool changed = false;
var extra = cfg.Properties().Where(pr => !ConfigEnvelopeKeys.Contains(pr.Name)).ToList();
foreach (var pr in extra) { pr.Remove(); changed = true; }
if (cfg["d"] is JObject d && d["d"] is JObject nested)
{
var unknown = nested.Properties().Where(pr => !ConfigNestedKnownKeys.Contains(pr.Name)).ToList();
foreach (var pr in unknown)
{
if (_strippedNested++ < 20)
Console.WriteLine($"[ugc] '{(string?)item["Name"]}' ({(string?)item["CustomAvatarItemId"]}): stripped unknown config key d.d.{pr.Name} (client can't parse it)");
pr.Remove(); changed = true;
}
}
if (!changed) continue;
save["AdditionalConfiguration"] = cfg.ToString(Formatting.None);
}
catch { /* not JSON - leave it for the client to reject */ }
}
}
/// <summary>
/// A custom item the client renders from an ASSET BUNDLE (CurrentSaves carry UnityAsset
/// files), as opposed to a custom SHIRT - a PNG design (DesignFilename) drawn onto a stock
/// base item, which the client paints itself and needs no bundle for.
///
/// The two are what the store search's tabs are: the CLOTHING tab asks for the modelled
/// outfit types (0, 2, 3, 10, 20, 100-103, 200, 300, 301) and the CUSTOM CLOTHING tab asks
/// for 105, the shirt designs. Every bundle-backed item in the catalog was built for a
/// newer Unity than this client and comes up "Can't load item", so while
/// ConfigCampus.HiddenItemsStorefronts is on they are kept out of the store (see
/// StoreListable) - the Clothing tab goes empty, and the Custom Clothing tab keeps the
/// shirts, which do render.
/// </summary>
public static bool IsBundleBacked(Rec r) => r.BundleBacked;
/// <summary>Whether the store (search, featured, hot) may list this item right now.</summary>
public static bool StoreListable(Rec r) =>
!ConfigCampus.HiddenItemsStorefronts || !r.BundleBacked;
/// <summary>All imported UGC (the dev-locker set), materialised. Prefer ImportedRecs + paging.</summary>
public static List<JObject> Imported => ImportedRecs.Select(r => r.ToJObject()).ToList();
public static bool IsImported(string? id) { if (string.IsNullOrEmpty(id)) return false; lock (_lock) return _importedIds.Contains(id!); }
/// <summary>Resolve specific ids (the outfit-build /bulk lookup). Order/duplicates follow input.</summary>
public static List<JObject> GetByIds(IEnumerable<string?> ids)
{
// Every id resolves that exists - bundle-backed ones included. Withholding those here
// (tried 2026-09-22, to keep the client off the CAI bundle path) emptied the locker
// and the store: the client re-resolves its whole cached custom-item set through this
// call at login (~5,000 ids in batches of 500) and does not cope with misses.
var res = new List<JObject>();
foreach (var id in ids)
if (!string.IsNullOrEmpty(id) && _byId.TryGetValue(id!, out var r)) res.Add(r.ToJObject());
return res;
}
public static JObject? Get(string? id) =>
!string.IsNullOrEmpty(id) && _byId.TryGetValue(id!, out var r) ? r.ToJObject() : null;
public static Rec? GetRec(string? id) =>
!string.IsNullOrEmpty(id) && _byId.TryGetValue(id!, out var r) ? r : null;
public static List<Rec> ByCreatorRecs(ulong creatorAccountId)
{
lock (_lock) return _byCreator.TryGetValue(creatorAccountId, out var l) ? new List<Rec>(l) : new List<Rec>();
}
public static List<JObject> ByCreator(ulong creatorAccountId) =>
ByCreatorRecs(creatorAccountId).Select(r => r.ToJObject()).ToList();
}
}
@@ -0,0 +1,83 @@
using System.Collections.Concurrent;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Short-lived codes for linking a Discord account to a Campus account.
///
/// FLOW: the website Settings > Discord tab (SiteController, site-cookie authed) calls
/// <see cref="Generate"/> for the signed-in account and shows the code; the player runs
/// `/link &lt;code&gt;` with the Discord bot (BotCommands), which calls <see cref="TryResolve"/>
/// then <see cref="Consume"/> to bind their Discord id.
///
/// This is DELIBERATELY its own single-purpose store, isolated from the admin session gate
/// (AdminGate) and everything else: redeeming a code ONLY records a Discord&lt;-&gt;account link
/// (PlayerDB.SetDiscordLink) and can never mint an admin session or grant any privilege. That is
/// the whole point of keeping it separate - linking Discord must never be a path into the admin
/// panel.
/// </summary>
public static class DiscordLinkCodes
{
private const int TtlSeconds = 600; // 10 minutes
// Unambiguous alphabet (no 0/O/1/I) so a 6-char code is easy to read off a screen and type.
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private static readonly ConcurrentDictionary<string, (ulong accountId, long expires)> _codes = new();
private static readonly Random _rng = new();
/// <summary>Mint a fresh code for this account (replacing any it already had), valid for 10 min.</summary>
public static (string code, int ttlSeconds) Generate(ulong accountId)
{
Sweep();
// Drop any outstanding code for this account so only the newest one works.
foreach (var kv in _codes)
if (kv.Value.accountId == accountId) _codes.TryRemove(kv.Key, out _);
string code;
do
{
var chars = new char[6];
lock (_rng)
for (int i = 0; i < chars.Length; i++) chars[i] = Alphabet[_rng.Next(Alphabet.Length)];
code = new string(chars);
} while (_codes.ContainsKey(code));
long expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + TtlSeconds;
_codes[code] = (accountId, expires);
return (code, TtlSeconds);
}
/// <summary>Look up (without consuming) the account a live code belongs to. False if unknown/expired.</summary>
public static bool TryResolve(string? code, out ulong accountId)
{
accountId = 0;
if (string.IsNullOrWhiteSpace(code)) return false;
code = code.Trim().ToUpperInvariant();
if (!_codes.TryGetValue(code, out var entry)) return false;
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > entry.expires)
{
_codes.TryRemove(code, out _);
return false;
}
accountId = entry.accountId;
return true;
}
/// <summary>Burn a code after a successful link so it can't be reused.</summary>
public static void Consume(string? code)
{
if (string.IsNullOrWhiteSpace(code)) return;
_codes.TryRemove(code.Trim().ToUpperInvariant(), out _);
}
private static long _lastSweep;
private static void Sweep()
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now - Interlocked.Read(ref _lastSweep) < 60) return;
Interlocked.Exchange(ref _lastSweep, now);
foreach (var kv in _codes)
if (now > kv.Value.expires) _codes.TryRemove(kv.Key, out _);
}
}
}
@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The room lists behind the Play tab, built from THIS server's rooms.
///
/// The Play page (Data/APIS/WatchPages/PlayHighlight.json) was captured from live Rec Room and
/// names its carousels by algorithmic-list endpoint: foryou, HotList, staffpicks, new,
/// inkshowcase, recentlyupdated and the genre rows (battle_algoendpoint, horror_algoendpoint,
/// ...). GET /lists/algorithmiclists/{name}?type=1 answered each from a file under
/// Data/APIS/AlgorithmicLists, and only two files existed - so "Recommended For You",
/// "Featured This Week", "Trending", "Rooms We Love" and every genre row were blank. Now each
/// name is a query over RoomDB, cached for a minute:
///
/// HotList / foryou live player count first, then cheers + visits (foryou reshuffles
/// hourly so the row isn't the same as Trending)
/// staffpicks the admin panel's featured rooms, padded from the top cheered
/// new newest published rooms
/// recentlyupdated most recently changed
/// inkshowcase player-made rooms with a picture, seeded shuffle
/// &lt;genre&gt;_algoendpoint rooms tagged with that genre (see GenreTags), padded from hot
///
/// Only rooms IsRoomListed says are public are ever listed; dorms never are.
/// </summary>
public static class DiscoveryLists
{
private static readonly object _lock = new();
private static readonly Dictionary<string, (DateTime at, List<string> ids)> _cache = new(StringComparer.OrdinalIgnoreCase);
private const int CacheSeconds = 60;
private const int PageSize = 30;
private static readonly Dictionary<string, string[]> GenreTags = new(StringComparer.OrdinalIgnoreCase)
{
["battle"] = new[] { "battle", "pvp", "shooter", "fps", "combat", "war", "gunfight", "paintball" },
["roleplay"] = new[] { "roleplay", "rp", "role-play", "story" },
["quests"] = new[] { "quest", "quests", "adventure", "rpg", "coop", "co-op" },
["horror"] = new[] { "horror", "scary", "spooky", "creepy", "backrooms" },
["hangout"] = new[] { "hangout", "hang out", "social", "chill", "chat", "lounge", "hangouts" },
["casual"] = new[] { "casual", "party", "minigame", "minigames", "fun", "game" },
["explore"] = new[] { "explore", "exploration", "showcase", "art", "museum", "parkour", "obby" },
};
/// <summary>Whether this list name is one the server builds (vs a file / store row).</summary>
public static bool Handles(string name)
{
string n = name.ToLowerInvariant();
return n is "hotlist" or "foryou" or "staffpicks" or "new" or "recentlyupdated" or "inkshowcase"
|| n.EndsWith("_algoendpoint");
}
public static List<string> RoomIds(string name)
{
lock (_lock)
{
if (_cache.TryGetValue(name, out var hit) && (DateTime.UtcNow - hit.at).TotalSeconds < CacheSeconds)
return hit.ids;
}
var ids = Build(name.ToLowerInvariant());
lock (_lock) _cache[name] = (DateTime.UtcNow, ids);
return ids;
}
private static List<RoomDBClasses.RoomRoot> Listed() =>
RoomDB.GetHotRooms().Where(r => !r.IsDorm && RoomDB.IsRoomListed(r) && !r.ExcludeFromLists).ToList();
private static long Score(RoomDBClasses.RoomRoot r, Dictionary<long, int> live)
{
long ccu = live.TryGetValue((long)r.RoomId, out var n) ? n : 0;
long cheers = r.Stats?.CheerCount ?? 0;
long visits = r.Stats?.VisitCount ?? 0;
return ccu * 100_000 + cheers * 50 + visits;
}
private static List<string> Ids(IEnumerable<RoomDBClasses.RoomRoot> rooms) =>
rooms.Select(r => r.RoomId.ToString()).Distinct().Take(PageSize).ToList();
private static List<string> Build(string name)
{
var rooms = Listed();
var live = HeartbeatDB.LivePlayerCountsByRoom();
var hot = rooms.OrderByDescending(r => Score(r, live)).ThenByDescending(r => r.CreatedAt).ToList();
int hourSeed = HashCode.Combine(name, DateTime.UtcNow.Year, DateTime.UtcNow.DayOfYear, DateTime.UtcNow.Hour);
switch (name)
{
case "hotlist":
return Ids(hot);
case "foryou":
{
// The top of the hot list in a per-hour order, then the rest shuffled in, so
// it reads as a different row from Trending without being random junk.
var rng = new Random(hourSeed);
var top = hot.Take(12).OrderBy(_ => rng.Next());
var rest = hot.Skip(12).OrderBy(_ => rng.Next());
return Ids(top.Concat(rest));
}
case "staffpicks":
{
var picked = new List<RoomDBClasses.RoomRoot>();
foreach (var rid in FeaturedRoomsStore.GetIds())
{
var r = RoomDB.GetRoom(rid);
if (r != null && !r.IsDorm) picked.Add(r);
}
var pad = hot.Where(r => !RoomDB.IsRecRoomOriginal(r)).OrderByDescending(r => r.Stats?.CheerCount ?? 0);
return Ids(picked.Concat(pad));
}
case "new":
return Ids(rooms.Where(r => !RoomDB.IsRecRoomOriginal(r)).OrderByDescending(r => r.CreatedAt));
case "recentlyupdated":
return Ids(rooms.Where(r => !RoomDB.IsRecRoomOriginal(r))
.OrderByDescending(r => r.PublishedAt > r.CreatedAt ? r.PublishedAt : r.CreatedAt));
case "inkshowcase":
{
var rng = new Random(hourSeed);
var made = rooms.Where(r => !RoomDB.IsRecRoomOriginal(r) && !string.IsNullOrEmpty(r.ImageName)).OrderBy(_ => rng.Next());
return Ids(made.Concat(hot));
}
}
if (name.EndsWith("_algoendpoint"))
{
string genre = name[..^"_algoendpoint".Length];
var tags = GenreTags.TryGetValue(genre, out var t) ? t : new[] { genre };
var tagged = hot.Where(r => tags.Any(tag => RoomDB.RoomHasTag(r, tag))
|| tags.Any(tag => (r.Name ?? "").Contains(tag, StringComparison.OrdinalIgnoreCase)));
return Ids(tagged.Concat(hot));
}
return Ids(hot);
}
/// <summary>
/// The Play page's "Featured Creator" tile: the captured page names a live-Rec-Room account
/// (730029). The local creator with the most public, player-made rooms takes its place -
/// never the system accounts (1-3) or the room importer.
/// </summary>
private static (DateTime at, ulong id) _creator;
public static ulong FeaturedCreator()
{
lock (_lock)
{
if (_creator.id != 0 && (DateTime.UtcNow - _creator.at).TotalSeconds < 600) return _creator.id;
}
ulong best = 0;
// The admin panel's Featured tab decides first; then whatever the community board
// file names; only then the most-rooms heuristic.
best = FeaturedStore.FeaturedPlayer();
if (best == 0)
{
try
{
string cb = System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "communityboard.json");
if (System.IO.File.Exists(cb))
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(System.IO.File.ReadAllText(cb));
ulong fp = (ulong?)jo["FeaturedPlayer"]?["Id"] ?? 0;
if (fp != 0 && PlayerDB.GetAccountById(fp) != null) best = fp;
}
}
catch { }
}
if (best != 0) { lock (_lock) _creator = (DateTime.UtcNow, best); return best; }
try
{
var byCreator = Listed()
.Where(r => !RoomDB.IsRecRoomOriginal(r) && r.CreatorAccountId > 3)
.GroupBy(r => r.CreatorAccountId)
.Select(g => (id: g.Key, n: g.Count(), cheers: g.Sum(r => r.Stats?.CheerCount ?? 0)))
.Where(x =>
{
var acc = PlayerDB.GetAccountById(x.id);
return acc != null && !string.Equals(acc.Username, "Importer", StringComparison.OrdinalIgnoreCase);
})
.OrderByDescending(x => x.n).ThenByDescending(x => x.cheers)
.FirstOrDefault();
best = byCreator.id;
}
catch { }
lock (_lock) _creator = (DateTime.UtcNow, best);
return best;
}
/// <summary>The room the Play page's banner shows: the first admin-featured room, else the hottest.</summary>
public static ulong BannerRoom()
{
foreach (var rid in FeaturedRoomsStore.GetIds())
{
var r = RoomDB.GetRoom(rid);
if (r != null && !r.IsDorm) return rid;
}
var top = RoomIds("hotlist").FirstOrDefault();
return ulong.TryParse(top, out var id) ? id : 0;
}
}
}
@@ -0,0 +1,1478 @@
namespace Campus_live_rewrite.api.server
{
public class Emojis
{
public static string[] emojis = new string[]
{
"😀",
"😃",
"😄",
"😁",
"😆",
"😅",
"🤣",
"😂",
"🙂",
"🙃",
"🫠",
"😉",
"😊",
"😇",
"🥰",
"😍",
"🤩",
"😘",
"😗",
"☺️",
"😚",
"😙",
"🥲",
"😋",
"😛",
"😜",
"🤪",
"😝",
"🤑",
"🤗",
"🤭",
"🫢",
"🫣",
"🤫",
"🤔",
"🫡",
"🤐",
"🤨",
"😐",
"😑",
"😶",
"🫥",
"😶‍🌫️",
"😏",
"😒",
"🙄",
"😬",
"😮‍💨",
"🤥",
"😌",
"😔",
"😪",
"😴",
"😷",
"🤒",
"🤕",
"🤢",
"🤮",
"🤧",
"🥶",
"🥴",
"😵",
"😵‍💫",
"🤯",
"🤠",
"🥳",
"🥸",
"😎",
"🤓",
"🧐",
"😕",
"🫤",
"😟",
"🙁",
"☹️",
"😯",
"😲",
"😳",
"🥺",
"🥹",
"😦",
"😧",
"😨",
"😰",
"😥",
"😢",
"😭",
"😱",
"😖",
"😣",
"😞",
"😓",
"🥱",
"😤",
"😡",
"😠",
"🤬",
"😈",
"👿",
"💀",
"☠️",
"🤡",
"👹",
"👺",
"👻",
"👽",
"👾",
"🤖",
"😺",
"😸",
"😹",
"😻",
"😼",
"😽",
"🙀",
"😿",
"😾",
"👋",
"🤚",
"🖐️",
"✋",
"✌️",
"🤞",
"🖕",
"👍",
"👎",
"👏",
"🙌",
"🤝",
"🙏",
"🤳",
"🦿",
"🦵",
"👂",
"🦻",
"👃",
"🧠",
"🫀",
"🫁",
"🦷",
"🦴",
"👀",
"👁️",
"👶",
"🧒",
"👦",
"👧",
"🧑",
"👱",
"👨",
"🧔",
"👨‍🦰",
"👨‍🦱",
"👨‍🦳",
"👨‍🦲",
"👩",
"👩‍🦰",
"🧑‍🦰",
"👩‍🦱",
"🧑‍🦱",
"👩‍🦳",
"🧑‍🦳",
"👩‍🦲",
"🧑‍🦲",
"👱‍♀️",
"👱‍♂️",
"🧓",
"👴",
"👵",
"🙍",
"🙍‍♂️",
"🙍‍♀️",
"🙎",
"🙎‍♂️",
"🙎‍♀️",
"🙅",
"🙅‍♂️",
"🙅‍♀️",
"🙆",
"🙆‍♂️",
"🙆‍♀️",
"💁",
"💁‍♂️",
"💁‍♀️",
"🙋",
"🙋‍♂️",
"🙋‍♀️",
"🧏",
"🧏‍♂️",
"🧏‍♀️",
"🙇",
"🙇‍♂️",
"🙇‍♀️",
"🤦",
"🤦‍♂️",
"🤦‍♀️",
"🤷",
"🤷‍♂️",
"🤷‍♀️",
"🧑‍⚕️",
"👨‍⚕️",
"👩‍⚕️",
"🧑‍🎓",
"👨‍🎓",
"👩‍🎓",
"🧑‍🏫",
"👨‍🏫",
"👩‍🏫",
"🧑‍⚖️",
"👨‍⚖️",
"👩‍⚖️",
"🧑‍🌾",
"👨‍🌾",
"👩‍🌾",
"🧑‍🍳",
"👨‍🍳",
"👩‍🍳",
"🧑‍🔧",
"👨‍🔧",
"👩‍🔧",
"🧑‍🏭",
"👨‍🏭",
"👩‍🏭",
"🧑‍💼",
"👨‍💼",
"👩‍💼",
"🧑‍🔬",
"👨‍🔬",
"👩‍🔬",
"🧑‍💻",
"👨‍💻",
"👩‍💻",
"🧑‍🎤",
"👨‍🎤",
"👩‍🎤",
"🧑‍🎨",
"👨‍🎨",
"👩‍🎨",
"🧑‍✈️",
"👨‍✈️",
"👩‍✈️",
"🧑‍🚀",
"👨‍🚀",
"👩‍🚀",
"🧑‍🚒",
"👨‍🚒",
"👩‍🚒",
"👮",
"👮‍♂️",
"👮‍♀️",
"🕵️",
"🕵️‍♂️",
"🕵️‍♀️",
"💂",
"💂‍♂️",
"💂‍♀️",
"🥷",
"👷",
"👷‍♂️",
"👷‍♀️",
"🫅",
"🤴",
"👸",
"👳",
"👳‍♂️",
"👳‍♀️",
"👲",
"🧕",
"🤵",
"🤵‍♂️",
"🤵‍♀️",
"👰",
"👰‍♂️",
"👰‍♀️",
"🤰",
"🫃",
"🫄",
"👩‍🍼",
"👨‍🍼",
"🧑‍🍼",
"👼",
"🎅",
"🤶",
"🧑‍🎄",
"🦸",
"🦸‍♂️",
"🦸‍♀️",
"🦹",
"🦹‍♂️",
"🦹‍♀️",
"🧙",
"🧙‍♂️",
"🧙‍♀️",
"🧚",
"🧚‍♂️",
"🧚‍♀️",
"🧛",
"🧛‍♂️",
"🧛‍♀️",
"🧜",
"🧜‍♂️",
"🧜‍♀️",
"🧝",
"🧝‍♂️",
"🧝‍♀️",
"🧞",
"🧞‍♂️",
"🧞‍♀️",
"🧟",
"🧟‍♂️",
"🧟‍♀️",
"🧌",
"💆",
"💆‍♂️",
"💆‍♀️",
"💇",
"💇‍♂️",
"💇‍♀️",
"🚶",
"🚶‍♂️",
"🚶‍♀️",
"🧍",
"🧍‍♂️",
"🧍‍♀️",
"🧎",
"🧎‍♂️",
"🧎‍♀️",
"🧑‍🦯",
"👨‍🦯",
"👩‍🦯",
"🧑‍🦼",
"👨‍🦼",
"👩‍🦼",
"🧑‍🦽",
"👨‍🦽",
"👩‍🦽",
"🏃",
"🏃‍♂️",
"🏃‍♀️",
"💃",
"🕺",
"🕴️",
"👯",
"👯‍♂️",
"👯‍♀️",
"🧖",
"🧖‍♂️",
"🧖‍♀️",
"🧘",
"🧑‍🤝‍🧑",
"👭",
"👫",
"👬",
"💏",
"👩‍❤️‍💋‍👨",
"👨‍❤️‍💋‍👨",
"👩‍❤️‍💋‍👩",
"💑",
"👩‍❤️‍👨",
"👨‍❤️‍👨",
"👩‍❤️‍👩",
"👪",
"👨‍👩‍👦",
"👨‍👩‍👧",
"👨‍👩‍👧‍👦",
"👨‍👩‍👦‍👦",
"👨‍👩‍👧‍👧",
"👨‍👨‍👦",
"👨‍👨‍👧",
"👨‍👨‍👧‍👦",
"👨‍👨‍👦‍👦",
"👨‍👨‍👧‍👧",
"👩‍👩‍👦",
"👩‍👩‍👧",
"👩‍👩‍👧‍👦",
"👩‍👩‍👦‍👦",
"👩‍👩‍👧‍👧",
"👨‍👦",
"👨‍👦‍👦",
"👨‍👧",
"👨‍👧‍👦",
"👨‍👧‍👧",
"👩‍👦",
"👩‍👦‍👦",
"👩‍👧",
"👩‍👧‍👦",
"👩‍👧‍👧",
"🗣️",
"👤",
"👥",
"🫂",
"🧳",
"🌂",
"☂️",
"🎃",
"🧵",
"🧶",
"👓",
"🕶️",
"🥽",
"🥼",
"🦺",
"👔",
"👕",
"👖",
"🧣",
"🧤",
"🧥",
"👗",
"👘",
"🥻",
"🩱",
"🩳",
"👚",
"👛",
"👜",
"👝",
"🎒",
"🩴",
"👞",
"👟",
"🥾",
"🥿",
"👠",
"👡",
"🩰",
"👢",
"👑",
"👒",
"🎩",
"🎓",
"🧢",
"🪖",
"⛑️",
"💄",
"💍",
"💼",
"🩸",
"🙈",
"🙉",
"🙊",
"💥",
"💫",
"💨",
"🐵",
"🐒",
"🦍",
"🦧",
"🐶",
"🐕",
"🦮",
"🐕‍🦺",
"🐩",
"🐺",
"🦊",
"🦝",
"🐱",
"🐈",
"🐈‍⬛",
"🦁",
"🐯",
"🐅",
"🐆",
"🐴",
"🐎",
"🦄",
"🦓",
"🦌",
"🦬",
"🐮",
"🐂",
"🐃",
"🐄",
"🐷",
"🐖",
"🐗",
"🐽",
"🐏",
"🐑",
"🐐",
"🐪",
"🐫",
"🦙",
"🦒",
"🐘",
"🦣",
"🦏",
"🦛",
"🐭",
"🐁",
"🐀",
"🐹",
"🐰",
"🐇",
"🐿️",
"🦫",
"🦔",
"🦇",
"🐻",
"🐻‍❄️",
"🐨",
"🐼",
"🦥",
"🦦",
"🦨",
"🦘",
"🦡",
"🐾",
"🦃",
"🐔",
"🐓",
"🐣",
"🐤",
"🐥",
"🐦",
"🐧",
"🕊️",
"🦅",
"🦆",
"🦢",
"🦉",
"🦤",
"🪶",
"🦩",
"🦚",
"🦜",
"🐸",
"🐊",
"🐢",
"🦎",
"🐍",
"🐲",
"🐉",
"🦕",
"🦖",
"🐳",
"🐋",
"🐬",
"🦭",
"🐟",
"🐠",
"🐡",
"🦈",
"🐙",
"🐚",
"🪸",
"🐌",
"🦋",
"🐛",
"🐜",
"🐝",
"🪲",
"🐞",
"🦗",
"🪳",
"🕷️",
"🕸️",
"🦂",
"🦟",
"🪰",
"🪱",
"🦠",
"💐",
"🌸",
"💮",
"🪷",
"🏵️",
"🌹",
"🥀",
"🌺",
"🌻",
"🌼",
"🌷",
"🌱",
"🪴",
"🌲",
"🌳",
"🌴",
"🌵",
"🌾",
"🌿",
"☘️",
"🍀",
"🍁",
"🍂",
"🍃",
"🪹",
"🪺",
"🍄",
"🌰",
"🦀",
"🦞",
"🦐",
"🦑",
"🌍",
"🌎",
"🌏",
"🌐",
"🪨",
"🌑",
"🌒",
"🌓",
"🌔",
"🌕",
"🌖",
"🌗",
"🌘",
"🌙",
"🌚",
"🌛",
"🌜",
"☀️",
"🌝",
"🌞",
"⭐",
"🌟",
"🌠",
"☁️",
"⛅",
"⛈️",
"🌤️",
"🌥️",
"🌦️",
"🌧️",
"🌨️",
"🌩️",
"🌪️",
"🌫️",
"🌬️",
"🌈",
"☔",
"⚡",
"❄️",
"☃️",
"⛄",
"☄️",
"🔥",
"💧",
"🌊",
"🎄",
"✨",
"🎋",
"🎍",
"🫧",
"🍇",
"🍈",
"🍉",
"🍊",
"🍋",
"🍍",
"🥭",
"🍎",
"🍏",
"🍐",
"🍓",
"🫐",
"🥝",
"🍅",
"🫒",
"🥥",
"🥑",
"🥔",
"🥕",
"🌽",
"🌶️",
"🫑",
"🥬",
"🥦",
"🧄",
"🧅",
"🥜",
"🫘",
"🍞",
"🥐",
"🥖",
"🫓",
"🥨",
"🥯",
"🥞",
"🧇",
"🧀",
"🍖",
"🍗",
"🥩",
"🥓",
"🍔",
"🍟",
"🍕",
"🌭",
"🥪",
"🌮",
"🌯",
"🫔",
"🥙",
"🧆",
"🥚",
"🍳",
"🥘",
"🍲",
"🫕",
"🥣",
"🥗",
"🍿",
"🧈",
"🧂",
"🥫",
"🍱",
"🍘",
"🍙",
"🍚",
"🍛",
"🍜",
"🍝",
"🍠",
"🍢",
"🍣",
"🍤",
"🍥",
"🥮",
"🍡",
"🥟",
"🥠",
"🥡",
"🦪",
"🍦",
"🍧",
"🍨",
"🍩",
"🍪",
"🎂",
"🍰",
"🧁",
"🥧",
"🍫",
"🍬",
"🍭",
"🍮",
"🍯",
"🍼",
"🥛",
"☕",
"🫖",
"🍵",
"🍶",
"🍾",
"🥂",
"🫗",
"🥤",
"🧋",
"🧃",
"🧉",
"🧊",
"🥢",
"🍽️",
"🍴",
"🥄",
"🫙",
"🧗",
"🧗‍♂️",
"🧗‍♀️",
"🤺",
"🏇",
"⛷️",
"🏂",
"🏌️",
"🏌️‍♂️",
"🏌️‍♀️",
"🏄",
"🏄‍♂️",
"🏄‍♀️",
"🚣",
"🚣‍♂️",
"🚣‍♀️",
"🏊",
"🏊‍♂️",
"🏊‍♀️",
"⛹️",
"⛹️‍♂️",
"⛹️‍♀️",
"🏋️",
"🏋️‍♂️",
"🏋️‍♀️",
"🚴",
"🚴‍♂️",
"🚴‍♀️",
"🚵",
"🚵‍♂️",
"🚵‍♀️",
"🤸",
"🤸‍♂️",
"🤸‍♀️",
"🤼",
"🤼‍♂️",
"🤼‍♀️",
"🤽",
"🤽‍♂️",
"🤽‍♀️",
"🤾",
"🤾‍♂️",
"🤾‍♀️",
"🤹",
"🤹‍♂️",
"🤹‍♀️",
"🧘‍♂️",
"🧘‍♀️",
"🎪",
"🛹",
"🛼",
"🛶",
"🎗️",
"🎟️",
"🎫",
"🎖️",
"🏆",
"🏅",
"🥇",
"🥈",
"🥉",
"⚽",
"⚾",
"🥎",
"🏀",
"🏐",
"🏈",
"🏉",
"🎾",
"🥏",
"🎳",
"🏏",
"🏑",
"🏒",
"🥍",
"🏓",
"🏸",
"🥊",
"🥋",
"🥅",
"⛳",
"⛸️",
"🎣",
"🎽",
"🎿",
"🛷",
"🥌",
"🎯",
"🎱",
"🎮",
"🎰",
"🎲",
"🧩",
"🪩",
"♟️",
"🎭",
"🎨",
"🎼",
"🎤",
"🎧",
"🎷",
"🪗",
"🎸",
"🎹",
"🎺",
"🎻",
"🥁",
"🪘",
"🎬",
"🏹",
"🗾",
"🏔️",
"⛰️",
"🌋",
"🗻",
"🏕️",
"🏖️",
"🏜️",
"🏝️",
"🏞️",
"🏟️",
"🏛️",
"🏗️",
"🛖",
"🏘️",
"🏚️",
"🏠",
"🏡",
"🏢",
"🏣",
"🏤",
"🏥",
"🏦",
"🏪",
"🏫",
"🏬",
"🏭",
"🏯",
"🏰",
"💒",
"🗼",
"🗽",
"⛪",
"🕌",
"🛕",
"🕍",
"⛩️",
"🕋",
"⛲",
"⛺",
"🌁",
"🌃",
"🏙️",
"🌄",
"🌅",
"🌆",
"🌇",
"🌉",
"🎠",
"🛝",
"🎡",
"🎢",
"🚂",
"🚃",
"🚄",
"🚅",
"🚆",
"🚇",
"🚈",
"🚉",
"🚊",
"🚝",
"🚞",
"🚋",
"🚌",
"🚍",
"🚎",
"🚐",
"🚑",
"🚒",
"🚓",
"🚔",
"🚕",
"🚖",
"🚗",
"🚘",
"🚙",
"🛻",
"🚚",
"🚛",
"🚜",
"🏎️",
"🏍️",
"🛵",
"🛺",
"🚲",
"🛴",
"🚏",
"🛣️",
"🛤️",
"⛽",
"🛞",
"🚨",
"🚥",
"🚦",
"🚧",
"⚓",
"🛟",
"⛵",
"🚤",
"🛳️",
"⛴️",
"🛥️",
"🚢",
"✈️",
"🛩️",
"🛫",
"🛬",
"🪂",
"💺",
"🚁",
"🚟",
"🚠",
"🚡",
"🛰️",
"🚀",
"🛸",
"🪐",
"🌌",
"⛱️",
"🎆",
"🎇",
"🎑",
"💴",
"💵",
"💶",
"💷",
"🗿",
"🛂",
"🛃",
"🛄",
"🛅",
"💌",
"🕳️",
"💣",
"🛀",
"🛌",
"🔪",
"🏺",
"🗺️",
"🧭",
"🧱",
"💈",
"🦽",
"🦼",
"🛢️",
"🛎️",
"⌛",
"⏳",
"⌚",
"⏰",
"⏱️",
"⏲️",
"🕰️",
"🌡️",
"🧨",
"🎈",
"🎉",
"🎊",
"🎎",
"🎏",
"🎐",
"🧧",
"🎀",
"🎁",
"🤿",
"🪀",
"🪁",
"🔮",
"🪄",
"🧿",
"🪬",
"🕹️",
"🧸",
"🪅",
"🪆",
"🖼️",
"🪡",
"🪢",
"🛍️",
"📿",
"💎",
"📯",
"🎙️",
"🎚️",
"🎛️",
"📻",
"🪕",
"📱",
"📲",
"☎️",
"📞",
"📟",
"📠",
"🔋",
"🔌",
"💻",
"🖥️",
"🖨️",
"⌨️",
"🖱️",
"🖲️",
"💽",
"💾",
"💿",
"📀",
"🧮",
"🎥",
"🎞️",
"📽️",
"📺",
"📷",
"📸",
"📹",
"📼",
"🔍",
"🔎",
"🕯️",
"💡",
"🔦",
"🏮",
"🪔",
"📔",
"📕",
"📖",
"📗",
"📘",
"📙",
"📚",
"📓",
"📒",
"📃",
"📜",
"📄",
"📰",
"🗞️",
"📑",
"🔖",
"🏷️",
"🪙",
"💸",
"💳",
"🧾",
"✉️",
"📨",
"📩",
"📤",
"📥",
"📦",
"📫",
"📪",
"📬",
"📭",
"📮",
"🗳️",
"✏️",
"✒️",
"🖋️",
"🖊️",
"🖌️",
"🖍️",
"📝",
"📁",
"📂",
"🗂️",
"📅",
"📆",
"🗒️",
"🗓️",
"📇",
"📈",
"📉",
"📊",
"📋",
"📌",
"📍",
"📎",
"🖇️",
"📐",
"✂️",
"🗃️",
"🗄️",
"🗑️",
"🔒",
"🔓",
"🔏",
"🔐",
"🔑",
"🗝️",
"🔨",
"🪓",
"⛏️",
"⚒️",
"🛠️",
"🗡️",
"⚔️",
"🔫",
"🪃",
"🛡️",
"🪚",
"🔧",
"🪛",
"🔩",
"⚙️",
"🗜️",
"⚖️",
"🦯",
"🔗",
"⛓️",
"🪝",
"🧰",
"🧲",
"🪜",
"⚗️",
"🧪",
"🧫",
"🧬",
"🔬",
"🔭",
"📡",
"💉",
"💊",
"🩹",
"🩼",
"🩺",
"🚪",
"🪞",
"🪟",
"🛏️",
"🛋️",
"🪑",
"🚽",
"🪠",
"🚿",
"🛁",
"🪤",
"🪒",
"🧷",
"🧹",
"🧺",
"🧻",
"🪣",
"🧼",
"🪥",
"🧽",
"🧯",
"🛒",
"⚰️",
"🪦",
"⚱️",
"🪧",
"🪪",
"🚰",
"💘",
"💝",
"💖",
"💗",
"💓",
"💞",
"💕",
"💟",
"❣️",
"💔",
"❤️‍🔥",
"❤️‍🩹",
"❤️",
"🧡",
"💛",
"💚",
"💙",
"💜",
"🤎",
"🖤",
"🤍",
"💯",
"💢",
"💬",
"👁️‍🗨️",
"🗨️",
"🗯️",
"💭",
"💤",
"♨️",
"🛑",
"🕛",
"🕧",
"🕐",
"🕜",
"🕑",
"🕝",
"🕒",
"🕞",
"🕓",
"🕟",
"🕔",
"🕠",
"🕕",
"🕡",
"🕖",
"🕢",
"🕗",
"🕣",
"🕘",
"🕤",
"🕙",
"🕥",
"🕚",
"🕦",
"🌀",
"♠️",
"♥️",
"♦️",
"♣️",
"🃏",
"🀄",
"🎴",
"📢",
"📣",
"🔔",
"🔕",
"🎵",
"🎶",
"💹",
"🛗",
"🚮",
"♿",
"🚹",
"🚺",
"🚻",
"🚼",
"⚠️",
"🚸",
"⛔",
"🚫",
"🚳",
"🚯",
"🚱",
"🚷",
"☢️",
"☣️",
"⬆️",
"↗️",
"➡️",
"↘️",
"⬇️",
"↙️",
"⬅️",
"↖️",
"↕️",
"↔️",
"↩️",
"↪️",
"⤴️",
"⤵️",
"🔃",
"🔄",
"🛐",
"⚛️",
"🕉️",
"✡️",
"☸️",
"☯️",
"✝️",
"☦️",
"☪️",
"☮️",
"🕎",
"🔯",
"♈",
"♉",
"♊",
"♋",
"♌",
"♍",
"♎",
"♏",
"♐",
"♑",
"♒",
"♓",
"⛎",
"🔀",
"🔁",
"🔂",
"▶️",
"⏩",
"⏭️",
"⏯️",
"◀️",
"⏪",
"⏮️",
"🔼",
"⏫",
"🔽",
"⏬",
"⏸️",
"⏹️",
"⏺️",
"⏏️",
"🎦",
"🔅",
"🔆",
"📶",
"📳",
"📴",
"♀️",
"♂️",
"✖️",
"➕",
"➖",
"➗",
"🟰",
"♾️",
"‼️",
"⁉️",
"❓",
"❔",
"❕",
"❗",
"〰️",
"💱",
"⚕️",
"♻️",
"⚜️",
"🔱",
"📛",
"🔰",
"➰",
"➿",
"✳️",
"✴️",
"❇️",
"#️⃣",
"*️⃣",
"🔢",
"🔣",
"🈁",
"🈂️",
"🈷️",
"🈶",
"🈯",
"🉐",
"🈹",
"🈚",
"🈲",
"🉑",
"🈸",
"🈴",
"🈳",
"㊗️",
"㊙️",
"🈺",
"🈵",
"🔴",
"🟠",
"🟡",
"🟢",
"🔵",
"🟣",
"🟤",
"⚫",
"⚪",
"🟥",
"🟧",
"🟨",
"🟩",
"🟦",
"🟪",
"🟫",
"⬛",
"⬜",
"◼️",
"◻️",
"◾",
"◽",
"▪️",
"▫️",
"🔶",
"🔷",
"🔸",
"🔹",
"🔺",
"🔻",
"💠",
"🔘",
"🔳",
"🔲",
"🏁",
"🚩",
"🎌",
"🏴",
"🏳️",
"🏳️‍🌈",
"🏳️‍⚧️",
"🏴‍☠️"
};
public static string GetRandomEmoji()
{
Random rand = new Random();
int index = rand.Next(emojis.Length);
return emojis[index];
}
}
}
@@ -0,0 +1,52 @@
using Newtonsoft.Json;
namespace Campus_live_rewrite.api.server
{
// A small, admin-curated, ORDERED list of featured room ids, persisted to a JSON file so the
// GAME (Room_server/featuredrooms/current), the WEBSITE (/api/site/rooms/featured) and the
// admin panel all read the exact same set - "featured" is one shared thing, not two lists.
// Order matters (it's the display order), so this is a list, not a flag per room.
public static class FeaturedRoomsStore
{
private static readonly string FilePath = Path.Combine(Environment.CurrentDirectory, "Data", "featured_rooms.json");
private static readonly object Lock = new();
public static List<ulong> GetIds()
{
lock (Lock)
{
try
{
if (File.Exists(FilePath))
return JsonConvert.DeserializeObject<List<ulong>>(File.ReadAllText(FilePath)) ?? new List<ulong>();
}
catch (Exception ex)
{
Console.WriteLine($"[featured] read failed: {ex.Message}");
}
return new List<ulong>();
}
}
// Replaces the whole list. De-duplicates while PRESERVING order (first wins).
public static void SetIds(IEnumerable<ulong> ids)
{
var clean = new List<ulong>();
foreach (var id in ids)
if (id != 0 && !clean.Contains(id)) clean.Add(id);
lock (Lock)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, JsonConvert.SerializeObject(clean));
}
catch (Exception ex)
{
Console.WriteLine($"[featured] write failed: {ex.Message}");
}
}
}
}
}
@@ -0,0 +1,61 @@
using Newtonsoft.Json;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The admin panel's "Featured" tab: the featured PLAYERS (the community board's Featured
/// Creator tile, the Play tab's Featured Creator) and the board's announcement. The featured
/// ROOMS stay in FeaturedRoomsStore (the game, the website and the panel all read that one
/// list). Persisted to Data/featured.json so it survives a restart and a redeploy.
/// </summary>
public static class FeaturedStore
{
private static readonly string FilePath = Path.Combine(Environment.CurrentDirectory, "Data", "featured.json");
private static readonly object Lock = new();
public class State
{
/// <summary>Featured players, in display order; the first is THE featured creator.</summary>
public List<ulong> PlayerIds { get; set; } = new();
public string? Announcement { get; set; }
public string? AnnouncementUrl { get; set; }
}
private static State? _cache;
public static State Get()
{
lock (Lock)
{
if (_cache != null) return _cache;
try
{
if (File.Exists(FilePath))
_cache = JsonConvert.DeserializeObject<State>(File.ReadAllText(FilePath));
}
catch (Exception ex) { Console.WriteLine($"[featured] read failed: {ex.Message}"); }
return _cache ??= new State();
}
}
public static void Set(State s)
{
var clean = new List<ulong>();
foreach (var id in s.PlayerIds ?? new()) if (id != 0 && !clean.Contains(id)) clean.Add(id);
s.PlayerIds = clean;
lock (Lock)
{
_cache = s;
try
{
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, JsonConvert.SerializeObject(s, Formatting.Indented));
}
catch (Exception ex) { Console.WriteLine($"[featured] write failed: {ex.Message}"); }
}
}
/// <summary>The featured creator: the first featured player the admin set, or 0.</summary>
public static ulong FeaturedPlayer() => Get().PlayerIds.FirstOrDefault();
}
}
@@ -0,0 +1,72 @@
using System;
using System.IO;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Data/APIS/Gameconfigs.json as the client receives it. The file is the source; a handful of
/// values are rewritten on the way out so that changing them is a config line rather than an
/// edit to an 870-entry JSON file. Cached on the file's mtime.
/// </summary>
public static class GameConfigs
{
private static readonly object _lock = new();
private static string? _json;
private static string _stamp = "";
private static string Stamp(string path) =>
(File.Exists(path) ? File.GetLastWriteTimeUtc(path).Ticks.ToString() : "-") + "|" + ConfigCampus.LevelRewardImage;
/// <summary>
/// The configs with every Level Rewards picture pointed at ConfigCampus.LevelRewardImage:
/// each entry of Lifecycle.PSF.Progression.RewardMap (one per level, keyed by the
/// synthetic GiftDropId ConfigV2 hands out) and UnknownRewardImage.
/// </summary>
public static string WithRewardImage(string path)
{
string stamp = Stamp(path);
if (_json != null && stamp == _stamp) return _json;
lock (_lock)
{
if (_json != null && stamp == _stamp) return _json;
string raw = File.ReadAllText(path);
try
{
var arr = JArray.Parse(raw);
int rewritten = 0;
foreach (var e in arr)
{
string key = (string?)e["Key"] ?? "";
if (key == "Lifecycle.PSF.Progression.UnknownRewardImage")
{
e["Value"] = ConfigCampus.LevelRewardImage; rewritten++;
}
else if (key == "Lifecycle.PSF.Progression.RewardMap")
{
// The value is a JSON object serialised as a string: {"2000001":"x.png",...}
try
{
var map = JObject.Parse((string?)e["Value"] ?? "{}");
foreach (var p in map.Properties()) p.Value = ConfigCampus.LevelRewardImage;
e["Value"] = map.ToString(Newtonsoft.Json.Formatting.None);
rewritten++;
}
catch (Exception ex) { Console.WriteLine($"[gameconfigs] RewardMap unreadable, left as-is: {ex.Message}"); }
}
}
_json = arr.ToString(Newtonsoft.Json.Formatting.None);
Console.WriteLine($"[gameconfigs] level reward image -> '{ConfigCampus.LevelRewardImage}' ({rewritten} config value(s) rewritten)");
}
catch (Exception ex)
{
Console.WriteLine($"[gameconfigs] could not rewrite ({ex.Message}) - serving the file as-is");
_json = raw;
}
_stamp = stamp;
return _json;
}
}
}
}
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using Campus_live_rewrite.api.server.Classes;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Rate limit on POST /api/gamerewards/v1/request - the whole request, not just the reward
/// box it may offer.
///
/// The client asks for a reward twice around the end of a game: rewardType=PostGameActivity
/// (the game complete) and, once a day, rewardType=FirstActivityOfDay. Before this the
/// currency and XP were paid on EVERY PostGameActivity - only the three-choice box sat behind
/// a cooldown - so a client replaying the call farmed tokens and levels.
///
/// The rule:
/// - FirstActivityOfDay is once per day (PlayerDB.TryClaimDailyReward, unchanged) and is
/// always let through when it is the day's first.
/// - PostGameActivity is at most ONE per ConfigCampus.GameRewardCompleteCooldownSeconds per
/// player ... EXCEPT when the player's daily FirstActivityOfDay was accepted within the
/// last GameRewardDailyGraceSeconds: the two arrive together at the end of the day's
/// first game, and that game complete must not be eaten by a cooldown the previous game
/// started. The grace is consumed by the one game complete it lets through.
/// A refused request is answered null (nothing offered) and pays nothing.
/// </summary>
public static class GameRewardGate
{
private static readonly object _lock = new();
private static readonly Dictionary<ulong, DateTime> _lastComplete = new();
private static readonly Dictionary<ulong, DateTime> _dailyGrace = new();
/// <summary>Call when a FirstActivityOfDay claim was ACCEPTED (it was the day's first).</summary>
public static void NoteDailyAccepted(ulong playerId)
{
lock (_lock) _dailyGrace[playerId] = DateTime.UtcNow;
}
/// <summary>
/// Whether a PostGameActivity may be paid now. Marks the clock when it may.
/// <paramref name="why"/> explains a refusal.
/// </summary>
public static bool TryGameComplete(ulong playerId, out string why)
{
why = "";
int cd = ConfigCampus.GameRewardCompleteCooldownSeconds;
var now = DateTime.UtcNow;
lock (_lock)
{
bool graced = _dailyGrace.TryGetValue(playerId, out var daily)
&& (now - daily).TotalSeconds <= ConfigCampus.GameRewardDailyGraceSeconds;
if (graced)
{
_dailyGrace.Remove(playerId); // one game complete rides on the daily bonus
_lastComplete[playerId] = now;
return true;
}
if (cd > 0 && _lastComplete.TryGetValue(playerId, out var last) && (now - last).TotalSeconds < cd)
{
why = $"game complete {Math.Round((now - last).TotalSeconds)}s after the last one (limit one per {cd}s)";
return false;
}
_lastComplete[playerId] = now;
return true;
}
}
/// <summary>The gate for a request of <paramref name="type"/>; only game completes are limited here.</summary>
public static bool Allow(ulong playerId, GameRewardType type, out string why)
{
why = "";
return type != GameRewardType.PostGameActivity || TryGameComplete(playerId, out why);
}
}
}
@@ -0,0 +1,206 @@
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes.db_class;
using Newtonsoft.Json;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The gift box as the client reads it - ONE shape for the GET /api/avatar/v2/gifts list and
/// for the hub frames that announce a box (GiftPackageReceived 30 / ReceivedImmediate 31 /
/// RewardSelectionReceived 32, which all carry the same GiftPackagePayload). Built off the
/// stored PendingGiftBox so the list and the pop-up can never disagree about what a box holds.
/// </summary>
public static class GiftBoxes
{
public const string FrameReceived = "30";
public const string FrameReceivedImmediate = "31";
public const string FrameRewardSelection = "32";
/// <summary>Does the box hold anything the client can show? Currency and XP count: a
/// token box or a plain XP box has no item and is still a real reward.</summary>
public static bool HasContent(PendingGiftBox g) =>
g.GiftDropId != 0
|| !string.IsNullOrWhiteSpace(g.CustomAvatarItemId)
|| !string.IsNullOrWhiteSpace(g.AvatarItemDesc)
|| !string.IsNullOrWhiteSpace(g.ConsumableItemDesc)
|| !string.IsNullOrWhiteSpace(g.EquipmentPrefabName)
|| !string.IsNullOrWhiteSpace(g.EquipmentModificationGuid)
|| (g.Currency > 0 && g.CurrencyType != 0)
|| g.Xp > 0;
/// <summary>
/// An XP-only box: no item, no currency. The client classifies a box in this order -
/// currency, equipment, consumable, AVATAR ITEM, else XP - and the avatar-item test is a
/// null check, not an empty check: an XP box sent with AvatarItemDesc "" was read as an
/// outfit item with nothing to load ("Can't load item. It's saved in your Dorm mirror.").
/// So an XP box's AvatarItemDesc goes out as JSON null.
/// </summary>
public static bool IsXpOnly(PendingGiftBox g) =>
g.Xp > 0
&& string.IsNullOrWhiteSpace(g.AvatarItemDesc) && string.IsNullOrWhiteSpace(g.ConsumableItemDesc)
&& string.IsNullOrWhiteSpace(g.EquipmentPrefabName) && string.IsNullOrWhiteSpace(g.EquipmentModificationGuid)
&& !(g.Currency > 0 && g.CurrencyType != 0);
/// <summary>Does the box hold an AVATAR item (legacy descriptor or custom item)?</summary>
public static bool IsAvatarContent(PendingGiftBox g) =>
!string.IsNullOrWhiteSpace(g.AvatarItemDesc) || !string.IsNullOrWhiteSpace(g.CustomAvatarItemId);
// The same null rule for every non-avatar box, not only the XP one: a skin / consumable /
// token box with AvatarItemDesc "" and AvatarItemType 0 was read as an avatar item with
// nothing to load ("Can't load item. It's saved in your Dorm mirror.") - the real store
// rows for those carry AvatarItemType null (see StoreCatalog.AvatarTypeFor).
private static object? AvatarDescFor(PendingGiftBox g) => string.IsNullOrWhiteSpace(g.AvatarItemDesc) ? null : g.AvatarItemDesc;
private static int? AvatarTypeFor(PendingGiftBox g) => IsAvatarContent(g) ? g.AvatarItemType : (int?)null;
// ---- package signature -------------------------------------------------------------
// A box spawned into a shared room is handed to the OTHER clients over Photon, and they
// judge it by its signature before showing what it holds ("Bad signature for remote gift
// package" in their logs, and a blank box). The reference left Signature null / false,
// which is exactly what fails that check. Sign every package the server hands out:
// HMAC-SHA256 over the fields that identify the box, base64 like the real one, and
// IsSignatureValid true so a remote client treats it as server-vouched.
private static readonly byte[] SigKey = System.Text.Encoding.UTF8.GetBytes(
"campus-gift-" + (Environment.GetEnvironmentVariable("CAMPUS_GIFT_SIG_KEY") ?? "9f1c3e7a-box"));
public static string SignatureFor(object id, ulong playerId, ulong fromPlayerId, string? friendly, string? message, long currency, int currencyType)
{
string canon = $"{id}|{playerId}|{fromPlayerId}|{friendly}|{message}|{currency}|{currencyType}";
using var h = new System.Security.Cryptography.HMACSHA256(SigKey);
return Convert.ToBase64String(h.ComputeHash(System.Text.Encoding.UTF8.GetBytes(canon)));
}
public static string SignatureFor(PendingGiftBox g, ulong ownerId) =>
SignatureFor(g.NumericId != 0 ? (object)g.NumericId : g.Id, ownerId, g.FromAccountId, g.FriendlyName, g.Message, ClampCurrency(g.Currency), g.CurrencyType);
// The client reads Currency as a 32-bit int: a box minted with 9999999999999 tokens made the
// WHOLE gifts list unparseable ("Malformed Response" on GET /api/avatar/v2/gifts) and no box
// showed at all. Never serialize more than fits.
public static long ClampCurrency(long c) => c > int.MaxValue ? int.MaxValue : (c < 0 ? 0 : c);
/// <summary>The GET /gifts entry (reference: toGiftContent + the stored id/context).</summary>
public static object ToGiftContent(PendingGiftBox g, ulong ownerId = 0) => new
{
Id = g.NumericId != 0 ? (object)g.NumericId : g.Id,
// Whose box it is. The reference's GiftPackage carries PlayerId (with a null
// Signature / false IsSignatureValid - a server-minted box is never signed for
// peer transfer), and a box spawned into a shared room is judged local-or-remote by
// it. Without it every box in the room looked like the viewer's own.
PlayerId = ownerId,
Signature = SignatureFor(g, ownerId),
IsSignatureValid = true,
CustomAvatarItemId = string.IsNullOrWhiteSpace(g.CustomAvatarItemId) ? null : g.CustomAvatarItemId,
GiftDropId = g.GiftDropId,
GiftContext = g.GiftContext,
Message = g.Message ?? "",
FromPlayerId = g.FromAccountId,
FromAccountId = g.FromAccountId,
AvatarItemDesc = AvatarDescFor(g),
ConsumableItemDesc = g.ConsumableItemDesc ?? "",
ConsumableCount = string.IsNullOrWhiteSpace(g.ConsumableItemDesc) ? 0 : 1,
ConsumableMappingId = 0,
ConsumablePreExistingCount = 0,
AvatarItemType = AvatarTypeFor(g),
CurrencyType = g.CurrencyType,
Currency = ClampCurrency(g.Currency),
FriendlyName = g.FriendlyName ?? "",
ThumbnailImageName = g.ThumbnailImageName ?? "",
GiftRarity = g.Rarity,
EquipmentPrefabName = g.EquipmentPrefabName ?? "",
EquipmentModificationGuid = g.EquipmentModificationGuid ?? "",
Xp = g.Xp,
PackageType = 0,
Platform = -1,
PlatformsToSpawnOn = -1,
BalanceType = (int?)null,
CreatedAt = g.CreatedAt
};
/// <summary>The hub payload announcing a box (reference: pushGiftReceived).</summary>
public static object ToPackagePayload(PendingGiftBox g, ulong ownerId = 0) => new
{
Id = g.NumericId != 0 ? (object)g.NumericId : g.Id,
PlayerId = ownerId,
Signature = SignatureFor(g, ownerId),
IsSignatureValid = true,
CustomAvatarItemId = string.IsNullOrWhiteSpace(g.CustomAvatarItemId) ? null : g.CustomAvatarItemId,
FromGiftDropId = 0,
FromPlayerId = g.FromAccountId,
ConsumableItemDesc = g.ConsumableItemDesc ?? "",
AvatarItemDesc = AvatarDescFor(g),
AvatarItemType = AvatarTypeFor(g),
EquipmentPrefabName = g.EquipmentPrefabName ?? "",
EquipmentModificationGuid = g.EquipmentModificationGuid ?? "",
CurrencyType = g.CurrencyType,
Currency = ClampCurrency(g.Currency),
Xp = g.Xp,
Level = 0,
Platform = -1,
PlatformsToSpawnOn = -1,
BalanceType = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket,
GiftContext = g.GiftContext,
GiftRarity = g.Rarity,
FriendlyName = g.FriendlyName ?? "",
ThumbnailImageName = g.ThumbnailImageName ?? "",
Message = g.Message ?? ""
};
/// <summary>Announce a box over the hub so it pops without waiting for the next gifts poll.</summary>
public static void Push(ulong playerId, PendingGiftBox box, string frameId, string why)
{
try
{
var frame = JsonConvert.SerializeObject(new WebsocketEvents.Reponse { Id = frameId, Msg = ToPackagePayload(box, playerId) });
Notifications.FireAndForget(Notifications.SendToPlayer(playerId, frame), why);
}
catch (Exception ex) { Console.WriteLine($"[gifts] push {why} to {playerId} failed: {ex.Message}"); }
}
/// <summary>
/// Credit a box's currency (if any) and push the new balance so the corner counter moves
/// now (reference: creditCurrency + pushBalanceUpdate). The box only DISPLAYS the amount;
/// this is where it is paid. Returns the resulting balance, or -1 when nothing was paid.
/// </summary>
public static long CreditCurrency(ulong playerId, PendingGiftBox box, string why)
{
if (box.Currency <= 0 || box.CurrencyType == 0) return -1;
long balance = PlayerDB.AddBalance(playerId, box.CurrencyType, box.Currency);
PushBalance(playerId, box.CurrencyType, balance, why);
Console.WriteLine($"[{why}] {playerId} +{box.Currency} currency {box.CurrencyType} -> {balance}");
return balance;
}
/// <summary>
/// A box was just opened: pay its currency now if it was minted pay-on-open. Idempotent -
/// the flag is cleared on the stored box so a replayed open can't pay twice.
/// </summary>
public static void PayOnOpen(ulong playerId, PendingGiftBox box)
{
bool payCurrency = box.PayOnOpen && box.Currency > 0 && box.CurrencyType != 0;
bool payXp = box.XpOnOpen && box.Xp > 0;
if (!payCurrency && !payXp) return;
if (!PlayerDB.ClearGiftBoxPayOnOpen(playerId, box)) return; // someone else got here first
if (payCurrency)
{
long balance = PlayerDB.AddBalance(playerId, box.CurrencyType, box.Currency);
PushBalance(playerId, box.CurrencyType, balance, "gift-open");
Console.WriteLine($"[gifts] {playerId} opened box {box.NumericId}: +{box.Currency} currency {box.CurrencyType} -> {balance}");
}
if (payXp)
{
// AddXp pushes the level/XP bar live and hands out any level-up boxes crossed.
var r = PlayerDB.AddXp(playerId, box.Xp);
Console.WriteLine($"[gifts] {playerId} opened box {box.NumericId}: +{box.Xp} XP -> level {r?.Level} (+{r?.LevelsGained})");
}
}
public static void PushBalance(ulong playerId, int currencyType, long balance, string why)
{
try
{
Notifications.FireAndForget(
Notifications.SendToPlayer(playerId,
JsonConvert.SerializeObject(WebsocketEvents.createResponse_balanceupdate(currencyType, balance))),
$"{why} balance update");
}
catch (Exception ex) { Console.WriteLine($"[{why}] balance push to {playerId} failed: {ex.Message}"); }
}
}
}
@@ -0,0 +1,284 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Hair is free for everybody.
///
/// Two halves to "every hair and hair colour", and only one of them needed code:
///
/// HAIR ITEMS - the styles themselves - are ordinary avatar items in
/// Data/APIS/items/avatar_items.json, and ownership gates them (APIController's
/// /api/avatar/v4/items filters the catalog through PlayerDB.OwnedAvatarItemDescs).
/// DefaultAvatarItems.json shipped only 39 of the 131 hair guids in the catalog, so the
/// rest had to be earned. This class supplies the other 92.
///
/// HAIR COLOUR is a storefront price, not an ownership flag. The client keeps its palette
/// in HairColorVault, split into NaturalColors (always available) and DyeColors, and
/// LinkDyesToStorefront ties each dye swatch to a STORE ITEM - so the 250-token badges on
/// the Modify Hair swatches come straight off Data/APIS/GiftDropStores/3.json, where 19
/// "Permanent hair dye (...)" entries are priced at 250. (The 22 "... Hair Dye" entries in
/// Consumables.json are a different, already-unlocked thing and were a red herring.)
/// IsHairDye + StoreJsonWithFreeDyes below zero that price on the way out, and StoreCatalog
/// zeroes it at the till, so a colour costs nothing but still goes through the normal
/// purchase that unlocks it for good.
///
/// The set is unioned into a player's ownership on READ (see PlayerDB.OwnedAvatarItemDescs)
/// rather than written into their wardrobe row: existing accounts get it with no migration,
/// and it cannot drift out of sync with the catalog.
/// </summary>
public static class HairGrants
{
private static HashSet<string>? _hairKeys;
private static readonly object _lock = new();
/// <summary>
/// Every catalog item whose name carries the word "hair", as normalised ownership keys.
/// Empty when ConfigCampus.EveryoneGetsAllHair is off.
///
/// Matching on the whole word keeps "Chair" and "Mohair" out while still catching the
/// handful of hair ACCESSORIES named for it - Bat Hair Bow, Flower Hair Clip, Anime Hair
/// Hat. Those are deliberately included: they are hair pieces, and eight free accessories
/// is a better outcome than a rule nobody can predict from the item's name.
/// </summary>
public static HashSet<string> FreeHairKeys()
{
if (!ConfigCampus.EveryoneGetsAllHair)
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (_hairKeys != null) return _hairKeys;
lock (_lock)
{
if (_hairKeys != null) return _hairKeys;
var keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "avatar_items.json");
if (!File.Exists(path))
{
Console.WriteLine($"[hair] {path} not found - no free hair granted");
_hairKeys = keys;
return _hairKeys;
}
var rx = new System.Text.RegularExpressions.Regex(
@"\bhair\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (var e in JArray.Parse(File.ReadAllText(path)))
{
string name = (string?)e["FriendlyName"] ?? "";
if (!rx.IsMatch(name)) continue;
// Grant the descriptor exactly as the catalog writes it, normalised the
// same way ownership is compared - a coloured variant is its own entry
// (base guid + combination), so every variant of a hair item lands too.
string key = dbs.PlayerDB.NormalizeAvatarItemDesc((string?)e["AvatarItemDesc"]);
if (key.Length > 0) keys.Add(key);
}
// The dye colours are owned too - same set, so a purchase check, a wardrobe
// read and the store's IsItemOwned all answer the same way.
foreach (var k in FreeHairDyeKeys()) keys.Add(k);
Console.WriteLine($"[hair] every account owns {keys.Count} hair item(s) (ConfigCampus.EveryoneGetsAllHair)");
}
catch (Exception ex)
{
Console.WriteLine($"[hair] could not build the free-hair set: {ex.Message}");
}
_hairKeys = keys;
return _hairKeys;
}
}
private static readonly System.Text.RegularExpressions.Regex _dyeRx =
new(@"hair\s*dye", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
/// <summary>Is this store item a hair dye? Matches "Permanent hair dye (Bucket Orange)".</summary>
public static bool IsHairDye(string? friendlyName) =>
ConfigCampus.FreeHairDyes
&& !string.IsNullOrEmpty(friendlyName)
&& _dyeRx.IsMatch(friendlyName);
// One cached rewrite per store file, keyed on path + last-write time so an edited or
// redeployed catalog is picked up without a restart.
private static readonly Dictionary<string, (DateTime Stamp, string Json)> _freeStores = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// A gift-drop store file with every hair dye priced at 0, or null when the file holds no
/// dyes (the caller then serves the original off disk and nothing is copied into memory).
/// </summary>
/// <summary>Zero every hair dye's price in a store object, in place. Returns how many.</summary>
public static int ZeroDyePrices(JObject root)
{
if (!ConfigCampus.FreeHairDyes) return 0;
int changed = 0;
foreach (var it in root["StoreItems"] as JArray ?? new JArray())
{
if (it is not JObject o) continue;
if (!IsHairDye((string?)o["GiftDrop"]?["FriendlyName"])) continue;
foreach (var key in new[] { "Prices", "SubscriberPrices" })
foreach (var pr in o[key] as JArray ?? new JArray())
if (pr is JObject pj) pj["Price"] = 0;
changed++;
}
return changed;
}
/// <summary>Drop rows HiddenItemsStorefronts says the client cannot draw. Returns how many.</summary>
public static int HideUnloadableItems(JObject root)
{
if (!ConfigCampus.HiddenItemsStorefronts) return 0;
if (root["StoreItems"] is not JArray arr) return 0;
int n = 0;
for (int i = arr.Count - 1; i >= 0; i--)
{
string? desc = (string?)arr[i]?["GiftDrop"]?["AvatarItemDesc"];
if (!Classes.ItemGuids.StoreCanList(desc)) { arr.RemoveAt(i); n++; }
}
return n;
}
public static string? StoreJsonWithFreeDyes(string path)
{
try
{
var stamp = File.GetLastWriteTimeUtc(path);
lock (_lock)
{
if (_freeStores.TryGetValue(path, out var hit) && hit.Stamp == stamp)
return hit.Json;
}
var root = JObject.Parse(File.ReadAllText(path));
int changed = 0;
foreach (var it in root["StoreItems"] as JArray ?? new JArray())
{
if (it is not JObject o) continue;
if (!IsHairDye((string?)o["GiftDrop"]?["FriendlyName"])) continue;
foreach (var key in new[] { "Prices", "SubscriberPrices" })
foreach (var pr in o[key] as JArray ?? new JArray())
if (pr is JObject pj) pj["Price"] = 0;
changed++;
}
changed += HideUnloadableItems(root); // HiddenItemsStorefronts applies to every store
if (changed == 0) return null; // nothing to rewrite - let the caller stream the file
string json = root.ToString(Newtonsoft.Json.Formatting.None);
lock (_lock) _freeStores[path] = (stamp, json);
Console.WriteLine($"[hair] {Path.GetFileName(path)}: {changed} hair dye(s) served at 0 (ConfigCampus.FreeHairDyes)");
return json;
}
catch (Exception ex)
{
Console.WriteLine($"[hair] could not free the dyes in {Path.GetFileName(path)}: {ex.Message}");
return null; // fall back to the untouched file
}
}
// ---- hair dye as OWNED INVENTORY -------------------------------------------------
//
// Pricing the dyes at 0 made the swatches read a0 instead of a250 - still a price tag,
// still a purchase. A dye the player OWNS has no badge at all, and ownership is what the
// client actually tests: StoreItemModel.IsItemOwned, resolved against the avatar-item
// inventory, because a dye's GiftDrop is an avatar item (AvatarItemType 1, an
// AvatarItemDesc, and an empty ConsumableItemDesc).
//
// The catch is that the 19 dye descriptors are NOT in avatar_items.json, so
// /api/avatar/v4/items - which filters the CATALOG by what you own - could never emit
// them however they were marked owned. So the records are synthesised here from the store
// entries and appended to that response, and their descs join FreeHairKeys() so every
// other ownership check agrees.
private static JArray? _dyeItems;
// Catalog AvatarItemIds run to 9503, and the dyes' own GiftDropIds (2554-2572) are ALREADY
// taken by real catalog items - so a synthetic range well clear of both. Reusing GiftDropId
// here would have made 19 items collide with 19 existing ones.
private const int DyeAvatarItemIdBase = 9_600_000;
/// <summary>
/// Every hair dye as an avatar-item record, shaped like an avatar_items.json entry.
/// Empty when ConfigCampus.FreeHairDyes is off.
/// </summary>
public static JArray FreeHairDyeItems()
{
if (!ConfigCampus.FreeHairDyes) return new JArray();
if (_dyeItems != null) return _dyeItems;
lock (_lock)
{
if (_dyeItems != null) return _dyeItems;
var arr = new JArray();
try
{
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "GiftDropStores");
if (Directory.Exists(dir))
{
int n = 0;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var file in Directory.GetFiles(dir, "*.json").OrderBy(f => f))
{
JObject root;
try { root = JObject.Parse(File.ReadAllText(file)); }
catch { continue; }
foreach (var it in root["StoreItems"] as JArray ?? new JArray())
{
var gd = it?["GiftDrop"] as JObject;
if (gd == null) continue;
string name = (string?)gd["FriendlyName"] ?? "";
if (!IsHairDye(name)) continue;
string desc = (string?)gd["AvatarItemDesc"] ?? "";
if (string.IsNullOrWhiteSpace(desc)) continue; // nothing to own
if (!seen.Add(desc)) continue; // same dye in two stores
arr.Add(new JObject
{
["AvatarItemDesc"] = desc,
["AvatarItemType"] = (int?)gd["AvatarItemType"] ?? 1,
["PlatformMask"] = -1,
["FriendlyName"] = name,
["Tooltip"] = null,
["Rarity"] = (int?)gd["Rarity"] ?? -1,
["TagList"] = null,
["AvatarItemId"] = DyeAvatarItemIdBase + n,
["IsBaseAvatarItem"] = false,
["CreatedAt"] = "2018-01-01T00:00:00.000Z",
["ThumbnailImage"] = null
});
n++;
}
}
if (n > 0) Console.WriteLine($"[hair] every account owns {n} hair dye colour(s) (ConfigCampus.FreeHairDyes)");
}
}
catch (Exception ex)
{
Console.WriteLine($"[hair] could not build the free-dye set: {ex.Message}");
}
_dyeItems = arr;
return _dyeItems;
}
}
/// <summary>The dye descriptors, as ownership keys.</summary>
public static IEnumerable<string> FreeHairDyeKeys() =>
FreeHairDyeItems()
.Select(e => dbs.PlayerDB.NormalizeAvatarItemDesc((string?)e["AvatarItemDesc"]))
.Where(k => k.Length > 0);
}
}
@@ -0,0 +1,196 @@
using System;
using System.IO;
using System.Text;
using System.Collections.Generic;
namespace Campus_live_rewrite.api.server
{
public class Images
{
public static readonly string ImgSavePath = Path.Combine(Environment.CurrentDirectory, "Data", "Images", "player_images");
public static readonly string InvSavePath = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "InventionBlobs");
public static readonly string RoomSavePath = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
public static readonly string CdnSavePath = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "DataBlobs");
public static readonly string ImgSavePathPolaroid = Path.Combine(Environment.CurrentDirectory, "Data", "Images", "polaroid_images");
public static readonly string ImgRelativePath = "player_images/";
private static int IndexOf(byte[] haystack, byte[] needle, int startIndex = 0)
{
for (int i = startIndex; i <= haystack.Length - needle.Length; i++)
{
bool match = true;
for (int j = 0; j < needle.Length; j++)
{
if (haystack[i + j] != needle[j])
{
match = false;
break;
}
}
if (match)
return i;
}
return -1;
}
public static rn_file SaveImageFile(byte[] request)
{
try
{
bool parsed;
byte[]? imageBytes = Utils.ParseData(request, "file.bin", out parsed);
if (!parsed || imageBytes == null || imageBytes.Length == 0)
{
return new rn_file
{
success = false,
saved = false,
error = "Failed to parse or empty file data",
FileName = "error.png",
rawfilename = "error.png"
};
}
if (!Directory.Exists(ImgSavePath))
Directory.CreateDirectory(ImgSavePath);
string fileName = $"{ClientSecurity.RandomString(30)}.png";
string fullPath = Path.Combine(ImgSavePath, fileName);
File.WriteAllBytes(fullPath, imageBytes);
return new rn_file
{
success = true,
saved = true,
FileName = fileName,
rawfilename = fileName
};
}
catch (Exception ex)
{
Console.WriteLine($"[Images.SaveImageFile] Error: {ex}");
return new rn_file
{
success = false,
saved = false,
error = ex.Message,
FileName = "error.png",
rawfilename = "error.png"
};
}
}
public static rn_file SaveFileUnifiedv2(byte[] request, FileType_data fileType)
{
try
{
if (request == null || request.Length == 0)
{
Console.WriteLine("Empty file data received.");
return new rn_file { saved = false, success = false, error = "Empty file data." };
}
string extension;
string basePath;
switch (fileType)
{
case FileType_data.Image:
case FileType_data.Unknown:
extension = ".png";
basePath = ImgSavePathPolaroid;
break;
case FileType_data.Invention:
extension = ".inv";
basePath = InvSavePath;
break;
case FileType_data.RoomSave:
extension = ".room";
basePath = RoomSavePath;
break;
case FileType_data.RoomMetadata:
extension = ".META";
basePath = RoomSavePath;
break;
case FileType_data.Holotar:
extension = ".htr";
basePath = CdnSavePath;
break;
default:
extension = ".dat";
basePath = CdnSavePath;
break;
}
if (!Directory.Exists(basePath))
Directory.CreateDirectory(basePath);
string fname = $"{ClientSecurity.RandomString(40)}{extension}";
string fullPath = Path.Combine(basePath, fname);
Console.WriteLine($"[Images] Saving {fileType} file to: {fullPath}");
using (var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, useAsync: true))
{
fileStream.Write(request, 0, request.Length);
}
return new rn_file
{
success = true,
saved = true,
FileName = fname,
rawfilename = fname,
FileType = fileType
};
}
catch (Exception ex)
{
Console.WriteLine($"[Images.SaveFileUnified_v2] Error saving {fileType}: {ex}");
return new rn_file
{
success = false,
saved = false,
error = ex.Message,
FileName = "error.file",
rawfilename = "error.file"
};
}
}
public class rn_file
{
public bool success { get; set; }
public bool saved { get; set; }
public string? error { get; set; }
public string? FileName { get; set; }
public string? rawfilename { get; set; }
public FileType_data FileType { get; set; }
}
public enum FileType_data
{
Unknown,
RoomSave,
Holotar,
Image,
Video,
Invention,
RoomMetadata
}
public class FileUploadModel
{
public IFormFile? File { get; set; }
public FileType_data FileType { get; set; } = FileType_data.Unknown;
}
}
}
@@ -0,0 +1,150 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Campus_live_rewrite.api.dbs;
using Newtonsoft.Json;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Instance blocks: what a room KICK leaves behind. A kicked player is kept out of the one
/// live instance they were kicked from (ConfigCampus.RoomKickInstanceBlockSeconds, 8 h by
/// default) and nothing else - they can still enter any other instance of the room, and
/// the room's ban list is untouched. A room BAN (POST rooms/{id}/bans) is the separate,
/// owner-lifted thing.
///
/// Before this a kick (roomModKick) was stored as a permanent room ban with the reason
/// "Kicked by X", so anyone a room moderator kicked from ^RecCenter was banned from the
/// whole map until someone found and lifted it ("random bans").
///
/// Kept in memory and mirrored to Data/instance_blocks.json so a restart does not lift
/// them early. An instance is matched by its id or its Photon room name (the same instance
/// can be handed around under either).
/// </summary>
public static class InstanceBlocks
{
public class Block
{
public ulong PlayerId { get; set; }
public long RoomInstanceId { get; set; }
public string PhotonRoom { get; set; } = "";
public long RoomId { get; set; }
public string RoomName { get; set; } = "";
public ulong By { get; set; }
public string Reason { get; set; } = "";
public DateTime StartedAt { get; set; }
public DateTime Until { get; set; }
}
private static readonly ConcurrentDictionary<string, Block> _blocks = new();
private static readonly object _io = new();
private static bool _loaded;
private static string PathOnDisk => Path.Combine(Environment.CurrentDirectory, "Data", "instance_blocks.json");
private static string Key(ulong player, long instanceId, string? photon)
=> $"{player}|{instanceId}|{photon ?? ""}";
private static void EnsureLoaded()
{
if (_loaded) return;
lock (_io)
{
if (_loaded) return;
_loaded = true;
try
{
if (!File.Exists(PathOnDisk)) return;
var list = JsonConvert.DeserializeObject<List<Block>>(File.ReadAllText(PathOnDisk)) ?? new();
int live = 0;
foreach (var b in list)
{
if (b.Until <= DateTime.UtcNow) continue;
_blocks[Key(b.PlayerId, b.RoomInstanceId, b.PhotonRoom)] = b;
live++;
}
if (live > 0) Console.WriteLine($"[kick] {live} instance block(s) still in force after restart");
}
catch (Exception ex) { Console.WriteLine($"[kick] could not read instance blocks: {ex.Message}"); }
}
}
private static void Save()
{
lock (_io)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(PathOnDisk)!);
var live = _blocks.Values.Where(b => b.Until > DateTime.UtcNow).ToList();
File.WriteAllText(PathOnDisk, JsonConvert.SerializeObject(live, Formatting.Indented));
}
catch (Exception ex) { Console.WriteLine($"[kick] could not save instance blocks: {ex.Message}"); }
}
}
/// <summary>Keep a player out of one live instance for a while.</summary>
public static Block Add(ulong player, HeartbeatDB.RoomInstance inst, ulong by, TimeSpan duration, string reason)
{
EnsureLoaded();
var b = new Block
{
PlayerId = player,
RoomInstanceId = inst.roomInstanceId,
PhotonRoom = inst.photonRoomIdYeee ?? "",
RoomId = inst.roomId,
RoomName = inst.name ?? "",
By = by,
Reason = reason ?? "",
StartedAt = DateTime.UtcNow,
Until = DateTime.UtcNow + duration
};
// One block per (player, instance): a second kick restarts the clock.
foreach (var k in _blocks.Where(kv => kv.Value.PlayerId == player && Same(kv.Value, inst)).Select(kv => kv.Key).ToList())
_blocks.TryRemove(k, out _);
_blocks[Key(player, b.RoomInstanceId, b.PhotonRoom)] = b;
Save();
Console.WriteLine($"[kick] {by} blocked {player} from instance {b.RoomInstanceId} of ^{b.RoomName} (room {b.RoomId}) for {duration.TotalHours:0.#} h");
return b;
}
private static bool Same(Block b, HeartbeatDB.RoomInstance inst)
=> (b.RoomInstanceId != 0 && b.RoomInstanceId == inst.roomInstanceId)
|| (b.PhotonRoom.Length > 0 && string.Equals(b.PhotonRoom, inst.photonRoomIdYeee, StringComparison.Ordinal));
/// <summary>The block keeping this player out of this instance, or null.</summary>
public static Block? Active(ulong player, HeartbeatDB.RoomInstance? inst)
{
if (inst == null || player == 0) return null;
EnsureLoaded();
Block? hit = null;
foreach (var kv in _blocks)
{
if (kv.Value.PlayerId != player) continue;
if (kv.Value.Until <= DateTime.UtcNow) { _blocks.TryRemove(kv.Key, out _); continue; }
if (Same(kv.Value, inst)) hit = kv.Value;
}
return hit;
}
public static bool IsBlocked(ulong player, HeartbeatDB.RoomInstance? inst) => Active(player, inst) != null;
/// <summary>Lift every block a player has on a room's instances (an owner "unban", or admin).</summary>
public static int Clear(ulong player, long roomId = 0)
{
EnsureLoaded();
var keys = _blocks.Where(kv => kv.Value.PlayerId == player && (roomId == 0 || kv.Value.RoomId == roomId)).Select(kv => kv.Key).ToList();
foreach (var k in keys) _blocks.TryRemove(k, out _);
if (keys.Count > 0) Save();
return keys.Count;
}
/// <summary>Every block in force (admin view).</summary>
public static List<Block> All()
{
EnsureLoaded();
return _blocks.Values.Where(b => b.Until > DateTime.UtcNow).OrderByDescending(b => b.StartedAt).ToList();
}
}
}
@@ -0,0 +1,96 @@
using System;
using System.Collections.Concurrent;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Plausibility gate on leaderboard writes in Rec Room ORIGINAL rooms only.
///
/// A stat write (POST Leaderboard/leaderboard/CheckAndSetStat) is the client saying "my score
/// is now N". Nothing verified N. In an RRO room the game modes are known and a real score
/// cannot jump by a thousand in a second, so a write that does is a hacked client and is
/// refused - not written, answered with the same bare 0 the client always gets, logged, and
/// filed as a system report in the admin panel's Reports tab under Cheating.
///
/// Player-made rooms are NOT gated: a creator's circuits can legitimately award anything at
/// any rate, and policing that would break their game. RoomDB decides what counts as RRO
/// (IsRRO, or the base / rro / recroomoriginal tags).
///
/// Two limits, both per (player, channel, room):
/// MaxGainPerSecond - |new - stored| divided by the seconds since the last write. The very
/// first write has no "since", so only the day cap applies to it.
/// MaxGainPerDay - the sum of |gains| accepted in the current UTC day.
/// The magnitude is used so a lowest-wins stat (a race time) is judged the same way.
/// </summary>
public static class LeaderboardGuard
{
public enum Verdict { Ok, NotOriginalRoom, TooFast, TooMuchToday }
// One system report per (player, channel) per hour - the client retries a refused write,
// and ten copies of the same complaint help nobody.
private static readonly ConcurrentDictionary<string, DateTime> _reported = new();
public static Verdict Check(ulong playerId, int statChannel, ulong roomId, int newValue,
LeaderboardDBClasses.Entry? stored, out string why)
{
why = "";
if (!ConfigCampus.LeaderboardGuardEnabled) return Verdict.Ok;
var room = RoomDB.GetRoom(roomId);
if (room == null || !RoomDB.IsRecRoomOriginal(room)) return Verdict.NotOriginalRoom;
var now = DateTime.UtcNow;
long gain = stored == null ? Math.Abs((long)newValue) : Math.Abs((long)newValue - stored.Score);
if (gain == 0) return Verdict.Ok;
// ---- rate: gain per second since the last accepted write -------------------------
if (stored != null && stored.UpdatedAt != default)
{
double secs = Math.Max(1.0, (now - stored.UpdatedAt).TotalSeconds);
double perSec = gain / secs;
if (perSec > ConfigCampus.LeaderboardMaxGainPerSecond)
{
why = $"+{gain} in {secs:0.#}s ({perSec:0} /s, cap {ConfigCampus.LeaderboardMaxGainPerSecond} /s)";
return Verdict.TooFast;
}
}
// ---- volume: gain accepted today -------------------------------------------------
long today = (stored != null && stored.DayGainDate == now.Date) ? stored.DayGain : 0;
if (today + gain > ConfigCampus.LeaderboardMaxGainPerDay)
{
why = $"+{gain} would make {today + gain} today (cap {ConfigCampus.LeaderboardMaxGainPerDay} /day)";
return Verdict.TooMuchToday;
}
return Verdict.Ok;
}
/// <summary>Refuse the write: log it, and file it once an hour as a system Cheating report.</summary>
public static void Refuse(ulong playerId, string username, int statChannel, ulong roomId, string roomName, int newValue, Verdict verdict, string why)
{
Console.WriteLine($"[leaderboard][CHEAT] {playerId} @{username} sc{statChannel} in '{roomName}' ({roomId}): score {newValue} refused - {verdict}: {why}");
string key = $"{playerId}:{statChannel}";
var last = _reported.GetOrAdd(key, DateTime.MinValue);
if ((DateTime.UtcNow - last).TotalHours < 1) return;
_reported[key] = DateTime.UtcNow;
try
{
ReportsDB.Create(new ReportsDB.Report
{
ReporterPlayerId = 0, // the server, not a player
ReportedPlayerId = playerId,
ReportCategory = (int)Moderation_Detail.ReportCategory.Cheating,
Details = $"Leaderboard guard: posted score {newValue} on stat channel {statChannel} in {roomName} - {why}. Write refused.",
RoomId = roomId,
RoomInstanceType = "LeaderboardGuard"
});
}
catch (Exception ex) { Console.WriteLine($"[leaderboard] could not file the cheat report: {ex.Message}"); }
}
}
}
@@ -0,0 +1,205 @@
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// What REACHING each level pays, and the grant of it. Ported from the reference's
/// progression-db.ts LEVEL_REWARDS + econ.app.ts grantLevelUpGifts: one gift box per level
/// crossed, announced like any other unasked-for gift. Nothing here was doing this before -
/// a level-up moved the crown and paid nothing.
///
/// The table is Rec Room's published level-reward ladder, in the star ratings it uses
/// (2-Star = rarity 10, 3-Star 20, 4-Star 30, 5-Star 50). The reference pays a CONSUMABLE on
/// the early levels the table marks that way; consumables are a static catalog on this server
/// (nothing is unlocked per player), so those levels pay a token box instead - a real reward
/// rather than a cosmetic one. Clothing levels roll a random avatar item of the level's
/// rarity from the general store (3.json, the only catalog with a pool at every tier) that
/// the player does NOT already own; when that pool is exhausted the level pays tokens too.
/// </summary>
public static class LevelRewards
{
private const int TOKENS = -1; // the reference's CONSUMABLE_REWARD slot
/// <summary>GiftContext.LevelUp - the client's level-up box look.</summary>
public const int GiftContextLevelUp = 100;
// Indexed BY level; level 0 is unreachable.
private static readonly int[] LEVEL_REWARDS =
{
0,
// 1-10: token boxes interleaved with the first clothing drops.
TOKENS, 10, TOKENS, 10, TOKENS,
TOKENS, TOKENS, 10, TOKENS, 10,
// 11-20: 2-Star clothing all the way.
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
// 21-30: 2-Star alternating with 3-Star.
10, 20, 10, 20, 10, 20, 10, 20, 10, 20,
// 31-40: 3-Star with a 4-Star every few levels.
30, 20, 20, 20, 30, 20, 20, 20, 20, 30,
// 41-50: 4-Star to the top, then the game's only 5-Star.
30, 30, 30, 30, 30, 30, 30, 30, 30, 50,
};
/// <summary>Tokens a level's token box pays: scales with the level so a late token level
/// is not a consolation prize.</summary>
public static long TokensForLevel(int level) => level switch
{
<= 5 => 50,
<= 10 => 100,
<= 20 => 150,
<= 30 => 200,
<= 40 => 300,
< 50 => 500,
_ => 1000
};
public static int RewardFor(int level) =>
level > 0 && level < LEVEL_REWARDS.Length ? LEVEL_REWARDS[level] : (level >= LEVEL_REWARDS.Length ? 30 : 0);
// ---- The roll pool: the general store's avatar items, by rarity --------------------------
public sealed class PoolItem
{
public string AvatarItemDesc = "";
public string FriendlyName = "";
public int Rarity;
public int AvatarItemType;
public int GiftDropId;
}
private static readonly object _lock = new();
private static List<PoolItem>? _pool;
private static readonly Random _rng = new();
private static List<PoolItem> Pool()
{
lock (_lock)
{
if (_pool != null) return _pool;
var list = new List<PoolItem>();
try
{
string file = System.IO.Path.Combine(StoreCatalog.StoreDirectory, "3.json");
if (System.IO.File.Exists(file))
{
var root = JObject.Parse(System.IO.File.ReadAllText(file));
foreach (var it in root["StoreItems"] as JArray ?? new JArray())
{
var gd = it["GiftDrop"] as JObject;
if (gd == null) continue;
if ((bool?)gd["IsQuery"] == true) continue;
string desc = (string?)gd["AvatarItemDesc"] ?? "";
if (string.IsNullOrWhiteSpace(desc)) continue;
int rarity = (int?)gd["Rarity"] ?? 0;
if (rarity <= 0) continue;
list.Add(new PoolItem
{
AvatarItemDesc = desc,
FriendlyName = (string?)gd["FriendlyName"] ?? "",
Rarity = rarity,
AvatarItemType = (int?)gd["AvatarItemType"] ?? 0,
GiftDropId = (int?)gd["GiftDropId"] ?? 0
});
}
}
}
catch (Exception ex) { Console.WriteLine($"[levelrewards] could not read the roll pool: {ex.Message}"); }
Console.WriteLine($"[levelrewards] roll pool: {list.Count} avatar items " +
$"({string.Join(", ", list.GroupBy(p => p.Rarity).OrderBy(g => g.Key).Select(g => $"{g.Key}:{g.Count()}"))})");
_pool = list;
return _pool;
}
}
/// <summary>A random avatar item of the rarity the player doesn't own, or null when none is left.</summary>
public static PoolItem? RollClothing(int rarity, HashSet<string> ownedKeys)
{
var candidates = Pool().Where(p => p.Rarity == rarity
&& !ownedKeys.Contains(PlayerDB.NormalizeAvatarItemDesc(p.AvatarItemDesc))).ToList();
if (candidates.Count == 0) return null;
lock (_lock) return candidates[_rng.Next(candidates.Count)];
}
/// <summary>
/// Grant the reward for every level in (fromLevel, toLevel]. Best-effort per level: the XP
/// and the level are already stored, so a failed roll costs a prize, not the level.
/// </summary>
public static void GrantLevelUps(ulong playerId, int fromLevel, int toLevel)
{
if (toLevel <= fromLevel) return;
HashSet<string>? owned = null;
for (int level = fromLevel + 1; level <= toLevel; level++)
{
try
{
int reward = RewardFor(level);
if (reward == 0) continue;
// The box the real game hands over: the LevelUp box (GiftContext 100), named
// "Level N Box", with "Congrats on getting to level N!" written on it.
string message = $"Congrats on getting to level {level}!";
PendingGiftBox box;
PoolItem? item = null;
// Items only when the item tables are switched on (their bundles are missing
// from the CDN, so they render blank); otherwise every level pays tokens.
if (reward > 0 && ConfigCampus.RewardItemsEnabled)
{
owned ??= PlayerDB.GetOwnedAvatarItemKeys(playerId);
item = RollClothing(reward, owned);
if (item != null) owned.Add(PlayerDB.NormalizeAvatarItemDesc(item.AvatarItemDesc));
else Console.WriteLine($"[levelrewards] {playerId} owns every rarity-{reward} item - level {level} pays tokens");
}
if (item != null)
{
box = new PendingGiftBox
{
GiftContext = GiftContextLevelUp,
Message = message,
FromAccountId = 0,
AvatarItemDesc = item.AvatarItemDesc,
AvatarItemType = item.AvatarItemType,
FriendlyName = item.FriendlyName,
GiftDropId = item.GiftDropId,
Rarity = item.Rarity
};
}
else
{
long tokens = TokensForLevel(level);
box = new PendingGiftBox
{
GiftContext = GiftContextLevelUp,
Message = message,
FromAccountId = 0,
FriendlyName = $"Level {level} Box", // the box's name, as the real game labels it
// Empty = the client's own progression icon (see
// ConfigCampus.UseClientProgressionIcons).
ThumbnailImageName = ConfigCampus.UseClientProgressionIcons ? "" : QuestRewards.TokenThumbnail,
CurrencyType = (int)CurrencyType.RecCenterTokens,
Currency = tokens,
Rarity = QuestRewards.TokenRarity(tokens),
PayOnOpen = true
};
}
if (!PlayerDB.AddPendingGiftBoxFull(playerId, box))
{
Console.WriteLine($"[levelrewards] {playerId}: could not store the level {level} box");
continue;
}
GiftBoxes.Push(playerId, box, GiftBoxes.FrameReceivedImmediate, $"level {level} gift");
Console.WriteLine($"[levelrewards] {playerId} reached level {level}: " +
(item != null ? $"'{item.FriendlyName}' (rarity {item.Rarity})" : $"{box.Currency} tokens") +
$" in box {box.NumericId}");
}
catch (Exception ex)
{
Console.WriteLine($"[levelrewards] {playerId} level {level} grant failed: {ex.Message}");
}
}
}
}
}
@@ -0,0 +1,112 @@
using System.Collections.Concurrent;
using System.Net;
using Microsoft.AspNetCore.Http;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Brute-force lockout for the credential endpoints: after ConfigCampus.LoginFailBlockThreshold
/// FAILED logins from one IP inside LoginFailWindowSeconds, that IP is locked out of the login
/// endpoint for LoginFailBlockMinutes. Unlike the TrafficGuard rate limiter (which counts EVERY
/// auth request), this only counts FAILURES - a legit client logging in successfully never trips
/// it, and one success clears the counter.
///
/// The lockout is enforced in TrafficGuardMiddleware (before the controller) and the failures are
/// recorded by AuthController on a bad password. Keyed on the trustworthy LAST X-Forwarded-For IP
/// (Caddy-appended; a client-sent XFF can't rotate it). Loopback is exempt.
/// </summary>
public static class LoginGuard
{
private sealed class State
{
public int Count;
public long WindowStart; // unix seconds
public long LockedUntil; // unix seconds, 0 = not locked
}
private static readonly ConcurrentDictionary<string, State> _byIp = new();
private static long _lastSweep;
public static bool IsLockedOut(string ip, out int retryAfterSeconds)
{
retryAfterSeconds = 0;
if (!ConfigCampus.LoginFailBlockEnabled || string.IsNullOrWhiteSpace(ip) || IsLoopback(ip))
return false;
if (AdminProtectionMiddleware.IsLimitExempt(ip)) return false;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (_byIp.TryGetValue(ip, out var s) && s.LockedUntil > now)
{
retryAfterSeconds = (int)(s.LockedUntil - now);
return true;
}
return false;
}
/// <summary>Record a FAILED login. Trips the lockout once the threshold is reached.</summary>
public static void RecordFailure(string ip)
{
if (!ConfigCampus.LoginFailBlockEnabled || string.IsNullOrWhiteSpace(ip) || IsLoopback(ip))
return;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
Sweep(now);
var s = _byIp.AddOrUpdate(ip,
_ => new State { Count = 1, WindowStart = now },
(_, cur) =>
{
// A fresh counting window starts when the old one elapsed AND we aren't mid-lockout.
if (cur.LockedUntil <= now && now - cur.WindowStart >= ConfigCampus.LoginFailWindowSeconds)
{
cur.Count = 1;
cur.WindowStart = now;
}
else
{
cur.Count++;
}
return cur;
});
if (s.LockedUntil <= now && s.Count >= ConfigCampus.LoginFailBlockThreshold)
{
s.LockedUntil = now + (long)ConfigCampus.LoginFailBlockMinutes * 60;
Console.WriteLine($"[loginguard] {ip} LOCKED OUT for {ConfigCampus.LoginFailBlockMinutes} min after {s.Count} failed logins");
}
}
/// <summary>A successful login clears that IP's failure counter and any lockout.</summary>
public static void RecordSuccess(string ip)
{
if (!string.IsNullOrWhiteSpace(ip)) _byIp.TryRemove(ip, out _);
}
/// <summary>Trustworthy client IP = the LAST X-Forwarded-For entry (Caddy-appended), else the socket.</summary>
public static string ClientIp(HttpContext ctx)
{
string xff = ctx.Request.Headers["X-Forwarded-For"].ToString();
if (!string.IsNullOrWhiteSpace(xff))
{
var parts = xff.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length > 0) return parts[parts.Length - 1];
}
return ctx.Connection.RemoteIpAddress?.ToString() ?? "";
}
private static bool IsLoopback(string ip)
{
if (ip == "::1" || ip.StartsWith("127.")) return true;
return IPAddress.TryParse(ip, out var parsed) && IPAddress.IsLoopback(parsed);
}
private static void Sweep(long now)
{
if (now - Interlocked.Read(ref _lastSweep) < 300) return;
Interlocked.Exchange(ref _lastSweep, now);
foreach (var kv in _byIp)
if (kv.Value.LockedUntil <= now && now - kv.Value.WindowStart > ConfigCampus.LoginFailWindowSeconds + 60)
_byIp.TryRemove(kv.Key, out _);
}
}
}
@@ -0,0 +1,109 @@
using System.Globalization;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Verification of a Meta (Oculus) login's platform_auth nonce against Meta's graph API.
/// Port of the reference recflare server's apps/auth/src/meta-nonce.ts.
///
/// A Meta login posts
/// platform_auth = {"Nonce":"&lt;64 chars&gt;","AppId":"1295523863648168","Source":"logged in user"}
/// platform_id = &lt;the Meta user id&gt;
/// The nonce means nothing on its own; asking Meta "was this nonce issued to this user, for
/// this app?" is what binds the two together. So the posted platform_id is an INPUT here, and a
/// spoofed one fails validation instead of being trusted. Only an id that comes back verified
/// is ever bound or linked to an account.
///
/// Needs the app secret (ConfigCampus.MetaAppSecret): the access token is OC|appId|secret,
/// which authenticates the APP - which is why the secret never leaves the server.
/// </summary>
public static class MetaNonce
{
private const string NonceValidateUrl = "https://graph.oculus.com/user_nonce_validate";
private const int MaxAttempts = 3;
private static readonly HashSet<int> TransientErrorCodes = new() { 1, 2 };
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(8) };
public readonly record struct Result(bool Ok, string Reason, ulong UserId, string AppId);
/// <summary>The {Nonce, AppId} a Meta platform_auth carries, or null when it isn't one.</summary>
public static (string nonce, string appId)? Parse(string? platformAuth)
{
if (string.IsNullOrEmpty(platformAuth)) return null;
JObject o;
try { o = JObject.Parse(System.Net.WebUtility.UrlDecode(platformAuth)); }
catch { try { o = JObject.Parse(platformAuth); } catch { return null; } }
string? nonce = o["Nonce"]?.Type == JTokenType.String ? o["Nonce"]!.Value<string>() : null;
string? appId = o["AppId"]?.Type == JTokenType.String ? o["AppId"]!.Value<string>() : null;
if (string.IsNullOrEmpty(nonce)) return null;
// AppId is interpolated into the access token below - keep a non-numeric string out of it.
if (string.IsNullOrEmpty(appId) || !appId.All(char.IsDigit)) return null;
return (nonce, appId);
}
/// <summary>
/// Verify platform_auth against the user id it is claimed for. Only succeeds for a nonce Meta
/// itself confirms was issued to that user for that app. Reasons are for the server log; the
/// client is only ever told the proof was rejected.
/// </summary>
public static async Task<Result> VerifyAsync(string? platformAuth, ulong userId, string appSecret)
{
if (string.IsNullOrEmpty(appSecret)) return new(false, "no app secret configured", 0, "");
if (userId == 0) return new(false, "missing platform_id", 0, "");
var parsed = Parse(platformAuth);
if (parsed == null) return new(false, "malformed platform_auth payload", 0, "");
var (nonce, appId) = parsed.Value;
var form = new Dictionary<string, string>
{
["nonce"] = nonce,
["user_id"] = userId.ToString(CultureInfo.InvariantCulture),
["access_token"] = $"OC|{appId}|{appSecret}",
};
string reason = "not attempted";
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
var (ok, retryable, why) = await ValidateOnce(form);
if (ok) return new(true, "", userId, appId);
reason = why;
if (!retryable || attempt == MaxAttempts) break;
await Task.Delay(attempt * attempt * 250);
}
return new(false, reason, 0, appId);
}
private static async Task<(bool ok, bool retryable, string reason)> ValidateOnce(Dictionary<string, string> form)
{
HttpResponseMessage res;
try
{
res = await Http.PostAsync(NonceValidateUrl, new FormUrlEncodedContent(form));
}
catch (Exception ex)
{
return (false, true, $"request failed: {ex.Message}");
}
JObject body;
try { body = JObject.Parse(await res.Content.ReadAsStringAsync()); }
catch
{
// A non-JSON body is Meta's edge (a 5xx page, a rate-limit page), not a verdict.
return (false, true, $"HTTP {(int)res.StatusCode} with a non-JSON body");
}
if (body["error"] is JObject err)
{
int? code = err["code"]?.Type == JTokenType.Integer ? err["code"]!.Value<int>() : null;
bool transient = err["is_transient"]?.Type == JTokenType.Boolean && err["is_transient"]!.Value<bool>();
bool retry = transient || (code.HasValue && TransientErrorCodes.Contains(code.Value));
return (false, retry, $"graph error {(code?.ToString() ?? "?")}: {err["message"]?.ToString() ?? "no message"}");
}
if (body["is_valid"]?.Type != JTokenType.Boolean || !body["is_valid"]!.Value<bool>())
return (false, false, "nonce rejected");
return (true, false, "");
}
}
}
@@ -0,0 +1,53 @@
using Newtonsoft.Json;
namespace Campus_live_rewrite.api.server
{
public class Moderation_Detail
{
public class ModerationBlockDetails
{
public Moderation_Detail.ReportCategory ReportCategory { get; set; } = Moderation_Detail.ReportCategory.Unknown;
public int Duration { get; set; } = 0;
public long GameSessionId { get; set; } = 0;
public bool? IsHostKick { get; set; } = false;
public string? Message { get; set; } = "";
public ulong? PlayerIdReporter { get; set; } = null;
public bool? IsBan { get; set; } = false;
// The rest of the 16-key 2025 DTO (wire-shapes: playerreporting-moderationblockdetails).
// IsWarning is what turns the kick frame into a warning dialog instead of a removal.
public bool IsVoiceModAutoban { get; set; } = false;
public bool IsDeviceBan { get; set; } = false;
public bool IsWarning { get; set; } = false;
public string? VoteKickReason { get; set; } = null;
public DateTime? TimeoutStartedAt { get; set; } = null;
[JsonIgnore]
public long ModerationSetUnixTime { get; set; } = 0;
[JsonIgnore]
public ulong BannedByPlayerId { get; set; } = 0;
}
public enum ReportCategory
{
Moderator = -1,
Unknown,
DEPRECATED_MicrophoneAbuse,
Harassment,
Cheating,
DEPRECATED_ImmatureBehavior,
AFK,
Misc,
Underage,
VoteKick = 10,
MisleadingPurchases = 11,
CoC_Underage = 100,
CoC_Sexual,
CoC_Discrimination,
CoC_Trolling,
CoC_NameOrProfile,
InappropriateClothing = 200,
IssuingInaccurateReports = 1000,
RoomInventoryItems = 1100,
InappropriateRooms = 1101
}
}
}
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// One set of rules for every place a name can be set - the website signup, the client's
/// first-run "choose a username" (acc/account/me/username), the in-game @name change
/// (account/me/username) and the in-game display-name change (account/me/displayname).
/// Before this each path had its own checks and the gaps between them were the spoofing
/// holes: the first-run path took ANY string (spaces, control characters, zero-width joiners,
/// unlimited length, no swear filter), @name uniqueness was case-sensitive (so "kyle" sat next
/// to "Kyle"), and a display name could be blank, all punctuation, or an admin's name.
///
/// @username 3-20 chars, [A-Za-z0-9_], unique case-insensitively, not a reserved word,
/// passes the swear filter.
/// display name 1-15 chars of printable ASCII, whitespace collapsed, at least one letter
/// or digit, passes the swear filter, and may not impersonate an owner /
/// staff account or a reserved word once look-alike noise is stripped
/// ("K y l e", "Coach_", "c0ach" are all "the same name" for this check).
/// </summary>
public static class NameRules
{
public const int UsernameMin = 3, UsernameMax = 20, DisplayNameMax = 15;
// Names nobody may hold, in any casing or with look-alike decoration. Owner/staff accounts
// themselves are protected by AccountLock's exemption list (their real usernames).
private static readonly HashSet<string> Reserved = new(StringComparer.OrdinalIgnoreCase)
{
"admin", "administrator", "moderator", "mod", "campus", "system", "root", "staff",
"support", "null", "undefined", "everyone", "here", "recroom", "official", "owner",
"developer", "dev", "coach", "campusteam", "campusstaff", "campusmod"
};
private static readonly Regex UsernameShape = new("^[A-Za-z0-9_]+$", RegexOptions.Compiled);
/// <summary>Fold a name to what a reader perceives: lowercase, letters and digits only,
/// common digit-for-letter swaps undone. Two names that fold alike are "the same name".</summary>
public static string Fold(string s)
{
var sb = new System.Text.StringBuilder(s.Length);
foreach (char ch in s.ToLowerInvariant())
{
char c = ch switch { '0' => 'o', '1' => 'l', '3' => 'e', '4' => 'a', '5' => 's', '7' => 't', '@' => 'a', '$' => 's', '|' => 'l', _ => ch };
if (char.IsLetterOrDigit(c)) sb.Append(c);
}
return sb.ToString();
}
/// <summary>Does this name fold to a reserved word or to an owner/staff account's name
/// (other than <paramref name="self"/>'s own)?</summary>
public static bool Impersonates(string name, ulong self)
{
string f = Fold(name);
if (f.Length == 0) return false;
foreach (var staff in ConfigCampus.LockExemptUsernames ?? Array.Empty<string>())
{
if (Fold(staff) != f) continue;
ulong id = PlayerDB.GetAccountIdByUsernameCI(staff);
if (id != 0 && id == self) return false; // it IS that account - its own name is fine
return true;
}
return Reserved.Any(r => Fold(r) == f);
}
/// <summary>Null = acceptable (and <paramref name="clean"/> is the value to store); otherwise the player-facing reason.</summary>
public static string? CheckUsername(string? raw, ulong self, out string clean)
{
clean = (raw ?? "").Trim();
if (clean.Length < UsernameMin) return $"Your @name must have at least {UsernameMin} characters.";
if (clean.Length > UsernameMax) return $"Your @name must not exceed {UsernameMax} characters.";
if (!UsernameShape.IsMatch(clean)) return "Your @name can only use letters, numbers and underscores.";
if (Sanitize.ContainsSwears(clean)) return "Your @name contains a word that isn't allowed.";
if (Impersonates(clean, self)) return "That @name isn't available.";
ulong taken = PlayerDB.GetAccountIdByUsernameCI(clean);
if (taken != 0 && taken != self) return "That @name is already taken.";
return null;
}
public static string? CheckDisplayName(string? raw, ulong self, out string clean)
{
clean = Regex.Replace((raw ?? "").Trim(), @"\s+", " ");
if (clean.Length == 0) return "Your display name can't be empty.";
if (clean.Length > DisplayNameMax) return $"Your display name must not exceed {DisplayNameMax} characters.";
if (clean.Any(c => c < 0x20 || c > 0x7E)) return "Your display name can only use letters, numbers and basic punctuation.";
if (!clean.Any(char.IsLetterOrDigit)) return "Your display name needs at least one letter or number.";
if (Sanitize.ContainsSwears(clean)) return "Your display name contains a word that isn't allowed.";
if (Impersonates(clean, self)) return "That display name isn't available.";
return null;
}
}
}
@@ -0,0 +1,101 @@
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// What a brand-new IN-GAME account needs so the client runs its own account-creation flow
/// (reference: auth placeNewPlayerInOrientation + the client's OrientationIdentityCreationFlow).
///
/// The client walks "Recroom.AccountCreation.*" player settings inside the Orientation room:
/// HasStarted -> AcceptedCodeOfConduct -> HasChosenUsername (the in-world name picker, which
/// PUTs /Accounts/account/me/username) -> HasCreatedPassword (POST /Auth/account/me/
/// changepassword) -> HasImportedFriends -> FinishedAvatarCustomization -> HasFinished. The
/// default settings mark all of those done (website accounts already chose a name and
/// password there), which is what left an in-game account stuck with its random name. A new
/// in-game account gets them cleared, its tutorial mask zeroed so Orientation loads, and its
/// presence seeded to the Orientation instance the client tags itself with (-2) - without that
/// seed the heartbeat answered "dorm", the client read presence as out-of-sync and bounced
/// the player out of the flow (the old "SocialOrientation loops -> logout").
/// </summary>
public static class NewPlayerBootstrap
{
public const long OrientationRoomId = 13;
public const long OrientationInstanceId = -2;
public static void Start(ulong accountId, Platforms platform, bool needsPassword)
{
if (ConfigCampus.SkipOrientation)
{
// Straight into the game: nothing to walk, nothing to seed. Marking the steps
// done (rather than leaving them unset) matters - the client re-runs the flow on
// any step that reads false, and the defaults it would otherwise inherit are only
// applied to accounts that have no settings row at all.
try
{
SettingsDB.MarkOrientationComplete(accountId);
Console.WriteLine($"[newplayer] {accountId}: Orientation skipped (ConfigCampus.SkipOrientation)");
}
catch (Exception ex) { Console.WriteLine($"[newplayer] {accountId}: orientation skip failed - {ex.Message}"); }
return;
}
try
{
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasStarted", "false", accountId);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.AcceptedCodeOfConduct", "false", accountId);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasChosenUsername", "false", accountId);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasCreatedPassword", needsPassword ? "false" : "true", accountId);
// Friend import needs the platform's friends API (Steam/Meta), which this server
// doesn't proxy - skip that step.
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasImportedFriends", "true", accountId);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.FinishedAvatarCustomization", "false", accountId);
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasFinished", "false", accountId);
SettingsDB.SetPlayerSetting("TUTORIAL_COMPLETE_MASK", "0", accountId);
}
catch (Exception ex) { Console.WriteLine($"[newplayer] {accountId}: settings failed - {ex.Message}"); }
try
{
var inst = OrientationInstance();
if (inst != null)
{
HeartbeatDB.UpdatePlayerHeartbeat(accountId, inst, online: true, platform: platform);
Console.WriteLine($"[newplayer] {accountId}: seeded into Orientation (room {OrientationRoomId}, instance {OrientationInstanceId})");
}
else Console.WriteLine($"[newplayer] {accountId}: no Orientation room ({OrientationRoomId}) in RoomDB - not seeded");
}
catch (Exception ex) { Console.WriteLine($"[newplayer] {accountId}: presence seed failed - {ex.Message}"); }
}
/// <summary>The Orientation presence the client expects to be echoed while it runs the new-user flow.</summary>
public static HeartbeatDB.RoomInstance? OrientationInstance()
{
var room = RoomDB.GetRoom((ulong)OrientationRoomId);
if (room == null) return null;
var sub = room.SubRooms?.FirstOrDefault();
return new HeartbeatDB.RoomInstance
{
roomInstanceId = OrientationInstanceId,
roomId = OrientationRoomId,
subRoomId = (long)(sub?.SubRoomId ?? 1),
location = sub?.UnitySceneId ?? "",
roomInstanceType = 0,
name = "^" + (string.IsNullOrEmpty(room.Name) ? "Orientation" : room.Name),
maxCapacity = sub?.MaxPlayers > 0 ? sub.MaxPlayers : 4,
isFull = false,
isPrivate = false,
isInProgress = false,
photonRoomIdYeee = $"rec.{OrientationRoomId}",
matchmakingPolicy = 0
};
}
public static bool IsInOrientation(ulong accountId)
{
try { return HeartbeatDB.GetSelfPresence(accountId).room?.roomInstanceId == OrientationInstanceId; }
catch { return false; }
}
}
}
@@ -0,0 +1,498 @@
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes.db_class;
using CampusLiveSource.Controllers;
using System.Net.WebSockets;
using System.Security.Principal;
using System.Text;
namespace Campus_live_rewrite.api.server
{
public static class Notifications
{
private static readonly byte[] Separator = Encoding.UTF8.GetBytes("\u001e");
// SendToPlayer/SendToPlayers are `async Task`, but 19 call sites discarded the
// returned Task (compiler warning CS4014). A websocket push that threw was then
// swallowed completely - a chat message would simply never arrive, with nothing
// in the log to say so.
//
// This preserves the exact fire-and-forget timing (awaiting would make a chat
// send wait on every recipient socket) but surfaces failures instead of hiding
// them. Behaviour is unchanged; only visibility improves.
public static void FireAndForget(Task task, string what)
{
if (task == null) return;
_ = task.ContinueWith(
t => Console.WriteLine($"[Notifications] {what} FAILED: {t.Exception?.GetBaseException().Message}"),
TaskContinuationOptions.OnlyOnFaulted);
}
public static async Task SendToPlayer(ulong playerId, string message)
{
// Any notification headed for the game also nudges the player's open website tabs to
// re-fetch their inbox, so the web bell updates the instant the game event fires.
SiteNotifications.Notify(playerId);
if (!NotificationsController.WebSockets.TryGetValue(playerId, out var socket) || socket == null)
{
Console.WriteLine($"[Notifications] No active WebSocket for player {playerId}");
return;
}
await SendAsync(socket, message, playerId);
}
// Wire names of the subscription-update frames. The client dispatches these by STRING name;
// the numeric ResponseResults values (12 / 11) were silently dropped, which is why nothing
// updated live. Confirmed against a live client by the reference (recflare notify
// notification-types.ts): "PresenceUpdate", "AccountUpdate", "SelfAccountUpdate".
public const string WirePresenceUpdate = "PresenceUpdate";
public const string WireAccountUpdate = "AccountUpdate";
public const string WireSelfAccountUpdate = "SelfAccountUpdate";
// The reduced, REDACTED presence payload (reference: presenceUpdateMessage). NOT the raw
// heartbeat: photonRoomIdYeee / voice ids must never ride along (a frame carrying them reads
// to the client as "local presence out of sync" and it walks itself home), statusVisibility
// is forced to 0 so the subject isn't hidden from a viewer allowed to see them, and
// appVersion must be a STRING or the client aborts the whole frame.
private static object PresencePayload(ulong subjectId, HeartbeatDB.Heartbeat? hb, bool forSelf = false)
{
var inst = hb?.roomInstance;
object? redacted = inst == null ? null : new
{
roomInstanceId = inst.roomInstanceId,
roomId = inst.roomId,
subRoomId = inst.subRoomId,
location = inst.location,
roomInstanceType = inst.roomInstanceType,
name = inst.name,
maxCapacity = inst.maxCapacity,
isFull = HeartbeatDB.InstanceIsFull(inst),
isPrivate = inst.isPrivate,
isInProgress = inst.isInProgress
};
return new
{
playerId = subjectId,
// Only the player themself learns their real "Appear Online To"; a viewer's copy
// says Everyone, because the setting has already been applied to what they see.
statusVisibility = forSelf ? (hb?.statusVisibility ?? 0) : 0,
deviceClass = hb?.deviceClass ?? 0,
vrMovementMode = hb?.vrMovementMode ?? 0,
roomInstance = redacted,
isOnline = hb?.isOnline ?? false,
appVersion = hb?.appVersion ?? Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion
};
}
private static string Frame(string wireId, object payload) =>
JsonConvert.SerializeObject(new Campus_live_rewrite.api.WebsocketEvents.Reponse { Id = wireId, Msg = payload });
/// <summary>
/// Push the subject's current presence to ONE connected viewer, with that viewer's own
/// relationship and the subject's "Appear Online To" applied. Used when a viewer first
/// subscribes, so their list is live immediately.
/// </summary>
public static async Task PushPresenceTo(ulong viewerId, ulong subjectId)
{
try
{
if (!NotificationsController.WebSockets.TryGetValue(viewerId, out var socket) || socket == null) return;
var rel = FriendsDB.GetRelationships(viewerId);
bool friend = rel.Any(r => r.PlayerID == subjectId && r.RelationshipType == RelationshipType.Friend);
bool favorite = friend && rel.Any(r => r.PlayerID == subjectId && r.Favorited != ReciprocalStatus.None);
var hb = HeartbeatDB.GetPresenceForList(subjectId, viewerIsSelf: false, viewerIsFriend: friend, viewerIsFavorite: favorite, viewerId: viewerId);
await SendAsync(socket, Frame(WirePresenceUpdate, PresencePayload(subjectId, hb)), viewerId, quiet: true);
}
catch (Exception ex)
{
Console.WriteLine($"[presence-push] {subjectId} -> {viewerId} failed: {ex.Message}");
}
}
/// <summary>
/// Push the player's OWN presence to their own socket - the record with their real
/// statusVisibility and IsOnline true. The client's "you" tile / status picker reads this;
/// without it a status change only showed after the next presence poll (a minute or
/// more), and "online" never lit up for yourself on connect.
/// </summary>
public static async Task PushPresenceToSelf(ulong playerId)
{
try
{
if (!NotificationsController.WebSockets.TryGetValue(playerId, out var socket) || socket == null) return;
var hb = HeartbeatDB.GetPresenceForList(playerId, viewerIsSelf: true, viewerIsFriend: true, viewerIsFavorite: true);
await SendAsync(socket, Frame(WirePresenceUpdate, PresencePayload(playerId, hb, forSelf: true)), playerId, quiet: true);
}
catch (Exception ex)
{
Console.WriteLine($"[presence-push] self {playerId} failed: {ex.Message}");
}
}
// Realtime presence. Pushes a PresenceUpdate to every connected viewer that should see the
// subject - their FRIENDS plus everyone who SUBSCRIBED to them over the hub (the in-room
// player list, party members, profile cards) - whenever the subject's presence changes
// (connect, disconnect, room change, "Appear Online To"). Per-viewer, so each sees the
// subject with THEIR relationship and the subject's visibility applied. Not through
// SendToPlayer, so a presence tick never nudges anyone's website inbox.
public static async Task PushPresenceToFriends(ulong subjectId)
{
try
{
var friendIds = FriendsDB.GetRelationships(subjectId)
.Where(r => r.RelationshipType == RelationshipType.Friend)
.Select(r => r.PlayerID)
.Distinct()
.ToHashSet();
var viewers = new HashSet<ulong>(friendIds);
foreach (var v in NotificationsController.SubscribersOf(subjectId)) viewers.Add(v);
viewers.Remove(subjectId);
// The subject sees the change too, instantly - status picker, own online dot.
await PushPresenceToSelf(subjectId);
// Friends' open website tabs re-render too (online friends rail, friends modal),
// and any tab looking at this player's profile page sees the new status/room.
foreach (var fid in friendIds) SiteNotifications.Notify(fid);
SiteNotifications.ProfileChanged(subjectId);
if (viewers.Count == 0) return;
int sent = 0;
foreach (var vid in viewers)
{
// Only currently-connected viewers get a live frame; the rest read fresh
// presence from GET /Matchmaking/player when their client next asks.
if (!NotificationsController.WebSockets.TryGetValue(vid, out var socket) || socket == null)
continue;
bool friend = friendIds.Contains(vid);
bool favorite = friend && FriendsDB.GetRelationships(vid)
.Any(r => r.PlayerID == subjectId && r.Favorited != ReciprocalStatus.None);
var hb = HeartbeatDB.GetPresenceForList(
subjectId, viewerIsSelf: false, viewerIsFriend: friend, viewerIsFavorite: favorite, viewerId: vid);
await SendAsync(socket, Frame(WirePresenceUpdate, PresencePayload(subjectId, hb)), vid, quiet: true);
sent++;
}
if (sent > 0) Console.WriteLine($"[presence-push] {subjectId} -> {sent} viewer(s)");
}
catch (Exception ex)
{
Console.WriteLine($"[presence-push] subject {subjectId} failed: {ex.Message}");
}
}
// The public account projection the hub carries (reference: AccountUpdatePayload).
// PascalCase, with `createdAt` lowercase-camel - the client's canonical spelling.
private static Dictionary<string, object> AccountUpdatePayload(player_data p)
{
return new Dictionary<string, object>
{
["AccountId"] = p.playerid,
["UserName"] = p.player?.Username ?? "",
["DisplayName"] = p.player?.DisplayName ?? "",
["DisplayEmoji"] = p.player?.DisplayEmoji ?? "",
["ProfileImage"] = string.IsNullOrEmpty(p.player?.profileImage) ? "DefaultProfileImage.jpg" : p.player!.profileImage!,
["BannerImage"] = p.player?.bannerImage ?? "",
["TreatAsJunior"] = p.player?.IsJunior ?? false,
["HasBirthday"] = true,
["PersonalPronouns"] = p.player?.pronounFlags ?? 0,
["IdentityFlags"] = p.player?.identityFlags ?? 0,
["createdAt"] = (p.player?.created_at ?? DateTime.UtcNow).ToString("o"),
["IsJunior"] = p.player?.IsJunior ?? false,
};
}
/// <summary>
/// Push the frames that follow a profile change (reference: pushAccountUpdate). The owner
/// receives SelfAccountUpdate (owner-only fields FIRST, then the public ones - the client's
/// decoder emits a derived DTO's own members before its base's) plus AccountUpdate; everyone
/// connected receives AccountUpdate, so player cards, lists and the room's nameplates
/// re-render with the new name / picture immediately. Nulls are omitted: a null enum field
/// makes the client's parser throw. Best-effort - never fails the write that triggered it.
/// </summary>
public static async Task PushAccountUpdate(ulong accountId)
{
try
{
var p = PlayerDB.GetFullAccount(accountId);
if (p?.player == null) return;
SiteNotifications.ProfileChanged(accountId); // website tabs on this profile re-fetch it
var pub = AccountUpdatePayload(p);
var self = new Dictionary<string, object>
{
["Email"] = p.player.Email ?? "",
["Phone"] = "",
["Birthday"] = string.IsNullOrEmpty(p.player.Birthday) ? "1904-01-01T00:00:00.000Z" : p.player.Birthday!,
["JuniorState"] = p.player.IsJunior ? 1 : 0,
["AvailableUsernameChanges"] = 3,
};
foreach (var kv in pub) self[kv.Key] = kv.Value;
if (NotificationsController.WebSockets.TryGetValue(accountId, out var own) && own != null)
{
await SendAsync(own, Frame(WireSelfAccountUpdate, self), accountId, quiet: true);
await SendAsync(own, Frame(WireAccountUpdate, pub), accountId, quiet: true);
}
string frame = Frame(WireAccountUpdate, pub);
int sent = 0;
foreach (var kv in NotificationsController.WebSockets)
{
if (kv.Key == accountId || kv.Value == null) continue;
await SendAsync(kv.Value, frame, kv.Key, quiet: true);
sent++;
}
Console.WriteLine($"[account-push] {accountId} -> self + {sent} viewer(s)");
}
catch (Exception ex)
{
Console.WriteLine($"[account-push] {accountId} failed: {ex.Message}");
}
}
// Invoke ONE of the client's OWN hub handlers by name (RelationshipChanged,
// FriendRequestReceived, RelationshipsInvalid, GiftPackageReceived, ...) instead of the
// generic "Notification" wrapper SendToPlayer uses. The 2023 server proved against real
// client traffic that these named handlers are what actually raise a friend-request alert
// and flip the friends list live; the wrapped "Notification" path updates a message toast
// but never fires them. `type=1` = a SignalR invocation. Additive and best-effort: a client
// without the handler ignores it.
// The client has exactly ONE hub handler: the "Notification" invocation carrying a JSON
// string {Id, Msg}, dispatched by Id (reference notify/notification-types.ts). A SignalR
// invocation with any other target name is parsed and dropped in silence - which is what
// every "RelationshipsInvalid" / "RelationshipChanged" sent this way was doing. So a named
// invocation is turned into the frame the client dispatches: the relationship names map
// to their numeric ids (50 / 1), anything else rides under its own name (AccountUpdate,
// RoomUpdate, PresenceUpdate ... are name-addressed on the client).
private static readonly Dictionary<string, string> HubInvocationToFrame = new(StringComparer.OrdinalIgnoreCase)
{
["RelationshipsInvalid"] = "50",
["RelationshipChanged"] = "1",
["MessageReceived"] = "2",
["MessageDeleted"] = "3",
["RefreshLogin"] = "5",
["Logout"] = "6",
};
// Names with no client channel at all - sending them is a no-op, so they are skipped
// (the RelationshipChanged / stored-message frames beside them carry the news).
private static readonly HashSet<string> HubInvocationNoChannel = new(StringComparer.OrdinalIgnoreCase)
{
"FriendRequestReceived", "FriendRequestCancelled",
};
public static async Task SendHubInvocation(ulong playerId, string target, params object[] arguments)
{
if (HubInvocationNoChannel.Contains(target)) return;
string id = HubInvocationToFrame.TryGetValue(target, out var mapped) ? mapped : target;
object? msg = arguments != null && arguments.Length > 0 ? arguments[0] : null;
var frame = new WebsocketEvents.Reponse { Id = id, Msg = msg };
if (!NotificationsController.WebSockets.TryGetValue(playerId, out var socket) || socket == null)
return;
if (socket.State != WebSocketState.Open) return;
try
{
await SendAsync(socket, JsonConvert.SerializeObject(frame), playerId, quiet: true);
Console.WriteLine($"[Notifications] hub '{target}' (frame {id}) -> {playerId}");
}
catch (Exception ex)
{
Console.WriteLine($"[Notifications] hub '{target}' -> {playerId} FAILED: {ex.Message}");
}
}
// Broadcast a named hub invocation to EVERY connected client (the SendToAll twin of
// SendHubInvocation). Used for the public AccountUpdate so anyone looking at a player's
// profile redraws it live.
public static async Task SendHubInvocationToAll(string target, params object[] arguments)
{
var packet = new { type = 1, target, arguments = arguments ?? Array.Empty<object>() };
var json = JsonConvert.SerializeObject(packet);
var data = Encoding.UTF8.GetBytes(json);
var buffer = new byte[data.Length + Separator.Length];
Buffer.BlockCopy(data, 0, buffer, 0, data.Length);
Buffer.BlockCopy(Separator, 0, buffer, data.Length, Separator.Length);
var tasks = new List<Task>();
foreach (var kv in NotificationsController.WebSockets)
{
var socket = kv.Value;
if (socket == null || socket.State != WebSocketState.Open) continue;
tasks.Add(socket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, CancellationToken.None));
}
try { await Task.WhenAll(tasks); } catch (Exception ex) { Console.WriteLine($"[Notifications] hub-all '{target}' FAILED: {ex.Message}"); }
}
// The PROVEN account-update push, mirroring the reference server (RecFlare) exactly:
// 1. SelfAccountUpdate (PRIVATE self DTO) -> owner: their own account state.
// 2. AccountUpdate (PUBLIC DTO) -> owner: redraws the owner's open profile.
// 3. AccountUpdate (PUBLIC DTO) -> broadcast: everyone redraws THIS player's
// profile (new picture / banner / name) without a refetch.
// The old code sent only SelfAccountUpdate (owner) - which does NOT redraw the profile
// panel (that's why a new photo needed a relog) - and, when it once broadcast, it sent the
// PRIVATE account_data_me to everyone, which the client applied to their OWN account and
// mass-kicked them. The PUBLIC DTO (no email/settings) is what makes the broadcast safe.
public static async Task PushAccountUpdate(ulong accountId, bool broadcast = true)
{
var self = PlayerDB.GetAccountMeSafe(accountId);
if (self == null) return;
var pub = new
{
accountId = self.accountId,
username = self.username,
displayName = self.displayName,
profileImage = self.profileImage,
bannerImage = self.bannerImage,
displayEmoji = self.displayEmoji,
isJunior = self.isJunior,
platforms = self.platforms,
personalPronouns = self.personalPronouns,
identityFlags = self.identityFlags,
createdAt = self.createdAt,
};
// Deliver the SAME way the working notifications do (level/reputation/accept toast):
// wrapped by SendToPlayer/SendToAll as target="Notification" with an inner {Id, Msg},
// where the client dispatches on the inner Id. A raw named-target SignalR invocation is
// ignored by THIS (2025) client - it only reads the "Notification" envelope.
//
// ONLY AccountUpdate is sent - deliberately. It's the profile-redraw notification and
// carries the new picture/banner/name. Sending SelfAccountUpdate as WELL would make the
// client reload the SAME image id a second time concurrently and double-complete its
// load promise -> the [385120] dorm drop. One update = one load.
string pubJson = JsonConvert.SerializeObject(new WebsocketEvents.Reponse { Id = "AccountUpdate", Msg = pub });
await SendToPlayer(accountId, pubJson); // owner: redraw their own profile
if (broadcast) await SendToAll(pubJson); // everyone: redraw THIS player's profile
}
public static async Task SendToAll(string message)
{
var tasks = NotificationsController.WebSockets
.Select(kv => SendAsync(kv.Value, message, kv.Key));
await Task.WhenAll(tasks);
}
public static async Task SendToPlayers(List<ulong> playerIds, string message)
{
var tasks = new List<Task>();
foreach (var id in playerIds)
{
if (NotificationsController.WebSockets.TryGetValue(id, out var socket) && socket != null)
tasks.Add(SendAsync(socket, message, id));
}
await Task.WhenAll(tasks);
}
private static async Task SendAsync(WebSocket socket, string message, ulong? playerId = null, bool quiet = false)
{
if (socket == null || socket.State != WebSocketState.Open)
return;
var packet = new SockSignalR
{
type = MessageTypes.Idk,
target = "Notification",
arguments = new object[] { message },
nonblocking = true,
result = "200 OK"
};
var data = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(packet));
var buffer = new byte[data.Length + Separator.Length];
Buffer.BlockCopy(data, 0, buffer, 0, data.Length);
Buffer.BlockCopy(Separator, 0, buffer, data.Length, Separator.Length);
// Log the actual message text, not the byte[] (which prints "System.Byte[]" and made
// this line useless for debugging why a push didn't land).
if (!quiet)
Console.WriteLine($"[Notifications] Sending to {(playerId.HasValue ? playerId.ToString() : "unknown player")}: {message}");
try
{
await socket.SendAsync(
new ArraySegment<byte>(buffer),
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken: CancellationToken.None
);
}
catch (Exception ex)
{
Console.WriteLine($"[Notifications] Failed to send to {(playerId.HasValue ? playerId.ToString() : "unknown player")}: {ex.Message}");
try
{
await socket.CloseAsync(WebSocketCloseStatus.InternalServerError, "Send failed", CancellationToken.None);
}
catch
{
}
}
}
/// <summary>
/// Tells a player their own account changed (new profile picture, banner, identity
/// flags, ...) so their client re-reads it.
///
/// ONLY the owner is notified, and that is deliberate.
///
/// This used to ALSO push an "AccountUpdate" to every connected socket via
/// SendToAll - and the payload was GetAccountMeSafe(accountId), i.e. the changing
/// player's OWN account object (account_data_me: their id, their settings, their
/// email). Every other player in the server received one person's personal account
/// record and applied it over their own, which corrupted their client state: they
/// were error-kicked to their dorm, sometimes with the game breaking outright, and
/// sometimes bounced back into the room afterwards.
///
/// It also silently broke moderation. A banned player who got mass-kicked simply
/// rejoined the room they were in, so changing a profile picture effectively undid
/// bans for everyone online.
///
/// Other players do not need this push: the client re-reads profiles through
/// /Accounts/account/bulk when it renders them, so a new picture still appears -
/// just on next fetch instead of instantly.
///
/// If a genuine public "someone's profile changed" broadcast is ever wanted, it
/// needs its own PUBLIC payload shape (never account_data_me) - the whole problem
/// was sending a private per-player object to everyone.
/// </summary>
public static async Task<bool> RefreshAccount(ulong accountId)
{
try
{
var selfUpdate = JsonConvert.SerializeObject(
WebsocketEvents.create_player_updated_account2_Response(
"SelfAccountUpdate", PlayerDB.GetAccountMeSafe(accountId)));
await Notifications.SendToPlayer(accountId, selfUpdate);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Failed to send notification: {ex.Message}");
return false;
}
}
private class SockSignalR
{
public MessageTypes type { get; set; }
public string? target { get; set; }
public object[]? arguments { get; set; }
public bool nonblocking { get; set; }
public object? result { get; set; }
}
private enum MessageTypes
{
Invocation,
Idk
}
}
}
@@ -0,0 +1,55 @@
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Confines a user-supplied path segment to a directory.
///
/// The file-serving routes (/img, /cdn/room, /cdn/data) are all catch-all
/// (<c>{*path}</c>), so the segment can contain slashes and "..". They fed that raw
/// segment straight into Path.Combine and served whatever came out - so
/// <c>GET /cdn/room/../../../../etc/passwd</c> returned any file on the box. This is the
/// arbitrary-file-read that let the whole server be pulled down over HTTP.
///
/// ResolveUnder returns the absolute path ONLY when it stays inside the intended root,
/// and null the moment it would escape - covering "..", absolute inputs (/etc/passwd,
/// C:\...), UNC paths and embedded NULs. Path.GetFullPath does the normalisation, so this
/// is decided on the CANONICAL path, not the raw string.
/// </summary>
public static class PathSafety
{
public static string? ResolveUnder(string root, string? userRelative)
{
if (string.IsNullOrEmpty(userRelative)) return null;
if (userRelative.IndexOf('\0') >= 0) return null;
string rootFull;
string combined;
try
{
rootFull = Path.GetFullPath(root);
// Path.Combine discards the root if userRelative is absolute or rooted, which
// GetFullPath then normalises - the confinement check below still catches it,
// because an absolute input resolves to somewhere outside rootFull.
combined = Path.GetFullPath(Path.Combine(rootFull, userRelative));
}
catch
{
// Malformed path (illegal characters, over-long, etc.) - treat as an escape.
return null;
}
string rootWithSep = rootFull.EndsWith(Path.DirectorySeparatorChar)
? rootFull
: rootFull + Path.DirectorySeparatorChar;
// Must be the root itself, or strictly beneath it.
if (string.Equals(combined, rootFull, StringComparison.Ordinal))
return combined;
if (combined.StartsWith(rootWithSep, StringComparison.Ordinal))
return combined;
return null;
}
}
}
@@ -0,0 +1,45 @@
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Platform ids that are NOT identities.
///
/// The Quest patch has two builds. The one headset players actually use (the "no-Meta-account"
/// build, no Oculus App Id configured) cannot obtain a real Meta user id, so it deliberately
/// sends the sentinel id 1 together with a fixed, unverifiable nonce. That is by design: the
/// platform login is MEANT to fail, at a step the client handles, so it falls through to its own
/// username/password screen.
///
/// The consequence is that EVERY no-Meta headset sends the same id 1. So it must never be used
/// to look an account up or to bind one:
/// - bound: the first Quest player to log in claims id 1, and the cached-login picker then
/// offers THAT player's account to every other headset - with requirePassword false, i.e.
/// a no-password login into someone else's account.
/// - clash-checked: every Quest player after the first is refused with "already linked to a
/// different Campus account".
///
/// Quest players are identified by their account credentials and their device id instead, so
/// the per-device half of the account lock still holds.
/// </summary>
public static class PlatformIdentity
{
/// <summary>The id the Quest no-Meta build sends in place of a Meta user id.</summary>
public const ulong OculusNoAccountSentinel = 1;
/// <summary>
/// The account id carried by the placeholder cached-login entry (requirePassword=true) that
/// sends a headset to the username/password screen. It is not an account: the client
/// resolves it through /Accounts/account/bulk to label the picker, and PlayerDB answers with
/// a neutral "sign in" identity instead of the "Player0" default that made it look like a
/// broken login into a real account. Nothing else may ever use id 0.
/// </summary>
public const ulong LoginPlaceholderAccountId = 0;
public const string LoginPlaceholderUsername = "campus";
public const string LoginPlaceholderDisplayName = "Sign in to Campus";
/// <summary>True when this platform id identifies nobody and must not be trusted as one.</summary>
public static bool IsSentinel(Platforms platform, ulong platformId) =>
platform == Platforms.Oculus && platformId == OculusNoAccountSentinel;
}
}
@@ -0,0 +1,356 @@
using Campus_live_rewrite.api.dbs;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The end-of-game / quest reward pools, loaded once from Data/APIS/quest-rewards.json.
///
/// The file is keyed by game-mode name (Dodgeball, Quest_Goblin_S, Soccer, ...); each value
/// is a list of reward entries a play session can roll. Every entry carries a GiftContext
/// (the "Context" field) - the same id the client sends to POST /api/gamerewards/v1/request -
/// so a reward is picked by matching the requested GiftContext, falling back to the mode name.
///
/// Content only: the entry describes WHAT to grant (avatar item / consumable / equipment +
/// rarity + XP). The actual grant is a gift box (PlayerDB.AddPendingGiftBoxFull), which also
/// puts the item in the wardrobe, plus any XP.
/// </summary>
public static class QuestRewards
{
public sealed class Reward
{
public int Id;
public string? AvatarItemDesc;
public string? ConsumableItemDesc;
public string? EquipmentPrefabName;
public string? EquipmentModificationGuid;
public int CurrencyType;
public long Currency;
public int Xp;
public int Level;
public int GiftRarity;
public string? Message;
public int Context;
public string Mode = "";
}
// GiftContext.GameRewards (50) is the XP box; 51 is its TOKENS variant - "for when a
// reward pays currency instead of XP" (reference econ.app.ts). The client picks the box
// art and the open sound off the context, so a token drop filed under 50 played the
// plain XP box; that was the "wrong box sound" on token rewards.
public const int GiftContextGameRewards = 50;
public const int GiftContextGameRewardTokens = 51;
// The token coin the picker / gift box shows for a currency drop (Data/Images, served by
// /img/<name> like a consumable's ThumbnailImageName). A currency drop has no item to
// draw, so without this the tile was blank text.
public static string TokenThumbnail => ConfigCampus.LevelRewardImage;
/// <summary>The box tier (star rarity) a token amount is presented at: 25 = 2-star ... 300 = 5-star.</summary>
public static int TokenRarity(long amount) => amount switch
{
< 50 => 10, // 2-star box
< 150 => 20, // 3-star
< 500 => 30, // 4-star
_ => 50 // 5-star: 500+
};
public static bool IsTokens(Reward r) => r.Currency > 0 && r.CurrencyType != 0 && !IsItem(r);
private static readonly object _lock = new();
private static readonly Dictionary<string, List<Reward>> _byMode =
new(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<int, List<Reward>> _byContext = new();
private static readonly List<Reward> _all = new();
private static readonly Random _rng = new();
public static bool Loaded { get; private set; }
private static readonly string File1 =
System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "quest-rewards.json");
public static void Load()
{
try
{
if (!System.IO.File.Exists(File1))
{
Console.WriteLine($"[questrewards] {File1} not found - no game rewards loaded");
return;
}
var root = JObject.Parse(System.IO.File.ReadAllText(File1));
int count = 0;
foreach (var modeProp in root.Properties())
{
if (modeProp.Value is not JArray arr) continue;
var list = new List<Reward>();
foreach (var e in arr.OfType<JObject>())
{
var r = new Reward
{
Id = (int?)e["Id"] ?? 0,
AvatarItemDesc = (string?)e["AvatarItemDesc"],
ConsumableItemDesc = (string?)e["ConsumableItemDesc"],
EquipmentPrefabName = (string?)e["EquipmentPrefabName"],
EquipmentModificationGuid = (string?)e["EquipmentModificationGuid"],
CurrencyType = (int?)e["CurrencyType"] ?? 0,
Currency = (long?)e["Currency"] ?? 0,
Xp = (int?)e["Xp"] ?? 0,
Level = (int?)e["Level"] ?? 0,
GiftRarity = (int?)e["GiftRarity"] ?? 0,
Message = (string?)e["Message"],
Context = (int?)e["Context"] ?? 0,
Mode = modeProp.Name
};
list.Add(r);
_all.Add(r);
if (r.Context != 0)
{
if (!_byContext.TryGetValue(r.Context, out var cl))
_byContext[r.Context] = cl = new List<Reward>();
cl.Add(r);
}
count++;
}
_byMode[modeProp.Name] = list;
}
Loaded = true;
Console.WriteLine($"[questrewards] loaded {count} reward entries across {_byMode.Count} modes " +
$"({_byContext.Count} distinct gift contexts)");
}
catch (Exception ex)
{
Console.WriteLine($"[questrewards] failed to load: {ex.Message}");
}
}
private static readonly Dictionary<ulong, DateTime> _lastGift = new();
/// <summary>
/// At-most-once-per-cooldown gate for delivering a game-reward gift box, so a client that
/// fires the reward request repeatedly cannot flood the gift inbox. Returns true (and marks
/// the clock) when a box may be delivered now.
/// </summary>
public static bool TryClaimGiftCooldown(ulong playerId)
{
int cd = ConfigCampus.GameRewardGiftCooldownSeconds;
if (cd <= 0) return true;
var now = DateTime.UtcNow;
lock (_lock)
{
if (_lastGift.TryGetValue(playerId, out var last) && (now - last).TotalSeconds < cd)
return false;
_lastGift[playerId] = now;
return true;
}
}
/// <summary>
/// Roll up to <paramref name="n"/> DISTINCT rewards (by Id) for the three-choice reward
/// selection. Draws from the GiftContext pool, then the room's mode pool, then the whole
/// catalog; if fewer than n distinct entries exist it pads with repeats so the client always
/// gets a full set. Empty only when nothing is loaded at all.
/// </summary>
// What a DUPLICATE item is worth when it is offered as tokens instead. Rec Room's own box
// converts an item you already own into tokens; the client renders such a drop from its
// Currency/CurrencyType, so a duplicate handed over with Currency=0 read as "0 Tokens" -
// the "0, 0, 0 tokens" gift box players who already owned an activity's rewards kept seeing.
public static long DuplicateTokenValue(int giftRarity) => giftRarity switch
{
<= 10 => 25,
<= 20 => 50,
<= 30 => 100,
<= 40 => 200,
_ => 300
};
/// <summary>A token drop standing in for an item the player already owns. Keeps the row's id
/// so the selection can still be redeemed by it.</summary>
public static Reward AsDuplicateTokens(Reward r) => new Reward
{
Id = r.Id,
CurrencyType = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens,
Currency = DuplicateTokenValue(r.GiftRarity),
Xp = r.Xp,
Level = r.Level,
GiftRarity = TokenRarity(DuplicateTokenValue(r.GiftRarity)),
Message = "Tokens",
Context = GiftContextGameRewardTokens,
Mode = r.Mode
};
/// <summary>Does the reward table know this activity (by gift context or by room/mode name)?</summary>
public static bool HasRewardsFor(int giftContext, string? modeName)
{
lock (_lock)
{
if (giftContext != 0 && _byContext.TryGetValue(giftContext, out var c) && c.Count > 0) return true;
if (!string.IsNullOrWhiteSpace(modeName))
{
string n = modeName.TrimStart('^').Trim();
if (_byMode.TryGetValue(n, out var m) && m.Count > 0) return true;
}
return false;
}
}
/// <summary>
/// Three token choices, for an activity the reward table doesn't know (GiftContext 0 /
/// unknown room). Drawing random items from EVERY activity's table here produced cards
/// the client couldn't render (blank image, "Reward") - a Game Complete box with nothing
/// readable in it. Tokens always render and always pay. Ids sit far above the table's.
/// </summary>
// The token ladder a card can land on, 10..1000. Weighted so the big payouts are rare:
// the draw is skewed toward the low end (cube of a uniform roll), then snapped to a step
// so cards read cleanly (10, 25, 50 ... 750, 1000). Three DISTINCT amounts per offer.
private static readonly long[] TokenLadder = { 10, 15, 20, 25, 30, 40, 50, 60, 75, 100, 125, 150, 200, 250, 300, 400, 500, 750, 1000 };
public static long RollTokenAmount()
{
double u;
lock (_lock) u = _rng.NextDouble();
// u^3 keeps ~87% of rolls in the bottom half of the ladder; 1000 lands ~1 in 60.
int idx = (int)Math.Floor(Math.Pow(u, 3) * TokenLadder.Length);
return TokenLadder[Math.Clamp(idx, 0, TokenLadder.Length - 1)];
}
/// <param name="boxContext">The GiftContext the box is filed under: an activity's own id
/// (4206 Crescendo, 4003 Golden, ...) so the client draws THAT box, else the tokens box.</param>
public static List<Reward> TokenDrops(int boxContext = GiftContextGameRewardTokens)
{
var picked = new HashSet<long>();
int guard = 0;
while (picked.Count < 3 && guard++ < 100) picked.Add(RollTokenAmount());
while (picked.Count < 3) foreach (var l in TokenLadder) { if (picked.Count >= 3) break; picked.Add(l); }
var amounts = picked.OrderBy(v => v).ToArray();
var list = new List<Reward>();
for (int i = 0; i < amounts.Length; i++)
list.Add(new Reward
{
// Ids encode the amount so a selection can be redeemed by id after a restart.
Id = 1_000_000 + (int)amounts[i],
CurrencyType = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens,
Currency = amounts[i],
Xp = Campus_live_rewrite.api.server.Classes.Progress.GAME_REWARD_XP,
GiftRarity = TokenRarity(amounts[i]),
Message = "Tokens",
Context = boxContext
});
return list;
}
public static bool IsItem(Reward r) =>
!string.IsNullOrWhiteSpace(r.AvatarItemDesc) || !string.IsNullOrWhiteSpace(r.ConsumableItemDesc)
|| !string.IsNullOrWhiteSpace(r.EquipmentPrefabName) || !string.IsNullOrWhiteSpace(r.EquipmentModificationGuid);
/// <summary>
/// Pick up to <paramref name="n"/> distinct rewards for a context, preferring items the player
/// does NOT own (reference: pickQuestReward draws "among the rows the player DOESN'T ALREADY
/// OWN"). Only when the unowned pool runs dry are owned items offered - as token drops.
/// </summary>
public static List<Reward> PickDistinct(int giftContext, string? modeName, int n, HashSet<string>? ownedKeys)
{
var picked = PickDistinct(giftContext, modeName, n * 4);
if (ownedKeys == null) return picked.Take(n).ToList();
var fresh = new List<Reward>();
var dupes = new List<Reward>();
foreach (var r in picked)
{
bool owned = !string.IsNullOrWhiteSpace(r.AvatarItemDesc)
&& ownedKeys.Contains(PlayerDB.NormalizeAvatarItemDesc(r.AvatarItemDesc));
if (owned) dupes.Add(AsDuplicateTokens(r)); else fresh.Add(r);
}
var result = fresh.Take(n).ToList();
foreach (var d in dupes) { if (result.Count >= n) break; result.Add(d); }
return result;
}
public static List<Reward> PickDistinct(int giftContext, string? modeName, int n)
{
lock (_lock)
{
var pool = new List<Reward>();
bool hintGiven = giftContext != 0 || !string.IsNullOrWhiteSpace(modeName);
if (giftContext != 0 && _byContext.TryGetValue(giftContext, out var byCtx)) pool.AddRange(byCtx);
if (!string.IsNullOrWhiteSpace(modeName))
{
string nm = modeName.TrimStart('^').Trim();
if (_byMode.TryGetValue(nm, out var byMode)) pool.AddRange(byMode);
}
// Prefer the RIGHT mode's/context's pool, but if the hint didn't resolve to anything
// (the client often sends giftContext=0 and a room name we don't have a pool for, e.g.
// Rec Royale post-game), fall back to the whole catalog rather than offering NOTHING -
// no reward at all is what made "the game-complete reward does nothing". A generic gift
// beats an empty hand.
if (pool.Count == 0) pool.AddRange(_all);
_ = hintGiven;
var result = new List<Reward>();
if (pool.Count == 0) return result;
var seen = new HashSet<int>();
var shuffled = pool.OrderBy(_ => _rng.Next()).ToList();
foreach (var r in shuffled)
{
if (result.Count >= n) break;
if (seen.Add(r.Id)) result.Add(r);
}
// Pad with repeats if the pool had fewer than n distinct entries.
for (int i = 0; result.Count < n && pool.Count > 0; i++)
result.Add(pool[i % pool.Count]);
return result;
}
}
/// <summary>The client's GiftDrop wire shape for one offered reward choice (GameRewardDrop).</summary>
// The drop as the client's reward picker / gift box reads it. Currency, CurrencyType and Xp
// come off the row (reference: toQuestRewardDrop) - they used to be hardcoded 0 / absent,
// which is what made every non-item drop show as "0 Tokens" and no box show its XP.
public static object ToDrop(Reward r) => new
{
GiftDropId = r.Id,
FriendlyName = IsTokens(r) ? $"{r.Currency} Tokens"
: string.IsNullOrWhiteSpace(r.Message) ? "Reward" : r.Message,
Tooltip = IsTokens(r) ? $"{r.Currency} Rec Center Tokens" : "",
// The coin image for a currency drop; an item drop draws its own thumbnail.
ThumbnailImageName = IsTokens(r) ? TokenThumbnail : "",
ConsumableItemDesc = r.ConsumableItemDesc ?? "",
AvatarItemDesc = r.AvatarItemDesc ?? "",
AvatarItemType = 0,
EquipmentPrefabName = r.EquipmentPrefabName ?? "",
EquipmentModificationGuid = r.EquipmentModificationGuid ?? "",
IsQuery = false,
Unique = false,
SubscribersOnly = false,
Rarity = r.GiftRarity,
// Items + XP only: tokens/currency are server/admin (see GameCurrency.IsPlayerGrantable).
CurrencyType = r.CurrencyType,
Currency = r.Currency,
Xp = r.Xp > 0 ? r.Xp : Campus_live_rewrite.api.server.Classes.Progress.GAME_REWARD_XP,
Context = r.Context,
ItemSetId = 1,
ItemSetFriendlyName = ""
};
/// <summary>Roll a random reward for a GiftContext, else for a mode name, else null.</summary>
public static Reward? Pick(int giftContext, string? modeName = null)
{
lock (_lock)
{
if (giftContext != 0 && _byContext.TryGetValue(giftContext, out var byCtx) && byCtx.Count > 0)
return byCtx[_rng.Next(byCtx.Count)];
if (!string.IsNullOrWhiteSpace(modeName))
{
string n = modeName.TrimStart('^').Trim();
if (_byMode.TryGetValue(n, out var byMode) && byMode.Count > 0)
return byMode[_rng.Next(byMode.Count)];
}
return null;
}
}
}
}
@@ -0,0 +1,89 @@
using System.Collections.Concurrent;
public static class RateLimit
{
private static readonly ConcurrentDictionary<string, DateTime> _requests = new();
public static bool TryConsume(
string key,
int cooldownSeconds,
out int retryAfterSeconds)
{
retryAfterSeconds = 0;
var now = DateTime.UtcNow;
var last = _requests.GetOrAdd(key, _ => DateTime.MinValue);
var elapsed = (now - last).TotalSeconds;
if (elapsed < cooldownSeconds)
{
retryAfterSeconds = (int)Math.Ceiling(cooldownSeconds - elapsed);
return false;
}
_requests[key] = now;
Cleanup(now);
return true;
}
private static void Cleanup(DateTime now)
{
if (_requests.Count < 10_000) return;
foreach (var kv in _requests)
{
if ((now - kv.Value).TotalMinutes > 10)
_requests.TryRemove(kv.Key, out _);
}
}
// ---- sliding-window quota ------------------------------------------------------------
//
// TryConsume above is a COOLDOWN: a minimum gap between two calls. On its own that does
// not stop abuse - someone can keep uploading forever as long as they wait out the gap.
// For anything that writes files to disk you want both: a cooldown to stop bursts, and a
// quota to cap the total over a window.
private static readonly ConcurrentDictionary<string, List<DateTime>> _windows = new();
private static readonly object _windowLock = new();
/// <summary>
/// Allows at most <paramref name="maxInWindow"/> calls per key per window. Returns false
/// (with seconds until the oldest call ages out) once the quota is spent.
/// </summary>
public static bool TryConsumeQuota(
string key,
int maxInWindow,
int windowSeconds,
out int retryAfterSeconds)
{
retryAfterSeconds = 0;
var now = DateTime.UtcNow;
var cutoff = now.AddSeconds(-windowSeconds);
// Lock rather than lock-free: the read-modify-write below is not atomic, and without
// it two concurrent uploads can both see a non-full window and both be admitted.
lock (_windowLock)
{
var stamps = _windows.GetOrAdd(key, _ => new List<DateTime>());
stamps.RemoveAll(t => t < cutoff);
if (stamps.Count >= maxInWindow)
{
retryAfterSeconds = (int)Math.Ceiling((stamps[0] - cutoff).TotalSeconds);
if (retryAfterSeconds < 1) retryAfterSeconds = 1;
return false;
}
stamps.Add(now);
if (_windows.Count > 10_000)
{
foreach (var kv in _windows)
if (kv.Value.Count == 0) _windows.TryRemove(kv.Key, out _);
}
return true;
}
}
}
@@ -0,0 +1,189 @@
using System.Collections.Concurrent;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The three-choice reward the client shows on the watch after a game/quest. POST
/// /api/gamerewards/v1/request mints one (three offered drops) and pushes it over the
/// notifications hub as "RewardSelectionReceived"; POST /api/gamerewards/v1/select consumes it
/// and grants the chosen drop. The offered drops are recorded so select can verify the player
/// is claiming one they were actually offered, and Consumed makes it single-use.
///
/// In-memory + auto-increment id + a TTL, mirroring RoomInvites - a selection is picked within
/// seconds of being offered, so it needn't survive a restart (the player just re-triggers it).
/// </summary>
public static class RewardSelections
{
public sealed class Selection
{
public int RewardSelectionId;
public ulong AccountId;
public string Message = "";
public int GiftContext;
public int RewardType;
public List<QuestRewards.Reward> Drops = new(); // the three offered, in order
public bool Consumed;
/// <summary>The GiftDropId the player picked (0 for selections consumed before this existed).</summary>
public int ChosenDropId;
public DateTime CreatedAt = DateTime.UtcNow;
}
private static int _next = 0;
private static readonly ConcurrentDictionary<int, Selection> _store = new();
// Persisted: a selection offered right before a redeploy/restart used to vanish, so the
// pick the player made seconds later was a 403 ("Failed to consume reward N"). Rows are
// the Selection as JSON keyed by id; loaded once, written on create and on consume.
public sealed class Row { [LiteDB.BsonId] public int Id { get; set; } public string Json { get; set; } = ""; public DateTime CreatedAt { get; set; } }
private static readonly Lazy<LiteDB.LiteDatabase> _db = new(() => new LiteDB.LiteDatabase(Path.Combine("Data", "DBs", "RewardSelections.db")));
private static bool _loaded;
private static readonly object _loadLock = new();
private static void EnsureLoaded()
{
if (_loaded) return;
lock (_loadLock)
{
if (_loaded) return;
try
{
var col = _db.Value.GetCollection<Row>("selections");
int max = 0, n = 0;
foreach (var r in col.FindAll())
{
var s = Newtonsoft.Json.JsonConvert.DeserializeObject<Selection>(r.Json);
if (s == null) continue;
_store[s.RewardSelectionId] = s;
if (s.RewardSelectionId > max) max = s.RewardSelectionId;
n++;
}
if (max > _next) _next = max;
if (n > 0) Console.WriteLine($"[gamereward] restored {n} reward selection(s), next id {_next + 1}");
}
catch (Exception ex) { Console.WriteLine($"[gamereward] could not restore selections: {ex.Message}"); }
_loaded = true;
}
}
private static void Persist(Selection s)
{
try
{
_db.Value.GetCollection<Row>("selections").Upsert(new Row { Id = s.RewardSelectionId, Json = Newtonsoft.Json.JsonConvert.SerializeObject(s), CreatedAt = s.CreatedAt });
}
catch (Exception ex) { Console.WriteLine($"[gamereward] could not persist selection {s.RewardSelectionId}: {ex.Message}"); }
}
// Matches the served Rewards.SelectionTimeout (48 h). The client runs an expiry timer of
// (CreatedAt + timeout - its own server-time estimate) and AUTO-PICKS card 1 + closes the
// watch when it fires; a PC whose clock/zone put "server time" 30+ min ahead of CreatedAt
// saw the old 30-min window as already expired and had the reward chosen for it within
// seconds of the offer. The window now outlives any timezone skew, so a selection must stay
// redeemable here for as long as the client thinks it is open.
private static readonly TimeSpan TTL = TimeSpan.FromHours(48);
public static Selection Create(ulong accountId, string message, int giftContext, int rewardType, List<QuestRewards.Reward> drops)
{
EnsureLoaded();
Prune();
int id = System.Threading.Interlocked.Increment(ref _next);
var s = new Selection
{
RewardSelectionId = id,
AccountId = accountId,
Message = message ?? "",
GiftContext = giftContext,
RewardType = rewardType,
Drops = drops
};
_store[id] = s;
Persist(s);
return s;
}
public static Selection? Get(int id) { EnsureLoaded(); return _store.TryGetValue(id, out var s) ? s : null; }
/// <summary>
/// Mint a three-card reward selection for a player and push it to their watch - the
/// same thing POST /api/gamerewards/v1/request does after a game. Tokens (10..1000) in the
/// activity's box unless item rewards are enabled and the table knows the context.
/// Returns the selection, or null when nothing could be offered.
/// </summary>
public static Selection? Offer(ulong accountId, int giftContext, string? modeName, string? message)
{
var owned = PlayerDB.GetOwnedAvatarItemKeys(accountId);
bool known = QuestRewards.HasRewardsFor(giftContext, modeName);
List<QuestRewards.Reward> drops;
if (ConfigCampus.RewardItemsEnabled && known)
drops = QuestRewards.PickDistinct(giftContext, modeName, 3, owned);
else
drops = QuestRewards.TokenDrops(giftContext != 0 ? giftContext : QuestRewards.GiftContextGameRewardTokens);
if (drops.Count != 3) return null;
string selMsg = string.IsNullOrWhiteSpace(message) ? "Activity completed!" : message!;
var sel = Create(accountId, selMsg, giftContext, 0, drops);
var frame = Newtonsoft.Json.JsonConvert.SerializeObject(new WebsocketEvents.Reponse { Id = "RewardSelectionReceived", Msg = ToPayload(sel) });
double delay = ConfigCampus.RewardSelectionPushDelaySeconds;
Notifications.FireAndForget(System.Threading.Tasks.Task.Run(async () =>
{
if (delay > 0) await System.Threading.Tasks.Task.Delay(TimeSpan.FromSeconds(delay));
await Notifications.SendToPlayer(accountId, frame);
}), "RewardSelectionReceived");
Console.WriteLine($"[gamereward] {accountId} offered reward selection {sel.RewardSelectionId} " +
$"ctx={giftContext} room='{modeName}' drops=[{string.Join(",", drops.Select(d => d.Id))}] in box {drops[0].Context}");
return sel;
}
/// <summary>The caller's still-unclaimed selections, oldest first (GET /gamerewards/v1/pending).</summary>
public static List<Selection> PendingFor(ulong accountId)
{
EnsureLoaded();
Prune();
return _store.Values.Where(s => s.AccountId == accountId && !s.Consumed)
.OrderBy(s => s.RewardSelectionId).ToList();
}
/// <summary>The selection as the client reads it - the RewardSelectionReceived frame's Msg
/// and one entry of GET /gamerewards/v1/pending are this same shape.</summary>
public static object ToPayload(Selection sel) => new
{
RewardSelectionId = sel.RewardSelectionId,
RewardType = sel.RewardType,
Message = sel.Message,
GiftContext = sel.GiftContext,
GiftDrop1 = QuestRewards.ToDrop(sel.Drops[0]),
GiftDrop2 = QuestRewards.ToDrop(sel.Drops[1]),
GiftDrop3 = QuestRewards.ToDrop(sel.Drops[2]),
// The reference sends the third drop again under the subscriber key; with no
// subscriber-only pool the two are the same drop.
Subscriber_GiftDrop3 = QuestRewards.ToDrop(sel.Drops[2]),
CreatedAt = sel.CreatedAt.AddSeconds(ConfigCampus.RewardSelectionCreatedAtOffsetSeconds).ToString("o")
};
/// <summary>Mark consumed. Returns false if it was already consumed (single-use, race-safe).</summary>
public static bool Consume(int id, int chosenDropId = 0)
{
if (!_store.TryGetValue(id, out var s)) return false;
lock (s)
{
if (s.Consumed) return false;
s.Consumed = true;
s.ChosenDropId = chosenDropId;
}
Persist(s);
return true;
}
private static void Prune()
{
var cutoff = DateTime.UtcNow - TTL;
foreach (var kv in _store)
if (kv.Value.CreatedAt < cutoff)
{
_store.TryRemove(kv.Key, out _);
try { _db.Value.GetCollection<Row>("selections").Delete(kv.Key); } catch { }
}
}
}
}
@@ -0,0 +1,150 @@
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Single source of truth for "can the client actually download this room blob?".
///
/// Room JSON references blobs by FILENAME ONLY. If the client is handed a name it
/// cannot download, the join aborts outright rather than degrading, with either
/// "Error downloading room data" (room-level DataBlob) or "Error downloading
/// subroom data" (SubRooms[].CurrentSave.DataBlob) - both "(Code 8715)".
///
/// On the live server most referenced blobs cannot be served:
/// - 148 of 151 files in Data/CDN/RoomBlobs are ZERO BYTES (created, never written)
/// - NO .meta files exist at all, yet room-level DataBlob points at .meta names
/// Those blobs are original rec.net CDN references we never held the content for;
/// there is nothing to restore, so the only fix is to stop advertising them.
///
/// Stripping is safe because null is a normal state for both fields - Rec Center
/// ships with DataBlob = null and loads its default scene fine.
///
/// IMPORTANT: only call this on GET/serialize paths. The PUT/POST endpoints
/// (SetRoomName, SetRoomData, ...) load-modify-save the very same objects, so
/// stripping there would PERSIST the null into LiteDB and destroy the reference
/// permanently. Applied at serialization only, this is self-correcting: drop real
/// files into Data/CDN/RoomBlobs and the blobs are served again with no code change.
/// </summary>
public static class RoomBlobFilter
{
/// <summary>
/// Shipped stand-in for a room's metadata blob. Byte-identical to what the client
/// uploads for a room with default economy settings.
/// </summary>
public const string DefaultRoomMetaBlob = "default_room.META";
private static bool HasDefaultRoomMeta()
{
try
{
var fi = new FileInfo(Path.Combine(BlobDirectory, DefaultRoomMetaBlob));
return fi.Exists && fi.Length > 0;
}
catch { return false; }
}
public static string BlobDirectory =>
Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
/// <summary>
/// True when the blob can actually be served. Existence is NOT enough:
/// File.Exists returns true for the zero-byte placeholders, which is what made
/// an earlier existence-only guard a silent no-op. Require real content.
/// </summary>
public static bool BlobHasContent(string? blob)
{
if (string.IsNullOrWhiteSpace(blob)) return true; // nothing referenced = nothing to fetch
try
{
var fi = new FileInfo(Path.Combine(BlobDirectory, blob!));
return fi.Exists && fi.Length > 0;
}
catch
{
return false; // unreadable path = treat as unservable
}
}
/// <summary>Nulls any blob reference on this room that the client could not download.</summary>
public static RoomDBClasses.RoomRoot? Strip(RoomDBClasses.RoomRoot? room)
{
if (room == null) return room;
// The room-level Accessibility the CLIENT reads must be the effective one (the Home
// subroom's - what the owner set). An imported room carried Private at room level
// with a Public Home, so it was served in a creator's list and the client hid it
// ("@Importer's Mr_Edwins_Reclympics_4770 doesn't appear"). Startup syncs the DB;
// this covers a room changed since.
try
{
if (!room.IsRRO && room.SubRooms != null && room.SubRooms.Count > 0)
{
var eff = Campus_live_rewrite.api.dbs.RoomDB.EffectiveRoomAccessibility(room);
if (room.Accessibility != eff) room.Accessibility = eff;
}
}
catch { }
// ROOM-LEVEL blob.
//
// Nulling this made rooms load, but the client then treats a room with no
// room-level blob as incomplete: it hides it from Create -> My Rooms and
// reports "This room has never been saved". Only room 84 - the one room that
// had a real DataBlob - was listed, out of nine owned rooms.
//
// Substituting a valid default is strictly better than nulling. Every .META
// the client uploads for a room with no custom economy config is byte-for-byte
// IDENTICAL (verified: 4 distinct uploads, one md5 - b77fd492...), so
// default_room.META is exactly what the client itself would have sent. Rooms
// that never saved, and rooms pointing at a dead rec.net .meta, both get a
// blob that actually downloads.
//
// A room with its own servable blob is left completely alone.
if (!BlobHasContent(room.DataBlob) || string.IsNullOrWhiteSpace(room.DataBlob))
{
if (BlobHasContent(DefaultRoomMetaBlob) && HasDefaultRoomMeta())
{
room.DataBlob = DefaultRoomMetaBlob;
}
else
{
// No default on disk - fall back to the old behaviour so the room at
// least loads rather than 404ing on a blob that isn't there.
Console.WriteLine($"[room {room.RoomId}] room-level DataBlob missing and " +
$"{DefaultRoomMetaBlob} is absent from Data/CDN/RoomBlobs - serving null");
room.DataBlob = null!;
}
}
if (room.SubRooms == null) return room;
foreach (var sub in room.SubRooms)
{
string? blob = sub?.CurrentSave?.DataBlob;
if (string.IsNullOrWhiteSpace(blob)) continue;
if (BlobHasContent(blob)) continue;
Console.WriteLine($"[room {room.RoomId}/sub {sub!.SubRoomId}] CurrentSave blob '{blob}' " +
"missing or empty in Data/CDN/RoomBlobs - serving room without it so it loads " +
"its default scene instead of failing with 'Error downloading subroom data'");
sub.CurrentSave = null;
}
return room;
}
/// <summary>
/// List overload. Listings matter as much as single-room GETs: the client caches
/// them and can join straight from a cached entry (rooms/visitedby/me is fetched
/// moments before every join), so an unservable blob left in a listing resurfaces
/// the same failure by another route.
/// </summary>
public static List<RoomDBClasses.RoomRoot>? Strip(List<RoomDBClasses.RoomRoot>? rooms)
{
if (rooms == null) return rooms;
foreach (var r in rooms) Strip(r);
return rooms;
}
}
}
@@ -0,0 +1,86 @@
using System.Collections.Concurrent;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// In-memory game invites: "player A invited player B into their room". Backs POST
/// /Matchmaking/invite (create + a MessageReceived frame) and the two redeem paths the 2025
/// client uses - /matchmake/invite/{inviteId} (by row id) and /matchmake/v2/player/{fromId}
/// (by sender, when the frame's InviteId isn't used). Mirrors RecFlare's room_invite table but
/// kept in memory: invites are ephemeral, single-use, and short-lived, so a restart dropping
/// them is fine (the sender just re-invites). Redeeming follows the inviter's LIVE presence, so
/// only the association (who invited whom) needs storing, not the room.
/// </summary>
public static class RoomInvites
{
private const int TtlSeconds = 900; // 15 min
public sealed class Invite
{
public long RoomInviteId { get; init; }
public ulong FromPlayerId { get; init; }
public ulong ToPlayerId { get; init; }
public long Expires { get; init; }
}
private static long _nextId = 1;
private static readonly ConcurrentDictionary<long, Invite> _byId = new();
public static Invite Create(ulong fromPlayerId, ulong toPlayerId)
{
Sweep();
long id = Interlocked.Increment(ref _nextId);
var inv = new Invite
{
RoomInviteId = id,
FromPlayerId = fromPlayerId,
ToPlayerId = toPlayerId,
Expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + TtlSeconds
};
_byId[id] = inv;
return inv;
}
public static Invite? Get(long inviteId)
{
if (!_byId.TryGetValue(inviteId, out var inv)) return null;
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > inv.Expires) { _byId.TryRemove(inviteId, out _); return null; }
return inv;
}
/// <summary>True if a live invite exists from <paramref name="fromPlayerId"/> to
/// <paramref name="toPlayerId"/> (the by-sender redeem path).</summary>
public static bool HasInviteFromTo(ulong fromPlayerId, ulong toPlayerId)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
foreach (var kv in _byId)
{
var i = kv.Value;
if (i.Expires < now) { _byId.TryRemove(kv.Key, out _); continue; }
if (i.FromPlayerId == fromPlayerId && i.ToPlayerId == toPlayerId) return true;
}
return false;
}
public static void Consume(long inviteId) => _byId.TryRemove(inviteId, out _);
/// <summary>Consume every live invite from a sender to a recipient (used when the join is
/// redeemed by sender rather than by a specific row id).</summary>
public static void ConsumeFromTo(ulong fromPlayerId, ulong toPlayerId)
{
foreach (var kv in _byId)
if (kv.Value.FromPlayerId == fromPlayerId && kv.Value.ToPlayerId == toPlayerId)
_byId.TryRemove(kv.Key, out _);
}
private static long _lastSweep;
private static void Sweep()
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now - Interlocked.Read(ref _lastSweep) < 120) return;
Interlocked.Exchange(ref _lastSweep, now);
foreach (var kv in _byId)
if (now > kv.Value.Expires) _byId.TryRemove(kv.Key, out _);
}
}
}
@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Campus_live_rewrite.api.dbs;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Shared pieces of the in-room moderation routes (warn / kick / room ban / instant kick).
///
/// Fields() reads a request's parameters whatever way the client sent them - form body,
/// query string or a JSON object - into one case-insensitive map, because the moderation
/// calls are form posts on some paths and JSON on others (instantKick) and the exact key
/// spellings of the 2025 build are not all pinned. Every caller logs Dump() so a new
/// spelling shows up in the journal instead of silently reading as "no player".
/// </summary>
public static class RoomModeration
{
public static Dictionary<string, string> Fields(HttpRequest req)
{
var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (var kv in req.Query) d[kv.Key] = string.Join(",", kv.Value.ToArray());
}
catch { }
try
{
if (req.HasFormContentType)
foreach (var kv in req.Form) d[kv.Key] = string.Join(",", kv.Value.ToArray());
}
catch { }
try
{
string ct = req.ContentType ?? "";
if (!req.HasFormContentType && (req.ContentLength ?? 0) > 0 || ct.Contains("json", StringComparison.OrdinalIgnoreCase))
{
req.EnableBuffering();
req.Body.Position = 0;
using var sr = new StreamReader(req.Body, leaveOpen: true);
string raw = sr.ReadToEndAsync().GetAwaiter().GetResult();
req.Body.Position = 0;
raw = raw.Trim();
if (raw.StartsWith("{"))
{
foreach (var p in JObject.Parse(raw).Properties())
{
if (p.Value is JArray arr) d[p.Name] = string.Join(",", arr.Select(x => x.ToString()));
else if (p.Value.Type == JTokenType.Null) d[p.Name] = "";
else d[p.Name] = p.Value.ToString();
}
}
else if (raw.Length > 0 && raw.Contains('=') && !raw.Contains('{'))
{
// A form body sent without the form content type.
foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
int eq = pair.IndexOf('=');
string k = Uri.UnescapeDataString(eq < 0 ? pair : pair[..eq]);
string v = eq < 0 ? "" : Uri.UnescapeDataString(pair[(eq + 1)..].Replace('+', ' '));
d[k] = v;
}
}
}
}
catch { }
return d;
}
public static string Dump(Dictionary<string, string> f) =>
f.Count == 0 ? "(no fields)" : string.Join(" ", f.Select(kv => $"{kv.Key}={kv.Value}"));
public static string? Str(Dictionary<string, string> f, params string[] names)
{
foreach (var n in names)
if (f.TryGetValue(n, out var v) && !string.IsNullOrWhiteSpace(v)) return v;
return null;
}
public static long Long(Dictionary<string, string> f, params string[] names)
{
foreach (var n in names)
{
if (!f.TryGetValue(n, out var v) || string.IsNullOrWhiteSpace(v)) continue;
string first = v.Split(',')[0].Trim();
if (long.TryParse(first, out var l)) return l;
if (double.TryParse(first, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var dbl)) return (long)dbl;
}
return 0;
}
public static ulong ULong(Dictionary<string, string> f, params string[] names)
{
long l = Long(f, names);
return l > 0 ? (ulong)l : 0;
}
public static List<ulong> ULongs(Dictionary<string, string> f, params string[] names)
{
var list = new List<ulong>();
foreach (var n in names)
{
if (!f.TryGetValue(n, out var v) || string.IsNullOrWhiteSpace(v)) continue;
foreach (var part in v.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries))
if (ulong.TryParse(part.Trim().Trim('[', ']', '"'), out var id) && id > 0 && !list.Contains(id)) list.Add(id);
if (list.Count > 0) break;
}
return list;
}
public static Moderation_Detail.ReportCategory Category(Dictionary<string, string> f, params string[] names)
{
string raw = Str(f, names) ?? "";
if (int.TryParse(raw, out var n)) return (Moderation_Detail.ReportCategory)n;
if (Enum.TryParse<Moderation_Detail.ReportCategory>(raw, true, out var c)) return c;
return Moderation_Detail.ReportCategory.Moderator;
}
/// <summary>
/// Take a player out of the instance they are in: the ModerationKick frame (id 22) their
/// client leaves on, and their presence cleared so the heartbeat resync does not put them
/// straight back. Nothing account-wide is stored - this is a room action, not a timeout.
/// </summary>
public static void RemoveFromRoom(ulong playerId, ulong roomId, long gameSessionId, string message, ulong by, bool isBan, bool isHostKick)
{
var block = new Moderation_Detail.ModerationBlockDetails
{
ReportCategory = Moderation_Detail.ReportCategory.Moderator,
Duration = 0,
GameSessionId = gameSessionId,
IsHostKick = isHostKick,
Message = message,
PlayerIdReporter = by,
IsBan = isBan,
IsWarning = false,
TimeoutStartedAt = DateTime.UtcNow
};
try { Notifications.SendToPlayer(playerId, JsonConvert.SerializeObject(WebsocketEvents.CreateBan(block))); } catch { }
try
{
var hb = HeartbeatDB.GetPlayerHeartbeat(playerId);
bool inThatRoom = hb?.roomInstance != null
&& (roomId == 0 || (ulong)hb.roomInstance.roomId == roomId)
&& (gameSessionId == 0 || hb.roomInstance.roomInstanceId == gameSessionId);
// Rewrite their presence to their own dorm rather than to "nowhere" (reference): a
// player who is online with no instance breaks the friend cards of everyone who
// has them on their list, and the kicked client lands in its dorm anyway.
if (inThatRoom)
{
var acc = PlayerDB.GetAccountById(playerId);
HeartbeatDB.RoomInstance? dorm = null;
try { dorm = Sessions.CreateDorm(playerId, acc?.Username ?? "player"); } catch { }
HeartbeatDB.UpdatePlayerHeartbeat(playerId, dorm, online: true);
}
}
catch (Exception ex) { Console.WriteLine($"[moderation] could not clear presence for {playerId}: {ex.Message}"); }
Console.WriteLine($"[moderation] {by} removed {playerId} from room {roomId} (instance {gameSessionId}){(isBan ? " [ban]" : "")}");
}
}
}
@@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes.db_class;
using Newtonsoft.Json;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// What each role may do inside a room - the table the client applies when it spawns
/// (GET /Room_server/photon_access_token -> Permissions), and the per-subroom overrides a
/// room's managers save on top of it (PUT /rooms/{id}/subrooms/{sub}/permissions).
/// Ported from the reference rooms.app.ts photonAccessToken / SubRoomPermissionsRequest.
///
/// Entries are addressed by the (Permission, Role) PAIR: an override with a pair the defaults
/// carry replaces that default in place (so the order never shifts under the client and no
/// pair is listed twice with two values); one naming a new pair is appended.
///
/// The defaults here are NOT the reference's. The reference hands CAN_USE_ROOM_RESET_BUTTON
/// and CAN_USE_DELETE_ALL_BUTTON to Role 0 - everyone - which is why any player in the Rec
/// Center could press Reset. Here those start at Moderator (ConfigCampus.RoomResetMinRole).
/// </summary>
public static class RoomPermissions
{
public class Entry
{
public bool Override { get; set; } = true;
public string Permission { get; set; } = "";
public int Role { get; set; }
public int Type { get; set; }
/// <summary>A STRING, not a bool - usually "True"/"False", kept verbatim.</summary>
public string Value { get; set; } = "True";
}
public const string ResetButton = "CAN_USE_ROOM_RESET_BUTTON";
public const string DeleteAll = "CAN_USE_DELETE_ALL_BUTTON";
public const string SaveInventions = "CAN_SAVE_INVENTIONS";
public const string SpawnInventions = "CAN_SPAWN_INVENTIONS";
public const string PlayGizmos = "CAN_USE_PLAY_GIZMOS_TOGGLE";
public const string MakerPen = "CAN_USE_MAKER_PEN";
public const string Invite = "CAN_INVITE";
private static Entry P(string permission, int role, bool overrideFlag = true, string value = "True") =>
new() { Permission = permission, Role = role, Override = overrideFlag, Type = 0, Value = value };
/// <summary>The default table for one caller in one room.</summary>
public static List<Entry> Defaults(ulong accountId)
{
int everyone = (int)RoomDBClasses.Role_data.None; // 0
int mod = ConfigCampus.RoomResetMinRole; // 20 = Moderator
int coOwner = (int)RoomDBClasses.Role_data.CoOwner; // 30
int host = (int)RoomDBClasses.Role_data.Host; // 10
int contributor = (int)RoomDBClasses.Role_data.Contributor; // 25
int creator = (int)RoomDBClasses.Role_data.Creator; // 255
// Every role gets an EXPLICIT row for the maker pen and the two destructive buttons.
// The table used to carry grants only (and the maker pen's one row as Override:false,
// i.e. "use the built-in default"), so a player whose role had no row - everyone at
// Role 0, a host, a moderator - fell to the client's own default, and the client's
// default lets the pen out. "You can pull it out anywhere." Now a role that may not
// has a row saying False, and a role that may has a row saying True.
// No developer shortcut: a dev used to get CAN_USE_MAKER_PEN True for EVERY role in every
// room just for carrying the flag. The pen is a room permission - creator / co-owner /
// contributor, or whatever the room's managers granted on the subroom - for everyone.
var t = new List<Entry>();
foreach (int role in new[] { everyone, host, mod, contributor, coOwner, creator })
{
bool staff = role >= mod; // moderator and up
bool builder = role >= coOwner; // co-owner and up (contributors build too, below)
bool canBuild = builder || role == contributor;
t.Add(P(MakerPen, role, value: canBuild ? "True" : "False"));
t.Add(P(ResetButton, role, value: staff ? "True" : "False"));
t.Add(P(DeleteAll, role, value: staff ? "True" : "False"));
// Everyone: build with inventions and toggle play/build.
t.Add(P(SaveInventions, role));
t.Add(P(SpawnInventions, role));
t.Add(P(PlayGizmos, role));
}
return t;
}
/// <summary>Defaults with a subroom's stored overrides merged over them, by (Permission, Role).</summary>
public static List<Entry> Merge(List<Entry> defaults, IEnumerable<Entry>? overrides)
{
if (overrides == null) return defaults;
foreach (var o in overrides)
{
if (string.IsNullOrWhiteSpace(o.Permission)) continue;
var stored = new Entry { Override = true, Permission = o.Permission, Role = o.Role, Type = o.Type, Value = o.Value ?? "True" };
int i = defaults.FindIndex(p => p.Permission == stored.Permission && p.Role == stored.Role);
if (i == -1) defaults.Add(stored); else defaults[i] = stored;
}
return defaults;
}
/// <summary>
/// Apply a PUT body to a subroom's stored overrides. `Override:true` stores the pair's
/// value; `Override:false` is the client's unchecked box - it DELETES the stored pair so
/// it falls back to the default. Pairs not sent are left alone. Returns the new table.
/// </summary>
public static List<Entry> Apply(List<Entry>? current, IEnumerable<Entry> changes)
{
var table = current?.Select(e => new Entry { Override = true, Permission = e.Permission, Role = e.Role, Type = e.Type, Value = e.Value }).ToList()
?? new List<Entry>();
foreach (var c in changes)
{
if (string.IsNullOrWhiteSpace(c.Permission)) continue;
table.RemoveAll(e => e.Permission == c.Permission && e.Role == c.Role);
if (c.Override)
table.Add(new Entry { Override = true, Permission = c.Permission, Role = c.Role, Type = c.Type, Value = c.Value ?? "True" });
}
return table;
}
/// <summary>Parse the client's JSON array body. Null when it is not one.</summary>
public static List<Entry>? ParseBody(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
try
{
var arr = JsonConvert.DeserializeObject<List<Entry>>(raw);
return arr?.Where(e => e != null && !string.IsNullOrWhiteSpace(e.Permission)).ToList();
}
catch { return null; }
}
// ---- "instant" ---------------------------------------------------------------------
/// <summary>
/// Tell a player's client its room changed - the SubscriptionUpdateRoom frame (id 15) the
/// reference pushes after a role change, which makes the client re-read the room and the
/// permissions it now grants them. Best-effort: the write has already committed.
/// </summary>
public static void PushRoomUpdate(ulong playerId, RoomDBClasses.RoomRoot? room)
{
if (room == null || playerId == 0) return;
try
{
// The client dispatches this by its STRING name ("RoomUpdate" - reference
// notification-types.ts); the numeric 15 was an unrelated id and silently dropped,
// so a permission / role change only showed after re-entering the instance.
var frame = new api.WebsocketEvents.Reponse
{
Id = "RoomUpdate",
Msg = RoomBlobFilter.Strip(room)
};
Notifications.FireAndForget(Notifications.SendToPlayer(playerId, JsonConvert.SerializeObject(frame)), "room update push");
}
catch (Exception ex) { Console.WriteLine($"[room-perms] push to {playerId} failed: {ex.Message}"); }
}
/// <summary>Everyone currently standing in any instance of this room.</summary>
public static List<ulong> PlayersInRoom(ulong roomId)
{
var ids = new List<ulong>();
try
{
foreach (var pid in PlayerDB.PlayerDBFile.GetCollection<player_data>("players").FindAll().Select(p => p.playerid))
{
var (online, inst) = HeartbeatDB.PeekPresence(pid);
if (online && inst != null && (ulong)inst.roomId == roomId) ids.Add(pid);
}
}
catch (Exception ex) { Console.WriteLine($"[room-perms] presence scan failed: {ex.Message}"); }
return ids;
}
}
}
@@ -0,0 +1,226 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using System.Security.Principal;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes.db_class;
using static Campus_live_rewrite.api.dbs.HeartbeatDB;
namespace Campus_live_rewrite.api.server
{
public class Sessions
{
private static readonly Random random = new Random();
public static RoomInstance? CreateRoom(string roomName, string sceneName = "", bool isPrivate = false)
{
//const string uuidPrefix = "Campus_live_2023";
string formattedRoomName = $"^{roomName}";
if (!string.IsNullOrEmpty(sceneName))
{
Console.WriteLine($"[Sessions]: Finding room: \"{roomName}\" with scene id: \"{sceneName}\"");
}
else
{
Console.WriteLine($"[Sessions]: Finding room: \"{roomName}\"");
}
long gameSessionId = random.Next(0x1000, 0x7fffffff);
RoomDBClasses.RoomRoot roomData = RoomDB.GetRoomByName(roomName);
if (roomData == null)
{
Console.WriteLine($"[Sessions]: Room \"{roomName}\" not found.");
return null;
}
Console.WriteLine($"[Sessions]: {roomName} found! Joining...");
var firstSubRoom = roomData.SubRooms?.FirstOrDefault();
string dataBlob = firstSubRoom?.CurrentSave?.DataBlob ?? string.Empty;
long instanceNumber = random.Next(1000, 999999999);
Guid instanceId = Guid.NewGuid();
// Public rooms used to get the CONSTANT "room" here. Photon groups players by
// room NAME, so every public instance of every room shared one Photon room:
// people in completely different rooms saw each other, could interact with
// each other's worlds, and showed up in each other's player lists.
//
// The name must be unique per INSTANCE but identical for everyone in that
// instance - that is exactly what makes two people meet. It is assigned at the
// end of this method instead of here, because ApplyRandomPublicSubroom /
// ApplySceneSettings can still change subRoomId below and the name has to
// reflect the final subroom.
string photonSuffix = isPrivate
? $"_private-room-{instanceNumber}-{instanceId}"
: "";
var session = new RoomInstance
{
roomInstanceId = instanceNumber,
roomId = (long)roomData.RoomId,
subRoomId = (long)roomData.SubRooms[0].SubRoomId,
location = roomData.SubRooms[0].UnitySceneId,
roomInstanceType = 0,
name = formattedRoomName,
maxCapacity = CapacityOf(roomData.SubRooms[0]),
isFull = false,
isPrivate = isPrivate,
isInProgress = false,
photonRoomIdYeee = photonSuffix,
voiceServerId = null, // this not supported and game may explode when connecting to a server that dont exist
voiceAuthId = null,
matchmakingPolicy = 0
};
if (!string.IsNullOrEmpty(sceneName))
{
ApplySceneSettings(session, roomData, roomName, sceneName, isPrivate);
}
else
{
ApplyRandomPublicSubroom(session, roomData, roomName, isPrivate);
}
// Now that the subroom is final, give a public instance its own Photon room.
// Room + subroom + instance: two players in the SAME instance produce the same
// name and meet; different instances never collide.
if (!isPrivate)
session.photonRoomIdYeee = $"room-{session.roomId}-{session.subRoomId}-{session.roomInstanceId}";
return session;
}
public static RoomInstance CreateDorm(ulong id, string name)
{
Random rng = new Random();
long RandomNumber = rng.Next(1000, 999999999);
ulong DormId = PlayerDB.GetDormId(id);
RoomDBClasses.RoomRoot dorm = RoomDB.GetRoom(DormId);
if (dorm == null)
{
// Was: created the dorm but never re-read it, then dereferenced the
// still-null `dorm` below at `dorm.SubRooms` - a guaranteed
// NullReferenceException. MatchmakeToDorm swallows that in its catch,
// so UpdatePlayerHeartbeat never ran and the player was handed a room
// instance the server had not registered.
Console.WriteLine($"[CreateDorm] player {id}: dorm room {DormId} missing - creating a fresh one");
if (!PlayerDB.CreateDormForPlayerId(id))
{
Console.WriteLine($"[CreateDorm] player {id}: could not create a dorm");
return null;
}
DormId = PlayerDB.GetDormId(id);
dorm = RoomDB.GetRoom(DormId);
if (dorm == null)
{
Console.WriteLine($"[CreateDorm] player {id}: dorm {DormId} still missing after create - giving up");
return null;
}
Console.WriteLine($"[CreateDorm] player {id}: dorm rebuilt as room {DormId}");
}
// REUSE the dorm's live session (reference: playerDormInstance - "the owner leaving and
// coming back lands in the same session"). Minting a fresh instance id + Photon room on
// every dorm matchmake meant: the client's presence disagreed with the server after any
// repeat request (it then re-matchmade, got yet another id, and looped - five new dorm
// ids in 30 s were seen live), and a guest invited into the dorm sat in a different
// Photon room than the owner the moment the owner re-entered. Order of preference: the
// owner's own current instance of this dorm, anyone else's live instance of it (a guest
// already inside), then the recently-live one from memory.
RoomInstance? existing = null;
var own = HeartbeatDB.GetSelfPresence(id).room;
if (own != null && own.roomId == (long)DormId && !string.IsNullOrEmpty(own.photonRoomIdYeee)) existing = own;
existing ??= HeartbeatDB.FindInstanceByRoomId((long)DormId);
existing ??= HeartbeatDB.RecentInstanceOfRoomAny(DormId);
if (existing != null && !string.IsNullOrEmpty(existing.photonRoomIdYeee))
{
// Refresh the scene/subroom off the room record so a dorm edit shows on the next visit.
existing.subRoomId = (long?)dorm.SubRooms.FirstOrDefault()?.SubRoomId ?? existing.subRoomId;
existing.name = $"@{name}'s Dorm";
RoomDB.IncreaseRoomVisit(DormId);
return existing;
}
long instanceNumber = random.Next(1000, 999999999);
Guid instanceId = Guid.NewGuid();
string photonSuffix = $"Campus-Dorm-{instanceNumber}-{instanceId}";
var session = new RoomInstance
{
roomInstanceId = RandomNumber,
roomId = (long)DormId,
subRoomId = (long?)dorm.SubRooms.FirstOrDefault()?.SubRoomId ?? 0,
location = "76d98498-60a1-430c-ab76-b54a29b7a163",
roomInstanceType = 2, // RoomInstanceType.Dormroom (reference enum: Public 0, Private 1, Dormroom 2)
name = $"@{name}'s Dorm",
maxCapacity = CapacityOf(dorm.SubRooms.FirstOrDefault()),
isFull = false,
isPrivate = true,
isInProgress = false,
photonRoomIdYeee = photonSuffix,
voiceServerId = null, // this not supported and game may explode when connecting to a server that dont exist
voiceAuthId = null,
matchmakingPolicy = 0
};
RoomDB.IncreaseRoomVisit(DormId);
return session;
}
// What the room settings show as "Max players" for that subroom; 4 when unset (reference).
public static int CapacityOf(RoomDBClasses.SubRoom? sub) => sub != null && sub.MaxPlayers > 0 ? sub.MaxPlayers : 4;
private static void ApplySceneSettings(RoomInstance session, RoomDBClasses.RoomRoot roomData, string roomName, string sceneName, bool isPrivate)
{
foreach (var subroom in roomData.SubRooms)
{
if (subroom.Name.Equals(sceneName, StringComparison.OrdinalIgnoreCase))
{
session.subRoomId = (long)subroom.SubRoomId;
session.location = subroom.UnitySceneId;
session.maxCapacity = CapacityOf(subroom); // the cap is per SUBROOM, not SubRooms[0]'s
if (!isPrivate)
{
//session.photonRoomId = $"{roomName}-hello-room-{sceneName}";
}
return;
}
}
Console.WriteLine($"Scene \"{sceneName}\" not found in room \"{roomName}\".");
}
private static void ApplyRandomPublicSubroom(RoomInstance session, RoomDBClasses.RoomRoot roomData, string roomName, bool isPrivate)
{
var publicSubrooms = roomData.SubRooms
.Where(sub => sub.Accessibility == RoomDBClasses.RoomAccessibility.Public)
.ToList();
if (publicSubrooms.Count == 0)
{
Console.WriteLine($"No public subrooms found for \"{roomName}\".");
return;
}
var selectedSubroom = publicSubrooms[random.Next(publicSubrooms.Count)];
session.subRoomId = (long)selectedSubroom.SubRoomId;
session.location = selectedSubroom.UnitySceneId;
session.maxCapacity = CapacityOf(selectedSubroom);
if (!isPrivate)
{
//session.photonRoomId = $"{roomName}-hello-room-{selectedSubroom.name}";
}
}
}
}
@@ -0,0 +1,202 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Campus_live_rewrite.api.dbs;
namespace Campus_live_rewrite.api.server
{
// A website-only realtime push channel, completely separate from the game's
// NotificationsController hub. That hub is keyed by player id and calls
// CloseExistingConnection, so if the browser connected there with the same id it would
// disconnect the player's GAME socket. This keeps its own socket set and supports several
// browser tabs per player.
//
// Messages (server -> browser), all JSON with a "type":
// refresh - re-fetch the inbox / anything social on screen (the original)
// profile {id} - the profile with that account id changed (presence, room,
// name/bio/avatar, friend counts): re-fetch it
// spotify {id, now_playing} - what that account is listening to changed, already filtered
// by their "show to" choice for THIS viewer
// ping - keep-alive, ignored
// The browser sends one thing: {"type":"watch","profile":"<account id | username>"} - the
// profile its page is showing (its own on Settings). That is what the profile/spotify pushes
// are addressed to; a tab that never sends it only gets refresh.
public static class SiteNotifications
{
private sealed class Conn
{
public ulong PlayerId;
public WebSocket Socket = null!;
public ulong Watching; // account id of the profile this tab shows (0 = none)
}
private static readonly ConcurrentDictionary<ulong, ConcurrentDictionary<Guid, Conn>> Sockets = new();
// profile account id -> the tabs looking at it
private static readonly ConcurrentDictionary<ulong, ConcurrentDictionary<Guid, Conn>> Watchers = new();
private static readonly byte[] RefreshMsg = Encoding.UTF8.GetBytes("{\"type\":\"refresh\"}");
private static readonly byte[] PingMsg = Encoding.UTF8.GetBytes("{\"type\":\"ping\"}");
// Holds one browser tab's socket open until it closes. Sends an app-level ping every 25s
// so the reverse proxy doesn't drop the idle connection (the client ignores "ping").
public static async Task HandleAsync(ulong playerId, WebSocket socket)
{
var connId = Guid.NewGuid();
var conn = new Conn { PlayerId = playerId, Socket = socket };
var bag = Sockets.GetOrAdd(playerId, _ => new ConcurrentDictionary<Guid, Conn>());
bag[connId] = conn;
EnsureSpotifyLoop();
using var pingCts = new CancellationTokenSource();
_ = Task.Run(async () =>
{
try
{
while (!pingCts.IsCancellationRequested && socket.State == WebSocketState.Open)
{
await Task.Delay(25000, pingCts.Token);
try { await socket.SendAsync(PingMsg, WebSocketMessageType.Text, true, CancellationToken.None); }
catch { break; }
}
}
catch { }
});
try
{
var buffer = new byte[4096];
while (socket.State == WebSocketState.Open)
{
var result = await socket.ReceiveAsync(buffer, CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close) break;
if (result.MessageType != WebSocketMessageType.Text || result.Count == 0) continue;
try { HandleClientMessage(connId, conn, Encoding.UTF8.GetString(buffer, 0, result.Count)); }
catch { /* a malformed message from a browser is ignored */ }
}
}
catch { }
finally
{
pingCts.Cancel();
bag.TryRemove(connId, out _);
if (bag.IsEmpty) Sockets.TryRemove(playerId, out _);
Unwatch(connId, conn);
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
try { await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); } catch { }
}
}
}
private static void HandleClientMessage(Guid connId, Conn conn, string text)
{
using var doc = JsonDocument.Parse(text);
var root = doc.RootElement;
string type = root.TryGetProperty("type", out var t) ? t.GetString() ?? "" : "";
if (type != "watch") return;
string who = root.TryGetProperty("profile", out var p) ? (p.ValueKind == JsonValueKind.Number ? p.GetRawText() : p.GetString() ?? "") : "";
ulong id = ulong.TryParse(who, out var n) ? n : PlayerDB.GetAccountIdByUsernameCI(who.TrimStart('@'));
Unwatch(connId, conn);
if (id == 0) return;
conn.Watching = id;
Watchers.GetOrAdd(id, _ => new ConcurrentDictionary<Guid, Conn>())[connId] = conn;
// First frame right away, so the page is current without waiting for a change.
_ = SendSpotifyTo(conn, id);
}
private static void Unwatch(Guid connId, Conn conn)
{
if (conn.Watching == 0) return;
if (Watchers.TryGetValue(conn.Watching, out var w)) { w.TryRemove(connId, out _); if (w.IsEmpty) Watchers.TryRemove(conn.Watching, out _); }
conn.Watching = 0;
}
// Tell every open browser tab for this player to re-fetch its inbox. Fire-and-forget,
// and a no-op (one dictionary lookup) when the player has no site tab open - safe to
// call from hot paths like chat. Anything social about a player is also a change to
// their PROFILE (friend counts, online friends), so the tabs watching it hear too.
public static void Notify(ulong playerId)
{
if (playerId == 0) return;
if (Sockets.TryGetValue(playerId, out var bag) && !bag.IsEmpty)
foreach (var kv in bag)
if (kv.Value.Socket.State == WebSocketState.Open) _ = SendSafe(kv.Value, kv.Key, RefreshMsg);
ProfileChanged(playerId);
}
/// <summary>Something visible on this account's profile changed (presence, room, name, bio, avatar, counts).</summary>
public static void ProfileChanged(ulong accountId)
{
if (accountId == 0 || !Watchers.TryGetValue(accountId, out var w) || w.IsEmpty) return;
var msg = Encoding.UTF8.GetBytes("{\"type\":\"profile\",\"id\":" + accountId + "}");
foreach (var kv in w)
if (kv.Value.Socket.State == WebSocketState.Open) _ = SendSafe(kv.Value, kv.Key, msg);
}
private static async Task SendSafe(Conn conn, Guid connId, byte[] msg)
{
try
{
await conn.Socket.SendAsync(new ArraySegment<byte>(msg), WebSocketMessageType.Text, true, CancellationToken.None);
}
catch
{
if (Sockets.TryGetValue(conn.PlayerId, out var bag)) bag.TryRemove(connId, out _);
Unwatch(connId, conn);
}
}
// ---- Spotify: push track changes to whoever is looking ---------------------------------
// One loop for the whole server. Every 10 s it asks Spotify about each WATCHED account
// that has a link (SpotifyLink caches per account, so that is one call per account, not
// per viewer) and pushes a frame to each watcher whose view of it changed - a new track,
// play/pause, a seek, or the song ending. Each watcher gets the payload as filtered by
// the owner's "show to" choice for THEM, so nothing leaks through the push that the
// page could not have fetched.
private static readonly ConcurrentDictionary<ulong, string> _lastSpotify = new(); // watched account -> last state key
private static int _spotifyLoopStarted;
private static void EnsureSpotifyLoop()
{
if (Interlocked.Exchange(ref _spotifyLoopStarted, 1) == 1) return;
_ = Task.Run(async () =>
{
while (true)
{
try
{
await Task.Delay(10000);
foreach (var kv in Watchers.ToArray())
{
ulong owner = kv.Key;
if (kv.Value.IsEmpty || SpotifyLink.Get(owner) == null) continue;
var np = await SpotifyLink.Fetch(owner);
string key = np == null ? "-" : $"{np.TrackId}|{np.IsPlaying}|{(np.ProgressMs / 1000) - (long)(DateTime.UtcNow - np.FetchedAt).TotalSeconds}";
// The third term is "where the song started" in wall-clock terms: it
// only moves on a seek/restart, not as the song plays.
_lastSpotify.TryGetValue(owner, out var last);
if (key == last) continue;
_lastSpotify[owner] = key;
foreach (var w in kv.Value) _ = SendSpotifyTo(w.Value, owner);
}
foreach (var k in _lastSpotify.Keys.ToArray()) if (!Watchers.ContainsKey(k)) _lastSpotify.TryRemove(k, out _);
}
catch (Exception ex) { Console.WriteLine($"[site-ws] spotify loop: {ex.Message}"); }
}
});
}
private static async Task SendSpotifyTo(Conn conn, ulong owner)
{
try
{
if (conn.Socket.State != WebSocketState.Open) return;
if (SpotifyLink.Get(owner) == null) return;
object? payload = await SpotifyLink.PayloadFor(owner, conn.PlayerId);
string json = JsonSerializer.Serialize(new { type = "spotify", id = owner, now_playing = payload });
await conn.Socket.SendAsync(Encoding.UTF8.GetBytes(json), WebSocketMessageType.Text, true, CancellationToken.None);
}
catch { }
}
}
}

Some files were not shown because too many files have changed in this diff Show More