the files
This commit is contained in:
@@ -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 <= 0 (or int.MaxValue) = permanent.</summary>
|
||||
public static bool BanDevice(string deviceId, ulong playerId, ulong bannedBy, string? reason, int durationSeconds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return false;
|
||||
return Upsert(Device, deviceId.Trim(), playerId, bannedBy, reason, durationSeconds);
|
||||
}
|
||||
|
||||
/// <summary>Bans an IP address (plaintext in; stored encrypted).</summary>
|
||||
public static bool BanIp(string ip, ulong playerId, ulong bannedBy, string? reason, int durationSeconds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ip)) return false;
|
||||
return Upsert(Ip, NormalizeIp(ip), playerId, bannedBy, reason, durationSeconds);
|
||||
}
|
||||
|
||||
private static bool Upsert(string type, string value, ulong playerId, ulong bannedBy, string? reason, int durationSeconds)
|
||||
{
|
||||
long expires = (durationSeconds <= 0 || durationSeconds == int.MaxValue)
|
||||
? 0
|
||||
: Now + durationSeconds;
|
||||
|
||||
// Drop any cached "not banned" decision so the 502 applies immediately.
|
||||
if (type == Ip) BannedIpMiddleware.Forget(value == null ? null : Utils.DecryptAes(value));
|
||||
|
||||
Col.Upsert(new BanEntry
|
||||
{
|
||||
Key = KeyFor(type, value),
|
||||
Type = type,
|
||||
Value = value,
|
||||
PlayerId = playerId,
|
||||
BannedBy = bannedBy,
|
||||
Reason = reason,
|
||||
CreatedAt = Now,
|
||||
ExpiresAt = expires
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- checking ----------------------------------------------------------
|
||||
|
||||
public static BanEntry? GetDeviceBan(string? deviceId)
|
||||
=> string.IsNullOrWhiteSpace(deviceId) ? null : GetActive(Device, deviceId.Trim());
|
||||
|
||||
public static BanEntry? GetIpBan(string? ip)
|
||||
=> string.IsNullOrWhiteSpace(ip) ? null : GetActive(Ip, NormalizeIp(ip));
|
||||
|
||||
public static bool IsDeviceBanned(string? deviceId) => GetDeviceBan(deviceId) != null;
|
||||
public static bool IsIpBanned(string? ip) => GetIpBan(ip) != null;
|
||||
|
||||
/// <summary>Returns the entry only if it is still in force; expired entries are removed.</summary>
|
||||
private static BanEntry? GetActive(string type, string value)
|
||||
{
|
||||
var entry = Col.FindById(KeyFor(type, value));
|
||||
if (entry == null) return null;
|
||||
|
||||
if (entry.ExpiresAt > 0 && entry.ExpiresAt <= Now)
|
||||
{
|
||||
Col.Delete(entry.Key); // self-cleaning: a lapsed ban stops applying
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ---- lifting -----------------------------------------------------------
|
||||
|
||||
public static bool UnbanDevice(string? deviceId)
|
||||
=> !string.IsNullOrWhiteSpace(deviceId) && Col.Delete(KeyFor(Device, deviceId!.Trim()));
|
||||
|
||||
public static bool UnbanIp(string? ip)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ip)) return false;
|
||||
BannedIpMiddleware.Forget(ip); // release immediately, don't wait for the cache
|
||||
return Col.Delete(KeyFor(Ip, NormalizeIp(ip!)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lifts every device/IP ban that originated from this account. Used by the admin
|
||||
/// panel's Unban so lifting a ban actually lets the player back in, instead of
|
||||
/// clearing the account block while their device stays blacklisted.
|
||||
/// </summary>
|
||||
public static int UnbanAllForPlayer(ulong playerId)
|
||||
{
|
||||
var entries = Col.Find(x => x.PlayerId == playerId).ToList();
|
||||
int n = 0;
|
||||
foreach (var e in entries)
|
||||
if (Col.Delete(e.Key)) n++;
|
||||
|
||||
// An unban must let them straight back in, not after the cache expires.
|
||||
if (n > 0) BannedIpMiddleware.ForgetAll();
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- listing -----------------------------------------------------------
|
||||
|
||||
public static List<BanEntry> ForPlayer(ulong playerId)
|
||||
=> Col.Find(x => x.PlayerId == playerId).ToList();
|
||||
|
||||
public static List<BanEntry> All()
|
||||
=> Col.FindAll().OrderByDescending(x => x.CreatedAt).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
/// <summary>
|
||||
/// In-game bug reports (the watch's "Report a bug" posts to /ado/bug - Azure DevOps on the real
|
||||
/// service). Everything the client sent is kept verbatim (fields, plus any attached files under
|
||||
/// Data/BugReports) and shown on the admin panel's Reports tab. The exact field names are not
|
||||
/// pinned, so the raw dump is the source of truth and Title/Description are best-effort picks.
|
||||
/// </summary>
|
||||
public static class BugReportDB
|
||||
{
|
||||
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "BugReports.db")));
|
||||
|
||||
public class Report
|
||||
{
|
||||
[BsonId] public int Id { get; set; }
|
||||
public ulong AccountId { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string Category { get; set; } = "";
|
||||
public string Room { get; set; } = "";
|
||||
public string Raw { get; set; } = "";
|
||||
public List<string> Files { get; set; } = new();
|
||||
public string Platform { get; set; } = "";
|
||||
public DateTime At { get; set; } = DateTime.UtcNow;
|
||||
public bool Resolved { get; set; }
|
||||
public string? ResolvedBy { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
private static ILiteCollection<Report> Col()
|
||||
{
|
||||
var c = _db.Value.GetCollection<Report>("reports");
|
||||
c.EnsureIndex(x => x.At);
|
||||
return c;
|
||||
}
|
||||
|
||||
public static Report Add(Report r) { Col().Insert(r); return r; }
|
||||
|
||||
public static List<Report> Recent(bool includeResolved, int take = 200) =>
|
||||
Col().FindAll().Where(r => includeResolved || !r.Resolved).OrderByDescending(r => r.At).Take(take).ToList();
|
||||
|
||||
public static int OpenCount() => Col().Count(r => !r.Resolved);
|
||||
|
||||
public static bool Resolve(int id, string by, string? note, bool resolved = true)
|
||||
{
|
||||
var col = Col();
|
||||
var r = col.FindById(id);
|
||||
if (r == null) return false;
|
||||
r.Resolved = resolved; r.ResolvedBy = by; r.Note = note;
|
||||
col.Update(r);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using LiteDB;
|
||||
using Newtonsoft.Json;
|
||||
using Campus_live_rewrite.api.server;
|
||||
using Campus_live_rewrite.api.server.Classes.db_class;
|
||||
using static Campus_live_rewrite.api.server.Classes.db_class.ChatDBClasses;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
/// <summary>
|
||||
/// Chat threads + messages (Data/DBs/Chat.db). One thread per exact member set for DMs and
|
||||
/// groups (fetch-or-create, like the reference's GetChatBetweenPlayers), party threads as
|
||||
/// type-1 threads, and per-member read / snooze / favorite state. Every write that another
|
||||
/// member should see is pushed over the notifications hub as ChatMessageReceived so the
|
||||
/// conversation (and the in-room bubble) updates without polling.
|
||||
/// </summary>
|
||||
public class ChatDB
|
||||
{
|
||||
public const int SystemSenderId = -5;
|
||||
// The client's ChatThreadType: 0 Player (DM / group), 1 Club, 2 Party. Party threads were
|
||||
// stored and served as 1 - a CLUB thread to the client, which is why the party's chat
|
||||
// rendered as an ordinary conversation named after the other member.
|
||||
public const int ThreadTypeUser = 0;
|
||||
public const int ThreadTypeClub = 1;
|
||||
public const int ThreadTypeParty = 2;
|
||||
public const int MaxThreadMembers = 50;
|
||||
|
||||
public static LiteDatabase ChatDBFile = new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/Chat.db");
|
||||
private static ILiteCollection<ChatThread> Threads => ChatDBFile.GetCollection<ChatThread>("threads");
|
||||
private static ILiteCollection<ChatMessage> Messages => ChatDBFile.GetCollection<ChatMessage>("messages");
|
||||
private static readonly object _idLock = new();
|
||||
|
||||
static ChatDB()
|
||||
{
|
||||
Threads.EnsureIndex(x => x.ChatThreadId, unique: true);
|
||||
Threads.EnsureIndex(x => x.PlayerIds);
|
||||
Messages.EnsureIndex(x => x.ChatThreadId);
|
||||
Messages.EnsureIndex(x => x.ChatMessageId, unique: true);
|
||||
|
||||
// One-time migration: party threads created while the type was mis-numbered (1) become 2.
|
||||
// No club threads exist in this store (clubs chat through their own service), so every
|
||||
// type-1 thread here is a party.
|
||||
try
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var t in Threads.Find(x => x.ChatThreadType == ThreadTypeClub).ToList())
|
||||
{ t.ChatThreadType = ThreadTypeParty; Threads.Update(t); n++; }
|
||||
if (n > 0) Console.WriteLine($"[chat] migrated {n} party thread(s) to ChatThreadType {ThreadTypeParty}");
|
||||
}
|
||||
catch (Exception ex) { Console.WriteLine($"[chat] party type migration failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static long NextThreadId()
|
||||
{
|
||||
var last = Threads.Query().OrderByDescending(x => x.ChatThreadId).Limit(1).FirstOrDefault();
|
||||
return (last?.ChatThreadId ?? 0) + 1;
|
||||
}
|
||||
private static long NextMessageId()
|
||||
{
|
||||
var last = Messages.Query().OrderByDescending(x => x.ChatMessageId).Limit(1).FirstOrDefault();
|
||||
return (last?.ChatMessageId ?? 0) + 1;
|
||||
}
|
||||
|
||||
public static string StartedChatContents(ulong by) =>
|
||||
JsonConvert.SerializeObject(new MessageJson { Type = MessageContentType.Text, Version = 1, Data = $"Player <@U{by}> started a chat" });
|
||||
|
||||
// ---------------- threads ----------------
|
||||
|
||||
/// <summary>Create a thread. type 0 = DM/group (posts the "started a chat" notice), 1 = party.</summary>
|
||||
public static ChatThread CreateThread(List<ulong> memberIds, ulong creatorid, string? name = null, int type = ThreadTypeUser)
|
||||
{
|
||||
ChatThread thread;
|
||||
lock (_idLock)
|
||||
{
|
||||
thread = new ChatThread
|
||||
{
|
||||
ChatThreadId = NextThreadId(),
|
||||
PlayerIds = memberIds.Distinct().ToList(),
|
||||
ChatThreadName = name ?? "",
|
||||
ChatThreadType = type,
|
||||
CreatorPlayerId = creatorid,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastActivityAt = DateTime.UtcNow,
|
||||
IsFavorited = false,
|
||||
SnoozedUntil = null,
|
||||
LastReadMessageId = 0,
|
||||
Messages = new List<ChatMessage>()
|
||||
};
|
||||
Threads.Insert(thread);
|
||||
}
|
||||
if (type == ThreadTypeUser)
|
||||
AddMessage(thread.ChatThreadId, SystemSenderId, StartedChatContents(creatorid));
|
||||
return GetThread(thread.ChatThreadId)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The existing type-0 thread whose members are EXACTLY this set, else null. Keeps one
|
||||
/// conversation per pair/group: every "message X" used to mint a brand-new thread.
|
||||
/// </summary>
|
||||
public static ChatThread? FindThreadWithExactMembers(List<ulong> memberIds)
|
||||
{
|
||||
var want = memberIds.Distinct().OrderBy(x => x).ToList();
|
||||
if (want.Count == 0) return null;
|
||||
ulong probe = want[0];
|
||||
var candidates = Threads.Find(x => x.PlayerIds.Contains(probe) && x.ChatThreadType == ThreadTypeUser).ToList();
|
||||
var hit = candidates
|
||||
.Where(t => t.PlayerIds != null && t.PlayerIds.Distinct().OrderBy(x => x).SequenceEqual(want))
|
||||
.OrderByDescending(t => t.LastActivityAt ?? DateTime.MinValue).ThenByDescending(t => t.ChatThreadId)
|
||||
.FirstOrDefault();
|
||||
return hit == null ? null : GetThread(hit.ChatThreadId);
|
||||
}
|
||||
|
||||
public static ChatThread GetOrCreateThreadWithMembers(List<ulong> memberIds, ulong creatorId)
|
||||
=> FindThreadWithExactMembers(memberIds) ?? CreateThread(memberIds, creatorId);
|
||||
|
||||
/// <summary>The caller's party thread (type 1, most recent) or null.</summary>
|
||||
public static ChatThread? GetPartyThreadFor(ulong playerId)
|
||||
{
|
||||
var t = Threads.Find(x => x.PlayerIds.Contains(playerId) && x.ChatThreadType == ThreadTypeParty)
|
||||
.OrderByDescending(x => x.LastActivityAt ?? DateTime.MinValue).ThenByDescending(x => x.ChatThreadId).FirstOrDefault();
|
||||
return t == null ? null : GetThread(t.ChatThreadId);
|
||||
}
|
||||
|
||||
public static ChatThread? GetThread(long threadId)
|
||||
{
|
||||
var thread = Threads.FindOne(x => x.ChatThreadId == threadId);
|
||||
if (thread == null) return null;
|
||||
thread.Messages = Messages.Find(x => x.ChatThreadId == threadId).OrderBy(x => x.ChatMessageId).ToList();
|
||||
thread.MemberStates ??= new();
|
||||
thread.PlayerIds ??= new();
|
||||
return thread;
|
||||
}
|
||||
|
||||
/// <summary>Threads the player is on, most recently active first, each with only its latest message.</summary>
|
||||
public static List<ChatThread> GetThreadsForPlayer(ulong playerId, int maxCount)
|
||||
{
|
||||
// Most recently active first; threads from before LastActivityAt existed sort by id.
|
||||
var list = Threads.Find(x => x.PlayerIds.Contains(playerId))
|
||||
.OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt ?? DateTime.MinValue)
|
||||
.ThenByDescending(x => x.ChatThreadId)
|
||||
.Take(maxCount).ToList();
|
||||
foreach (var t in list)
|
||||
{
|
||||
t.Messages = Messages.Find(x => x.ChatThreadId == t.ChatThreadId).OrderByDescending(x => x.ChatMessageId).Take(1).ToList();
|
||||
t.MemberStates ??= new();
|
||||
t.PlayerIds ??= new();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static bool IsMember(long threadId, ulong playerId)
|
||||
{
|
||||
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
|
||||
return t?.PlayerIds != null && t.PlayerIds.Contains(playerId);
|
||||
}
|
||||
|
||||
public static ChatThread? AddMember(long threadId, ulong playerId)
|
||||
{
|
||||
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
|
||||
if (t == null) return null;
|
||||
t.PlayerIds ??= new();
|
||||
if (!t.PlayerIds.Contains(playerId)) { t.PlayerIds.Add(playerId); t.LastActivityAt = DateTime.UtcNow; Threads.Update(t); }
|
||||
return GetThread(threadId);
|
||||
}
|
||||
|
||||
public static ChatThread? RemoveMember(long threadId, ulong playerId)
|
||||
{
|
||||
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
|
||||
if (t == null) return null;
|
||||
t.PlayerIds ??= new();
|
||||
if (t.PlayerIds.Remove(playerId)) { t.LastActivityAt = DateTime.UtcNow; Threads.Update(t); }
|
||||
return GetThread(threadId);
|
||||
}
|
||||
|
||||
public static ChatThread? SetName(long threadId, string name)
|
||||
{
|
||||
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
|
||||
if (t == null) return null;
|
||||
t.ChatThreadName = name ?? "";
|
||||
Threads.Update(t);
|
||||
return GetThread(threadId);
|
||||
}
|
||||
|
||||
// ---------------- per-member state ----------------
|
||||
|
||||
public static ChatMemberState StateFor(ChatThread t, ulong playerId)
|
||||
{
|
||||
t.MemberStates ??= new();
|
||||
if (t.MemberStates.TryGetValue(playerId.ToString(), out var s) && s != null) return s;
|
||||
// No entry yet: fall back to the old shared thread-level values.
|
||||
return new ChatMemberState { LastReadMessageId = t.LastReadMessageId, SnoozedUntil = t.SnoozedUntil, IsFavorited = t.IsFavorited };
|
||||
}
|
||||
|
||||
public static void UpdateState(long threadId, ulong playerId, Action<ChatMemberState> change)
|
||||
{
|
||||
var t = Threads.FindOne(x => x.ChatThreadId == threadId);
|
||||
if (t == null) return;
|
||||
t.MemberStates ??= new();
|
||||
string key = playerId.ToString();
|
||||
if (!t.MemberStates.TryGetValue(key, out var s) || s == null)
|
||||
s = new ChatMemberState { LastReadMessageId = t.LastReadMessageId, SnoozedUntil = t.SnoozedUntil, IsFavorited = t.IsFavorited };
|
||||
change(s);
|
||||
t.MemberStates[key] = s;
|
||||
Threads.Update(t);
|
||||
}
|
||||
|
||||
/// <summary>Move the read pointer forward (never back, never past the latest message).</summary>
|
||||
public static void MarkRead(long threadId, ulong playerId, long? upToMessageId = null)
|
||||
{
|
||||
var t = GetThread(threadId);
|
||||
if (t == null) return;
|
||||
long latest = t.Messages.Count > 0 ? t.Messages[^1].ChatMessageId : 0;
|
||||
long target = Math.Min(upToMessageId ?? latest, latest);
|
||||
UpdateState(threadId, playerId, s => { if (target > s.LastReadMessageId) s.LastReadMessageId = target; });
|
||||
}
|
||||
|
||||
// ---------------- messages ----------------
|
||||
|
||||
public static ChatMessage? GetMessage(long messageId) => Messages.FindOne(x => x.ChatMessageId == messageId);
|
||||
|
||||
public static ChatMessage? AddMessage(long threadId, int senderId, string jsonContents)
|
||||
{
|
||||
var thread = Threads.FindOne(x => x.ChatThreadId == threadId);
|
||||
if (thread == null) return null;
|
||||
|
||||
ChatMessage msg;
|
||||
lock (_idLock)
|
||||
{
|
||||
msg = new ChatMessage
|
||||
{
|
||||
ChatMessageId = NextMessageId(),
|
||||
ChatThreadId = threadId,
|
||||
SenderPlayerId = senderId,
|
||||
TimeSent = DateTime.UtcNow,
|
||||
Contents = jsonContents,
|
||||
ModerationState = 0
|
||||
};
|
||||
Messages.Insert(msg);
|
||||
}
|
||||
thread.LastActivityAt = msg.TimeSent;
|
||||
// The sender has read their own message.
|
||||
if (senderId > 0)
|
||||
{
|
||||
thread.MemberStates ??= new();
|
||||
string key = ((ulong)senderId).ToString();
|
||||
if (!thread.MemberStates.TryGetValue(key, out var s) || s == null) s = new ChatMemberState();
|
||||
s.LastReadMessageId = msg.ChatMessageId;
|
||||
thread.MemberStates[key] = s;
|
||||
}
|
||||
Threads.Update(thread);
|
||||
return msg;
|
||||
}
|
||||
|
||||
public static object MessageDto(ChatMessage m) => new
|
||||
{
|
||||
chatMessageId = m.ChatMessageId,
|
||||
chatThreadId = m.ChatThreadId,
|
||||
senderPlayerId = m.SenderPlayerId,
|
||||
timeSent = m.TimeSent.ToUniversalTime(), // LiteDB hands back local time; the client wants a UTC instant
|
||||
contents = m.Contents,
|
||||
moderationState = m.ModerationState
|
||||
};
|
||||
|
||||
/// <summary>Push a message to every member of its thread (ChatMessageReceived, or another frame id).</summary>
|
||||
public static void Push(ChatThread thread, ChatMessage m, string frameId = "ChatMessageReceived")
|
||||
{
|
||||
var payload = JsonConvert.SerializeObject(new { Id = frameId, Msg = MessageDto(m) });
|
||||
Notifications.FireAndForget(Notifications.SendToPlayers(thread.PlayerIds.ToList(), payload), $"chat push {frameId}");
|
||||
}
|
||||
|
||||
/// <summary>Post a system (-5) notice into a thread and push it.</summary>
|
||||
public static ChatMessage? SystemNotice(long threadId, string text)
|
||||
{
|
||||
var contents = JsonConvert.SerializeObject(new MessageJson { Type = MessageContentType.Text, Version = 1, Data = text });
|
||||
var m = AddMessage(threadId, SystemSenderId, contents);
|
||||
if (m == null) return null;
|
||||
var t = GetThread(threadId);
|
||||
if (t != null) Push(t, m);
|
||||
return m;
|
||||
}
|
||||
|
||||
// Kept for older callers.
|
||||
public static ChatThread? RenameThread(long threadId, ulong senderId, string newThreadName)
|
||||
{
|
||||
var t = SetName(threadId, newThreadName);
|
||||
if (t != null) SystemNotice(threadId, $"Player <@U{senderId}> renamed the chat");
|
||||
return GetThread(threadId);
|
||||
}
|
||||
|
||||
public static ChatThread? LeaveThread(long threadId, ulong senderId)
|
||||
{
|
||||
// Notice first (the leaver is still a member and gets the push that tells their
|
||||
// client the thread is gone), then drop the membership.
|
||||
SystemNotice(threadId, $"Player <@U{senderId}> left");
|
||||
return RemoveMember(threadId, senderId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
using LiteDB;
|
||||
|
||||
// Club storage for the in-game Clubs feature. Ported to match the RecFlare reference
|
||||
// (apps/clubs): clubs + membership rows, the { error, success, value } create envelope,
|
||||
// and the ClubDetails payload the client renders a club screen from.
|
||||
//
|
||||
// Global namespace (like ImageMetadataDB) so controllers reference ClubDB.* unqualified.
|
||||
public static class ClubDB
|
||||
{
|
||||
private static readonly string DbPath = Path.Combine("Data", "DBs", "Clubs.db");
|
||||
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(DbPath));
|
||||
private static LiteDatabase Db => _db.Value;
|
||||
|
||||
// Membership tiers (ClubMembershipType): -1 banned, 0 none, 1 pending request,
|
||||
// 2 pending invite, 3 denied, 10 member, 20 moderator, 30 co-owner, 100 creator.
|
||||
public const int TierNone = 0, TierMember = 10, TierModerator = 20, TierCoOwner = 30, TierCreator = 100;
|
||||
|
||||
public const string DefaultMainImage = "DefaultImgPurple";
|
||||
public const int MaxNameLength = 40;
|
||||
public const int MaxDescriptionLength = 512;
|
||||
|
||||
public class Club
|
||||
{
|
||||
public int ClubId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string Category { get; set; } = "Social";
|
||||
public int Visibility { get; set; } = 1; // 0 Private, 1 Public
|
||||
public int Joinability { get; set; } = 0; // 0 Open, 1 InviteOnly, 2 AskToJoin
|
||||
public bool AllowJuniors { get; set; } = true;
|
||||
public string MainImageName { get; set; } = DefaultMainImage;
|
||||
public int ClubType { get; set; } = 0; // 0 regular (never 1 = subscription)
|
||||
public int? ClubhouseRoomId { get; set; } = null;
|
||||
public ulong CreatorAccountId { get; set; }
|
||||
public bool IsRRO { get; set; } = false;
|
||||
public int MinLevel { get; set; } = 0;
|
||||
public int State { get; set; } = 0;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public List<string> CustomTags { get; set; } = new();
|
||||
public List<string> AdditionalImageNames { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClubMember
|
||||
{
|
||||
public int ClubMemberId { get; set; }
|
||||
public int ClubId { get; set; }
|
||||
public ulong AccountId { get; set; }
|
||||
public int MembershipType { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>A club noticeboard post (reference: ClubAnnouncement).</summary>
|
||||
public class ClubAnnouncement
|
||||
{
|
||||
public int AnnouncementId { get; set; }
|
||||
public int ClubId { get; set; }
|
||||
public ulong AccountId { get; set; }
|
||||
public string Title { get; set; } = "";
|
||||
public string Body { get; set; } = "";
|
||||
public string ImageName { get; set; } = "";
|
||||
public string Meta { get; set; } = "";
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? ModifiedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The club a player spawns into (their "home club"); one row per account.</summary>
|
||||
public class HomeClub
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public ulong AccountId { get; set; }
|
||||
public int ClubId { get; set; }
|
||||
}
|
||||
|
||||
private static ILiteCollection<HomeClub> Homes()
|
||||
{
|
||||
var c = Db.GetCollection<HomeClub>("home_clubs");
|
||||
c.EnsureIndex(x => x.AccountId, true);
|
||||
return c;
|
||||
}
|
||||
public static int GetHomeClubId(ulong accountId) => Homes().FindOne(h => h.AccountId == accountId)?.ClubId ?? 0;
|
||||
public static void SetHomeClub(ulong accountId, int clubId)
|
||||
{
|
||||
var col = Homes();
|
||||
var h = col.FindOne(x => x.AccountId == accountId);
|
||||
if (h == null) col.Insert(new HomeClub { AccountId = accountId, ClubId = clubId });
|
||||
else { h.ClubId = clubId; col.Update(h); }
|
||||
}
|
||||
public static void ClearHomeClub(ulong accountId) => Homes().DeleteMany(x => x.AccountId == accountId);
|
||||
|
||||
/// <summary>How far a player has read a club's noticeboard (the unread badge).</summary>
|
||||
public class AnnouncementRead
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClubId { get; set; }
|
||||
public ulong AccountId { get; set; }
|
||||
public int LastReadAnnouncementId { get; set; }
|
||||
}
|
||||
|
||||
// ---- CREATOR (subscription) CLUBS ----
|
||||
// Subscribing to a player joins that player's auto-created "creator club" (ClubType 1). They
|
||||
// are not stored: the id is 5_000_000 + creator account id (ClubsController.CreatorClubId)
|
||||
// and the club is synthesised from the account + its subscriber list whenever it's asked
|
||||
// for, so /club/{id}, /details, /members and the noticeboard all work for it. Before this
|
||||
// every one of those 404'd (the "click a player's subscribers" screen was blank).
|
||||
public const long CreatorClubBase = 5_000_000L;
|
||||
public static bool IsCreatorClub(long clubId) => clubId >= CreatorClubBase;
|
||||
public static ulong CreatorOfClub(long clubId) => IsCreatorClub(clubId) ? (ulong)(clubId - CreatorClubBase) : 0;
|
||||
|
||||
public static Club? CreatorClub(ulong creatorAccountId)
|
||||
{
|
||||
var acc = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountById(creatorAccountId);
|
||||
if (acc == null) return null;
|
||||
var full = Campus_live_rewrite.api.dbs.PlayerDB.GetFullAccount(creatorAccountId);
|
||||
return new Club
|
||||
{
|
||||
ClubId = (int)(CreatorClubBase + (long)creatorAccountId),
|
||||
Name = acc.Username ?? creatorAccountId.ToString(),
|
||||
Description = "",
|
||||
Category = "CreatorAutoTag",
|
||||
Visibility = 1,
|
||||
Joinability = 0,
|
||||
AllowJuniors = true,
|
||||
MainImageName = "DefaultClubImage2k.jpg",
|
||||
ClubType = 1,
|
||||
ClubhouseRoomId = null,
|
||||
CreatorAccountId = creatorAccountId,
|
||||
IsRRO = false,
|
||||
MinLevel = 0,
|
||||
State = 0,
|
||||
CreatedAt = full?.player?.created_at ?? DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
private static ILiteCollection<Club> Clubs()
|
||||
{
|
||||
var c = Db.GetCollection<Club>("clubs");
|
||||
c.EnsureIndex(x => x.ClubId, true);
|
||||
c.EnsureIndex(x => x.CreatorAccountId);
|
||||
return c;
|
||||
}
|
||||
private static ILiteCollection<ClubMember> Members()
|
||||
{
|
||||
var c = Db.GetCollection<ClubMember>("members");
|
||||
c.EnsureIndex(x => x.ClubId);
|
||||
c.EnsureIndex(x => x.AccountId);
|
||||
return c;
|
||||
}
|
||||
|
||||
private static int NextId<T>(ILiteCollection<T> col, System.Linq.Expressions.Expression<System.Func<T, int>> key)
|
||||
{
|
||||
var last = col.Query().OrderByDescending(key).Limit(1).FirstOrDefault();
|
||||
return last == null ? 1 : (key.Compile()(last) + 1);
|
||||
}
|
||||
|
||||
public static int CountByCreator(ulong accountId) =>
|
||||
Clubs().Count(x => x.CreatorAccountId == accountId);
|
||||
|
||||
public static Club? GetById(int clubId) =>
|
||||
IsCreatorClub(clubId) ? CreatorClub(CreatorOfClub(clubId)) : Clubs().FindById(clubId);
|
||||
|
||||
public static List<Club> GetByCreator(ulong accountId) =>
|
||||
Clubs().Query().Where(x => x.CreatorAccountId == accountId && x.ClubType == 0)
|
||||
.OrderByDescending(x => x.ClubId).ToList();
|
||||
|
||||
// Clubs the account belongs to (member tier or above), excluding subscription clubs.
|
||||
public static List<Club> GetByMember(ulong accountId)
|
||||
{
|
||||
var ids = Members().Query()
|
||||
.Where(m => m.AccountId == accountId && m.MembershipType >= TierMember)
|
||||
.ToList().Select(m => m.ClubId).Distinct().ToList();
|
||||
if (ids.Count == 0) return new();
|
||||
return Clubs().Query().Where(x => ids.Contains(x.ClubId) && x.ClubType == 0)
|
||||
.OrderByDescending(x => x.ClubId).ToList();
|
||||
}
|
||||
|
||||
public static int MemberCount(int clubId) =>
|
||||
IsCreatorClub(clubId)
|
||||
? Campus_live_rewrite.api.dbs.PlayerDB.GetSubscriberIds(CreatorOfClub(clubId)).Count
|
||||
: Members().Count(m => m.ClubId == clubId && m.MembershipType >= TierMember);
|
||||
|
||||
public static List<ClubMember> GetMembers(int clubId, int? membershipType, string? sortBy)
|
||||
{
|
||||
List<ClubMember> list;
|
||||
if (IsCreatorClub(clubId))
|
||||
{
|
||||
// The creator owns their club; every subscriber is a plain member.
|
||||
ulong creator = CreatorOfClub(clubId);
|
||||
list = new List<ClubMember> { new() { ClubMemberId = 0, ClubId = clubId, AccountId = creator, MembershipType = TierCreator } };
|
||||
int n = 1;
|
||||
foreach (var sub in Campus_live_rewrite.api.dbs.PlayerDB.GetSubscriberIds(creator))
|
||||
list.Add(new ClubMember { ClubMemberId = n++, ClubId = clubId, AccountId = sub, MembershipType = TierMember });
|
||||
}
|
||||
else list = Members().Query().Where(m => m.ClubId == clubId).ToList();
|
||||
if (membershipType.HasValue) list = list.Where(m => m.MembershipType == membershipType.Value).ToList();
|
||||
list = sortBy switch
|
||||
{
|
||||
"1" => list.OrderBy(m => m.AccountId).ToList(),
|
||||
"2" => list.OrderBy(m => m.CreatedAt).ToList(),
|
||||
_ => list.OrderByDescending(m => m.MembershipType).ThenBy(m => m.CreatedAt).ToList(),
|
||||
};
|
||||
return list;
|
||||
}
|
||||
|
||||
// The caller's tier in a club (0 = none / signed out).
|
||||
public static int MyMembership(int clubId, ulong accountId)
|
||||
{
|
||||
if (accountId == 0) return TierNone;
|
||||
if (IsCreatorClub(clubId))
|
||||
{
|
||||
ulong creator = CreatorOfClub(clubId);
|
||||
if (creator == accountId) return TierCreator;
|
||||
return Campus_live_rewrite.api.dbs.PlayerDB.GetSubscribedToIds(accountId).Contains(creator) ? TierMember : TierNone;
|
||||
}
|
||||
var m = Members().FindOne(x => x.ClubId == clubId && x.AccountId == accountId);
|
||||
return m?.MembershipType ?? TierNone;
|
||||
}
|
||||
|
||||
// Create a club and seed the creator's membership row (tier 100). Returns the new club.
|
||||
public static Club CreateClub(ulong creator, string name, string description, string category,
|
||||
int visibility, int joinability, bool allowJuniors, int minLevel)
|
||||
{
|
||||
var clubs = Clubs();
|
||||
var club = new Club
|
||||
{
|
||||
ClubId = NextId(clubs, x => x.ClubId),
|
||||
Name = name,
|
||||
Description = description ?? "",
|
||||
Category = string.IsNullOrWhiteSpace(category) ? "Social" : category,
|
||||
Visibility = visibility,
|
||||
Joinability = joinability,
|
||||
AllowJuniors = allowJuniors,
|
||||
MainImageName = DefaultMainImage,
|
||||
ClubType = 0,
|
||||
CreatorAccountId = creator,
|
||||
MinLevel = minLevel < 0 ? 0 : minLevel,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CustomTags = new(),
|
||||
};
|
||||
clubs.Insert(club);
|
||||
|
||||
var members = Members();
|
||||
members.Insert(new ClubMember
|
||||
{
|
||||
ClubMemberId = NextId(members, x => x.ClubMemberId),
|
||||
ClubId = club.ClubId,
|
||||
AccountId = creator,
|
||||
MembershipType = TierCreator,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
return club;
|
||||
}
|
||||
|
||||
// Set the club's main image (the club photo). Returns the updated club, or null.
|
||||
public static Club? SetMainImage(int clubId, string imageName)
|
||||
{
|
||||
var col = Clubs();
|
||||
var club = col.FindById(clubId);
|
||||
if (club == null) return null;
|
||||
club.MainImageName = imageName;
|
||||
col.Update(club);
|
||||
return club;
|
||||
}
|
||||
|
||||
// Set one gallery image by position (packed: past-the-end appends). Returns updated club.
|
||||
public static Club? SetAdditionalImage(int clubId, int index, string imageName)
|
||||
{
|
||||
var col = Clubs();
|
||||
var club = col.FindById(clubId);
|
||||
if (club == null) return null;
|
||||
club.AdditionalImageNames ??= new();
|
||||
if (index < 0 || index >= club.AdditionalImageNames.Count) club.AdditionalImageNames.Add(imageName);
|
||||
else club.AdditionalImageNames[index] = imageName;
|
||||
col.Update(club);
|
||||
return club;
|
||||
}
|
||||
|
||||
// Remove one gallery image and shift the rest up (no blank slot). Returns updated club.
|
||||
public static Club? RemoveAdditionalImage(int clubId, int index)
|
||||
{
|
||||
var col = Clubs();
|
||||
var club = col.FindById(clubId);
|
||||
if (club == null) return null;
|
||||
club.AdditionalImageNames ??= new();
|
||||
if (index >= 0 && index < club.AdditionalImageNames.Count) club.AdditionalImageNames.RemoveAt(index);
|
||||
col.Update(club);
|
||||
return club;
|
||||
}
|
||||
|
||||
// Edit a club's details (co-owner+). Every param is optional: null = leave unchanged.
|
||||
// A non-null customTags REPLACES the tag set (capped at 3). Returns the updated club, or null.
|
||||
public static Club? UpdateDetails(int clubId, string? name, string? description, string? category,
|
||||
int? visibility, int? joinability, bool? allowJuniors, int? minLevel, List<string>? customTags)
|
||||
{
|
||||
var col = Clubs();
|
||||
var club = col.FindById(clubId);
|
||||
if (club == null) return null;
|
||||
if (!string.IsNullOrWhiteSpace(name)) club.Name = name.Trim();
|
||||
if (description != null) club.Description = description;
|
||||
if (!string.IsNullOrWhiteSpace(category)) club.Category = category.Trim();
|
||||
if (visibility.HasValue) club.Visibility = visibility.Value;
|
||||
if (joinability.HasValue) club.Joinability = joinability.Value;
|
||||
if (allowJuniors.HasValue) club.AllowJuniors = allowJuniors.Value;
|
||||
if (minLevel.HasValue) club.MinLevel = minLevel.Value < 0 ? 0 : minLevel.Value;
|
||||
if (customTags != null) club.CustomTags = customTags.Take(3).ToList();
|
||||
col.Update(club);
|
||||
return club;
|
||||
}
|
||||
|
||||
// ---- ANNOUNCEMENTS (the club noticeboard) ----
|
||||
private static ILiteCollection<ClubAnnouncement> Announcements()
|
||||
{
|
||||
var c = Db.GetCollection<ClubAnnouncement>("announcements");
|
||||
c.EnsureIndex(x => x.AnnouncementId, true);
|
||||
c.EnsureIndex(x => x.ClubId);
|
||||
return c;
|
||||
}
|
||||
private static ILiteCollection<AnnouncementRead> Reads()
|
||||
{
|
||||
var c = Db.GetCollection<AnnouncementRead>("announcement_reads");
|
||||
c.EnsureIndex(x => x.ClubId);
|
||||
c.EnsureIndex(x => x.AccountId);
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>A club's noticeboard, newest first.</summary>
|
||||
public static List<ClubAnnouncement> GetAnnouncements(int clubId) =>
|
||||
Announcements().Query().Where(a => a.ClubId == clubId).ToList()
|
||||
.OrderByDescending(a => a.AnnouncementId).ToList();
|
||||
|
||||
public static int LatestAnnouncementId(int clubId) =>
|
||||
Announcements().Query().Where(a => a.ClubId == clubId).ToList()
|
||||
.Select(a => a.AnnouncementId).DefaultIfEmpty(0).Max();
|
||||
|
||||
public static ClubAnnouncement PostAnnouncement(int clubId, ulong accountId, string? title, string? body, string? imageName, string? meta)
|
||||
{
|
||||
var col = Announcements();
|
||||
var a = new ClubAnnouncement
|
||||
{
|
||||
AnnouncementId = NextId(col, x => x.AnnouncementId),
|
||||
ClubId = clubId,
|
||||
AccountId = accountId,
|
||||
Title = (title ?? "").Trim(),
|
||||
Body = (body ?? "").Trim(),
|
||||
ImageName = (imageName ?? "").Trim(),
|
||||
Meta = meta ?? "",
|
||||
};
|
||||
col.Insert(a);
|
||||
// The poster has obviously read their own post.
|
||||
MarkAnnouncementsRead(clubId, accountId, a.AnnouncementId);
|
||||
return a;
|
||||
}
|
||||
|
||||
public static ClubAnnouncement? EditAnnouncement(int announcementId, string? title, string? body, string? imageName, string? meta)
|
||||
{
|
||||
var col = Announcements();
|
||||
var a = col.FindOne(x => x.AnnouncementId == announcementId);
|
||||
if (a == null) return null;
|
||||
if (title != null) a.Title = title.Trim();
|
||||
if (body != null) a.Body = body;
|
||||
if (imageName != null) a.ImageName = imageName.Trim();
|
||||
if (meta != null) a.Meta = meta;
|
||||
a.ModifiedAt = DateTime.UtcNow;
|
||||
col.Update(a);
|
||||
return a;
|
||||
}
|
||||
|
||||
public static ClubAnnouncement? DeleteAnnouncement(int announcementId)
|
||||
{
|
||||
var col = Announcements();
|
||||
var a = col.FindOne(x => x.AnnouncementId == announcementId);
|
||||
if (a != null) col.DeleteMany(x => x.AnnouncementId == announcementId);
|
||||
return a;
|
||||
}
|
||||
|
||||
public static int LastReadAnnouncementId(int clubId, ulong accountId) =>
|
||||
Reads().FindOne(r => r.ClubId == clubId && r.AccountId == accountId)?.LastReadAnnouncementId ?? 0;
|
||||
|
||||
public static void MarkAnnouncementsRead(int clubId, ulong accountId, int upToAnnouncementId)
|
||||
{
|
||||
if (accountId == 0) return;
|
||||
var col = Reads();
|
||||
var r = col.FindOne(x => x.ClubId == clubId && x.AccountId == accountId);
|
||||
if (r == null) col.Insert(new AnnouncementRead { ClubId = clubId, AccountId = accountId, LastReadAnnouncementId = upToAnnouncementId });
|
||||
else if (upToAnnouncementId > r.LastReadAnnouncementId) { r.LastReadAnnouncementId = upToAnnouncementId; col.Update(r); }
|
||||
}
|
||||
|
||||
public static bool HasUnreadAnnouncements(int clubId, ulong accountId) =>
|
||||
LatestAnnouncementId(clubId) > LastReadAnnouncementId(clubId, accountId);
|
||||
|
||||
public static void Checkpoint()
|
||||
{
|
||||
try { Db.Checkpoint(); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// ---- browse ----------------------------------------------------------------------------
|
||||
public const int TierBanned = -1, TierPendingRequest = 1, TierPendingInvite = 2, TierDenied = 3;
|
||||
|
||||
/// <summary>
|
||||
/// The Clubs browse page (reference searchClubs): public, non-subscription clubs, optionally
|
||||
/// one category (exact, case-insensitive) and a name/description substring; sort 1 = newest,
|
||||
/// 2 = by name, anything else = most members first. Returns the page and the full match count.
|
||||
/// </summary>
|
||||
public static (List<Club> page, int total) Search(string? category, string? query, string? sort, int count)
|
||||
{
|
||||
var all = Clubs().Query().Where(x => x.Visibility == 1 && x.ClubType == 0).ToList();
|
||||
string wanted = (category ?? "").Trim();
|
||||
string term = (query ?? "").Trim();
|
||||
var matched = all.Where(c =>
|
||||
(wanted.Length == 0 || string.Equals(c.Category, wanted, StringComparison.OrdinalIgnoreCase)) &&
|
||||
(term.Length == 0
|
||||
|| (c.Name ?? "").Contains(term, StringComparison.OrdinalIgnoreCase)
|
||||
|| (c.Description ?? "").Contains(term, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
var counts = matched.ToDictionary(c => c.ClubId, c => MemberCount(c.ClubId));
|
||||
matched = sort switch
|
||||
{
|
||||
"1" => matched.OrderByDescending(c => c.CreatedAt).ToList(),
|
||||
"2" => matched.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
_ => matched.OrderByDescending(c => counts[c.ClubId]).ThenByDescending(c => c.CreatedAt).ToList(),
|
||||
};
|
||||
return (matched.Take(count).ToList(), matched.Count);
|
||||
}
|
||||
|
||||
/// <summary>Clubs an account created that anyone may see (public ones), for their profile.</summary>
|
||||
public static List<Club> PublicByCreator(ulong accountId) =>
|
||||
Clubs().Query().Where(x => x.CreatorAccountId == accountId && x.ClubType == 0 && x.Visibility == 1)
|
||||
.OrderByDescending(x => x.ClubId).ToList();
|
||||
|
||||
// ---- membership writes -----------------------------------------------------------------
|
||||
private static void Upsert(int clubId, ulong accountId, int tier)
|
||||
{
|
||||
var members = Members();
|
||||
var m = members.FindOne(x => x.ClubId == clubId && x.AccountId == accountId);
|
||||
if (m == null)
|
||||
{
|
||||
members.Insert(new ClubMember
|
||||
{
|
||||
ClubMemberId = NextId(members, x => x.ClubMemberId),
|
||||
ClubId = clubId, AccountId = accountId, MembershipType = tier, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
else { m.MembershipType = tier; members.Update(m); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The caller asks to join (reference requesttojoin / join): an Open club takes them in as a
|
||||
/// Member, an AskToJoin club records a pending request, an InviteOnly club refuses. A pending
|
||||
/// invite is accepted by asking. Banned stays banned. Returns the resulting tier.
|
||||
/// </summary>
|
||||
public static int RequestJoin(int clubId, ulong accountId)
|
||||
{
|
||||
var club = Clubs().FindById(clubId);
|
||||
if (club == null) return TierNone;
|
||||
int have = MyMembership(clubId, accountId);
|
||||
if (have == TierBanned || have >= TierMember) return have;
|
||||
if (have == TierPendingInvite) { Upsert(clubId, accountId, TierMember); return TierMember; }
|
||||
if (club.Joinability == 0) { Upsert(clubId, accountId, TierMember); return TierMember; }
|
||||
if (club.Joinability == 2) { Upsert(clubId, accountId, TierPendingRequest); return TierPendingRequest; }
|
||||
return have; // InviteOnly: nothing changes
|
||||
}
|
||||
|
||||
/// <summary>Leave (or withdraw a request). The creator can't; a ban stays. Returns the new tier.</summary>
|
||||
public static int Leave(int clubId, ulong accountId)
|
||||
{
|
||||
int have = MyMembership(clubId, accountId);
|
||||
if (have == TierCreator || have == TierBanned) return have;
|
||||
Members().DeleteMany(x => x.ClubId == clubId && x.AccountId == accountId);
|
||||
return TierNone;
|
||||
}
|
||||
|
||||
/// <summary>A co-owner adds / promotes / demotes someone (reference members/invite). Never the creator, never to Creator.</summary>
|
||||
public static bool SetTier(int clubId, ulong accountId, int tier)
|
||||
{
|
||||
var club = Clubs().FindById(clubId);
|
||||
if (club == null || club.CreatorAccountId == accountId) return false;
|
||||
if (tier != TierMember && tier != TierModerator && tier != TierCoOwner && tier != TierBanned
|
||||
&& tier != TierPendingInvite && tier != TierNone) return false;
|
||||
if (tier == TierNone) { Members().DeleteMany(x => x.ClubId == clubId && x.AccountId == accountId); return true; }
|
||||
Upsert(clubId, accountId, tier);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static Club? SetClubhouse(int clubId, int? roomId)
|
||||
{
|
||||
var clubs = Clubs();
|
||||
var c = clubs.FindById(clubId);
|
||||
if (c == null) return null;
|
||||
c.ClubhouseRoomId = roomId;
|
||||
clubs.Update(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>Delete a club with its memberships and announcements (creator only - the caller checks).</summary>
|
||||
public static bool Delete(int clubId)
|
||||
{
|
||||
var clubs = Clubs();
|
||||
if (clubs.FindById(clubId) == null) return false;
|
||||
Members().DeleteMany(x => x.ClubId == clubId);
|
||||
try { Db.GetCollection<ClubAnnouncement>("announcements").DeleteMany(x => x.ClubId == clubId); } catch { }
|
||||
clubs.Delete(clubId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
/// <summary>
|
||||
/// A player's own curated lists (the Lists service's `list` table in the reference): the
|
||||
/// Play menu's "Saved for Later" row is `__SavedForLater_Rooms` (type 1, room ids), created by
|
||||
/// the client itself through PUT /lists/curatedlists/{name}/items/{itemId}/createlistifneeded.
|
||||
/// One list per (owner, type, name).
|
||||
/// </summary>
|
||||
public static class CuratedListDB
|
||||
{
|
||||
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "CuratedLists.db")));
|
||||
|
||||
public class PlayerList
|
||||
{
|
||||
[BsonId] public long ListId { get; set; }
|
||||
public ulong CreatorAccountId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public int Type { get; set; } = 1;
|
||||
public int Accessibility { get; set; } = 0;
|
||||
public List<string> ItemIds { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static ILiteCollection<PlayerList> Col()
|
||||
{
|
||||
var c = _db.Value.GetCollection<PlayerList>("lists");
|
||||
c.EnsureIndex(x => x.CreatorAccountId);
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stable id for (owner, type, name) - the same list has the same id before and after it
|
||||
/// is created, so a read that answers a not-yet-created reserved list (`__SavedForLater_Rooms`)
|
||||
/// hands out the id the later write will use. Positive, 63-bit.
|
||||
/// </summary>
|
||||
public static long IdFor(ulong owner, int type, string name)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
ulong h = 1469598103934665603UL;
|
||||
foreach (char ch in (name ?? "").ToLowerInvariant()) { h ^= ch; h *= 1099511628211UL; }
|
||||
h ^= (ulong)type; h *= 1099511628211UL;
|
||||
h ^= owner; h *= 1099511628211UL;
|
||||
return (long)(h & 0x7FFF_FFFF_FFFF_FFFFUL) | 1L;
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerList? Get(ulong owner, int type, string name) =>
|
||||
Col().FindById(IdFor(owner, type, name));
|
||||
|
||||
public static PlayerList? GetById(long listId) => Col().FindById(listId);
|
||||
|
||||
public static PlayerList AddItem(ulong owner, int type, string name, int accessibility, string itemId)
|
||||
{
|
||||
var col = Col();
|
||||
long id = IdFor(owner, type, name);
|
||||
var list = col.FindById(id) ?? new PlayerList
|
||||
{
|
||||
ListId = id, CreatorAccountId = owner, Name = name, Type = type, Accessibility = accessibility, CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
if (!list.ItemIds.Contains(itemId)) list.ItemIds.Add(itemId);
|
||||
col.Upsert(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
public static PlayerList? RemoveItem(ulong owner, int type, string name, string itemId)
|
||||
{
|
||||
var col = Col();
|
||||
var list = col.FindById(IdFor(owner, type, name));
|
||||
if (list == null) return null;
|
||||
list.ItemIds.RemoveAll(x => x == itemId);
|
||||
col.Update(list);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
// LiteDB v5 writes through a write-ahead log (`<name>-log.db`) and only merges
|
||||
// it back into the main data file on Checkpoint() - which happens automatically
|
||||
// ONLY on LiteDatabase.Dispose(), or once the log passes CheckpointSize
|
||||
// (default 1000 pages / ~8MB).
|
||||
//
|
||||
// Every LiteDatabase in this project is a `static` field that is never disposed,
|
||||
// and `systemctl stop campus` kills the process without unwinding statics. So no
|
||||
// checkpoint ever ran, and the logs sat just under the 1000-page auto-threshold
|
||||
// (573-825 pages) and never tripped it either.
|
||||
//
|
||||
// The result, as of 2026-08-21: Heartbeats.db and Settings.db were 8192 bytes -
|
||||
// a bare header page - while Heartbeats-log.db was 5.6MB and Settings-log.db
|
||||
// 6.4MB. 100% of that data lived in the WAL and none of it in the data file.
|
||||
//
|
||||
// That matters beyond tidiness because install.sh deliberately preserves
|
||||
// Data/DBs/* across deploys, so those logs were copied forward verbatim into
|
||||
// every new build. Deploying a known-good server could not fix a data-level
|
||||
// problem, because the data came along with it. That is why swapping in a
|
||||
// working server still broke.
|
||||
public static class DbMaintenance
|
||||
{
|
||||
// Checkpoint every store. Each is wrapped separately so one bad file can't
|
||||
// stop the rest from flushing.
|
||||
private static Timer? _autoCheckpoint;
|
||||
|
||||
// Checkpoint on a schedule, not just at shutdown.
|
||||
//
|
||||
// Writes land in <name>-log.db and are only merged into <name>.db by a
|
||||
// checkpoint. Until then the data is recoverable ONLY by replaying that log.
|
||||
// Measured 2026-08-23: a setting written and read back survived a restart, but
|
||||
// Settings.db was still 8,192 bytes (untouched since 12 Aug) while
|
||||
// Settings-log.db had grown to 7.37 MB - i.e. every setting the server has ever
|
||||
// stored lived in the log alone.
|
||||
//
|
||||
// That works until the log is lost or truncated, at which point everything
|
||||
// written since the last checkpoint disappears at once. Shutdown-only
|
||||
// checkpointing means a hard kill (crash, SIGKILL, power loss) never merges.
|
||||
//
|
||||
// A periodic checkpoint bounds that exposure to `minutes`. It only flushes data
|
||||
// already committed - it cannot change any response or alter stored values.
|
||||
public static void StartAutoCheckpoint(int minutes = 5)
|
||||
{
|
||||
if (_autoCheckpoint != null) return;
|
||||
var period = TimeSpan.FromMinutes(minutes);
|
||||
_autoCheckpoint = new Timer(_ =>
|
||||
{
|
||||
try { CheckpointAll(quiet: true); }
|
||||
catch (Exception ex) { Console.WriteLine($"[DbMaintenance] auto-checkpoint failed: {ex.Message}"); }
|
||||
}, null, period, period);
|
||||
Console.WriteLine($"[DbMaintenance] auto-checkpoint every {minutes} min");
|
||||
}
|
||||
|
||||
public static void StopAutoCheckpoint()
|
||||
{
|
||||
_autoCheckpoint?.Dispose();
|
||||
_autoCheckpoint = null;
|
||||
}
|
||||
|
||||
public static void CheckpointAll(bool quiet = false)
|
||||
{
|
||||
Flush("Players", () => PlayerDB.PlayerDBFile.Checkpoint(), quiet);
|
||||
Flush("Rooms", () => RoomDB.rooms_db.Checkpoint(), quiet);
|
||||
Flush("Chat", () => ChatDB.ChatDBFile.Checkpoint(), quiet);
|
||||
Flush("Events", () => EventDB.EventDBFile.Checkpoint(), quiet);
|
||||
Flush("PlayersFriends", () => FriendsDB.PlayerFriendDBFile.Checkpoint(), quiet);
|
||||
Flush("Heartbeats", HeartbeatDB.Checkpoint, quiet);
|
||||
Flush("Settings", SettingsDB.Checkpoint, quiet);
|
||||
Flush("Leaderboard", LeaderboardDB.Checkpoint, quiet);
|
||||
Flush("Support", SupportDB.Checkpoint, quiet);
|
||||
Flush("Clubs", ClubDB.Checkpoint, quiet);
|
||||
Flush("Inventions", InventionDB.Checkpoint, quiet);
|
||||
}
|
||||
|
||||
private static void Flush(string name, Action action, bool quiet = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
if (!quiet) Console.WriteLine($"[DbMaintenance] checkpointed {name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[DbMaintenance] FAILED to checkpoint {name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Print the data-file / log-file split at startup. A main file stuck at 8192
|
||||
// bytes with a multi-megabyte log means the checkpoint path is broken again.
|
||||
public static void ReportWalState()
|
||||
{
|
||||
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "DBs");
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
Console.WriteLine($"[DbMaintenance] no DB directory at {dir}");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var db in Directory.GetFiles(dir, "*.db").OrderBy(f => f))
|
||||
{
|
||||
if (db.EndsWith("-log.db", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
var main = new FileInfo(db);
|
||||
var log = new FileInfo(db[..^3] + "-log.db");
|
||||
long logLen = log.Exists ? log.Length : 0;
|
||||
|
||||
string flag = (main.Length <= 8192 && logLen > 1024 * 1024)
|
||||
? " <-- ALL DATA IS IN THE LOG, main file is empty"
|
||||
: "";
|
||||
|
||||
Console.WriteLine(
|
||||
$"[DbMaintenance] {main.Name,-24} data={main.Length,10:n0} log={logLen,12:n0}{flag}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using LiteDB;
|
||||
using Campus_live_rewrite.api.server;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
/// <summary>
|
||||
/// Creator earnings. When a player buys something another player MADE - an invention, a UGC
|
||||
/// clothing item, a room key, room currency, a room consumable - the maker gets
|
||||
/// ConfigCampus.CreatorEarningsPercent (30%) of what the buyer spent, in the same currency.
|
||||
///
|
||||
/// It is not paid on the spot: an earning PENDS for ConfigCampus.CreatorEarningsPendDays (14)
|
||||
/// and is then paid on the first SUNDAY (00:00 UTC) on or after that. The Coach / system
|
||||
/// accounts and the room importer never earn. Every payout is logged and pushes the balance,
|
||||
/// so the number in the corner moves when it lands.
|
||||
/// </summary>
|
||||
public static class EarningsDB
|
||||
{
|
||||
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "Earnings.db")));
|
||||
|
||||
public class Entry
|
||||
{
|
||||
[BsonId] public int Id { get; set; }
|
||||
public ulong SellerId { get; set; }
|
||||
public ulong BuyerId { get; set; }
|
||||
public string Kind { get; set; } = ""; // invention / ugc / roomkey / roomcurrency / roomconsumable
|
||||
public string ItemId { get; set; } = "";
|
||||
public string ItemName { get; set; } = "";
|
||||
public int CurrencyType { get; set; } = 2;
|
||||
public long Spent { get; set; }
|
||||
public long Share { get; set; }
|
||||
public DateTime BoughtAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime AvailableAt { get; set; }
|
||||
public DateTime PayoutAt { get; set; }
|
||||
public DateTime? PaidAt { get; set; }
|
||||
}
|
||||
|
||||
private static ILiteCollection<Entry> Col()
|
||||
{
|
||||
var c = _db.Value.GetCollection<Entry>("earnings");
|
||||
c.EnsureIndex(x => x.SellerId);
|
||||
c.EnsureIndex(x => x.PaidAt);
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>The first Sunday 00:00 UTC on or after <paramref name="t"/>.</summary>
|
||||
public static DateTime NextSunday(DateTime t)
|
||||
{
|
||||
var day = new DateTime(t.Year, t.Month, t.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||
int add = ((int)DayOfWeek.Sunday - (int)day.DayOfWeek + 7) % 7;
|
||||
if (add == 0 && day < t) add = 7;
|
||||
return day.AddDays(add);
|
||||
}
|
||||
|
||||
private static bool CanEarn(ulong seller)
|
||||
{
|
||||
if (seller == 0) return false;
|
||||
var acc = PlayerDB.GetAccountById(seller);
|
||||
if (acc == null) return false;
|
||||
foreach (var n in ConfigCampus.NoEarningsUsernames)
|
||||
if (string.Equals(acc.Username, n, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Record a sale. Returns the entry, or null when the seller does not earn (buying your own, Coach, unknown).</summary>
|
||||
public static Entry? Record(ulong seller, ulong buyer, string kind, string itemId, string itemName, int currencyType, long spent)
|
||||
{
|
||||
if (spent <= 0 || seller == buyer || !CanEarn(seller)) return null;
|
||||
long share = (long)Math.Floor(spent * ConfigCampus.CreatorEarningsPercent / 100.0);
|
||||
if (share <= 0) return null;
|
||||
var now = DateTime.UtcNow;
|
||||
var available = now.AddDays(ConfigCampus.CreatorEarningsPendDays);
|
||||
var e = new Entry
|
||||
{
|
||||
SellerId = seller, BuyerId = buyer, Kind = kind, ItemId = itemId, ItemName = itemName,
|
||||
CurrencyType = currencyType, Spent = spent, Share = share,
|
||||
BoughtAt = now, AvailableAt = available, PayoutAt = NextSunday(available)
|
||||
};
|
||||
try { Col().Insert(e); }
|
||||
catch (Exception ex) { Console.WriteLine($"[earnings] record failed: {ex.Message}"); return null; }
|
||||
Console.WriteLine($"[earnings] {seller} earns {share} of {spent} (currency {currencyType}) from {buyer} buying {kind} '{itemName}' - pays out {e.PayoutAt:yyyy-MM-dd}");
|
||||
return e;
|
||||
}
|
||||
|
||||
/// <summary>Pay every earning whose Sunday has come. Called by the timer and on demand.</summary>
|
||||
public static int PayDue()
|
||||
{
|
||||
int paid = 0;
|
||||
try
|
||||
{
|
||||
var col = Col();
|
||||
var now = DateTime.UtcNow;
|
||||
var due = col.Find(x => x.PaidAt == null).Where(x => x.PayoutAt <= now).ToList();
|
||||
foreach (var group in due.GroupBy(x => (x.SellerId, x.CurrencyType)))
|
||||
{
|
||||
long total = group.Sum(x => x.Share);
|
||||
if (total <= 0) continue;
|
||||
long balance = PlayerDB.AddBalance(group.Key.SellerId, group.Key.CurrencyType, total);
|
||||
foreach (var e in group) { e.PaidAt = now; col.Update(e); paid++; }
|
||||
GiftBoxes.PushBalance(group.Key.SellerId, group.Key.CurrencyType, balance, "creator earnings");
|
||||
Console.WriteLine($"[earnings] paid {total} (currency {group.Key.CurrencyType}) to {group.Key.SellerId} for {group.Count()} sale(s) -> balance {balance}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Console.WriteLine($"[earnings] payout failed: {ex.Message}"); }
|
||||
return paid;
|
||||
}
|
||||
|
||||
public static List<Entry> ForPlayer(ulong seller, int take = 200) =>
|
||||
Col().Find(x => x.SellerId == seller).OrderByDescending(x => x.BoughtAt).Take(take).ToList();
|
||||
|
||||
public static List<Entry> Recent(int take = 200) =>
|
||||
Col().FindAll().OrderByDescending(x => x.BoughtAt).Take(take).ToList();
|
||||
|
||||
public static (long pending, long paid, DateTime? nextPayout) Summary(ulong seller)
|
||||
{
|
||||
var all = Col().Find(x => x.SellerId == seller).ToList();
|
||||
long pending = all.Where(x => x.PaidAt == null).Sum(x => x.Share);
|
||||
long paidTotal = all.Where(x => x.PaidAt != null).Sum(x => x.Share);
|
||||
DateTime? next = all.Where(x => x.PaidAt == null).Select(x => (DateTime?)x.PayoutAt).Min();
|
||||
return (pending, paidTotal, next);
|
||||
}
|
||||
|
||||
private static System.Threading.Timer? _timer;
|
||||
/// <summary>Check for due payouts every 15 minutes (and once at boot).</summary>
|
||||
public static void Start()
|
||||
{
|
||||
_timer ??= new System.Threading.Timer(_ => PayDue(), null, TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(15));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
// What each player has equipped, per equipment prefab.
|
||||
//
|
||||
// The client POSTs /api/equipment/v1/update every time a skin is equipped, sending
|
||||
// the full entry for that prefab:
|
||||
//
|
||||
// [{"PrefabName":"[ShareCamera]","ModificationGuid":"48c97ace-...",
|
||||
// "FriendlyName":"Camping Share Camera","Tooltip":"","Rarity":0,
|
||||
// "ThumbnailImageName":null,"Favorited":true}]
|
||||
//
|
||||
// That route did not exist, so the POST 404'd and the choice lived only in the
|
||||
// running client - at the next login everyone was back on the default skin.
|
||||
//
|
||||
// Stored as a collection inside Players.db rather than its own file, so it rides the
|
||||
// existing checkpoint (DbMaintenance.CheckpointAll) instead of adding another
|
||||
// write-ahead log that never gets merged.
|
||||
public static class EquipmentDB
|
||||
{
|
||||
public class EquippedItem
|
||||
{
|
||||
public string PrefabName { get; set; } = "";
|
||||
public string ModificationGuid { get; set; } = "";
|
||||
public string? FriendlyName { get; set; }
|
||||
public string? Tooltip { get; set; }
|
||||
public int Rarity { get; set; }
|
||||
public string? ThumbnailImageName { get; set; }
|
||||
public bool Favorited { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerEquipment
|
||||
{
|
||||
[BsonId]
|
||||
public ulong PlayerId { get; set; }
|
||||
public List<EquippedItem> Equipped { get; set; } = new();
|
||||
}
|
||||
|
||||
private static readonly ILiteCollection<PlayerEquipment> _col =
|
||||
PlayerDB.PlayerDBFile.GetCollection<PlayerEquipment>("player_equipment");
|
||||
|
||||
public static List<EquippedItem> Get(ulong playerId) =>
|
||||
_col.FindById(playerId)?.Equipped ?? new List<EquippedItem>();
|
||||
|
||||
/// <summary>
|
||||
/// Records the state the client just sent, keyed by prefab AND modification guid.
|
||||
/// Returns the player's full loadout afterwards.
|
||||
///
|
||||
/// Keying by prefab alone loses data. One request carries SEVERAL entries for the
|
||||
/// same prefab - the newly equipped skin, plus the one it replaced so its flag can
|
||||
/// be cleared:
|
||||
///
|
||||
/// [MakerPen] Professor Maker Pen (the new one)
|
||||
/// [MakerPen] Host 2 Maker Pen (the old one, flag being cleared)
|
||||
///
|
||||
/// Replacing "all entries for [MakerPen]" therefore kept only whichever happened to
|
||||
/// come LAST in the array - frequently the one being switched OFF - so the skin came
|
||||
/// back unequipped. The client decides which entry is current; our job is to store
|
||||
/// exactly what it told us, per item.
|
||||
/// </summary>
|
||||
public static List<EquippedItem> Update(ulong playerId, IEnumerable<EquippedItem>? items)
|
||||
{
|
||||
var row = _col.FindById(playerId) ?? new PlayerEquipment { PlayerId = playerId };
|
||||
if (items == null) return row.Equipped;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item == null || string.IsNullOrWhiteSpace(item.PrefabName)) continue;
|
||||
|
||||
row.Equipped.RemoveAll(e =>
|
||||
string.Equals(e.PrefabName, item.PrefabName, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(e.ModificationGuid, item.ModificationGuid, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
row.Equipped.Add(item);
|
||||
}
|
||||
|
||||
_col.Upsert(row);
|
||||
return row.Equipped;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
using Discord;
|
||||
using LiteDB;
|
||||
using Campus_live_rewrite.api.server.Classes.db_class;
|
||||
using static Campus_live_rewrite.api.server.Classes.db_class.EventDBClasses;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
public class EventDB
|
||||
{
|
||||
public static LiteDatabase EventDBFile = new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/Events.db");
|
||||
private static ILiteCollection<FullPlayerEvent> Events => EventDBFile.GetCollection<FullPlayerEvent>("events");
|
||||
|
||||
private static ulong GetNextId()
|
||||
{
|
||||
var last = Events.Query()
|
||||
.OrderByDescending(x => x.PlayerEventId)
|
||||
.FirstOrDefault();
|
||||
|
||||
return last == null ? 1UL : last.PlayerEventId + 1;
|
||||
}
|
||||
|
||||
public static FullPlayerEvent? GetFullEventById(ulong eventId)
|
||||
{
|
||||
return Events.FindById(eventId);
|
||||
}
|
||||
|
||||
/// <summary>Apply an edit to a stored event and persist it. Null when the event is unknown.</summary>
|
||||
public static FullPlayerEvent? Mutate(ulong eventId, Action<FullPlayerEvent> edit)
|
||||
{
|
||||
var ev = Events.FindById(eventId);
|
||||
if (ev == null) return null;
|
||||
edit(ev);
|
||||
Events.Update(ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
public static FullPlayerEvent? SetEventName(ulong eventId, string NewName)
|
||||
{
|
||||
var ev = Events.FindById(eventId);
|
||||
if (ev == null)
|
||||
return null;
|
||||
|
||||
ev.Name = NewName;
|
||||
Events.Update(ev);
|
||||
|
||||
return ev;
|
||||
}
|
||||
|
||||
public static FullPlayerEvent? SetEventImageName(ulong eventId, string NewImageName)
|
||||
{
|
||||
var ev = Events.FindById(eventId);
|
||||
if (ev == null)
|
||||
return null;
|
||||
|
||||
ev.ImageName = NewImageName;
|
||||
Events.Update(ev);
|
||||
|
||||
return ev;
|
||||
}
|
||||
|
||||
public static FullPlayerEvent? SetEventDescription(ulong eventId, string newDescription)
|
||||
{
|
||||
var ev = Events.FindById(eventId);
|
||||
if (ev == null) return null;
|
||||
ev.Description = newDescription;
|
||||
Events.Update(ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
public static FullPlayerEvent? SetEventTime(ulong eventId, DateTime start, DateTime end)
|
||||
{
|
||||
var ev = Events.FindById(eventId);
|
||||
if (ev == null) return null;
|
||||
ev.StartTime = start;
|
||||
ev.EndTime = end;
|
||||
Events.Update(ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
public static FullPlayerEvent? SetEventAccessibility(ulong eventId, EventDBClasses.PlayerEventAccessibility accessibility)
|
||||
{
|
||||
var ev = Events.FindById(eventId);
|
||||
if (ev == null) return null;
|
||||
ev.Accessibility = accessibility;
|
||||
Events.Update(ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
public static FullPlayerEvent? SetEventTags(ulong eventId, List<string> tags)
|
||||
{
|
||||
var ev = Events.FindById(eventId);
|
||||
if (ev == null) return null;
|
||||
ev.Tags = (tags ?? new List<string>())
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Select(t => new EventDBClasses.FullTag { Tag = t.Trim(), Type = EventDBClasses.TagType.Unknown1 })
|
||||
.ToList();
|
||||
Events.Update(ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
public static List<FullPlayerEvent> SearchEventByQuery(string? query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Events.Query()
|
||||
.OrderBy(e => e.StartTime)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
query = query.Trim();
|
||||
|
||||
bool isTagSearch =
|
||||
query.StartsWith("#") ||
|
||||
query.StartsWith("%23");
|
||||
|
||||
if (isTagSearch)
|
||||
{
|
||||
string tag = query
|
||||
.Replace("%23", "")
|
||||
.TrimStart('#')
|
||||
.ToLower();
|
||||
|
||||
return Events
|
||||
.Find(Query.EQ("Tags.Tag", tag))
|
||||
.OrderBy(e => e.StartTime)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
string nameQuery = query.ToLower();
|
||||
|
||||
return Events.Query()
|
||||
.Where(e =>
|
||||
e.Name != null &&
|
||||
e.Name.ToLower().Contains(nameQuery))
|
||||
.OrderBy(e => e.StartTime)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Events scheduled in a given room. /api/playerevents/v1/room/{RoomId} used to answer
|
||||
/// a hardcoded "[]" (marked "todo db"), so a room never showed the events booked in it.
|
||||
/// </summary>
|
||||
public static List<FullPlayerEvent> GetEventsForRoom(ulong roomId)
|
||||
{
|
||||
return Events.Query()
|
||||
.Where(e => e.RoomId == roomId)
|
||||
.OrderBy(e => e.StartTime)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Events happening right now - started, not yet ended. /api/playerevents/v1/searchlive
|
||||
/// was another hardcoded "[]", so the "live now" list was always empty however many
|
||||
/// events were running.
|
||||
/// </summary>
|
||||
public static List<FullPlayerEvent> GetLiveEvents()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return Events.Query()
|
||||
.ToList()
|
||||
.Where(e => e.StartTime.ToUniversalTime() <= now && e.EndTime.ToUniversalTime() >= now)
|
||||
.OrderBy(e => e.StartTime)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a player's RSVP and keeps AttendeeCount in step with it. One response per
|
||||
/// player per event - responding again replaces the previous answer.
|
||||
/// </summary>
|
||||
public static FullPlayerEvent? SetResponse(ulong eventId, ulong playerId, PlayerEventResponseType type)
|
||||
{
|
||||
var ev = Events.FindById(eventId);
|
||||
if (ev == null) return null;
|
||||
|
||||
ev.Responses ??= new List<FullEventResponses>();
|
||||
ev.Responses.RemoveAll(r => r.PlayerId == playerId);
|
||||
|
||||
if (type != PlayerEventResponseType.None)
|
||||
{
|
||||
ulong nextId = ev.Responses.Count == 0 ? 1UL : ev.Responses.Max(r => r.PlayerEventResponseId) + 1;
|
||||
ev.Responses.Add(new FullEventResponses
|
||||
{
|
||||
PlayerEventResponseId = nextId,
|
||||
PlayerEventId = eventId,
|
||||
PlayerId = playerId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Type = type
|
||||
});
|
||||
}
|
||||
|
||||
// AttendeeCount was never maintained - it sat at 0 no matter how many people
|
||||
// responded, so every event advertised itself as empty.
|
||||
ev.AttendeeCount = ev.Responses.Count(r => r.Type == PlayerEventResponseType.Yes);
|
||||
Events.Update(ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a player may enter an event's room, and why not when they may not.
|
||||
///
|
||||
/// Rules:
|
||||
/// - the CREATOR may join at any time, including before it starts (they have to be
|
||||
/// able to set the room up).
|
||||
/// - anyone INVITED may join at any time. An invite is a response row: the invite
|
||||
/// flow writes Pending, and Yes / Interested / No all mean the player is already
|
||||
/// part of the event, so any response row counts.
|
||||
/// - everyone else may only join once it has actually started, and not after it has
|
||||
/// ended.
|
||||
///
|
||||
/// Returns true when entry is allowed; `reason` explains a refusal for the log.
|
||||
/// </summary>
|
||||
public static bool CanPlayerJoin(FullPlayerEvent ev, ulong playerId, out string reason)
|
||||
{
|
||||
reason = "";
|
||||
|
||||
if (ev.CreatorPlayerId == playerId)
|
||||
return true;
|
||||
|
||||
bool invited = ev.Responses?.Any(r => r.PlayerId == playerId) == true;
|
||||
if (invited)
|
||||
return true;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Doors open a few minutes early so people can be in the room when it starts.
|
||||
if (now < ev.StartTime.ToUniversalTime().AddMinutes(-Campus_live_rewrite.api.server.ConfigCampus.EventEarlyJoinMinutes))
|
||||
{
|
||||
reason = $"event has not started (starts {ev.StartTime:u}, not invited)";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (now > ev.EndTime.ToUniversalTime())
|
||||
{
|
||||
reason = $"event already ended ({ev.EndTime:u})";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Marks an event live / not live. Used by roominstance/{id}/inprogress.</summary>
|
||||
public static FullPlayerEvent? SetBroadcastingInstance(ulong eventId, ulong roomInstanceId, bool inProgress)
|
||||
{
|
||||
var ev = Events.FindById(eventId);
|
||||
if (ev == null) return null;
|
||||
|
||||
ev.BroadcastingRoomInstanceId = inProgress ? roomInstanceId : 0;
|
||||
ev.State = inProgress ? 1 : 0;
|
||||
Events.Update(ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
/// <summary>The event currently broadcasting to a given room instance, if any.</summary>
|
||||
public static FullPlayerEvent? GetEventForRoomInstance(ulong roomInstanceId)
|
||||
{
|
||||
if (roomInstanceId == 0) return null;
|
||||
return Events.Query()
|
||||
.Where(e => e.BroadcastingRoomInstanceId == roomInstanceId)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>Removes an event outright. Used by the admin panel for moderation.</summary>
|
||||
public static bool DeleteEvent(ulong eventId) => Events.Delete(eventId);
|
||||
|
||||
public static FullPlayerEvent CreateEvent(ulong creatorPlayerId, EventDBClasses.CreateEventRequest request)
|
||||
{
|
||||
var ev = new FullPlayerEvent
|
||||
{
|
||||
PlayerEventId = GetNextId(),
|
||||
CreatorPlayerId = creatorPlayerId,
|
||||
RoomId = request.RoomId,
|
||||
SubRoomId = request.SubRoomId,
|
||||
ClubId = request.ClubId,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
ImageName = request.ImageName,
|
||||
Tags = request.Tags?.Select(t => new EventDBClasses.FullTag
|
||||
{
|
||||
Tag = t,
|
||||
Type = EventDBClasses.TagType.Unknown1
|
||||
}).ToList(),
|
||||
StartTime = request.StartTime,
|
||||
EndTime = request.EndTime,
|
||||
AttendeeCount = 0,
|
||||
State = 0,
|
||||
Accessibility = request.Accessibility,
|
||||
IsMultiInstance = request.IsMultiInstance,
|
||||
SupportMultiInstanceRoomChat = request.SupportMultiInstanceRoomChat,
|
||||
DefaultBroadcastPermissions = (EventDBClasses.BroadcastPermissionsEnum)request.DefaultBroadcastPermissions,
|
||||
CanRequestBroadcastPermissions = (EventDBClasses.BroadcastPermissionsEnum)request.CanRequestBroadcastPermissions,
|
||||
Responses = new List<EventDBClasses.FullEventResponses>()
|
||||
};
|
||||
|
||||
Events.Insert(ev);
|
||||
return ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
using System.Net.WebSockets;
|
||||
using LiteDB;
|
||||
using Newtonsoft.Json;
|
||||
using Campus_live_rewrite.api.server;
|
||||
using Campus_live_rewrite.api.server.Classes.db_class;
|
||||
using Campus_live_rewrite.Controllers;
|
||||
using static Campus_live_rewrite.api.WebsocketEvents;
|
||||
using static Campus_live_rewrite.Controllers.AccountsController;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
public class FriendsDB
|
||||
{
|
||||
public static LiteDatabase PlayerFriendDBFile = new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/PlayersFriends.db");
|
||||
|
||||
|
||||
// ==========================
|
||||
// GET ALL RELATIONSHIPS
|
||||
// ==========================
|
||||
public static List<RelationshipDetail> GetRelationships(ulong playerId)
|
||||
{
|
||||
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
|
||||
|
||||
List<RelationshipDetail> list = new();
|
||||
|
||||
// Read the caller's OWN side of each pair (PlayerID == caller). The client's
|
||||
// RelationshipResponse is projected from the caller's point of view: PlayerID is the
|
||||
// OTHER player, RelationshipType/Favorited/Ignored/Muted are the CALLER's own values
|
||||
// (RecFlare's toResponse does exactly this).
|
||||
//
|
||||
// This used to read the OTHER player's row instead (filter OtherPlayerID == caller),
|
||||
// which returned that player's flags and, worse, the OTHER side's request direction -
|
||||
// so a friend request you SENT showed up as one you RECEIVED (and the flags a favorite
|
||||
// will set below would have landed on the wrong person's list).
|
||||
foreach (var data in col.FindAll())
|
||||
{
|
||||
if (data.Relationship == null || data.Relationship.PlayerID != playerId)
|
||||
continue;
|
||||
|
||||
list.Add(new RelationshipDetail
|
||||
{
|
||||
Id = data.Relationship.Id,
|
||||
PlayerID = data.Relationship.OtherPlayerID, // the OTHER player (client's shape)
|
||||
OtherPlayerID = playerId, // the caller
|
||||
RelationshipType = data.Relationship.RelationshipType,
|
||||
Muted = data.Relationship.Muted,
|
||||
Ignored = data.Relationship.Ignored,
|
||||
Favorited = data.Relationship.Favorited,
|
||||
VoiceVolume = data.Relationship.VoiceVolume
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// BLOCK ("ignore" on the wire). Unlike favorite / mute this lands on BOTH sides of the
|
||||
// pair: the blocker's row gets Local, the blocked player's row gets Remote (Mutual when
|
||||
// both have blocked each other), so each of them reads Ignored = true and their clients
|
||||
// hide and mute the other. Only the blocker's own part is theirs to lift: the blocked
|
||||
// player's "unblock" cannot clear a Remote, so a block stays until the player who set it
|
||||
// takes it off. Blocking also ends a friendship / pending request between the two.
|
||||
// Both players get a live RelationshipChanged frame (id 1) - which is what the client
|
||||
// repaints its card / friends list from - plus a website refresh.
|
||||
// ==========================
|
||||
public static RelationshipDetail SetBlock(ulong caller, ulong target, bool value)
|
||||
{
|
||||
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
|
||||
var mine = GetOrCreateRow(col, caller, target);
|
||||
var theirs = GetOrCreateRow(col, target, caller);
|
||||
|
||||
static ReciprocalStatus With(ReciprocalStatus s, ReciprocalStatus part, bool on)
|
||||
{
|
||||
bool local = s == ReciprocalStatus.Local || s == ReciprocalStatus.Mutual;
|
||||
bool remote = s == ReciprocalStatus.Remote || s == ReciprocalStatus.Mutual;
|
||||
if (part == ReciprocalStatus.Local) local = on; else remote = on;
|
||||
return local && remote ? ReciprocalStatus.Mutual : local ? ReciprocalStatus.Local : remote ? ReciprocalStatus.Remote : ReciprocalStatus.None;
|
||||
}
|
||||
bool wasBlocking = mine.Relationship.Ignored == ReciprocalStatus.Local || mine.Relationship.Ignored == ReciprocalStatus.Mutual;
|
||||
mine.Relationship.Ignored = With(mine.Relationship.Ignored, ReciprocalStatus.Local, value);
|
||||
theirs.Relationship.Ignored = With(theirs.Relationship.Ignored, ReciprocalStatus.Remote, value);
|
||||
|
||||
bool unfriended = false;
|
||||
if (value && (mine.Relationship.RelationshipType != RelationshipType.None || theirs.Relationship.RelationshipType != RelationshipType.None))
|
||||
{
|
||||
mine.Relationship.RelationshipType = RelationshipType.None;
|
||||
theirs.Relationship.RelationshipType = RelationshipType.None;
|
||||
unfriended = true;
|
||||
}
|
||||
col.Update(mine);
|
||||
col.Update(theirs);
|
||||
if (unfriended)
|
||||
{
|
||||
try { RemoveFriendInviteMessage(caller, target); RemoveFriendInviteMessage(target, caller); } catch { }
|
||||
try { SitePrefsDB.Forget(caller, target); SitePrefsDB.Forget(target, caller); } catch { } // nickname / home pin go with the friendship
|
||||
}
|
||||
|
||||
string what = value ? (wasBlocking ? "re-blocked" : "blocked") : (wasBlocking ? "unblocked" : "asked to unblock (not theirs to lift)");
|
||||
Console.WriteLine($"[block] {caller} {what} {target}: mine={mine.Relationship.Ignored} theirs={theirs.Relationship.Ignored}{(unfriended ? " (friendship / request ended)" : "")}");
|
||||
|
||||
// Live to both sides: the frame the client's RelationshipChanged handler repaints
|
||||
// from, in each player's own point of view.
|
||||
Notifications.FireAndForget(Notifications.SendToPlayer(caller, JsonConvert.SerializeObject(new Reponse { Id = "1", Msg = RelationshipView(mine.Relationship) })), "block changed (self)");
|
||||
Notifications.FireAndForget(Notifications.SendToPlayer(target, JsonConvert.SerializeObject(new Reponse { Id = "1", Msg = RelationshipView(theirs.Relationship) })), "block changed (other)");
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(caller, "RelationshipsInvalid"), "block invalidate (self)");
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(target, "RelationshipsInvalid"), "block invalidate (other)");
|
||||
SiteNotifications.Notify(caller);
|
||||
SiteNotifications.Notify(target);
|
||||
// Each now sees the other as offline (PresenceIsBlocked); refresh what they see.
|
||||
Notifications.FireAndForget(Notifications.PushPresenceTo(caller, target), "block presence refresh (self)");
|
||||
Notifications.FireAndForget(Notifications.PushPresenceTo(target, caller), "block presence refresh (other)");
|
||||
|
||||
return new RelationshipDetail
|
||||
{
|
||||
Id = mine.Relationship.Id,
|
||||
PlayerID = target,
|
||||
OtherPlayerID = caller,
|
||||
RelationshipType = mine.Relationship.RelationshipType,
|
||||
Muted = mine.Relationship.Muted,
|
||||
Ignored = mine.Relationship.Ignored,
|
||||
Favorited = mine.Relationship.Favorited,
|
||||
VoiceVolume = mine.Relationship.VoiceVolume
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The caller's voice volume for one player (their own side of the row).</summary>
|
||||
public static void SetVoiceVolume(ulong caller, ulong target, int volume)
|
||||
{
|
||||
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
|
||||
var row = GetOrCreateRow(col, caller, target);
|
||||
row.Relationship.VoiceVolume = volume;
|
||||
col.Update(row);
|
||||
}
|
||||
|
||||
/// <summary>True when either of the two has blocked the other.</summary>
|
||||
public static bool IsBlockedEitherWay(ulong a, ulong b)
|
||||
{
|
||||
if (a == 0 || b == 0 || a == b) return false;
|
||||
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
|
||||
var row = col.FindOne(x => x.PlayerID == a && x.Relationship.OtherPlayerID == b);
|
||||
if (row?.Relationship != null && row.Relationship.Ignored != ReciprocalStatus.None) return true;
|
||||
var other = col.FindOne(x => x.PlayerID == b && x.Relationship.OtherPlayerID == a);
|
||||
return other?.Relationship != null && other.Relationship.Ignored != ReciprocalStatus.None;
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="blocker"/> has blocked <paramref name="other"/> (their own Local part).</summary>
|
||||
public static bool HasBlocked(ulong blocker, ulong other)
|
||||
{
|
||||
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
|
||||
var row = col.FindOne(x => x.PlayerID == blocker && x.Relationship.OtherPlayerID == other);
|
||||
return row?.Relationship != null && (row.Relationship.Ignored == ReciprocalStatus.Local || row.Relationship.Ignored == ReciprocalStatus.Mutual);
|
||||
}
|
||||
|
||||
private static YourFriendData GetOrCreateRow(ILiteCollection<YourFriendData> col, ulong owner, ulong other)
|
||||
{
|
||||
var row = col.FindOne(x => x.PlayerID == owner && x.Relationship.OtherPlayerID == other);
|
||||
if (row != null) return row;
|
||||
row = new YourFriendData
|
||||
{
|
||||
Id = ObjectId.NewObjectId(),
|
||||
PlayerID = owner,
|
||||
Relationship = new RelationshipDetail
|
||||
{
|
||||
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PlayerID = owner,
|
||||
OtherPlayerID = other,
|
||||
RelationshipType = RelationshipType.None,
|
||||
Favorited = ReciprocalStatus.None,
|
||||
Ignored = ReciprocalStatus.None,
|
||||
Muted = ReciprocalStatus.None,
|
||||
VoiceVolume = 100
|
||||
}
|
||||
};
|
||||
col.Insert(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// SET A PER-SIDE FLAG (favorite / ignore / mute) on the CALLER's own side.
|
||||
// Mirrors RecFlare's setRelationshipFlag: the write lands on the caller's side of the
|
||||
// pair, and flagging someone you have no relationship with creates a bare (None) row to
|
||||
// hold the flag - so you can favorite/ignore/mute a player you were never friends with.
|
||||
// Value maps to ReciprocalStatus.Local (set, serializes as 1) or None (clear, 0), which
|
||||
// is the 0/1 the client reads for each flag. Returns the caller-POV relationship.
|
||||
// ==========================
|
||||
public static RelationshipDetail SetRelationshipFlag(ulong caller, ulong target, FriendInfo flag, bool value)
|
||||
{
|
||||
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
|
||||
|
||||
var row = col.FindOne(x => x.PlayerID == caller && x.Relationship.OtherPlayerID == target);
|
||||
if (row == null)
|
||||
{
|
||||
row = new YourFriendData
|
||||
{
|
||||
Id = ObjectId.NewObjectId(),
|
||||
PlayerID = caller,
|
||||
Relationship = new RelationshipDetail
|
||||
{
|
||||
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PlayerID = caller,
|
||||
OtherPlayerID = target,
|
||||
RelationshipType = RelationshipType.None,
|
||||
Favorited = ReciprocalStatus.None,
|
||||
Ignored = ReciprocalStatus.None,
|
||||
Muted = ReciprocalStatus.None,
|
||||
VoiceVolume = 100
|
||||
}
|
||||
};
|
||||
col.Insert(row);
|
||||
}
|
||||
|
||||
var status = value ? ReciprocalStatus.Local : ReciprocalStatus.None;
|
||||
switch (flag)
|
||||
{
|
||||
case FriendInfo.Favorited: row.Relationship.Favorited = status; break;
|
||||
case FriendInfo.Muted: row.Relationship.Muted = status; break;
|
||||
case FriendInfo.Ignored: row.Relationship.Ignored = status; break;
|
||||
}
|
||||
col.Update(row);
|
||||
|
||||
// Realtime: the caller's game re-reads its relationships (favorite star, mute/ignore
|
||||
// state) and their website tabs refresh - the same channels a friend request uses.
|
||||
// A favorite also changes what the caller may SEE of the target ("Appear Online To:
|
||||
// Favorites"), so re-send that one presence to them with the new relationship applied.
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(caller, "RelationshipsInvalid"), "relationship-flag invalidate");
|
||||
Notifications.FireAndForget(Notifications.SendToPlayer(caller, JsonConvert.SerializeObject(new Reponse { Id = "1", Msg = RelationshipView(row.Relationship) })), "relationship-flag changed");
|
||||
SiteNotifications.Notify(caller);
|
||||
if (flag == FriendInfo.Favorited)
|
||||
Notifications.FireAndForget(Notifications.PushPresenceTo(caller, target), "favorite presence refresh");
|
||||
|
||||
// Return in the caller's POV shape (PlayerID = the other player), the same projection
|
||||
// GetRelationships uses, so the caller can apply it directly.
|
||||
return new RelationshipDetail
|
||||
{
|
||||
Id = row.Relationship.Id,
|
||||
PlayerID = target,
|
||||
OtherPlayerID = caller,
|
||||
RelationshipType = row.Relationship.RelationshipType,
|
||||
Muted = row.Relationship.Muted,
|
||||
Ignored = row.Relationship.Ignored,
|
||||
Favorited = row.Relationship.Favorited,
|
||||
VoiceVolume = row.Relationship.VoiceVolume
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// COUNT ONLINE FRIENDS - the caller's accepted friends that currently hold a live
|
||||
// notification websocket. Replaces the hardcoded int.MaxValue/0 stub.
|
||||
// ==========================
|
||||
public static int CountOnlineFriends(ulong playerId)
|
||||
{
|
||||
return GetRelationships(playerId)
|
||||
.Where(r => r.RelationshipType == RelationshipType.Friend)
|
||||
.Select(r => r.PlayerID)
|
||||
.Distinct()
|
||||
.Count(fid => CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(fid));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// ==========================
|
||||
// SEND FRIEND REQUEST
|
||||
// ==========================
|
||||
public static RelationshipDetail SendFriendRequest(ulong fromPlayer, ulong toPlayer)
|
||||
{
|
||||
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
|
||||
|
||||
// Blocked either way: nothing is sent and nobody is told; the caller just gets their
|
||||
// current (None) relationship back.
|
||||
if (IsBlockedEitherWay(fromPlayer, toPlayer))
|
||||
{
|
||||
Console.WriteLine($"[friends] {fromPlayer} -> {toPlayer}: friend request refused, one has blocked the other");
|
||||
var blockedRow = GetOrCreateRow(col, fromPlayer, toPlayer).Relationship;
|
||||
return new RelationshipDetail
|
||||
{
|
||||
Id = blockedRow.Id, PlayerID = toPlayer, OtherPlayerID = fromPlayer,
|
||||
RelationshipType = blockedRow.RelationshipType, Muted = blockedRow.Muted, Ignored = blockedRow.Ignored,
|
||||
Favorited = blockedRow.Favorited, VoiceVolume = blockedRow.VoiceVolume
|
||||
};
|
||||
}
|
||||
|
||||
// ---- A -> B ----
|
||||
var relA = col.FindOne(x =>
|
||||
x.PlayerID == fromPlayer &&
|
||||
x.Relationship.OtherPlayerID == toPlayer
|
||||
);
|
||||
|
||||
if (relA == null)
|
||||
{
|
||||
relA = new YourFriendData
|
||||
{
|
||||
Id = ObjectId.NewObjectId(),
|
||||
PlayerID = fromPlayer,
|
||||
Relationship = new RelationshipDetail
|
||||
{
|
||||
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PlayerID = fromPlayer,
|
||||
OtherPlayerID = toPlayer,
|
||||
RelationshipType = RelationshipType.None,
|
||||
Favorited = ReciprocalStatus.None,
|
||||
Ignored = ReciprocalStatus.None,
|
||||
Muted = ReciprocalStatus.None,
|
||||
VoiceVolume = 100
|
||||
}
|
||||
};
|
||||
|
||||
col.Insert(relA);
|
||||
}
|
||||
|
||||
// ---- B -> A ----
|
||||
var relB = col.FindOne(x =>
|
||||
x.PlayerID == toPlayer &&
|
||||
x.Relationship.OtherPlayerID == fromPlayer
|
||||
);
|
||||
|
||||
if (relB == null)
|
||||
{
|
||||
relB = new YourFriendData
|
||||
{
|
||||
Id = ObjectId.NewObjectId(),
|
||||
PlayerID = toPlayer,
|
||||
Relationship = new RelationshipDetail
|
||||
{
|
||||
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PlayerID = toPlayer,
|
||||
OtherPlayerID = fromPlayer,
|
||||
RelationshipType = RelationshipType.None,
|
||||
Favorited = ReciprocalStatus.None,
|
||||
Ignored = ReciprocalStatus.None,
|
||||
Muted = ReciprocalStatus.None,
|
||||
VoiceVolume = 100
|
||||
}
|
||||
};
|
||||
|
||||
col.Insert(relB);
|
||||
}
|
||||
|
||||
|
||||
// Update types
|
||||
relA.Relationship.RelationshipType = RelationshipType.FriendRequestSent;
|
||||
relB.Relationship.RelationshipType = RelationshipType.FriendRequestReceived;
|
||||
|
||||
col.Update(relA);
|
||||
col.Update(relB);
|
||||
|
||||
//todo: send ws event to anounce the player that it got a new friend request
|
||||
|
||||
// Websocket pushes. fromPlayer is always the SENDER, toPlayer the RECIPIENT (both
|
||||
// callers pass that order).
|
||||
// - sender: their side is now "Sent" (relationship changed -> shows Pending).
|
||||
// - recipient: the incoming request, as BOTH a relationship change (so the friends
|
||||
// UI updates live) AND a message notification "<sender> sent you a friend request".
|
||||
// The message used to be sent to the SENDER, so the recipient's game never got the
|
||||
// heads-up and the request only showed on a later fetch.
|
||||
Notifications.SendToPlayer(
|
||||
fromPlayer,
|
||||
JsonConvert.SerializeObject(CreateResponseFriendRequestSent(toPlayer))
|
||||
);
|
||||
|
||||
Notifications.SendToPlayer(
|
||||
toPlayer,
|
||||
JsonConvert.SerializeObject(CreateResponseFriendRequestReceived(fromPlayer, toPlayer))
|
||||
);
|
||||
|
||||
// A stored FriendInvite message for the recipient: the in-game notifications list is
|
||||
// /api/messages/v2/get, so a request that only ever went out as a live frame vanished
|
||||
// from it on the next open - "friend requests don't show unless you search for the
|
||||
// player". One per sender: re-sending replaces rather than stacks. Removed again when
|
||||
// the request is accepted, declined or cancelled (RemoveFriendInviteMessage).
|
||||
RemoveFriendInviteMessage(toPlayer, fromPlayer);
|
||||
var inviteMsg = PlayerDB.AddMessage(toPlayer, fromPlayer, WebsocketEvents.MessageType.FriendInvite, "");
|
||||
if (inviteMsg != null)
|
||||
Notifications.FireAndForget(Notifications.SendToPlayer(toPlayer, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(inviteMsg))), "friend-request message");
|
||||
|
||||
// (The extra "FriendInvite" message frame that used to go out here as well showed up
|
||||
// as a SECOND notification for the same request - one alert per request now.)
|
||||
|
||||
// Fire the client's OWN hub handlers (proven by the 2023 server) so the request shows
|
||||
// in-game live: FriendRequestReceived raises the alert on the recipient, and
|
||||
// RelationshipsInvalid makes BOTH sides re-fetch /api/relationships so the incoming
|
||||
// request and the sender's "pending" state appear without a relog.
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "FriendRequestReceived", (long)fromPlayer), "friend-request alert");
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "RelationshipChanged", RelationshipView(relB.Relationship)), "friend-request changed (recipient)");
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(fromPlayer, "RelationshipChanged", RelationshipView(relA.Relationship)), "friend-request changed (sender)");
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "RelationshipsInvalid"), "friend-request invalidate (recipient)");
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(fromPlayer, "RelationshipsInvalid"), "friend-request invalidate (sender)");
|
||||
return relA.Relationship;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Befriend with no pending-request step (the in-game handshake). A pending request either
|
||||
/// way, or an existing friendship, is simply accepted / re-confirmed; otherwise the pair is
|
||||
/// minted as a request from <paramref name="me"/> and accepted in the same call, so both
|
||||
/// sides end up Friend with the usual "relationship changed" pushes.
|
||||
/// </summary>
|
||||
public static RelationshipDetail? AddFriendDirect(ulong me, ulong other)
|
||||
{
|
||||
if (me == 0 || other == 0 || me == other) return null;
|
||||
var accepted = AcceptFriendRequest(other, me);
|
||||
if (accepted != null) return accepted;
|
||||
SendFriendRequest(me, other);
|
||||
return AcceptFriendRequest(me, other);
|
||||
}
|
||||
|
||||
public static RelationshipDetail? AcceptFriendRequest(ulong fromPlayer, ulong toPlayer)
|
||||
{
|
||||
if (fromPlayer == 0 || toPlayer == 0 || fromPlayer == toPlayer) return null;
|
||||
// The request is being answered: its notification goes, whichever side held it.
|
||||
RemoveFriendInviteMessage(fromPlayer, toPlayer);
|
||||
RemoveFriendInviteMessage(toPlayer, fromPlayer);
|
||||
|
||||
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
|
||||
|
||||
var relA = col.FindOne(x => x.PlayerID == fromPlayer && x.Relationship.OtherPlayerID == toPlayer);
|
||||
var relB = col.FindOne(x => x.PlayerID == toPlayer && x.Relationship.OtherPlayerID == fromPlayer);
|
||||
|
||||
static bool IsRequest(YourFriendData? r) =>
|
||||
r?.Relationship != null &&
|
||||
(r.Relationship.RelationshipType == RelationshipType.FriendRequestSent ||
|
||||
r.Relationship.RelationshipType == RelationshipType.FriendRequestReceived);
|
||||
static bool IsFriend(YourFriendData? r) =>
|
||||
r?.Relationship != null && r.Relationship.RelationshipType == RelationshipType.Friend;
|
||||
|
||||
// There must be a real, pending request on at least one side (or an already-formed
|
||||
// friendship we're just re-confirming). This blocks "accepting" a request nobody sent -
|
||||
// otherwise the row-healing below would let anyone force-friend a stranger.
|
||||
if (!IsRequest(relA) && !IsRequest(relB) && !IsFriend(relA) && !IsFriend(relB))
|
||||
return null;
|
||||
|
||||
// Heal a half-formed pair: if one side's row is missing (e.g. a partial write or an
|
||||
// orphan cleanup removed it), recreate it so acceptance ALWAYS ends in a mutual
|
||||
// friendship rather than silently doing nothing. Mirrors SendFriendRequest's row shape.
|
||||
YourFriendData MakeRow(ulong owner, ulong other) => new YourFriendData
|
||||
{
|
||||
Id = ObjectId.NewObjectId(),
|
||||
PlayerID = owner,
|
||||
Relationship = new RelationshipDetail
|
||||
{
|
||||
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PlayerID = owner,
|
||||
OtherPlayerID = other,
|
||||
RelationshipType = RelationshipType.None,
|
||||
Favorited = ReciprocalStatus.None,
|
||||
Ignored = ReciprocalStatus.None,
|
||||
Muted = ReciprocalStatus.None,
|
||||
VoiceVolume = 100
|
||||
}
|
||||
};
|
||||
if (relA == null) { relA = MakeRow(fromPlayer, toPlayer); col.Insert(relA); }
|
||||
if (relB == null) { relB = MakeRow(toPlayer, fromPlayer); col.Insert(relB); }
|
||||
|
||||
// Work out who ORIGINALLY SENT the request (had FriendRequestSent) vs who ACCEPTED it
|
||||
// (had FriendRequestReceived), BEFORE overwriting both to Friend. The two callers pass
|
||||
// fromPlayer/toPlayer in opposite orders (website: accepter,sender - game: sender,accepter),
|
||||
// so we can't trust the arg order - the row types are the source of truth. Without this
|
||||
// the "X accepted your request" notice went to whoever happened to be fromPlayer, so the
|
||||
// ACCEPTER got told the SENDER accepted (backwards).
|
||||
ulong sender, accepter;
|
||||
if (relA.Relationship.RelationshipType == RelationshipType.FriendRequestSent) { sender = fromPlayer; accepter = toPlayer; }
|
||||
else if (relB.Relationship.RelationshipType == RelationshipType.FriendRequestSent) { sender = toPlayer; accepter = fromPlayer; }
|
||||
else { sender = fromPlayer; accepter = toPlayer; } // no pending side (re-confirm) - harmless default
|
||||
|
||||
// Set both sides to Friend
|
||||
relA.Relationship.RelationshipType = RelationshipType.Friend;
|
||||
relB.Relationship.RelationshipType = RelationshipType.Friend;
|
||||
|
||||
col.Update(relA);
|
||||
col.Update(relB);
|
||||
|
||||
// Relationship-changed -> BOTH clients update their friends list live (order-agnostic).
|
||||
Notifications.SendToPlayer(fromPlayer,
|
||||
JsonConvert.SerializeObject(CreateResponseFriendAccepted(toPlayer, fromPlayer)));
|
||||
|
||||
Notifications.SendToPlayer(toPlayer,
|
||||
JsonConvert.SerializeObject(CreateResponseFriendAccepted(fromPlayer, toPlayer)));
|
||||
|
||||
// "<accepter> accepted your friend request" -> ONLY the original sender, naming the
|
||||
// accepter. Also persisted as a saved message so it shows in the sender's website inbox,
|
||||
// survives, and (via the AddMessage -> SiteNotifications.Notify hook) updates the web live.
|
||||
Notifications.SendToPlayer(sender,
|
||||
JsonConvert.SerializeObject(CreateResponseFriendAcceptedMsg(accepter)));
|
||||
PlayerDB.AddMessage(sender, accepter, WebsocketEvents.MessageType.FriendRequestAccepted, null);
|
||||
|
||||
// Refresh BOTH friends lists live (the client's own RelationshipsInvalid handler).
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(fromPlayer, "RelationshipsInvalid"), "accept invalidate");
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(toPlayer, "RelationshipsInvalid"), "accept invalidate");
|
||||
|
||||
// return the accepted version
|
||||
return relA.Relationship;
|
||||
}
|
||||
|
||||
|
||||
private static WebsocketEvents.Reponse CreateResponseFriendAcceptedMsg(ulong fromPlayer) => new WebsocketEvents.Reponse
|
||||
{
|
||||
Id = "2",
|
||||
Msg = new
|
||||
{
|
||||
Id = (ulong)new Random().Next(),
|
||||
FromPlayerId = fromPlayer,
|
||||
SentTime = DateTime.UtcNow,
|
||||
Type = WebsocketEvents.MessageType.FriendRequestAccepted,
|
||||
Data = "",
|
||||
RoomId = (ulong?)null,
|
||||
PlayerEventId = (ulong?)null,
|
||||
}
|
||||
};
|
||||
|
||||
public static void DeleteFriends(ulong playerId)
|
||||
{
|
||||
if (playerId == 0) return;
|
||||
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
|
||||
col.DeleteMany(x => x.PlayerID == playerId);
|
||||
}
|
||||
|
||||
// The RelationshipChanged (1) payload is EXACTLY the five fields the client's
|
||||
// RelationshipResponse has (reference RelationshipChangedPayload: PlayerID,
|
||||
// RelationshipType, Muted, Ignored, Favorited) - the same strict parser that dropped the
|
||||
// whole /relationships/v2/get array over the extra Id/OtherPlayerID/VoiceVolume keys.
|
||||
// These frames used to carry the raw RelationshipDetail (with its ~1.79-trillion Id), so
|
||||
// the live "you have a friend request" frame never parsed and requests sent from the
|
||||
// website only showed up on the next relationships fetch. PlayerID = the OTHER player.
|
||||
private static object RelWire(ulong otherPlayer, RelationshipType type, bool favorited = false, bool muted = false, ReciprocalStatus ignored = ReciprocalStatus.None) => new
|
||||
{
|
||||
PlayerID = otherPlayer,
|
||||
RelationshipType = (int)type,
|
||||
Muted = muted ? 1 : 0,
|
||||
Ignored = IgnoredWire(ignored),
|
||||
Favorited = favorited ? 1 : 0
|
||||
};
|
||||
|
||||
public static Reponse CreateResponseFriendAccepted(ulong playerid, ulong fromplayerid)
|
||||
=> new Reponse { Id = "1", Msg = RelWire(playerid, RelationshipType.Friend) };
|
||||
|
||||
public static Reponse CreateResponseFriendRemoved(ulong playerid, ulong fromplayerid)
|
||||
=> new Reponse { Id = "1", Msg = RelWire(playerid, RelationshipType.None) };
|
||||
|
||||
|
||||
// Sent to the RECIPIENT. Client convention (see GetRelationships): PlayerID = the OTHER
|
||||
// player (the sender), OtherPlayerID = the caller (the recipient). OtherPlayerID used to be
|
||||
// set to the sender too, which is wrong and can stop the client rendering the request.
|
||||
private static WebsocketEvents.Reponse CreateResponseFriendRequestReceived(ulong fromPlayer, ulong toPlayer) => new WebsocketEvents.Reponse
|
||||
{
|
||||
Id = "1",
|
||||
Msg = RelWire(fromPlayer, RelationshipType.FriendRequestReceived)
|
||||
};
|
||||
|
||||
private static WebsocketEvents.Reponse CreateResponseFriendRequestMsg(ulong fromPlayer) => new WebsocketEvents.Reponse
|
||||
{
|
||||
Id = "2",
|
||||
Msg = new
|
||||
{
|
||||
Id = (ulong)new Random().Next(),
|
||||
FromPlayerId = fromPlayer,
|
||||
SentTime = DateTime.UtcNow,
|
||||
Type = WebsocketEvents.MessageType.FriendInvite,
|
||||
Data = "",
|
||||
RoomId = (ulong?)null,
|
||||
PlayerEventId = (ulong?)null,
|
||||
}
|
||||
};
|
||||
|
||||
// The relationship as the client's RelationshipChanged handler expects it (the same shape
|
||||
// /relationships/v2/get lists): PlayerID = the OTHER player from the receiver's view.
|
||||
private static object RelationshipView(RelationshipDetail r) =>
|
||||
RelWire(r.OtherPlayerID, r.RelationshipType, r.Favorited != ReciprocalStatus.None, r.Muted != ReciprocalStatus.None, r.Ignored);
|
||||
|
||||
/// <summary>
|
||||
/// What the wire `Ignored` means: "I have blocked this player" - the caller's OWN part of
|
||||
/// the pair (Local / Mutual). A Remote (the other player blocked me) is kept for the
|
||||
/// server-side gates but is never shown: the blocked player is not told, and reading it
|
||||
/// as true made their client draw the block as if THEY had blocked the blocker.
|
||||
/// </summary>
|
||||
public static bool IsOwnBlock(ReciprocalStatus s) => s == ReciprocalStatus.Local || s == ReciprocalStatus.Mutual;
|
||||
|
||||
/// <summary>
|
||||
/// The wire `Ignored` is the client's ReciprocalStatus, from the receiver's point of view:
|
||||
/// 0 None - nobody blocked anybody
|
||||
/// 1 Local - I blocked them (their card shows Blocked + my Unblock button)
|
||||
/// 2 Remote - they blocked me (their card shows Blocked, no Unblock - not mine to lift)
|
||||
/// 3 Mutual - both (Unblock lifts only MY part; theirs stays -> 2)
|
||||
/// Sending a flat 0/1 was the bug: a 1 on both rows drew the block as the blocked
|
||||
/// player's own (they got an Unblock button), and a 0 hid it from them entirely.
|
||||
/// </summary>
|
||||
public static int IgnoredWire(ReciprocalStatus s) => (int)s;
|
||||
|
||||
/// <summary>Drop the stored "X sent you a friend request" notification (and tell the game).</summary>
|
||||
public static void RemoveFriendInviteMessage(ulong toPlayer, ulong fromPlayer)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = PlayerDB.GetFullAccount(toPlayer);
|
||||
var stale = full?.player_Extra?.Messages?
|
||||
.Where(m => m.Type == WebsocketEvents.MessageType.FriendInvite && m.FromPlayerId == fromPlayer)
|
||||
.Select(m => m.Id).ToList();
|
||||
if (stale == null || stale.Count == 0) return;
|
||||
foreach (var id in stale)
|
||||
{
|
||||
PlayerDB.DeleteMessage(toPlayer, id);
|
||||
Notifications.FireAndForget(Notifications.SendToPlayer(toPlayer, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageDeleted(id))), "friend-request message gone");
|
||||
}
|
||||
SiteNotifications.Notify(toPlayer);
|
||||
}
|
||||
catch (Exception ex) { Console.WriteLine($"[friends] invite message cleanup {fromPlayer}->{toPlayer}: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public static RelationshipDetail RemoveFriend(ulong playerA, ulong playerB)
|
||||
{
|
||||
// website extras (nickname / home pin) don't outlive the friendship
|
||||
try { SitePrefsDB.Forget(playerA, playerB); SitePrefsDB.Forget(playerB, playerA); } catch { }
|
||||
// Decline / cancel / unfriend: any pending "sent you a friend request" notification goes.
|
||||
RemoveFriendInviteMessage(playerA, playerB);
|
||||
RemoveFriendInviteMessage(playerB, playerA);
|
||||
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
|
||||
|
||||
// A -> B
|
||||
var relA = col.FindOne(x => x.PlayerID == playerA &&
|
||||
x.Relationship.OtherPlayerID == playerB);
|
||||
|
||||
// B -> A
|
||||
var relB = col.FindOne(x => x.PlayerID == playerB &&
|
||||
x.Relationship.OtherPlayerID == playerA);
|
||||
|
||||
// If they were never connected, return empty relationship
|
||||
if (relA == null && relB == null)
|
||||
{
|
||||
return new RelationshipDetail
|
||||
{
|
||||
PlayerID = playerA,
|
||||
OtherPlayerID = playerB,
|
||||
RelationshipType = RelationshipType.None
|
||||
};
|
||||
}
|
||||
|
||||
// Cancelling a PENDING request (or declining one) - tell the other side the request is
|
||||
// gone, so their notification disappears instead of lingering until they act on it.
|
||||
bool wasPending = (relA?.Relationship.RelationshipType == RelationshipType.FriendRequestSent
|
||||
|| relA?.Relationship.RelationshipType == RelationshipType.FriendRequestReceived
|
||||
|| relB?.Relationship.RelationshipType == RelationshipType.FriendRequestSent
|
||||
|| relB?.Relationship.RelationshipType == RelationshipType.FriendRequestReceived);
|
||||
|
||||
// Delete A->B
|
||||
if (relA != null)
|
||||
col.Delete(relA.Id);
|
||||
|
||||
// Delete B->A
|
||||
if (relB != null)
|
||||
col.Delete(relB.Id);
|
||||
if (wasPending)
|
||||
{
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(playerB, "FriendRequestCancelled", (long)playerA), "friend-request cancelled");
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(playerB, "RelationshipChanged", RelationshipView(new RelationshipDetail { PlayerID = playerB, OtherPlayerID = playerA, RelationshipType = RelationshipType.None, VoiceVolume = 100 })), "friend-request cancelled (changed)");
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(playerA, "RelationshipChanged", RelationshipView(new RelationshipDetail { PlayerID = playerA, OtherPlayerID = playerB, RelationshipType = RelationshipType.None, VoiceVolume = 100 })), "friend-request cancelled (self)");
|
||||
Console.WriteLine($"[friends] {playerA} cancelled/declined the pending request with {playerB}");
|
||||
}
|
||||
|
||||
// Send WS update to player A
|
||||
Notifications.SendToPlayer(
|
||||
playerA,
|
||||
JsonConvert.SerializeObject(
|
||||
CreateResponseFriendRemoved(playerA, playerB)
|
||||
)
|
||||
);
|
||||
|
||||
// Send WS update to player B
|
||||
Notifications.SendToPlayer(
|
||||
playerB,
|
||||
JsonConvert.SerializeObject(
|
||||
CreateResponseFriendRemoved(playerB, playerA)
|
||||
)
|
||||
);
|
||||
|
||||
// Refresh BOTH friends lists live (unfriend / decline / cancel).
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(playerA, "RelationshipsInvalid"), "remove invalidate");
|
||||
Notifications.FireAndForget(Notifications.SendHubInvocation(playerB, "RelationshipsInvalid"), "remove invalidate");
|
||||
|
||||
// Return final relationship state
|
||||
return new RelationshipDetail
|
||||
{
|
||||
PlayerID = playerA,
|
||||
OtherPlayerID = playerB,
|
||||
RelationshipType = RelationshipType.None,
|
||||
Favorited = ReciprocalStatus.None,
|
||||
Ignored = ReciprocalStatus.None,
|
||||
Muted = ReciprocalStatus.None,
|
||||
VoiceVolume = 100
|
||||
};
|
||||
}
|
||||
|
||||
public static RelationshipDetail AddFriend(ulong playerA, ulong playerB)
|
||||
{
|
||||
var col = PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
|
||||
|
||||
// A -> B
|
||||
var relA = col.FindOne(x => x.PlayerID == playerA &&
|
||||
x.Relationship.OtherPlayerID == playerB);
|
||||
|
||||
if (relA == null)
|
||||
{
|
||||
relA = new YourFriendData
|
||||
{
|
||||
Id = ObjectId.NewObjectId(),
|
||||
PlayerID = playerA,
|
||||
Relationship = new RelationshipDetail
|
||||
{
|
||||
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PlayerID = playerA,
|
||||
OtherPlayerID = playerB,
|
||||
Favorited = ReciprocalStatus.None,
|
||||
Ignored = ReciprocalStatus.None,
|
||||
Muted = ReciprocalStatus.None,
|
||||
VoiceVolume = 100
|
||||
}
|
||||
};
|
||||
col.Insert(relA);
|
||||
}
|
||||
|
||||
// B -> A
|
||||
var relB = col.FindOne(x => x.PlayerID == playerB &&
|
||||
x.Relationship.OtherPlayerID == playerA);
|
||||
|
||||
if (relB == null)
|
||||
{
|
||||
relB = new YourFriendData
|
||||
{
|
||||
Id = ObjectId.NewObjectId(),
|
||||
PlayerID = playerB,
|
||||
Relationship = new RelationshipDetail
|
||||
{
|
||||
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PlayerID = playerB,
|
||||
OtherPlayerID = playerA,
|
||||
Favorited = ReciprocalStatus.None,
|
||||
Ignored = ReciprocalStatus.None,
|
||||
Muted = ReciprocalStatus.None,
|
||||
VoiceVolume = 100
|
||||
}
|
||||
};
|
||||
col.Insert(relB);
|
||||
}
|
||||
|
||||
// Set both sides to Friend
|
||||
relA.Relationship.RelationshipType = RelationshipType.Friend;
|
||||
relB.Relationship.RelationshipType = RelationshipType.Friend;
|
||||
|
||||
col.Update(relA);
|
||||
col.Update(relB);
|
||||
|
||||
// WebSocket updates
|
||||
Notifications.SendToPlayer(
|
||||
playerA,
|
||||
JsonConvert.SerializeObject(CreateResponseFriendAccepted(playerA, playerB))
|
||||
);
|
||||
|
||||
Notifications.SendToPlayer(
|
||||
playerB,
|
||||
JsonConvert.SerializeObject(CreateResponseFriendAccepted(playerB, playerA))
|
||||
);
|
||||
|
||||
return relA.Relationship;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// {"type":1,"target":"Notification","arguments":["{\"id\":\"2\",\"msg\":{\"id\":16212007930,\"fromPlayerId\":1764227894,\"sentTime\":\"2025-06-23T17:20:53.72092Z\",\"type\":4,\"data\":\"\",\"roomId\":null,\"playerEventId\":null}}"]}
|
||||
|
||||
private static WebsocketEvents.Reponse CreateResponseFriendRequestSent(ulong toPlayer) => new WebsocketEvents.Reponse
|
||||
{
|
||||
Id = "50",
|
||||
Msg = ""
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using LiteDB;
|
||||
using Campus_live_rewrite.api.server.Classes.db_class;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
public class HeartbeatDB
|
||||
{
|
||||
private static readonly LiteDatabase _db = new($"{Environment.CurrentDirectory}/Data/DBs/Heartbeats.db");
|
||||
|
||||
// Merge this store's write-ahead log back into the data file.
|
||||
// See DbMaintenance for why this must be called explicitly.
|
||||
public static void Checkpoint() => _db.Checkpoint();
|
||||
private static readonly ILiteCollection<PlayerHeartbeatData> _col =
|
||||
_db.GetCollection<PlayerHeartbeatData>("players_heartbeat");
|
||||
|
||||
public static void Setup()
|
||||
{
|
||||
_col.EnsureIndex(x => x.playerid, unique: true);
|
||||
|
||||
// Rows written before the key change carry a sequential iD (1, 2, 3...)
|
||||
// rather than the player's id, so the next insert for a player whose id
|
||||
// matches an old sequential value collides on _id and throws - which is
|
||||
// the failure the key change exists to remove.
|
||||
//
|
||||
// Heartbeats are transient (GetPlayerHeartbeat falls back to
|
||||
// CreateDefaultHeartbeat when a player has no row), so dropping stale
|
||||
// rows costs nothing and is safer than leaving the collision armed.
|
||||
try
|
||||
{
|
||||
var stale = _col.FindAll().Where(x => x.iD != x.playerid).ToList();
|
||||
if (stale.Count > 0)
|
||||
{
|
||||
foreach (var row in stale) _col.Delete(row.iD);
|
||||
Console.WriteLine($"[HeartbeatDB] migrated: dropped {stale.Count} row(s) keyed by row-count instead of playerid");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[HeartbeatDB] key migration skipped: {ex.Message}");
|
||||
}
|
||||
|
||||
// Correct EVERY stored appVersion to the current client build. The old hardcoded
|
||||
// "20250731.01" is NEWER than the 20250718.01 client, so a player whose row still
|
||||
// carried it showed to everyone as "ON NEWER VERSION" and couldn't be joined. The
|
||||
// per-heartbeat fix only touches a row when that player moves; this catches the
|
||||
// offline/stationary rows at startup so nobody is left stale.
|
||||
try
|
||||
{
|
||||
string want = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion;
|
||||
var wrong = _col.FindAll().Where(x => x.Heartbeat != null && x.Heartbeat.appVersion != want).ToList();
|
||||
foreach (var row in wrong) { row.Heartbeat.appVersion = want; _col.Update(row); }
|
||||
if (wrong.Count > 0)
|
||||
Console.WriteLine($"[HeartbeatDB] appVersion migration: corrected {wrong.Count} row(s) to {want}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[HeartbeatDB] appVersion migration skipped: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public class PlayerHeartbeatData
|
||||
{
|
||||
// Keyed by playerid. This used to be `(ulong)_col.LongCount() + 1` at every
|
||||
// insert, which is not a usable primary key: delete any row and the count
|
||||
// drops, so the next insert reuses an existing id and LiteDB throws
|
||||
// "Cannot insert duplicate key in unique index '_id'". Two concurrent
|
||||
// inserts read the same count and collide identically. A throw there aborts
|
||||
// the write, so the heartbeat row silently stops being updated.
|
||||
// playerid is already unique (see the index in Setup), so it IS the key.
|
||||
[BsonId]
|
||||
public ulong iD { get; set; }
|
||||
public ulong playerid { get; set; }
|
||||
public Heartbeat Heartbeat { get; set; }
|
||||
}
|
||||
|
||||
private static Heartbeat CreateDefaultHeartbeat(ulong playerid, Platforms platform, DeviceClasses deviceClasses) =>
|
||||
new()
|
||||
{
|
||||
playerId = (long)playerid,
|
||||
appVersion = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion,
|
||||
// 0 = Unknown. Was -1, which is NOT a valid DeviceClasses value - the client's
|
||||
// strict presence parser threw on the out-of-range enum and dropped the WHOLE
|
||||
// presence array, so a friends/player list built against it (offline friends all
|
||||
// get this default) came back EMPTY. Same class of bug as the statusVisibility=4
|
||||
// fix below; the reference emits `deviceClass ?? 0`.
|
||||
deviceClass = 0,
|
||||
isOnline = false,
|
||||
platform = 0,
|
||||
roomInstance = null,
|
||||
statusVisibility = 0, // 0 = Everyone (matches the reference); was 4 (out of range)
|
||||
vrMovementMode = 1, // reference default (`?? 1`); 0 was also fine but match it
|
||||
};
|
||||
|
||||
// Where we last DELIBERATELY put each player, held in memory alongside the DB row.
|
||||
//
|
||||
// The heartbeat answer is authoritative to the client: answer "nowhere" and it
|
||||
// decides it was disconnected and walks itself home to its dorm. So a room
|
||||
// instance that goes missing by ACCIDENT - a row that never got written, a lost
|
||||
// LiteDB update, a restart, a concurrent write - must not be reported as "nowhere".
|
||||
//
|
||||
// Only UpdatePlayerHeartbeat writes this, so an entry existing means "we put them
|
||||
// there on purpose and nothing has deliberately moved them since". A real logout or
|
||||
// ban clears the entry (it goes through UpdatePlayerHeartbeat with a null instance),
|
||||
// so intentional "nowhere" still answers nowhere.
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, RoomInstance> _placedIn = new();
|
||||
|
||||
// Last instance id we answered per player, purely so a CHANGE can be logged. A
|
||||
// change the player did not ask for is the drop, and this is the line that names it.
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, long> _lastAnswered = new();
|
||||
|
||||
// ---- Short-term memory that survives a mass drop -------------------------------------
|
||||
//
|
||||
// An instance only "exists" while some ONLINE heartbeat row points at it. That is fine
|
||||
// until the Photon master client (the first player into the instance) leaves: every other
|
||||
// client in that Photon room runs its master-switch recovery - a full logout + re-login,
|
||||
// then "join the player I was with" - and the drop itself has just wiped all of their
|
||||
// rows. So each recovering player asks about a friend whose row is now empty, gets
|
||||
// "target offline / not in a room", and lands in their dorm. Seen live 2026-09-11 22:12:
|
||||
// 6 leaves ^RecCenter -> 11 and 64 both reset within 6 s -> all three in dorms.
|
||||
//
|
||||
// These two maps remember, for a few minutes, where each player was LAST placed and
|
||||
// which instances were live, independent of the rows. They are consulted only when the
|
||||
// live answer is empty, so nothing changes for a player who is simply somewhere else.
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<long, (RoomInstance inst, DateTime seen)> _recentInstances = new();
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, (RoomInstance inst, DateTime seen)> _lastPlacement = new();
|
||||
/// <summary>How long after a drop a player's last instance is still offered for regrouping.</summary>
|
||||
public static readonly TimeSpan RegroupWindow = TimeSpan.FromMinutes(2);
|
||||
/// <summary>How long an instance is answerable by id after its last occupant's row is cleared.</summary>
|
||||
public static readonly TimeSpan InstanceMemory = TimeSpan.FromMinutes(15);
|
||||
|
||||
// The instance each player was in BEFORE their latest move, so "were these two just
|
||||
// together?" can be answered after one of them has already moved on (the party leader
|
||||
// has left the room by the time the member asks to follow).
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, (long instanceId, DateTime left)> _prevPlacement = new();
|
||||
|
||||
private static void Remember(ulong playerid, RoomInstance inst)
|
||||
{
|
||||
if (inst == null || inst.roomInstanceId == 0) return;
|
||||
var now = DateTime.UtcNow;
|
||||
if (_lastPlacement.TryGetValue(playerid, out var last) && last.inst.roomInstanceId != inst.roomInstanceId)
|
||||
_prevPlacement[playerid] = (last.inst.roomInstanceId, now);
|
||||
_recentInstances[inst.roomInstanceId] = (inst, now);
|
||||
_lastPlacement[playerid] = (inst, now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Were <paramref name="a"/> and <paramref name="b"/> standing in the same instance within
|
||||
/// the regroup window? True when a's current/last instance equals b's current OR the one b
|
||||
/// just left. This is what stands in for "same party" on a follow: the client keeps its
|
||||
/// party on the Photon side and never posts the member list, so being together a moment
|
||||
/// ago is the best evidence the server has.
|
||||
/// </summary>
|
||||
public static bool WereTogetherRecently(ulong a, ulong b)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow - RegroupWindow;
|
||||
long aInst = _lastPlacement.TryGetValue(a, out var la) && la.seen >= cutoff ? la.inst.roomInstanceId : 0;
|
||||
if (aInst == 0) return false;
|
||||
if (_lastPlacement.TryGetValue(b, out var lb) && lb.inst.roomInstanceId == aInst) return true;
|
||||
if (_prevPlacement.TryGetValue(b, out var pb) && pb.instanceId == aInst && pb.left >= cutoff) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>The instance this player was in within RegroupWindow, or null.</summary>
|
||||
public static RoomInstance? RecentInstanceFor(ulong playerid)
|
||||
{
|
||||
if (!_lastPlacement.TryGetValue(playerid, out var p)) return null;
|
||||
return DateTime.UtcNow - p.seen <= RegroupWindow ? p.inst : null;
|
||||
}
|
||||
|
||||
/// <summary>The most recently live instance of a room (public or private) within InstanceMemory, or null.</summary>
|
||||
public static RoomInstance? RecentInstanceOfRoomAny(ulong roomId)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow - InstanceMemory;
|
||||
return _recentInstances.Values
|
||||
.Where(v => v.seen >= cutoff && v.inst.roomId == (long)roomId && !string.IsNullOrEmpty(v.inst.photonRoomIdYeee))
|
||||
.OrderByDescending(v => v.seen)
|
||||
.Select(v => v.inst)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>The most recently live PUBLIC instance of a room within RegroupWindow, or null.</summary>
|
||||
public static RoomInstance? RecentInstanceOfRoom(ulong roomId)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow - RegroupWindow;
|
||||
return _recentInstances.Values
|
||||
.Where(v => v.seen >= cutoff && v.inst.roomId == (long)roomId && !v.inst.isPrivate
|
||||
&& !string.IsNullOrEmpty(v.inst.photonRoomIdYeee))
|
||||
.OrderByDescending(v => v.seen)
|
||||
.Select(v => v.inst)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this player has a stored heartbeat at all. Distinct from "has no room
|
||||
/// instance" - a player sitting in a menu has a row with a null instance.
|
||||
/// </summary>
|
||||
public static bool HasHeartbeatRow(ulong playerid) =>
|
||||
_col.FindOne(x => x.playerid == playerid) != null;
|
||||
|
||||
/// <summary>
|
||||
/// Read a player's presence WITHOUT the repair-writes/logging that GetPlayerHeartbeat does -
|
||||
/// for read-only surfaces like the admin players list that must not mutate state or spam the
|
||||
/// console when they enumerate everyone. Returns whether they're online and their current
|
||||
/// room instance (null if nowhere), honoring the in-memory placement fallback.
|
||||
/// </summary>
|
||||
public static (bool online, RoomInstance? room) PeekPresence(ulong playerid)
|
||||
{
|
||||
// Online = a LIVE notification WebSocket, not the stored isOnline flag. The flag goes
|
||||
// stale on a hard disconnect (shows "online" forever) and is unaffected by - but also
|
||||
// doesn't help with - "appear offline". The WebSocket is ground truth: it's opened on
|
||||
// connect, removed on disconnect, and is independent of the player's status visibility,
|
||||
// so the admin sees who is ACTUALLY connected even if they're appearing offline to
|
||||
// everyone else. If they're not connected, they're offline here regardless of the flag.
|
||||
bool online = CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(playerid);
|
||||
|
||||
var hb = _col.FindOne(x => x.playerid == playerid)?.Heartbeat;
|
||||
RoomInstance? room = hb?.roomInstance;
|
||||
if (room == null && _placedIn.TryGetValue(playerid, out var placed))
|
||||
room = placed;
|
||||
|
||||
// Only surface a room while actually online, so a stale room from a dropped session
|
||||
// doesn't read as "still in that room".
|
||||
return online ? (true, room) : (false, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presence of ANOTHER player as it should appear in someone else's friends/player list.
|
||||
/// Unlike GetPlayerHeartbeat (which is the player's OWN authoritative heartbeat and
|
||||
/// deliberately refuses to answer "nowhere"), this is what OTHERS see, so it is gated on a
|
||||
/// LIVE notification WebSocket - the same ground truth PeekPresence uses for the admin panel:
|
||||
/// * not actually connected -> isOnline=false, roomInstance=null (fixes "shows online in
|
||||
/// a room they aren't in" after a crash/close, where the stored row keeps isOnline=true
|
||||
/// and the last room forever, and _placedIn still holds the old instance);
|
||||
/// * connected but "appear offline" (statusVisibility 3 = No One) -> also reported offline.
|
||||
/// Side-effect-free: never writes the DB or restores _placedIn, so enumerating a whole
|
||||
/// friends list doesn't churn repair-writes or spam the console. Enum fields stay valid
|
||||
/// (appVersion/deviceClass/etc.) so the client's strict presence parser still accepts it.
|
||||
/// </summary>
|
||||
public static Heartbeat GetPresenceForList(ulong playerid, bool viewerIsSelf, bool viewerIsFriend, bool viewerIsFavorite, ulong viewerId = 0)
|
||||
{
|
||||
// An invite is consent to be seen: while a live invite from this player to the viewer
|
||||
// exists, the viewer sees them online and in their (private) instance no matter what
|
||||
// "Appear Online To" says. Without this the invitee's client refused the invite with
|
||||
// "Cannot accept invite. Target player is offline." whenever the inviter wasn't
|
||||
// visible to them (non-friend + Friends-only status, or Favorites-only).
|
||||
if (!viewerIsSelf && viewerId != 0 && viewerId != playerid
|
||||
&& Campus_live_rewrite.api.server.RoomInvites.HasInviteFromTo(playerid, viewerId))
|
||||
{
|
||||
viewerIsFriend = true;
|
||||
viewerIsFavorite = true;
|
||||
}
|
||||
var stored = _col.FindOne(x => x.playerid == playerid)?.Heartbeat;
|
||||
var hb = stored ?? CreateDefaultHeartbeat(playerid, Platforms.Steam, DeviceClasses.Screen);
|
||||
|
||||
hb.serverTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
if (string.IsNullOrEmpty(hb.lastOnline)) hb.lastOnline = DateTime.UtcNow.ToString("o");
|
||||
hb.appVersion = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion;
|
||||
|
||||
// Defensive clamp: a legacy row with an out-of-range deviceClass (-1) or vrMovementMode
|
||||
// makes the client's strict presence parser throw and drop the WHOLE friends-list array
|
||||
// (see the deviceClass=-1 trap). Never serve one from this read.
|
||||
if (hb.deviceClass < 0) hb.deviceClass = 0; // 0 = Unknown
|
||||
if (hb.vrMovementMode < 0) hb.vrMovementMode = 1;
|
||||
// statusVisibility is 0..3 on the client; the old default row carried 4, and one such
|
||||
// entry in a friends-list page made the client drop the WHOLE page ("some friends are
|
||||
// missing and their profile won't open"). Clamp on the way out too.
|
||||
if (hb.statusVisibility < 0 || hb.statusVisibility > 3) hb.statusVisibility = 0;
|
||||
|
||||
bool live = CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(playerid);
|
||||
|
||||
// "Appear Online To" (statusVisibility): 0 Everyone, 1 Friends, 2 Favorites, 3 No One.
|
||||
// The player always sees their OWN real presence; others see it only if the setting
|
||||
// allows THEM. Changing this in-game (PUT player/statusvisibility) therefore reflects to
|
||||
// friends within one presence poll. Unknown/legacy values are treated as Everyone.
|
||||
int vis = hb.statusVisibility;
|
||||
bool hiddenByStatus = !viewerIsSelf && (
|
||||
vis == 3
|
||||
|| (vis == 1 && !viewerIsFriend)
|
||||
|| (vis == 2 && !viewerIsFavorite));
|
||||
// A block hides presence both ways: neither of them sees the other online or where.
|
||||
if (!viewerIsSelf && viewerId != 0 && !hiddenByStatus && FriendsDB.IsBlockedEitherWay(viewerId, playerid))
|
||||
hiddenByStatus = true;
|
||||
|
||||
// online-in-no-room: a player whose presence says online but names no instance (just
|
||||
// kicked, mid-recovery) NREs the 2025 client's friend card (NotInSameInstanceHide
|
||||
// dereferences the instance when online) - the card renders blank and the profile
|
||||
// won't open. To everyone else that player reads as offline until they land somewhere.
|
||||
if (!viewerIsSelf && hb.isOnline && hb.roomInstance == null) hb.isOnline = false;
|
||||
|
||||
if (!live || hiddenByStatus)
|
||||
{
|
||||
hb.isOnline = false;
|
||||
hb.roomInstance = null;
|
||||
}
|
||||
// "Full" on the friends list / room card: computed from live presence, never stored.
|
||||
if (hb.roomInstance != null) hb.roomInstance.isFull = InstanceIsFull(hb.roomInstance);
|
||||
return hb;
|
||||
}
|
||||
|
||||
public static Heartbeat GetPlayerHeartbeat(ulong playerid)
|
||||
{
|
||||
var data = _col.FindOne(x => x.playerid == playerid);
|
||||
var hb = data?.Heartbeat;
|
||||
// own-heartbeat clamp: the same out-of-range guard GetPresenceForList applies
|
||||
if (hb != null)
|
||||
{
|
||||
if (hb.statusVisibility < 0 || hb.statusVisibility > 3) hb.statusVisibility = 0;
|
||||
if (hb.deviceClass < 0) hb.deviceClass = 0;
|
||||
if (hb.vrMovementMode < 0) hb.vrMovementMode = 1;
|
||||
}
|
||||
|
||||
if (hb == null)
|
||||
{
|
||||
hb = CreateDefaultHeartbeat(playerid, Platforms.Steam, DeviceClasses.Screen);
|
||||
|
||||
// No row at all. If we know where we put them, say that rather than
|
||||
// "nowhere", and write the row back so matchmaking agrees with us.
|
||||
if (_placedIn.TryGetValue(playerid, out var placed))
|
||||
{
|
||||
hb.roomInstance = placed;
|
||||
hb.isOnline = true;
|
||||
Console.WriteLine($"[heartbeat] {playerid}: no row - restored instance " +
|
||||
$"{placed.roomInstanceId} ({placed.name}) instead of answering 'nowhere'");
|
||||
try { UpdatePlayerHeartbeat(playerid, placed); } catch { }
|
||||
}
|
||||
}
|
||||
else if (hb.roomInstance == null && _placedIn.TryGetValue(playerid, out var placed))
|
||||
{
|
||||
// Row exists but has lost its room instance while we still believe the
|
||||
// player is somewhere. Nothing deliberate nulls it without clearing
|
||||
// _placedIn, so this is drift - repair it rather than sending them home.
|
||||
hb.roomInstance = placed;
|
||||
hb.isOnline = true;
|
||||
Console.WriteLine($"[heartbeat] {playerid}: row lost its instance - restored " +
|
||||
$"{placed.roomInstanceId} ({placed.name}) instead of answering 'nowhere'");
|
||||
try { UpdatePlayerHeartbeat(playerid, placed); } catch { }
|
||||
}
|
||||
|
||||
// serverTime was DECLARED and never assigned anywhere, so every presence
|
||||
// heartbeat this server has ever sent carried serverTime = 0 - i.e. 1970. Any
|
||||
// client-side check that compares our clock to its own sees a ~56 year skew.
|
||||
// Unix SECONDS, matching the JWT iat/exp the client already parses from our
|
||||
// tokens and the rest of this codebase (BanDB, moderation timestamps).
|
||||
hb.serverTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
// Also never set: null reads as "never seen", which is wrong for a player who
|
||||
// is heartbeating at us right now.
|
||||
hb.lastOnline = DateTime.UtcNow.ToString("o");
|
||||
|
||||
long now = hb.roomInstance?.roomInstanceId ?? 0;
|
||||
long prev = _lastAnswered.TryGetValue(playerid, out var p) ? p : 0;
|
||||
if (now != prev)
|
||||
{
|
||||
Console.WriteLine($"[heartbeat] {playerid}: answer changed {prev} -> {now} " +
|
||||
$"({hb.roomInstance?.name ?? "nowhere"})");
|
||||
_lastAnswered[playerid] = now;
|
||||
}
|
||||
|
||||
return hb;
|
||||
}
|
||||
|
||||
public static string GetPlayerHeartbeatRoomName(ulong playerid)
|
||||
{
|
||||
var data = _col.FindOne(x => x.playerid == playerid);
|
||||
return data?.Heartbeat?.roomInstance?.name ?? "nowhere!";
|
||||
}
|
||||
|
||||
public static bool CreatePlayerHeartbeat(ulong playerid, out Heartbeat heartbeat, Platforms platform, DeviceClasses deviceClasses)
|
||||
{
|
||||
if (_col.Exists(x => x.playerid == playerid))
|
||||
{
|
||||
heartbeat = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
heartbeat = CreateDefaultHeartbeat(playerid, platform, deviceClasses);
|
||||
_col.Insert(new PlayerHeartbeatData
|
||||
{
|
||||
iD = playerid, // keyed by playerid - see note on PlayerHeartbeatData.iD
|
||||
playerid = playerid,
|
||||
Heartbeat = heartbeat
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void UpdateVrMovementMode(ulong playerid, int mode)
|
||||
{
|
||||
var data = _col.FindOne(x => x.playerid == playerid);
|
||||
if (data?.Heartbeat == null) return;
|
||||
data.Heartbeat.vrMovementMode = mode;
|
||||
_col.Update(data);
|
||||
}
|
||||
|
||||
public static void UpdateStatusVisibility(ulong playerid, int visibility)
|
||||
{
|
||||
var data = _col.FindOne(x => x.playerid == playerid);
|
||||
if (data != null)
|
||||
{
|
||||
data.Heartbeat.statusVisibility = visibility;
|
||||
_col.Update(data);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Upsert: a player who changes their status before any heartbeat row exists used
|
||||
// to have the change silently dropped. Create the row so it persists (and so the
|
||||
// change is honored by GetPresenceForList right away).
|
||||
var hb = CreateDefaultHeartbeat(playerid, Platforms.Steam, DeviceClasses.Screen);
|
||||
hb.statusVisibility = visibility;
|
||||
_col.Insert(new PlayerHeartbeatData { iD = playerid, playerid = playerid, Heartbeat = hb });
|
||||
}
|
||||
Console.WriteLine($"[presence] {playerid} statusVisibility -> {visibility}");
|
||||
}
|
||||
|
||||
public static string GetPlayerCurrentRoomName(ulong playerid)
|
||||
{
|
||||
var data = _col.FindOne(x => x.playerid == playerid);
|
||||
return data?.Heartbeat?.roomInstance?.name ?? "not in a room.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a live room instance by its id, whoever is in it.
|
||||
///
|
||||
/// connection-info is asked about a SPECIFIC roomInstanceId and used to answer it
|
||||
/// from the caller's own heartbeat instead. That was survivable only while every
|
||||
/// public instance shared the Photon room name "room" - any mismatch still landed
|
||||
/// the player in the same place. With per-instance names a mismatch sends them to
|
||||
/// the wrong Photon room and the load dies with "disconnect during load".
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// The live instances of a room with who is standing in each - the owner's "Instances"
|
||||
/// tab (reference match GET /room/{id}/instances). Grouped by Photon room, which IS the
|
||||
/// instance as far as the client is concerned.
|
||||
/// </summary>
|
||||
public static List<(RoomInstance inst, List<ulong> players)> LiveInstancesOfRoom(ulong roomId)
|
||||
{
|
||||
var rows = _col.FindAll()
|
||||
.Where(x => x.Heartbeat != null && x.Heartbeat.isOnline && x.Heartbeat.roomInstance != null
|
||||
&& x.Heartbeat.roomInstance.roomId == (long)roomId
|
||||
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid))
|
||||
.ToList();
|
||||
return rows
|
||||
.GroupBy(x => string.IsNullOrEmpty(x.Heartbeat!.roomInstance!.photonRoomIdYeee) ? x.Heartbeat.roomInstance.roomInstanceId.ToString() : x.Heartbeat.roomInstance.photonRoomIdYeee!)
|
||||
.Select(g => (g.First().Heartbeat!.roomInstance!, g.Select(x => x.playerid).Distinct().ToList()))
|
||||
.OrderBy(t => t.Item1.roomInstanceId)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Change a flag on a live instance. Presence keeps a COPY of the instance on every
|
||||
/// player's row, so a change (private, in progress) has to land on each of them or the
|
||||
/// next heartbeat serves the old value back.
|
||||
/// </summary>
|
||||
public static int UpdateInstanceEverywhere(long roomInstanceId, Action<RoomInstance> edit)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var row in _col.FindAll().Where(x => x.Heartbeat?.roomInstance != null && x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId).ToList())
|
||||
{
|
||||
edit(row.Heartbeat!.roomInstance!);
|
||||
_col.Update(row);
|
||||
n++;
|
||||
}
|
||||
if (_recentInstances.TryGetValue(roomInstanceId, out var r)) edit(r.inst);
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-off repair of stored presence rows that carry values the client's strict parser
|
||||
/// refuses (statusVisibility 4 from the old default, deviceClass -1). Returns how many.
|
||||
/// </summary>
|
||||
public static int FixOutOfRangePresence()
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var row in _col.FindAll().ToList())
|
||||
{
|
||||
var hb = row.Heartbeat;
|
||||
if (hb == null) continue;
|
||||
bool changed = false;
|
||||
if (hb.statusVisibility < 0 || hb.statusVisibility > 3) { hb.statusVisibility = 0; changed = true; }
|
||||
if (hb.deviceClass < 0) { hb.deviceClass = 0; changed = true; }
|
||||
if (hb.vrMovementMode < 0) { hb.vrMovementMode = 1; changed = true; }
|
||||
if (changed) { _col.Update(row); n++; }
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
public static RoomInstance? FindInstanceById(long roomInstanceId)
|
||||
{
|
||||
if (roomInstanceId == 0) return null;
|
||||
|
||||
var live = _col.FindAll()
|
||||
.Where(x => x.Heartbeat?.roomInstance != null
|
||||
&& x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId)
|
||||
.Select(x => x.Heartbeat!.roomInstance!)
|
||||
.FirstOrDefault();
|
||||
if (live != null) return live;
|
||||
|
||||
// Nobody's row points at it right now - but if it was live minutes ago, a client that
|
||||
// still holds the id (a recovering one) can be sent back to the same Photon room.
|
||||
if (_recentInstances.TryGetValue(roomInstanceId, out var r) && DateTime.UtcNow - r.seen <= InstanceMemory)
|
||||
return r.inst;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A LIVE instance of a given room, whoever is in it - the same (name, isPrivate) the admin
|
||||
/// panel shows. Used by the Discord-presence room lookup so it reflects the instance the
|
||||
/// player is actually in (works for dorms, which the static room API doesn't describe the
|
||||
/// same way). Returns null if nobody is in an instance of that room right now.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// A player's OWN current room + their status-visibility, straight from their heartbeat row
|
||||
/// (no WebSocket gating - the caller IS the player, so they're online by definition). Used
|
||||
/// by the Discord-presence self endpoint: statusVisibility decides whether the room may be
|
||||
/// shown publicly at all, roomInstance is where they are. Honors the placement fallback.
|
||||
/// </summary>
|
||||
public static (RoomInstance? room, int statusVisibility) GetSelfPresence(ulong playerid)
|
||||
{
|
||||
var hb = _col.FindOne(x => x.playerid == playerid)?.Heartbeat;
|
||||
RoomInstance? room = hb?.roomInstance;
|
||||
if (room == null && _placedIn.TryGetValue(playerid, out var placed)) room = placed;
|
||||
return (room, hb?.statusVisibility ?? 0);
|
||||
}
|
||||
|
||||
public static RoomInstance? FindInstanceByRoomId(long roomId)
|
||||
{
|
||||
if (roomId == 0) return null;
|
||||
|
||||
return _col.FindAll()
|
||||
.Where(x => x.Heartbeat?.roomInstance != null
|
||||
&& x.Heartbeat.roomInstance.roomId == roomId)
|
||||
.Select(x => x.Heartbeat!.roomInstance!)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a public instance of <paramref name="roomId"/> that someone is already in
|
||||
/// and that still has space, so a second player joining the same room LANDS WITH
|
||||
/// THEM instead of in a private world of their own.
|
||||
///
|
||||
/// This is required by the unique Photon room names in Sessions.CreateRoom. While
|
||||
/// every public instance shared the constant name "room", players met by accident -
|
||||
/// and also met everyone in every other room, which was the bug. Now that names are
|
||||
/// per-instance, matchmaking has to deliberately reuse an instance for people to
|
||||
/// meet at all.
|
||||
///
|
||||
/// Returns null when nobody is in the room, or every instance is full - the caller
|
||||
/// then creates a fresh one.
|
||||
/// </summary>
|
||||
public static RoomInstance? FindJoinableInstance(ulong roomId, ulong excludePlayerId = 0)
|
||||
{
|
||||
var occupants = _col.FindAll()
|
||||
.Where(x => x.playerid != excludePlayerId
|
||||
&& x.Heartbeat != null
|
||||
&& x.Heartbeat.isOnline
|
||||
&& x.Heartbeat.roomInstance != null
|
||||
&& x.Heartbeat.roomInstance.roomId == (long)roomId
|
||||
&& !x.Heartbeat.roomInstance.isPrivate
|
||||
&& !string.IsNullOrEmpty(x.Heartbeat.roomInstance.photonRoomIdYeee)
|
||||
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid)
|
||||
// an instance they were kicked out of is not offered to them again
|
||||
&& !server.InstanceBlocks.IsBlocked(excludePlayerId, x.Heartbeat.roomInstance))
|
||||
.Select(x => x.Heartbeat.roomInstance!)
|
||||
.ToList();
|
||||
|
||||
if (occupants.Count == 0)
|
||||
{
|
||||
// Nobody recorded in the room - but if an instance of it was live moments ago, this
|
||||
// is most likely a player recovering from the mass drop above. Put them back in the
|
||||
// same Photon room so the group re-forms instead of each opening a fresh instance.
|
||||
var recent = RecentInstanceOfRoom(roomId);
|
||||
if (recent != null && server.InstanceBlocks.IsBlocked(excludePlayerId, recent)) recent = null;
|
||||
if (recent != null)
|
||||
Console.WriteLine($"[matchmake] regrouping into recent instance {recent.roomInstanceId} of room {roomId} (photon {recent.photonRoomIdYeee})");
|
||||
return recent;
|
||||
}
|
||||
|
||||
// Group by the Photon room - that IS the instance as far as the client is
|
||||
// concerned - and pick the fullest one with space, so players gather together
|
||||
// rather than scattering one-per-instance.
|
||||
var best = occupants
|
||||
.GroupBy(r => r.photonRoomIdYeee)
|
||||
.Select(g => new { Instance = g.First(), Count = g.Count() })
|
||||
.Where(g => g.Count < EffectiveCapacity(g.Instance))
|
||||
.OrderByDescending(g => g.Count)
|
||||
.FirstOrDefault();
|
||||
|
||||
return best?.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live player count per ROOM id, in one pass over presence - what Room.CCU carries.
|
||||
/// Rooms with nobody in them are simply absent from the map.
|
||||
/// </summary>
|
||||
private static Dictionary<long, int> _ccuCache = new();
|
||||
private static DateTime _ccuCacheAt = DateTime.MinValue;
|
||||
|
||||
public static Dictionary<long, int> LivePlayerCountsByRoom()
|
||||
{
|
||||
// Cached for a few seconds: this is called once per room response (including the hot
|
||||
// list) and a full presence scan per request would be the expensive part of it.
|
||||
if ((DateTime.UtcNow - _ccuCacheAt).TotalSeconds < 5) return _ccuCache;
|
||||
|
||||
var counts = new Dictionary<long, int>();
|
||||
foreach (var x in _col.FindAll())
|
||||
{
|
||||
var inst = x?.Heartbeat?.roomInstance;
|
||||
if (x?.Heartbeat == null || !x.Heartbeat.isOnline || inst == null) continue;
|
||||
counts[inst.roomId] = counts.TryGetValue(inst.roomId, out var n) ? n + 1 : 1;
|
||||
}
|
||||
_ccuCache = counts; _ccuCacheAt = DateTime.UtcNow;
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The cap that actually applies to an instance: the subroom's CURRENT MaxPlayers (what
|
||||
/// the room settings show), falling back to the value stamped when the instance was
|
||||
/// opened, and finally 4 like the reference. Read live so an owner raising the cap in
|
||||
/// room settings frees the running instance instead of it staying "full" at the old
|
||||
/// number. Cached briefly - this runs on every heartbeat.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<(long, long), (int cap, DateTime at)> _capCache = new();
|
||||
public static int EffectiveCapacity(RoomInstance? inst)
|
||||
{
|
||||
if (inst == null) return 0;
|
||||
int fromSub = 0;
|
||||
var key = (inst.roomId, inst.subRoomId);
|
||||
lock (_capCache)
|
||||
{
|
||||
if (_capCache.TryGetValue(key, out var c) && (DateTime.UtcNow - c.at).TotalSeconds < 20) fromSub = c.cap;
|
||||
else
|
||||
{
|
||||
try { fromSub = RoomDB.GetSubRoom((ulong)inst.roomId, (ulong)inst.subRoomId)?.MaxPlayers ?? 0; } catch { fromSub = 0; }
|
||||
_capCache[key] = (fromSub, DateTime.UtcNow);
|
||||
if (_capCache.Count > 5000) _capCache.Clear();
|
||||
}
|
||||
}
|
||||
if (fromSub > 0) return fromSub;
|
||||
return inst.maxCapacity > 0 ? inst.maxCapacity : 4;
|
||||
}
|
||||
|
||||
/// <summary>Whether the instance is at its player cap (the subroom's MaxPlayers).</summary>
|
||||
public static bool InstanceIsFull(RoomInstance? inst)
|
||||
{
|
||||
if (inst == null) return false;
|
||||
int cap = EffectiveCapacity(inst);
|
||||
inst.maxCapacity = cap;
|
||||
return cap > 0 && CountInInstance(inst.roomInstanceId) >= cap;
|
||||
}
|
||||
|
||||
/// <summary>How many online players' presence points at this instance.</summary>
|
||||
// live-connection gate: a presence row stays "online" after a crash / closed game until
|
||||
// the player logs in again, so ghosts piled up in an instance and it read as full while
|
||||
// half the slots were empty. Only a player holding a live notification socket counts.
|
||||
/// <summary>Players in any instance of this room right now (live websocket only).</summary>
|
||||
public static int CountInRoom(ulong roomId) =>
|
||||
_col.FindAll().Count(x => x.Heartbeat != null && x.Heartbeat.isOnline
|
||||
&& x.Heartbeat.roomInstance != null
|
||||
&& x.Heartbeat.roomInstance.roomId == (long)roomId
|
||||
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid));
|
||||
|
||||
public static int CountInInstance(long roomInstanceId) =>
|
||||
_col.FindAll().Count(x => x.Heartbeat != null && x.Heartbeat.isOnline
|
||||
&& x.Heartbeat.roomInstance != null
|
||||
&& x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId
|
||||
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid));
|
||||
|
||||
public static void ClearPlayersHeartbeats()
|
||||
{
|
||||
foreach (var data in _col.FindAll())
|
||||
{
|
||||
UpdatePlayerHeartbeat(data.playerid, null, false);
|
||||
}
|
||||
}
|
||||
|
||||
public static void ClearPlayerHeartbeat(ulong playerId) =>
|
||||
UpdatePlayerHeartbeat(playerId, null, false);
|
||||
|
||||
/// <summary>
|
||||
/// When this player was last connected, from the STORED row (GetPlayerHeartbeat rewrites
|
||||
/// lastOnline to "now" on every read, so it can't be used for this). Stamped by
|
||||
/// StampLastSeen on every placement and on notification-socket close, so for an offline
|
||||
/// player it is the moment they disconnected. Null when never seen.
|
||||
/// </summary>
|
||||
public static string? LastSeen(ulong playerid) => _col.FindOne(x => x.playerid == playerid)?.Heartbeat?.lastOnline;
|
||||
|
||||
public static void StampLastSeen(ulong playerid)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = _col.FindOne(x => x.playerid == playerid);
|
||||
if (data?.Heartbeat == null) return;
|
||||
data.Heartbeat.lastOnline = DateTime.UtcNow.ToString("o");
|
||||
_col.Update(data);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void UpdatePlayerHeartbeat(
|
||||
ulong playerid,
|
||||
RoomInstance? roomInstance,
|
||||
bool online = true,
|
||||
Platforms platform = Platforms.All,
|
||||
DeviceClasses deviceClasses = DeviceClasses.Unknown)
|
||||
{
|
||||
// Record the DELIBERATE placement before touching the DB. Everything that
|
||||
// moves a player goes through here, so this is the one place that can tell
|
||||
// "we put them nowhere" apart from "they fell off the record by accident".
|
||||
// See _placedIn on GetPlayerHeartbeat.
|
||||
if (online && roomInstance != null) { _placedIn[playerid] = roomInstance; Remember(playerid, roomInstance); }
|
||||
else _placedIn.TryRemove(playerid, out _);
|
||||
|
||||
var data = _col.FindOne(x => x.playerid == playerid);
|
||||
|
||||
// Capture the prior state so we only push a realtime presence update to friends when it
|
||||
// ACTUALLY changed (online<->offline, or a different room instance) - the periodic
|
||||
// heartbeat doesn't come through here, but matchmake/join/dorm/ban all do, some with the
|
||||
// same instance repeatedly.
|
||||
long oldInstance = data?.Heartbeat?.roomInstance?.roomInstanceId ?? 0;
|
||||
bool wasOnline = data?.Heartbeat?.isOnline ?? false;
|
||||
|
||||
if (data != null)
|
||||
{
|
||||
var hb = data.Heartbeat;
|
||||
hb.roomInstance = online ? roomInstance : null;
|
||||
hb.isOnline = online;
|
||||
hb.lastOnline = DateTime.UtcNow.ToString("o"); // see LastSeen
|
||||
// Correct any row still carrying the old hardcoded build, so existing players stop
|
||||
// showing as a version mismatch on their next heartbeat (not just fresh rows).
|
||||
hb.appVersion = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion;
|
||||
|
||||
_col.Update(data);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create a new heartbeat and assign the current roomInstance
|
||||
var hb = CreateDefaultHeartbeat(playerid, platform, deviceClasses);
|
||||
hb.roomInstance = roomInstance;
|
||||
hb.isOnline = online;
|
||||
|
||||
_col.Insert(new PlayerHeartbeatData
|
||||
{
|
||||
iD = playerid, // keyed by playerid - see note on PlayerHeartbeatData.iD
|
||||
playerid = playerid,
|
||||
Heartbeat = hb
|
||||
});
|
||||
}
|
||||
|
||||
long newInstance = (online ? roomInstance?.roomInstanceId : null) ?? 0;
|
||||
if (wasOnline != online || oldInstance != newInstance)
|
||||
{
|
||||
Campus_live_rewrite.api.server.Notifications.FireAndForget(
|
||||
Campus_live_rewrite.api.server.Notifications.PushPresenceToFriends(playerid),
|
||||
$"presence-change {playerid}");
|
||||
}
|
||||
}
|
||||
|
||||
public class Heartbeat
|
||||
{
|
||||
public long serverTime { get; set; }
|
||||
public long playerId { get; set; }
|
||||
public int statusVisibility { get; set; }
|
||||
public int platform { get; set; }
|
||||
public int deviceClass { get; set; }
|
||||
public int vrMovementMode { get; set; }
|
||||
public RoomInstance roomInstance { get; set; } // Can be null
|
||||
public string lastOnline { get; set; } // ISO 8601 string
|
||||
public bool isOnline { get; set; }
|
||||
public string appVersion { get; set; }
|
||||
}
|
||||
|
||||
public class FullRoomInstance
|
||||
{
|
||||
public RoomInstance? roomInstance { get; set; }
|
||||
public string? correlationId { get; set; }
|
||||
}
|
||||
|
||||
public class RoomInstance
|
||||
{
|
||||
public long roomInstanceId { get; set; }
|
||||
public long roomId { get; set; }
|
||||
public long subRoomId { get; set; }
|
||||
public string? location { get; set; }
|
||||
public int roomInstanceType { get; set; }
|
||||
public string? name { get; set; }
|
||||
// The event/club this instance belongs to (0 = none). The client reads eventId to
|
||||
// show the "This Event" tab on the room panel when you're in an event instance
|
||||
// (mirrors the reference RoomInstanceDto's eventId/clubId).
|
||||
public long eventId { get; set; }
|
||||
public long clubId { get; set; }
|
||||
public int maxCapacity { get; set; }
|
||||
public bool isFull { get; set; }
|
||||
public bool isPrivate { get; set; }
|
||||
public bool isInProgress { get; set; }
|
||||
[JsonIgnore]
|
||||
public string? photonRoomIdYeee { get; set; } // im crack
|
||||
public string? voiceServerId { get; set; }
|
||||
public string? voiceAuthId { get; set; }
|
||||
public int matchmakingPolicy { get; set; }
|
||||
|
||||
// Whether the Photon Voice stream is encrypted. RecFlare sets this to false on every
|
||||
// room instance, and it MUST be present: absent, the client falls back to its own
|
||||
// default (encrypt), and then peers encrypt their voice with no shared key exchange
|
||||
// set up - they connect fine but hear each other as silence, which is exactly the
|
||||
// "mic works, nobody can hear you" symptom. false = plain voice, everyone hears everyone.
|
||||
public bool EncryptVoiceChat { get; set; } = false;
|
||||
}
|
||||
|
||||
public enum MatchmakingErrorCode
|
||||
{
|
||||
Success,
|
||||
NoSuchGame,
|
||||
PlayerNotOnline,
|
||||
InsufficientSpace,
|
||||
EventNotStarted,
|
||||
EventAlreadyFinished,
|
||||
EventCreatorNotReady,
|
||||
BlockedFromRoom,
|
||||
ProfileLocked,
|
||||
NoBirthday,
|
||||
MarkedForDelete,
|
||||
JuniorNotAllowed,
|
||||
Banned,
|
||||
AlreadyInBestInstance,
|
||||
InsufficientRelationship,
|
||||
UpdateRequired = 16,
|
||||
AlreadyInTargetInstance,
|
||||
RegistrationRequired,
|
||||
UGCNotAllowed,
|
||||
NoSuchRoom,
|
||||
RoomCreatorNotReady,
|
||||
RoomIsNotActive,
|
||||
RoomBlockedByCreator,
|
||||
RoomBlockingCreator,
|
||||
RoomIsPrivate,
|
||||
RoomInstanceIsPrivate,
|
||||
DeviceClassNotSupported = 30,
|
||||
DeviceClassNotSupportedByRoomOwner,
|
||||
VRMovementModeNotSupportedByRoomOwner,
|
||||
EventIsPrivate = 35,
|
||||
RoomInviteExpired = 40,
|
||||
NoAvailableRegion = 45,
|
||||
NotorietyTooPoor = 50,
|
||||
BannedFromRoom = 55
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
using LiteDB;
|
||||
using Newtonsoft.Json;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public static class ImageMetadataDB
|
||||
{
|
||||
private static readonly string DbPath = Path.Combine("Data", "DBs", "Images.db");
|
||||
|
||||
public class FullSavedImage
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public ImageAccessibility Accessibility { get; set; }
|
||||
public bool AccessibilityLocked { get; set; } = false;
|
||||
public int CheerCount { get; set; } = 0;
|
||||
public int CommentCount { get; set; } = 0;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public string? Description { get; set; }
|
||||
public string ImageName { get; set; }
|
||||
public ulong PlayerEventId { get; set; }
|
||||
public ulong PlayerId { get; set; }
|
||||
public int RoomId { get; set; }
|
||||
//public string Description { get; set; } = "";
|
||||
public SavedImageTypeEnum Type { get; set; }
|
||||
public List<ulong> TaggedPlayerIds { get; set; } = new();
|
||||
// Website-only extra state. The game reads Accessibility (Private/Public) and never sees
|
||||
// this - a friends-only photo is stored Accessibility=Private (safe in-game), FriendsOnly=true,
|
||||
// and the WEBSITE shows it to the owner's friends.
|
||||
public bool FriendsOnly { get; set; } = false;
|
||||
}
|
||||
|
||||
// NOTE: the client's v6 record keys the id as SavedImageId and the type as SavedImageType
|
||||
// (wire-shapes: api-images-v6). `Id`/`Type` are dropped silently by Utf8Json, which left every
|
||||
// v6 lookup with SavedImageId 0.
|
||||
public class ImageV6
|
||||
{
|
||||
public long SavedImageId { get; set; }
|
||||
public SavedImageTypeEnum SavedImageType { get; set; }
|
||||
public ulong ClubId { get; set; }
|
||||
|
||||
public ImageAccessibility Accessibility { get; set; }
|
||||
public bool AccessibilityLocked { get; set; } = false;
|
||||
public int CheerCount { get; set; } = 0;
|
||||
public int CommentCount { get; set; } = 0;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public string? Description { get; set; }
|
||||
public int Id { get; set; }
|
||||
public string ImageName { get; set; }
|
||||
public ulong PlayerEventId { get; set; }
|
||||
public ulong PlayerId { get; set; }
|
||||
public int RoomId { get; set; }
|
||||
public List<ulong> TaggedPlayerIds { get; set; } = new();
|
||||
public SavedImageTypeEnum Type { get; set; }
|
||||
}
|
||||
|
||||
public class ImagesPlayer
|
||||
{
|
||||
public ImageAccessibility Accessibility { get; set; }
|
||||
public bool AccessibilityLocked { get; set; } = false;
|
||||
public int CheerCount { get; set; } = 0;
|
||||
public int CommentCount { get; set; } = 0;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public string? Description { get; set; }
|
||||
public string ImageName { get; set; }
|
||||
public ulong PlayerEventId { get; set; }
|
||||
public ulong PlayerId { get; set; }
|
||||
public int RoomId { get; set; }
|
||||
public int SavedImageId { get; set; }
|
||||
public SavedImageTypeEnum SavedImageType { get; set; }
|
||||
public List<ulong> TaggedPlayerIds { get; set; } = new();
|
||||
public bool FriendsOnly { get; set; } = false;
|
||||
}
|
||||
|
||||
public class ImageMetadata
|
||||
{
|
||||
public List<ulong> playerIds { get; set; } = new();
|
||||
public SavedImageTypeEnum savedImageType { get; set; }
|
||||
public int roomId { get; set; }
|
||||
public ImageAccessibility accessibility { get; set; }
|
||||
public string? description { get; set; }
|
||||
}
|
||||
|
||||
public enum SavedImageTypeEnum
|
||||
{
|
||||
None, // 0
|
||||
ShareCamera, // 1
|
||||
OutfitThumbnail, // 2
|
||||
RoomThumbnail, // 3
|
||||
ProfileThumbnail, // 4
|
||||
InventionThumbnail, // 5
|
||||
PlayerEventThumbnail, // 6
|
||||
RoomLoadScreen // 7
|
||||
}
|
||||
|
||||
public enum ImageAccessibility
|
||||
{
|
||||
Private = 0,
|
||||
Public = 1,
|
||||
}
|
||||
|
||||
public class Cheer
|
||||
{
|
||||
public ObjectId Id { get; set; }
|
||||
public int SavedImageId { get; set; }
|
||||
public ulong PlayerId { get; set; }
|
||||
}
|
||||
|
||||
public class CheerRequest
|
||||
{
|
||||
public int SavedImageId { get; set; }
|
||||
public bool Cheer { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A website comment on a photo. CommentCount on the image is kept in step.</summary>
|
||||
public class ImageComment
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int SavedImageId { get; set; }
|
||||
public ulong PlayerId { get; set; }
|
||||
public string Text { get; set; } = "";
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static FullSavedImage? GetByName(string name) =>
|
||||
string.IsNullOrWhiteSpace(name) ? null : GetDb().GetCollection<FullSavedImage>("images").FindOne(x => x.ImageName == name);
|
||||
|
||||
public static ImageComment? GetComment(int commentId) =>
|
||||
GetDb().GetCollection<ImageComment>("comments").FindById(commentId);
|
||||
|
||||
public static List<ImageComment> GetComments(int savedImageId) =>
|
||||
GetDb().GetCollection<ImageComment>("comments").Find(x => x.SavedImageId == savedImageId).OrderBy(x => x.CreatedAt).ToList();
|
||||
|
||||
public static ImageComment? AddComment(int savedImageId, ulong playerId, string text)
|
||||
{
|
||||
var db = GetDb();
|
||||
if (db.GetCollection<FullSavedImage>("images").FindById(savedImageId) == null) return null;
|
||||
var c = new ImageComment { SavedImageId = savedImageId, PlayerId = playerId, Text = text, CreatedAt = DateTime.UtcNow };
|
||||
db.GetCollection<ImageComment>("comments").Insert(c);
|
||||
RecountComments(savedImageId);
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>Delete a comment: its author, or the photo's owner, may.</summary>
|
||||
public static bool DeleteComment(int commentId, ulong byPlayer)
|
||||
{
|
||||
var db = GetDb();
|
||||
var col = db.GetCollection<ImageComment>("comments");
|
||||
var c = col.FindById(commentId);
|
||||
if (c == null) return false;
|
||||
var img = db.GetCollection<FullSavedImage>("images").FindById(c.SavedImageId);
|
||||
if (c.PlayerId != byPlayer && (img == null || img.PlayerId != byPlayer)) return false;
|
||||
col.Delete(commentId);
|
||||
RecountComments(c.SavedImageId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void RecountComments(int savedImageId)
|
||||
{
|
||||
var db = GetDb();
|
||||
var col = db.GetCollection<FullSavedImage>("images");
|
||||
var img = col.FindById(savedImageId);
|
||||
if (img == null) return;
|
||||
img.CommentCount = db.GetCollection<ImageComment>("comments").Count(x => x.SavedImageId == savedImageId);
|
||||
col.Update(img);
|
||||
}
|
||||
|
||||
// PERF: Keep a thread-safe singleton LiteDatabase instance to avoid reopening files constantly.
|
||||
private static readonly Lazy<LiteDatabase> _dbInstance = new(() => new LiteDatabase(DbPath));
|
||||
public static LiteDatabase GetDb() => _dbInstance.Value;
|
||||
|
||||
public static FullSavedImage InsertImage(FullSavedImage image)
|
||||
{
|
||||
var db = GetDb();
|
||||
var col = db.GetCollection<FullSavedImage>("images");
|
||||
col.EnsureIndex(x => x.Id, true);
|
||||
|
||||
// If the image doesn't already have an ID, assign the next sequential ID.
|
||||
if (image.Id == 0)
|
||||
{
|
||||
// Get the highest existing ID and increment by 1.
|
||||
// PERF: This uses descending order to get the latest ID efficiently.
|
||||
var last = col.Query()
|
||||
.OrderByDescending(x => x.Id)
|
||||
.Limit(1)
|
||||
.FirstOrDefault();
|
||||
|
||||
image.Id = (last?.Id ?? 0) + 1;
|
||||
}
|
||||
|
||||
col.Insert(image);
|
||||
return image;
|
||||
}
|
||||
|
||||
|
||||
public static List<FullSavedImage> GetRoomImages(
|
||||
ulong playerId,
|
||||
int roomId,
|
||||
int sort = 0,
|
||||
int filter = 0,
|
||||
int take = 50,
|
||||
int skip = 0)
|
||||
{
|
||||
var db = GetDb();
|
||||
var col = db.GetCollection<FullSavedImage>("images");
|
||||
|
||||
// Base query: only images in this room
|
||||
var query = col.Query()
|
||||
.Where(x => x.RoomId == roomId);
|
||||
|
||||
// Apply filter conditions
|
||||
switch (filter)
|
||||
{
|
||||
case 2: // Only show player's private images
|
||||
query = query.Where(x => x.PlayerId == playerId);
|
||||
break;
|
||||
|
||||
case 3: // Only images that have been cheered
|
||||
query = query.Where(x => x.CheerCount > 0);
|
||||
break;
|
||||
|
||||
// filter == 1 will be handled in sorting
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (sort == 0 && filter == 1)
|
||||
{
|
||||
// Special case: newest first when sort=0 & filter=1
|
||||
query = query.OrderByDescending(x => x.CreatedAt);
|
||||
}
|
||||
else
|
||||
{
|
||||
query = sort switch
|
||||
{
|
||||
1 => query.OrderByDescending(x => x.CreatedAt), // Newest first
|
||||
2 => query.OrderByDescending(x => x.CheerCount), // Most cheered
|
||||
3 => query.OrderByDescending(x => x.CommentCount),// Most commented
|
||||
_ => query.OrderBy(x => x.CreatedAt), // Default: oldest first
|
||||
};
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
return query
|
||||
.Skip(skip)
|
||||
.Limit(take)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
public static List<FullSavedImage> GetGlobalImages()
|
||||
{
|
||||
var db = GetDb();
|
||||
var col = db.GetCollection<FullSavedImage>("images");
|
||||
|
||||
return col.Query()
|
||||
.Where(x => x.Accessibility == ImageAccessibility.Public)
|
||||
.OrderByDescending(x => x.CheerCount)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
public const int SlideshowLimit = 10;
|
||||
public const int SlideshowMaxLimit = 100;
|
||||
|
||||
/// <summary>
|
||||
/// The global slideshow feed: the most recent PUBLIC ShareCamera photos across all rooms,
|
||||
/// newest first. Ported from RecFlare's getSlideshowImages. Public-only (Accessibility ==
|
||||
/// Public) because this endpoint is unauthenticated - it must never surface a private photo.
|
||||
/// The caller joins Username + RoomName on top of these rows.
|
||||
/// </summary>
|
||||
public static List<FullSavedImage> GetSlideshowImages(int limit)
|
||||
{
|
||||
if (limit <= 0) limit = SlideshowLimit;
|
||||
if (limit > SlideshowMaxLimit) limit = SlideshowMaxLimit;
|
||||
|
||||
var db = GetDb();
|
||||
return db.GetCollection<FullSavedImage>("images")
|
||||
.Query()
|
||||
.Where(x => x.Accessibility == ImageAccessibility.Public
|
||||
&& x.Type == SavedImageTypeEnum.ShareCamera)
|
||||
.OrderByDescending(x => x.Id)
|
||||
.Limit(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The website "Recent Photos" feed: the newest PUBLIC camera photos across all rooms.
|
||||
/// Like GetSlideshowImages but ALSO includes Type == None, because this server's camera
|
||||
/// photos are saved as None (only ShareCamera would match nothing - the home feed was empty).
|
||||
/// Still excludes thumbnails (Outfit/Room/Profile/Invention/PlayerEvent/RoomLoadScreen) so
|
||||
/// profile pictures and room thumbnails never appear as "photos". Public-only (unauthenticated).
|
||||
/// </summary>
|
||||
// Public camera photos taken in one room, newest first - the website room page. Filters
|
||||
// in the query (not after a Take) so a room full of thumbnails can't crowd real photos
|
||||
// out of the window; None is included because this server saves camera photos as None.
|
||||
public static List<FullSavedImage> GetRoomPublicPhotos(int roomId, int limit)
|
||||
{
|
||||
if (limit <= 0) limit = 24;
|
||||
var db = GetDb();
|
||||
return db.GetCollection<FullSavedImage>("images")
|
||||
.Query()
|
||||
.Where(x => x.RoomId == roomId
|
||||
&& x.Accessibility == ImageAccessibility.Public
|
||||
&& (x.Type == SavedImageTypeEnum.None || x.Type == SavedImageTypeEnum.ShareCamera))
|
||||
.OrderByDescending(x => x.Id)
|
||||
.Limit(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static List<FullSavedImage> GetRecentPublicPhotos(int limit)
|
||||
{
|
||||
if (limit <= 0) limit = SlideshowLimit;
|
||||
if (limit > SlideshowMaxLimit) limit = SlideshowMaxLimit;
|
||||
|
||||
// Only the LAST 24 HOURS - "Recent Photos" should be genuinely recent, not a months-deep
|
||||
// archive. Cutoff computed here (not inline) so LiteDB compares against a fixed value.
|
||||
// CreatedAt is stored UTC (DateTime.UtcNow default), so compare in UTC.
|
||||
var cutoff = DateTime.UtcNow.AddHours(-24);
|
||||
|
||||
var db = GetDb();
|
||||
return db.GetCollection<FullSavedImage>("images")
|
||||
.Query()
|
||||
.Where(x => x.Accessibility == ImageAccessibility.Public
|
||||
&& (x.Type == SavedImageTypeEnum.None || x.Type == SavedImageTypeEnum.ShareCamera)
|
||||
&& x.CreatedAt >= cutoff)
|
||||
.OrderByDescending(x => x.Id)
|
||||
.Limit(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Every saved photo, newest first - for the admin gallery (no accessibility filter).</summary>
|
||||
public static List<FullSavedImage> GetAllImages()
|
||||
{
|
||||
var db = GetDb();
|
||||
return db.GetCollection<FullSavedImage>("images")
|
||||
.Query()
|
||||
.OrderByDescending(x => x.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes one photo: its metadata row, its cheers, and the image file on disk. Returns
|
||||
/// the deleted row (for logging), or null if there was no such photo.
|
||||
/// </summary>
|
||||
public static FullSavedImage? DeleteImage(int id)
|
||||
{
|
||||
var db = GetDb();
|
||||
var imageCol = db.GetCollection<FullSavedImage>("images");
|
||||
var img = imageCol.FindById(id);
|
||||
if (img == null) return null;
|
||||
|
||||
imageCol.Delete(id);
|
||||
db.GetCollection<Cheer>("cheers").DeleteMany(c => c.SavedImageId == id);
|
||||
db.GetCollection<ImageComment>("comments").DeleteMany(c => c.SavedImageId == id);
|
||||
|
||||
// Remove the file from wherever /img serves polaroids from. Best-effort: the metadata
|
||||
// is already gone, so a stray file just 404s and is invisible.
|
||||
if (!string.IsNullOrWhiteSpace(img.ImageName))
|
||||
{
|
||||
foreach (var dir in new[] { "polaroid_images", "player_images" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(Environment.CurrentDirectory, "Data", "Images", dir, Path.GetFileName(img.ImageName));
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) { Console.WriteLine($"[Delete] could not remove file {img.ImageName}: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[Delete] removed photo {id} '{img.ImageName}' taken by {img.PlayerId}");
|
||||
return img;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A player's photos. `sort`: 0 newest first (default), 1 most cheered (the client's
|
||||
/// "sort by cheers" toggle, same value the reference uses), 2 oldest first. Was unsorted -
|
||||
/// LiteDB's natural order is insertion order, i.e. oldest first, and the toggle did nothing.
|
||||
/// </summary>
|
||||
public static List<ImagesPlayer> GetPlayerImages(ulong playeridthatrequesting, ulong playerid, int sort = 0, int skip = 0, int take = 0)
|
||||
{
|
||||
var db = GetDb();
|
||||
var col = db.GetCollection<FullSavedImage>("images");
|
||||
|
||||
var query = col.Query().Where(x => x.PlayerId == playerid);
|
||||
|
||||
if (playeridthatrequesting != playerid)
|
||||
{
|
||||
query = query.Where(x => x.Accessibility == ImageAccessibility.Public);
|
||||
}
|
||||
|
||||
IEnumerable<FullSavedImage> rows = query.ToEnumerable();
|
||||
rows = sort switch
|
||||
{
|
||||
1 => rows.OrderByDescending(x => x.CheerCount).ThenByDescending(x => x.Id),
|
||||
2 => rows.OrderBy(x => x.Id),
|
||||
_ => rows.OrderByDescending(x => x.Id),
|
||||
};
|
||||
if (skip > 0) rows = rows.Skip(skip);
|
||||
if (take > 0) rows = rows.Take(take);
|
||||
return rows.Select(MapToImagesPlayer).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A player's photo FEED - the images they took PLUS the ones they are tagged in, newest
|
||||
/// first. This is what the photo tab on a profile shows (RecFlare getPlayerFeed +
|
||||
/// /api/images/v3/feed/player). Public-only unless the requester is the player themselves.
|
||||
/// </summary>
|
||||
public static List<ImagesPlayer> GetPlayerFeed(ulong requester, ulong playerid, int skip = 0, int take = 100, bool viewerIsFriend = false, int sort = 0)
|
||||
{
|
||||
var db = GetDb();
|
||||
var col = db.GetCollection<FullSavedImage>("images");
|
||||
|
||||
IEnumerable<FullSavedImage> feed = col.FindAll()
|
||||
.Where(x => x.PlayerId == playerid
|
||||
|| (x.TaggedPlayerIds != null && x.TaggedPlayerIds.Contains(playerid)));
|
||||
|
||||
// Non-owner sees public photos, plus friends-only ones when they're a friend
|
||||
// (friends-only is stored Accessibility=Private + FriendsOnly=true).
|
||||
if (requester != playerid)
|
||||
feed = feed.Where(x => x.Accessibility == ImageAccessibility.Public
|
||||
|| (x.FriendsOnly && viewerIsFriend));
|
||||
|
||||
feed = sort switch
|
||||
{
|
||||
1 => feed.OrderByDescending(x => x.CheerCount).ThenByDescending(x => x.Id),
|
||||
2 => feed.OrderBy(x => x.Id),
|
||||
_ => feed.OrderByDescending(x => x.Id),
|
||||
};
|
||||
return feed.Skip(skip < 0 ? 0 : skip)
|
||||
.Take(take <= 0 ? 100 : take)
|
||||
.Select(MapToImagesPlayer)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static ImagesPlayer MapToImagesPlayer(FullSavedImage x)
|
||||
{
|
||||
return new ImagesPlayer
|
||||
{
|
||||
Accessibility = x.Accessibility,
|
||||
AccessibilityLocked = x.AccessibilityLocked,
|
||||
CheerCount = x.CheerCount,
|
||||
CommentCount = x.CommentCount,
|
||||
CreatedAt = x.CreatedAt,
|
||||
Description = x.Description,
|
||||
ImageName = x.ImageName,
|
||||
PlayerEventId = x.PlayerEventId,
|
||||
PlayerId = x.PlayerId,
|
||||
RoomId = x.RoomId,
|
||||
SavedImageId = x.Id,
|
||||
SavedImageType = x.Type,
|
||||
TaggedPlayerIds = x.TaggedPlayerIds ?? new(),
|
||||
FriendsOnly = x.FriendsOnly
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>One image row by id (null if none). For the website detail/edit endpoints.</summary>
|
||||
public static FullSavedImage? GetById(int id)
|
||||
=> GetDb().GetCollection<FullSavedImage>("images").FindById(id);
|
||||
|
||||
/// <summary>Owner-only visibility change: set Accessibility (what the game reads) + FriendsOnly
|
||||
/// (website). Returns false if the photo doesn't exist.</summary>
|
||||
public static bool SetVisibility(int id, ImageAccessibility accessibility, bool friendsOnly)
|
||||
{
|
||||
var col = GetDb().GetCollection<FullSavedImage>("images");
|
||||
var img = col.FindById(id);
|
||||
if (img == null) return false;
|
||||
img.Accessibility = accessibility;
|
||||
img.FriendsOnly = friendsOnly;
|
||||
col.Update(img);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Owner-only caption change.</summary>
|
||||
public static bool SetDescription(int id, string description)
|
||||
{
|
||||
var col = GetDb().GetCollection<FullSavedImage>("images");
|
||||
var img = col.FindById(id);
|
||||
if (img == null) return false;
|
||||
img.Description = description;
|
||||
col.Update(img);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Remove one tagged player from a photo. Returns the updated tag list, or null if
|
||||
/// the photo doesn't exist.</summary>
|
||||
public static List<ulong>? RemoveTag(int id, ulong taggedPlayerId)
|
||||
{
|
||||
var col = GetDb().GetCollection<FullSavedImage>("images");
|
||||
var img = col.FindById(id);
|
||||
if (img == null) return null;
|
||||
img.TaggedPlayerIds ??= new();
|
||||
img.TaggedPlayerIds.RemoveAll(x => x == taggedPlayerId);
|
||||
col.Update(img);
|
||||
return img.TaggedPlayerIds;
|
||||
}
|
||||
|
||||
public static void UpdateImageCheerCount(int savedImageId, int cheerCount)
|
||||
{
|
||||
var db = GetDb();
|
||||
var col = db.GetCollection<FullSavedImage>("images");
|
||||
var img = col.FindById(savedImageId);
|
||||
if (img != null)
|
||||
{
|
||||
img.CheerCount = cheerCount;
|
||||
col.Update(img);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ToggleCheer(int savedImageId, ulong playerId, bool cheer)
|
||||
{
|
||||
var db = GetDb();
|
||||
var col = db.GetCollection<Cheer>("cheers");
|
||||
|
||||
var existing = col.FindOne(x => x.SavedImageId == savedImageId && x.PlayerId == playerId);
|
||||
|
||||
if (cheer && existing == null)
|
||||
{
|
||||
col.Insert(new Cheer { SavedImageId = savedImageId, PlayerId = playerId });
|
||||
}
|
||||
else if (!cheer && existing != null)
|
||||
{
|
||||
col.Delete(existing.Id);
|
||||
}
|
||||
|
||||
return col.Exists(x => x.SavedImageId == savedImageId && x.PlayerId == playerId);
|
||||
}
|
||||
|
||||
public static int GetCheerCount(int savedImageId)
|
||||
{
|
||||
var db = GetDb();
|
||||
return db.GetCollection<Cheer>("cheers").Count(x => x.SavedImageId == savedImageId);
|
||||
}
|
||||
|
||||
public static bool HasUserCheered(int savedImageId, ulong playerId)
|
||||
{
|
||||
var db = GetDb();
|
||||
return db.GetCollection<Cheer>("cheers")
|
||||
.Exists(x => x.SavedImageId == savedImageId && x.PlayerId == playerId);
|
||||
}
|
||||
|
||||
private static readonly string OldDir = Path.Combine("Data", "images_metadata");
|
||||
|
||||
/// <summary>A v6 record for an image that does not exist - every id 0, so the client's
|
||||
/// normalising hook is satisfied and nothing renders. Never answer [] here.</summary>
|
||||
public static ImageV6 EmptyImageV6(string name) => new ImageV6
|
||||
{
|
||||
SavedImageId = 0, ImageName = name ?? "", PlayerId = 0, RoomId = 0,
|
||||
PlayerEventId = 0, ClubId = 0, Description = null,
|
||||
Accessibility = ImageAccessibility.Public, AccessibilityLocked = false,
|
||||
SavedImageType = SavedImageTypeEnum.None, CreatedAt = DateTime.UtcNow,
|
||||
CheerCount = 0, CommentCount = 0
|
||||
};
|
||||
|
||||
public static ImageV6? GetImageV6ByName(string name) => ToV6(
|
||||
GetDb().GetCollection<FullSavedImage>("images").FindOne(x => x.ImageName == name));
|
||||
|
||||
public static ImageV6? GetImageV6ById(long savedImageId) => ToV6(
|
||||
GetDb().GetCollection<FullSavedImage>("images").FindOne(x => x.Id == (int)savedImageId));
|
||||
|
||||
private static ImageV6? ToV6(FullSavedImage? r) => r == null ? null : new ImageV6
|
||||
{
|
||||
SavedImageId = r.Id,
|
||||
Id = r.Id,
|
||||
ImageName = r.ImageName,
|
||||
PlayerId = r.PlayerId,
|
||||
RoomId = r.RoomId,
|
||||
PlayerEventId = r.PlayerEventId,
|
||||
ClubId = 0,
|
||||
Description = r.Description,
|
||||
Accessibility = r.Accessibility,
|
||||
AccessibilityLocked = r.AccessibilityLocked,
|
||||
SavedImageType = r.Type,
|
||||
Type = r.Type,
|
||||
CreatedAt = r.CreatedAt,
|
||||
CheerCount = r.CheerCount,
|
||||
CommentCount = r.CommentCount,
|
||||
TaggedPlayerIds = r.TaggedPlayerIds ?? new()
|
||||
};
|
||||
|
||||
public static ImageV6 GetImageByName(string name)
|
||||
{
|
||||
var db = GetDb();
|
||||
var col = db.GetCollection<FullSavedImage>("images");
|
||||
var record = col.FindOne(x => x.ImageName == name);
|
||||
|
||||
if (record == null) return null;
|
||||
|
||||
return new ImageV6
|
||||
{
|
||||
Accessibility = record.Accessibility,
|
||||
AccessibilityLocked = record.AccessibilityLocked,
|
||||
CheerCount = record.CheerCount,
|
||||
CommentCount = record.CommentCount,
|
||||
CreatedAt = record.CreatedAt,
|
||||
Description = record.Description,
|
||||
Id = record.Id,
|
||||
ImageName = record.ImageName,
|
||||
PlayerEventId = record.PlayerEventId,
|
||||
PlayerId = record.PlayerId,
|
||||
RoomId = record.RoomId,
|
||||
TaggedPlayerIds = record.TaggedPlayerIds,
|
||||
Type = record.Type,
|
||||
};
|
||||
}
|
||||
|
||||
public static void DeleteImagesFromPlayerId(ulong playerId)
|
||||
{
|
||||
var db = GetDb();
|
||||
var imageCol = db.GetCollection<FullSavedImage>("images");
|
||||
var cheerCol = db.GetCollection<Cheer>("cheers");
|
||||
|
||||
var imagesToDelete = imageCol.Query().Where(x => x.PlayerId == playerId).ToList();
|
||||
|
||||
if (imagesToDelete.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var img in imagesToDelete)
|
||||
{
|
||||
imageCol.Delete(img.Id);
|
||||
cheerCol.DeleteMany(c => c.SavedImageId == img.Id);
|
||||
}
|
||||
|
||||
Console.WriteLine($"[Delete] Removed {imagesToDelete.Count} images and their cheers for PlayerId {playerId}");
|
||||
}
|
||||
|
||||
public static List<FullSavedImage> GetImagesByIds(List<int> ids)
|
||||
{
|
||||
if (ids == null || ids.Count == 0)
|
||||
return new List<FullSavedImage>();
|
||||
|
||||
var db = GetDb();
|
||||
var col = db.GetCollection<FullSavedImage>("images");
|
||||
|
||||
// Efficient LiteDB WHERE query using Contains()
|
||||
return col.Query()
|
||||
.Where(x => ids.Contains(x.Id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
public static void ImportOldJson()
|
||||
{
|
||||
if (!Directory.Exists(OldDir))
|
||||
{
|
||||
Console.WriteLine($"[Import] Old directory not found: {OldDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
var db = GetDb();
|
||||
var imageCol = db.GetCollection<FullSavedImage>("images");
|
||||
var cheerCol = db.GetCollection<Cheer>("cheers");
|
||||
imageCol.EnsureIndex(x => x.Id, true);
|
||||
|
||||
// PERF: Parallelize JSON import for multiple files.
|
||||
var files = Directory.GetFiles(OldDir, "*.json");
|
||||
var cheerFiles = files.Where(f => f.EndsWith("_cheers.json")).ToArray();
|
||||
var imageFiles = files.Except(cheerFiles).ToArray();
|
||||
|
||||
Parallel.ForEach(imageFiles, file =>
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(file);
|
||||
var images = JsonConvert.DeserializeObject<List<FullSavedImage>>(json);
|
||||
if (images == null) return;
|
||||
|
||||
foreach (var img in images)
|
||||
{
|
||||
if (!imageCol.Exists(x => x.Id == img.Id))
|
||||
{
|
||||
imageCol.Insert(img);
|
||||
Console.WriteLine($"[Import] Imported image {img.Id} from {fileName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[Import] Skipped duplicate image {img.Id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Import] Failed to import {fileName}: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
Parallel.ForEach(cheerFiles, file =>
|
||||
{
|
||||
try
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
int savedImageId = int.Parse(fileName.Replace("_cheers.json", ""));
|
||||
|
||||
string json = File.ReadAllText(file);
|
||||
var cheerList = JsonConvert.DeserializeObject<List<ulong>>(json);
|
||||
if (cheerList == null) return;
|
||||
|
||||
foreach (var pid in cheerList)
|
||||
{
|
||||
if (!cheerCol.Exists(x => x.SavedImageId == savedImageId && x.PlayerId == pid))
|
||||
{
|
||||
cheerCol.Insert(new Cheer { SavedImageId = savedImageId, PlayerId = pid });
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[Import] Imported {cheerList.Count} cheers for image {savedImageId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Import] Failed to import cheers from {file}: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using LiteDB;
|
||||
|
||||
// Invention persistence for the Maker Pen "save selection as invention" flow.
|
||||
// Minimal but real: stores the metadata the client's v6/v9 save posts (the circuit blob
|
||||
// itself is uploaded separately and referenced by name), assigns an id + replication id,
|
||||
// and lists a creator's own inventions. Global namespace like ImageMetadataDB/ClubDB.
|
||||
public static class InventionDB
|
||||
{
|
||||
private static readonly string DbPath = Path.Combine("Data", "DBs", "Inventions.db");
|
||||
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(DbPath));
|
||||
public static LiteDatabase Db => _db.Value;
|
||||
public static ILiteCollection<Invention> Collection() => Col();
|
||||
|
||||
public class Invention
|
||||
{
|
||||
public int InventionId { get; set; }
|
||||
public string ReplicationId { get; set; } = "";
|
||||
public ulong CreatorPlayerId { get; set; }
|
||||
public string Name { get; set; } = "Untitled";
|
||||
public string Description { get; set; } = "No description yet";
|
||||
public string ImageName { get; set; } = "";
|
||||
public string BlobName { get; set; } = ""; // inventionDataFilename (the .inv blob)
|
||||
public int CurrentVersionNumber { get; set; } = 1;
|
||||
public int InstantiationCost { get; set; }
|
||||
public int LightsCost { get; set; }
|
||||
public int ChipsCost { get; set; }
|
||||
public int CloudVariablesCost { get; set; }
|
||||
public int AICost { get; set; }
|
||||
public int Accessibility { get; set; } = 0; // 0 private until published
|
||||
public bool IsPublished { get; set; } = false;
|
||||
public int CreationRoomId { get; set; }
|
||||
public int Price { get; set; }
|
||||
public int UgcVersion { get; set; }
|
||||
public bool HasBetaContent { get; set; }
|
||||
public string? LongDescription { get; set; }
|
||||
public string? DisplayMetadataJson { get; set; }
|
||||
public List<int> ReferencedInventions { get; set; } = new();
|
||||
public List<string> ReferencedUnityAssetIds { get; set; } = new();
|
||||
public List<string> Tags { get; set; } = new();
|
||||
public bool IsFeatured { get; set; } = false; // admin-curated (InventionStore.SetFeatured)
|
||||
public int NumDownloads { get; set; } = 0; // purchases / pickups
|
||||
public int CheerCount { get; set; } = 0;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? FirstPublishedAt { get; set; } = null;
|
||||
}
|
||||
|
||||
private static ILiteCollection<Invention> Col()
|
||||
{
|
||||
var c = Db.GetCollection<Invention>("inventions");
|
||||
c.EnsureIndex(x => x.InventionId, true);
|
||||
c.EnsureIndex(x => x.CreatorPlayerId);
|
||||
return c;
|
||||
}
|
||||
|
||||
public static Invention Create(Invention inv)
|
||||
{
|
||||
var col = Col();
|
||||
if (inv.InventionId == 0)
|
||||
{
|
||||
var last = col.Query().OrderByDescending(x => x.InventionId).Limit(1).FirstOrDefault();
|
||||
inv.InventionId = (last?.InventionId ?? 0) + 1;
|
||||
}
|
||||
if (string.IsNullOrEmpty(inv.ReplicationId)) inv.ReplicationId = Guid.NewGuid().ToString("N");
|
||||
inv.CreatedAt = inv.ModifiedAt = DateTime.UtcNow;
|
||||
col.Insert(inv);
|
||||
return inv;
|
||||
}
|
||||
|
||||
public static Invention? GetById(int id) => Col().FindById(id);
|
||||
|
||||
public static List<Invention> GetByCreator(ulong creator) =>
|
||||
Col().Query().Where(x => x.CreatorPlayerId == creator).OrderByDescending(x => x.InventionId).ToList();
|
||||
|
||||
public static Invention? Publish(int id, ulong creator, bool published)
|
||||
{
|
||||
var col = Col();
|
||||
var inv = col.FindById(id);
|
||||
if (inv == null || inv.CreatorPlayerId != creator) return null;
|
||||
inv.IsPublished = published;
|
||||
inv.Accessibility = published ? 1 : 0;
|
||||
if (published && inv.FirstPublishedAt == null) inv.FirstPublishedAt = DateTime.UtcNow;
|
||||
inv.ModifiedAt = DateTime.UtcNow;
|
||||
col.Update(inv);
|
||||
return inv;
|
||||
}
|
||||
|
||||
public static void Checkpoint() { try { Db.Checkpoint(); } catch { } }
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using LiteDB;
|
||||
|
||||
// The invention browse / store side of InventionDB: search, feeds, ownership (bought
|
||||
// inventions), prices, cheers, deletes. Kept next to InventionDB (same LiteDB file) so one
|
||||
// checkpoint covers both. Everything the client browses is PUBLISHED inventions only; a
|
||||
// creator always sees their own.
|
||||
public static class InventionStore
|
||||
{
|
||||
private static ILiteCollection<InventionDB.Invention> Col() => InventionDB.Collection();
|
||||
|
||||
public class Owned
|
||||
{
|
||||
[BsonId] public string Key { get; set; } = ""; // "{player}|{invention}"
|
||||
public ulong PlayerId { get; set; }
|
||||
public int InventionId { get; set; }
|
||||
public int PricePaid { get; set; }
|
||||
public DateTime At { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class Cheer
|
||||
{
|
||||
[BsonId] public string Key { get; set; } = ""; // "{player}|{invention}"
|
||||
public ulong PlayerId { get; set; }
|
||||
public int InventionId { get; set; }
|
||||
}
|
||||
|
||||
private static ILiteCollection<Owned> OwnedCol()
|
||||
{
|
||||
var c = InventionDB.Db.GetCollection<Owned>("owned");
|
||||
c.EnsureIndex(x => x.PlayerId);
|
||||
c.EnsureIndex(x => x.InventionId);
|
||||
return c;
|
||||
}
|
||||
|
||||
private static ILiteCollection<Cheer> CheerCol()
|
||||
{
|
||||
var c = InventionDB.Db.GetCollection<Cheer>("cheers");
|
||||
c.EnsureIndex(x => x.InventionId);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---- reads --------------------------------------------------------------------------
|
||||
|
||||
public static List<InventionDB.Invention> Published() =>
|
||||
Col().Query().Where(x => x.IsPublished).ToList();
|
||||
|
||||
public static List<InventionDB.Invention> GetByIds(IEnumerable<int> ids)
|
||||
{
|
||||
var set = new HashSet<int>(ids);
|
||||
if (set.Count == 0) return new();
|
||||
var col = Col();
|
||||
return set.Select(id => col.FindById(id)).Where(i => i != null).Select(i => i!).ToList();
|
||||
}
|
||||
|
||||
public static List<InventionDB.Invention> ByRoom(long roomId) =>
|
||||
Col().Query().Where(x => x.CreationRoomId == (int)roomId && x.IsPublished)
|
||||
.ToList().OrderByDescending(x => x.CreatedAt).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// The store's search. `value` is the text the player typed; "#something" is a tag or a
|
||||
/// creator's @name (the store's creator filter posts it that way). No text = every published
|
||||
/// invention, newest first. Terms match the name, the description and the creator's name.
|
||||
/// </summary>
|
||||
public static List<InventionDB.Invention> Search(string? value, int skip, int take)
|
||||
{
|
||||
var all = Published();
|
||||
string q = (value ?? "").Trim();
|
||||
IEnumerable<InventionDB.Invention> hits = all;
|
||||
if (q.StartsWith("#") && q.Length > 1)
|
||||
{
|
||||
string tag = q[1..].Trim();
|
||||
ulong creator = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountIdByUsernameCI(tag);
|
||||
hits = all.Where(i =>
|
||||
(creator != 0 && i.CreatorPlayerId == creator)
|
||||
|| (i.Tags ?? new()).Any(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
else if (q.Length > 0)
|
||||
{
|
||||
var terms = q.ToLowerInvariant().Split(new[] { ' ', '+', ',' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var names = new Dictionary<ulong, string>();
|
||||
string CreatorName(ulong id)
|
||||
{
|
||||
if (!names.TryGetValue(id, out var n))
|
||||
{
|
||||
n = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountById(id)?.Username ?? "";
|
||||
names[id] = n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
hits = all.Where(i =>
|
||||
{
|
||||
string hay = ((i.Name ?? "") + " " + (i.Description ?? "") + " " + string.Join(" ", i.Tags ?? new()) + " " + CreatorName(i.CreatorPlayerId)).ToLowerInvariant();
|
||||
return terms.All(t => hay.Contains(t));
|
||||
});
|
||||
}
|
||||
return hits.OrderByDescending(i => i.CreatedAt).ThenByDescending(i => i.InventionId)
|
||||
.Skip(Math.Max(0, skip)).Take(take <= 0 ? 100 : take).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Featured / top today: the admin-featured ones first, then by downloads and cheers.</summary>
|
||||
public static List<InventionDB.Invention> Top(int skip, int take) =>
|
||||
Published().OrderByDescending(i => i.IsFeatured).ThenByDescending(i => i.NumDownloads + i.CheerCount * 3)
|
||||
.ThenByDescending(i => i.CreatedAt).Skip(Math.Max(0, skip)).Take(take <= 0 ? 50 : take).ToList();
|
||||
|
||||
// ---- ownership ------------------------------------------------------------------------
|
||||
|
||||
public static HashSet<int> OwnedIds(ulong player) =>
|
||||
OwnedCol().Find(x => x.PlayerId == player).Select(x => x.InventionId).ToHashSet();
|
||||
|
||||
public static bool Owns(ulong player, int inventionId) =>
|
||||
OwnedCol().FindById($"{player}|{inventionId}") != null;
|
||||
|
||||
public static bool AddOwned(ulong player, int inventionId, int pricePaid)
|
||||
{
|
||||
var col = OwnedCol();
|
||||
string key = $"{player}|{inventionId}";
|
||||
if (col.FindById(key) != null) return false;
|
||||
col.Insert(new Owned { Key = key, PlayerId = player, InventionId = inventionId, PricePaid = pricePaid });
|
||||
var c = Col();
|
||||
var inv = c.FindById(inventionId);
|
||||
if (inv != null) { inv.NumDownloads++; c.Update(inv); }
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Created + bought, newest first (the maker pen's Inventions tab).</summary>
|
||||
public static List<InventionDB.Invention> Mine(ulong player)
|
||||
{
|
||||
var mine = InventionDB.GetByCreator(player);
|
||||
var bought = GetByIds(OwnedIds(player));
|
||||
var byId = new Dictionary<int, InventionDB.Invention>();
|
||||
foreach (var i in mine.Concat(bought)) byId[i.InventionId] = i;
|
||||
return byId.Values.OrderByDescending(i => i.CreatedAt).ThenByDescending(i => i.InventionId).ToList();
|
||||
}
|
||||
|
||||
public static bool OwnsAll(ulong player, IEnumerable<int> ids)
|
||||
{
|
||||
var owned = OwnedIds(player);
|
||||
var col = Col();
|
||||
foreach (int id in ids)
|
||||
{
|
||||
var inv = col.FindById(id);
|
||||
if (inv == null) return false;
|
||||
if (inv.CreatorPlayerId == player || owned.Contains(id)) continue;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- cheers ---------------------------------------------------------------------------
|
||||
|
||||
public static bool IsCheering(ulong player, int inventionId) => CheerCol().FindById($"{player}|{inventionId}") != null;
|
||||
|
||||
public static InventionDB.Invention? SetCheer(ulong player, int inventionId, bool cheer)
|
||||
{
|
||||
var col = Col();
|
||||
var inv = col.FindById(inventionId);
|
||||
if (inv == null) return null;
|
||||
var cc = CheerCol();
|
||||
string key = $"{player}|{inventionId}";
|
||||
bool has = cc.FindById(key) != null;
|
||||
if (cheer && !has) { cc.Insert(new Cheer { Key = key, PlayerId = player, InventionId = inventionId }); inv.CheerCount++; }
|
||||
else if (!cheer && has) { cc.Delete(key); inv.CheerCount = Math.Max(0, inv.CheerCount - 1); }
|
||||
col.Update(inv);
|
||||
return inv;
|
||||
}
|
||||
|
||||
// ---- creator writes -------------------------------------------------------------------
|
||||
|
||||
/// <summary>Edit the creator's own invention; null when it is not theirs (or does not exist).</summary>
|
||||
public static InventionDB.Invention? Edit(int id, ulong creator, Action<InventionDB.Invention> edit, bool staffOverride = false)
|
||||
{
|
||||
var col = Col();
|
||||
var inv = col.FindById(id);
|
||||
if (inv == null || (inv.CreatorPlayerId != creator && !staffOverride)) return null;
|
||||
edit(inv);
|
||||
inv.ModifiedAt = DateTime.UtcNow;
|
||||
col.Update(inv);
|
||||
return inv;
|
||||
}
|
||||
|
||||
public static bool Delete(int id, ulong creator, bool staffOverride = false)
|
||||
{
|
||||
var col = Col();
|
||||
var inv = col.FindById(id);
|
||||
if (inv == null || (inv.CreatorPlayerId != creator && !staffOverride)) return false;
|
||||
col.Delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void SetFeatured(int id, bool featured)
|
||||
{
|
||||
var col = Col();
|
||||
var inv = col.FindById(id);
|
||||
if (inv == null) return;
|
||||
inv.IsFeatured = featured;
|
||||
col.Update(inv);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using LiteDB;
|
||||
using static Campus_live_rewrite.api.server.Classes.db_class.LeaderboardDBClasses;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
public class LeaderboardDB
|
||||
{
|
||||
private static readonly LiteDatabase DB = new LiteDatabase(
|
||||
Environment.CurrentDirectory + "/Data/DBs/Leaderboard.db"
|
||||
);
|
||||
|
||||
// Merge this store's write-ahead log back into the data file.
|
||||
// See DbMaintenance for why this must be called explicitly.
|
||||
public static void Checkpoint() => DB.Checkpoint();
|
||||
|
||||
private static ILiteCollection<Entry> Entries =>
|
||||
DB.GetCollection<Entry>("leaderboard");
|
||||
|
||||
static LeaderboardDB()
|
||||
{
|
||||
// Short index names to avoid LiteDB 32-char name limit
|
||||
Entries.EnsureIndex("pid", x => x.PlayerId);
|
||||
Entries.EnsureIndex("sc", x => x.StatChannel);
|
||||
Entries.EnsureIndex("rid", x => x.RoomId);
|
||||
|
||||
// Score index for sorting
|
||||
Entries.EnsureIndex("score", x => x.Score);
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------
|
||||
// Add or Update Stat
|
||||
// ------------------------------------------
|
||||
// `expected` is the client's CurrentStatValue. The reference honours it as an optimistic
|
||||
// compare-and-set (only overwrite if the row still holds that value), but that silently
|
||||
// DROPPED real updates here: a returning player's client sends CurrentStatValue 0 (fresh
|
||||
// session, hasn't re-read the board) while the server still holds their old score, so the
|
||||
// compare failed and their new score got STUCK. We take the value UNCONDITIONALLY instead
|
||||
// (the reference's CurrentStatValue=null path) so a score always records; `expected` is only
|
||||
// logged when it disagreed. The endpoint answers 0 either way.
|
||||
public static Entry? GetEntry(ulong playerId, int statChannel, ulong roomId) =>
|
||||
Entries.FindOne(x => x.PlayerId == (int)playerId && x.StatChannel == statChannel && x.RoomId == (long)roomId);
|
||||
|
||||
public static RequestResult SetStat(ulong playerId, int statChannel, ulong roomId, int newValue, int? expected = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = GetEntry(playerId, statChannel, roomId);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (entry == null)
|
||||
{
|
||||
entry = new Entry
|
||||
{
|
||||
PlayerId = (int)playerId,
|
||||
StatChannel = statChannel,
|
||||
RoomId = (long)roomId,
|
||||
Score = newValue,
|
||||
UpdatedAt = now,
|
||||
DayGainDate = now.Date,
|
||||
DayGain = Math.Abs((long)newValue)
|
||||
};
|
||||
Entries.Insert(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (expected.HasValue && entry.Score != expected.Value)
|
||||
Console.WriteLine($"[leaderboard] {playerId} sc{statChannel} room{roomId}: CurrentStatValue {expected} != stored {entry.Score} - writing {newValue} anyway (would have stuck otherwise)");
|
||||
long gain = Math.Abs((long)newValue - entry.Score);
|
||||
if (entry.DayGainDate != now.Date) { entry.DayGainDate = now.Date; entry.DayGain = 0; }
|
||||
entry.DayGain += gain;
|
||||
entry.UpdatedAt = now;
|
||||
entry.Score = newValue;
|
||||
Entries.Update(entry);
|
||||
}
|
||||
|
||||
// Rank is computed at READ time now (see OrderedBoard), so there is nothing to
|
||||
// recompute on write - and doing it descending-only here was the bug.
|
||||
return RequestResult.Success;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return RequestResult.InvalidStat;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------
|
||||
// The board's entries in RANK ORDER, computed for THIS read.
|
||||
//
|
||||
// Order is decided by the caller's SortAscending, exactly as RecFlare's leaderboard
|
||||
// does it: descending (highest first) by default, ascending (lowest first) for a
|
||||
// lowest-wins stat like a race time, with ties broken on the LOWER player id either
|
||||
// way so the order is stable. Rank (1-based) is assigned by position here, at READ
|
||||
// time - so the SAME stored board reads correctly in either direction.
|
||||
//
|
||||
// This replaced a Rank baked DESCENDING-ONLY into every row at write time: every
|
||||
// ascending stat (times, fewest-deaths) was ranked backwards - the WORST score shown
|
||||
// as #1 - and no read could get the right order because the direction was already
|
||||
// frozen the wrong way.
|
||||
private static List<Entry> OrderedBoard(int statChannel, ulong roomId, bool sortAscending)
|
||||
{
|
||||
var list = Entries.Find(x => x.StatChannel == statChannel && x.RoomId == (long)roomId).ToList();
|
||||
|
||||
var ordered = (sortAscending
|
||||
? list.OrderBy(x => x.Score)
|
||||
: list.OrderByDescending(x => x.Score))
|
||||
.ThenBy(x => x.PlayerId) // ties: lower id first, either direction
|
||||
.ToList();
|
||||
|
||||
// Ranks are 0-BASED (RecFlare/live-service convention): the client ADDS ONE before it
|
||||
// draws, so Rank 0 renders as "#1". Assigning i+1 here made every board show everyone
|
||||
// one place too low (the winner as "#2").
|
||||
for (int i = 0; i < ordered.Count; i++) ordered[i].Rank = i;
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// ------------------------------------------
|
||||
// Get Player Rank
|
||||
// ------------------------------------------
|
||||
public static Entry? GetPlayerRank(ulong playerId, int statChannel, ulong roomId, bool sortAscending = false)
|
||||
{
|
||||
return OrderedBoard(statChannel, roomId, sortAscending)
|
||||
.FirstOrDefault(x => x.PlayerId == (int)playerId);
|
||||
}
|
||||
|
||||
// ------------------------------------------
|
||||
// Nearby Scores Window - windowSize entries either side of the caller.
|
||||
// ------------------------------------------
|
||||
public static List<Entry> GetNearbyScores(
|
||||
ulong playerId,
|
||||
int statChannel,
|
||||
ulong roomId,
|
||||
int windowSize,
|
||||
bool sortAscending = false)
|
||||
{
|
||||
var board = OrderedBoard(statChannel, roomId, sortAscending);
|
||||
|
||||
int myIndex = board.FindIndex(x => x.PlayerId == (int)playerId);
|
||||
if (myIndex < 0)
|
||||
return board.Take(windowSize * 2 + 1).ToList(); // caller not on the board yet: top slice
|
||||
|
||||
int start = Math.Max(myIndex - windowSize, 0);
|
||||
int end = Math.Min(myIndex + windowSize, board.Count - 1);
|
||||
return board.GetRange(start, end - start + 1);
|
||||
}
|
||||
|
||||
// ------------------------------------------
|
||||
// Range Get - GetRanks API. RankStart/RankEnd are 0-based, inclusive (the client
|
||||
// sends RankStart=0, RankEnd=9 for the top ten).
|
||||
// ------------------------------------------
|
||||
public static List<Entry> GetRanks(
|
||||
int statChannel,
|
||||
ulong roomId,
|
||||
int rankStart,
|
||||
int rankEnd,
|
||||
bool sortAscending = false)
|
||||
{
|
||||
var board = OrderedBoard(statChannel, roomId, sortAscending);
|
||||
int start = Math.Max(rankStart, 0);
|
||||
int count = Math.Max(rankEnd - start + 1, 0);
|
||||
return board.Skip(start).Take(count).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using LiteDB;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Campus_live_rewrite.api.server.Classes;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
public static class LinkDB
|
||||
{
|
||||
private static readonly string _dbPath = "Data/DBs/Links.db";
|
||||
private static readonly string _collection = "actionlinks";
|
||||
|
||||
public static ActionLink Insert(ActionLink link)
|
||||
{
|
||||
using var db = new LiteDatabase(_dbPath);
|
||||
var col = db.GetCollection<ActionLink>(_collection);
|
||||
col.Insert(link);
|
||||
return link;
|
||||
}
|
||||
|
||||
public static ActionLink? Get(string linkIdOrCreatorCode)
|
||||
{
|
||||
using var db = new LiteDatabase(_dbPath);
|
||||
var col = db.GetCollection<ActionLink>(_collection);
|
||||
|
||||
ActionLink? link = col.FindById(linkIdOrCreatorCode);
|
||||
|
||||
// If not found by ID, try as a creator code (case-insensitive)
|
||||
if (link == null)
|
||||
{
|
||||
string code = linkIdOrCreatorCode.Trim().ToLowerInvariant();
|
||||
var player = PlayerDB.GetInfluencerByCreatorCode(code);
|
||||
if (player?.player == null) return null; // unknown link AND unknown creator code
|
||||
return new ActionLink // {"creatorPlayerId":4213153,"data":"{\"Type\":6}","isValid":true}
|
||||
{
|
||||
creatorPlayerId = player.player.Id,
|
||||
data = JsonConvert.SerializeObject(new { Type = (int)LinkType.Influencer }),
|
||||
isValid = true
|
||||
};
|
||||
}
|
||||
|
||||
if (link == null)
|
||||
return null;
|
||||
|
||||
// Automatically invalidate expired links
|
||||
if (link.ExpiresAt < DateTime.UtcNow && link.isValid)
|
||||
{
|
||||
link.isValid = false;
|
||||
col.Update(link);
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
|
||||
public static bool Consume(string linkId, bool alwayValid = false)
|
||||
{
|
||||
using var db = new LiteDatabase(_dbPath);
|
||||
var col = db.GetCollection<ActionLink>(_collection);
|
||||
var link = col.FindById(linkId);
|
||||
if (link == null || !link.isValid || link.ExpiresAt < DateTime.UtcNow)
|
||||
return false;
|
||||
|
||||
if (!alwayValid)
|
||||
{
|
||||
link.isValid = false;
|
||||
}
|
||||
col.Update(link);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static IEnumerable<ActionLink> GetAll()
|
||||
{
|
||||
using var db = new LiteDatabase(_dbPath);
|
||||
var col = db.GetCollection<ActionLink>(_collection);
|
||||
return col.FindAll().ToList();
|
||||
}
|
||||
|
||||
public static void DeleteLinksFromPlayerId(ulong playerId)
|
||||
{
|
||||
using var db = new LiteDatabase(_dbPath);
|
||||
var col = db.GetCollection<ActionLink>(_collection);
|
||||
try
|
||||
{
|
||||
col.DeleteMany(x => x.creatorPlayerId == playerId);
|
||||
Console.WriteLine("deleted links from playerid " + playerId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using LiteDB;
|
||||
using Campus_live_rewrite.api.server;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
/// <summary>
|
||||
/// Append-only record of moderation actions (warnings, kicks, room bans and unbans): who did
|
||||
/// what to whom, where and why. The reference server keeps `warning` / `report` tables for
|
||||
/// this; before now a warning here left no trace once its (0-second) block was overwritten.
|
||||
/// Read by the admin panel's player view; never by the client.
|
||||
/// </summary>
|
||||
public static class ModerationLogDB
|
||||
{
|
||||
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "ModerationLog.db")));
|
||||
|
||||
public class Entry
|
||||
{
|
||||
[BsonId] public int Id { get; set; }
|
||||
public string Kind { get; set; } = ""; // warning / modkick / kick / roomban / roomunban
|
||||
public ulong TargetId { get; set; }
|
||||
public ulong ByAccountId { get; set; }
|
||||
public ulong RoomId { get; set; }
|
||||
public long GameSessionId { get; set; }
|
||||
public long DurationSeconds { get; set; }
|
||||
public int Category { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
public string Note { get; set; } = "";
|
||||
public DateTime At { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static ILiteCollection<Entry> Col()
|
||||
{
|
||||
var c = _db.Value.GetCollection<Entry>("actions");
|
||||
c.EnsureIndex(x => x.TargetId);
|
||||
return c;
|
||||
}
|
||||
|
||||
public static void Record(string kind, ulong target, ulong by, ulong roomId, long gameSessionId, long durationSeconds,
|
||||
Moderation_Detail.ReportCategory category, string? message, string? note = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Col().Insert(new Entry
|
||||
{
|
||||
Kind = kind, TargetId = target, ByAccountId = by, RoomId = roomId, GameSessionId = gameSessionId,
|
||||
DurationSeconds = durationSeconds, Category = (int)category, Message = message ?? "", Note = note ?? ""
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { Console.WriteLine($"[moderation] log write failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public static List<Entry> ForPlayer(ulong target, int take = 50) =>
|
||||
Col().Find(x => x.TargetId == target).OrderByDescending(x => x.At).Take(take).ToList();
|
||||
|
||||
public static List<Entry> Recent(int take = 100) =>
|
||||
Col().FindAll().OrderByDescending(x => x.At).Take(take).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3753 @@
|
||||
using System;
|
||||
using System.Numerics;
|
||||
using System.Security.Principal;
|
||||
using LiteDB;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Campus_live_rewrite.api;
|
||||
using Campus_live_rewrite.api.server;
|
||||
using Campus_live_rewrite.api.server.Classes;
|
||||
using Campus_live_rewrite.api.server.Classes.db_class;
|
||||
using Campus_live_rewrite.Controllers;
|
||||
using SixLabors.ImageSharp;
|
||||
using static ImageMetadataDB;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
public class PlayerDB
|
||||
{
|
||||
public static LiteDatabase PlayerDBFile = new LiteDatabase(Environment.CurrentDirectory + $"/Data/DBs/Players.db");
|
||||
private static readonly Random random = new Random();
|
||||
private static readonly object randLock = new object();
|
||||
|
||||
public static bool CreateAccount(Platforms platform, ulong platformId, string deviceId, ulong discord_id, out account_data account)
|
||||
{
|
||||
account = new account_data();
|
||||
|
||||
Console.WriteLine($"[CreateAccount] === START === platform={platform} platformId={platformId} deviceId='{deviceId}' discord_id={discord_id}");
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var maxId = col.FindAll().OrderByDescending(x => x.id).FirstOrDefault()?.id ?? 0;
|
||||
ulong accid = maxId + 1;
|
||||
account.accountId = accid;
|
||||
string name = Utils.GetRandomName();
|
||||
|
||||
Console.WriteLine($"[CreateAccount] Assigning accid={accid} (maxId was {maxId}), generated name='{name}'");
|
||||
|
||||
account = new account_data
|
||||
{
|
||||
createdAt = DateTime.UtcNow,
|
||||
identityFlags = 0,
|
||||
isJunior = null,
|
||||
personalPronouns = 0,
|
||||
platforms = 0,
|
||||
displayName = name,
|
||||
username = name,
|
||||
profileImage = "DefaultPFP.png",
|
||||
accountId = accid
|
||||
};
|
||||
col.Insert(new player_data
|
||||
{
|
||||
playerid = account.accountId,
|
||||
id = accid,
|
||||
platform = platform,
|
||||
//platformId = platformId,
|
||||
CreatedInGame = true,
|
||||
deviceId = new List<string>
|
||||
{
|
||||
deviceId
|
||||
},
|
||||
authtoken = Utils.GetRandomString(400),
|
||||
password = "",
|
||||
player = new player_info
|
||||
{
|
||||
Id = accid,
|
||||
Username = account.username,
|
||||
DisplayName = account.displayName,
|
||||
XP = 0,
|
||||
Level = 1,
|
||||
created_at = account.createdAt,
|
||||
bannerImage = Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage,
|
||||
profileImage = account.profileImage,
|
||||
IsJunior = false,
|
||||
AvoidJuniors = true,
|
||||
PlayerReputation = new mPlayerReputation
|
||||
{
|
||||
Noteriety = 0,
|
||||
CheerCredit = 20,
|
||||
CheerGeneral = 1,
|
||||
CheerHelpful = 1,
|
||||
CheerGreatHost = 1,
|
||||
CheerSportsman = 1,
|
||||
CheerCreative = 1,
|
||||
SubscriberCount = 0,
|
||||
SubscribedCount = 0,
|
||||
SelectedCheer = CheerCategoryEnum.None
|
||||
},
|
||||
PlatformIds = new List<mPlatformID>
|
||||
{
|
||||
new mPlatformID
|
||||
{
|
||||
Platform = platform,
|
||||
PlatformId = platformId
|
||||
}
|
||||
}
|
||||
},
|
||||
player_Extra = new player_extra
|
||||
{
|
||||
DormRoomID = 1,
|
||||
IpAddresses = new List<string>(),
|
||||
DiscordId = discord_id,
|
||||
Bio = "",
|
||||
avatar = "{\"FaceFeatures\": \"\",\"HairColor\": \"\",\"OutfitSelections\": \"\",\"SkinColor\": \"\"}",
|
||||
SavedOutfits = new List<avatar_data_saved>(),
|
||||
//ModerationBlockDetails = new Moderation_Detail.ModerationBlockDetails()
|
||||
CheeredRooms = new List<ulong>(),
|
||||
FavoritedRooms = new List<ulong>(),
|
||||
sub_count = 0,
|
||||
SubbedTo = new List<ulong>(),
|
||||
IsRecentRoomHistoryVisible = true,
|
||||
Influencer = new Influencer
|
||||
{
|
||||
creatorCode = "",
|
||||
isInfluencer = false,
|
||||
},
|
||||
CustomAvatarItems = new Campus_live_rewrite.api.server.Classes.db_class.CustomAvatarItems(),
|
||||
},
|
||||
dev_Flag = DevFlag.none
|
||||
});
|
||||
//Settings.SaveSettings_db(Settings.CreateDefaultSettings(), accid);
|
||||
Console.WriteLine($"[CreateAccount] Row inserted. accid={accid} platform={platform} discord_id={discord_id} display='{name}' img='{account.profileImage}'");
|
||||
HeartbeatDB.CreatePlayerHeartbeat(accid, out _, platform, DeviceClasses.Unknown);
|
||||
Console.WriteLine($"[CreateAccount] Heartbeat created for accid={accid}");
|
||||
CreateDormForPlayerId(accid);
|
||||
Console.WriteLine($"[CreateAccount] Dorm created for accid={accid}. === DONE ===");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ================================================================================
|
||||
// WEBSITE ACCOUNT CREATION (Campus accounts are now made on the website, not in game)
|
||||
//
|
||||
// The game used to auto-create an account on first launch (AuthController
|
||||
// grant_type=create_account). That path is disabled - accounts are made here, on the
|
||||
// site, with a username + password the player chooses, and the game only ever LOGS IN
|
||||
// to them. The account carries NO platform binding at creation: a browser has no Steam
|
||||
// id. The Steam id is stamped on the account the first time the player signs in from
|
||||
// the game (AccountLock.TryEnforceAndBind), which is what "locks the account to your
|
||||
// Steam" means.
|
||||
// ================================================================================
|
||||
|
||||
/// <summary>Case-insensitive username -> account id, or 0 if no such account.</summary>
|
||||
public static ulong GetAccountIdByUsernameCI(string username)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username)) return 0;
|
||||
string want = username.Trim();
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var p = col.FindAll().FirstOrDefault(x =>
|
||||
x?.player?.Username != null &&
|
||||
string.Equals(x.player.Username, want, StringComparison.OrdinalIgnoreCase));
|
||||
return p?.playerid ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>Case-insensitive: is this exact username already in use?</summary>
|
||||
public static bool IsUsernameTaken(string username)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username)) return true;
|
||||
string want = username.Trim();
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
return col.FindAll().Any(p =>
|
||||
p?.player?.Username != null &&
|
||||
string.Equals(p.player.Username, want, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an account from the website. No device id, no platform binding - those are
|
||||
/// stamped on the first in-game login. The account is marked onboarding-complete so the
|
||||
/// game drops the player straight in (no first-run "make a room" / username / password
|
||||
/// flow) since they already chose all of that here.
|
||||
/// </summary>
|
||||
public static bool CreateWebAccount(string username, string displayName, string password,
|
||||
string? email, out account_data account, out string error,
|
||||
bool isJunior = false, string? birthday = null)
|
||||
{
|
||||
account = new account_data();
|
||||
error = "";
|
||||
|
||||
username = (username ?? "").Trim();
|
||||
displayName = string.IsNullOrWhiteSpace(displayName) ? username : displayName.Trim();
|
||||
|
||||
if (IsUsernameTaken(username))
|
||||
{
|
||||
error = "That username is already taken.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var maxId = col.FindAll().OrderByDescending(x => x.id).FirstOrDefault()?.id ?? 0;
|
||||
ulong accid = maxId + 1;
|
||||
|
||||
account = new account_data
|
||||
{
|
||||
createdAt = DateTime.UtcNow,
|
||||
identityFlags = 0,
|
||||
isJunior = isJunior,
|
||||
personalPronouns = 0,
|
||||
platforms = 0,
|
||||
displayName = displayName,
|
||||
username = username,
|
||||
profileImage = "DefaultPFP.png",
|
||||
accountId = accid
|
||||
};
|
||||
|
||||
col.Insert(new player_data
|
||||
{
|
||||
playerid = accid,
|
||||
id = accid,
|
||||
platform = Platforms.All,
|
||||
CreatedInGame = false,
|
||||
deviceId = new List<string>(), // bound on first in-game login
|
||||
authtoken = Utils.GetRandomString(400),
|
||||
password = ClientSecurity.HashPassword(password),
|
||||
player = new player_info
|
||||
{
|
||||
Id = accid,
|
||||
Username = username,
|
||||
DisplayName = displayName,
|
||||
Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||
Birthday = string.IsNullOrWhiteSpace(birthday) ? null : birthday.Trim(),
|
||||
XP = 0,
|
||||
Level = 1,
|
||||
created_at = account.createdAt,
|
||||
bannerImage = Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage,
|
||||
profileImage = account.profileImage,
|
||||
IsJunior = isJunior,
|
||||
// Juniors are placed among juniors (don't avoid them); adults avoid juniors by default.
|
||||
AvoidJuniors = !isJunior,
|
||||
PlayerReputation = new mPlayerReputation
|
||||
{
|
||||
Noteriety = 0,
|
||||
CheerCredit = 20,
|
||||
CheerGeneral = 1,
|
||||
CheerHelpful = 1,
|
||||
CheerGreatHost = 1,
|
||||
CheerSportsman = 1,
|
||||
CheerCreative = 1,
|
||||
SubscriberCount = 0,
|
||||
SubscribedCount = 0,
|
||||
SelectedCheer = CheerCategoryEnum.None
|
||||
},
|
||||
PlatformIds = new List<mPlatformID>() // NO steam id yet
|
||||
},
|
||||
player_Extra = new player_extra
|
||||
{
|
||||
DormRoomID = 1,
|
||||
IpAddresses = new List<string>(),
|
||||
DiscordId = 0,
|
||||
Bio = "",
|
||||
avatar = "{\"FaceFeatures\": \"\",\"HairColor\": \"\",\"OutfitSelections\": \"\",\"SkinColor\": \"\"}",
|
||||
SavedOutfits = new List<avatar_data_saved>(),
|
||||
CheeredRooms = new List<ulong>(),
|
||||
FavoritedRooms = new List<ulong>(),
|
||||
sub_count = 0,
|
||||
SubbedTo = new List<ulong>(),
|
||||
IsRecentRoomHistoryVisible = true,
|
||||
Influencer = new Influencer { creatorCode = "", isInfluencer = false },
|
||||
CustomAvatarItems = new Campus_live_rewrite.api.server.Classes.db_class.CustomAvatarItems(),
|
||||
},
|
||||
dev_Flag = DevFlag.none
|
||||
});
|
||||
|
||||
HeartbeatDB.CreatePlayerHeartbeat(accid, out _, Platforms.All, DeviceClasses.Unknown);
|
||||
CreateDormForPlayerId(accid);
|
||||
|
||||
// Mark first-run onboarding + tutorial complete so the game goes straight in - the
|
||||
// player already chose username/password on the website (mirrors the Kyle seed in
|
||||
// Program.cs and SettingsDB.CreateDefaultSettings).
|
||||
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasStarted", "true", accid);
|
||||
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasFinished", "true", accid);
|
||||
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasChosenUsername", "true", accid);
|
||||
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasCreatedPassword","true", accid);
|
||||
SettingsDB.SetPlayerSetting("TUTORIAL_COMPLETE_MASK", "4294967295", accid);
|
||||
|
||||
Console.WriteLine($"[web-signup] created account {accid} @{username} (\"{displayName}\")"
|
||||
+ (email != null ? " with email" : ""));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The account's bound platform id for a platform (0 if none). Ignores the placeholder
|
||||
/// 0 entry that some legacy rows carry.
|
||||
/// </summary>
|
||||
public static ulong GetAccountPlatformId(ulong accid, Platforms platform)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var p = col.FindOne(x => x.playerid == accid);
|
||||
var ids = p?.player?.PlatformIds;
|
||||
if (ids == null) return 0;
|
||||
var match = ids.FirstOrDefault(pid => pid.Platform == platform && pid.PlatformId != 0);
|
||||
return match?.PlatformId ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>Append a platform id to the account if it is not already present.</summary>
|
||||
public static void AddPlatformId(ulong accid, Platforms platform, ulong platformId)
|
||||
{
|
||||
if (platformId == 0) return;
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var p = col.FindOne(x => x.playerid == accid);
|
||||
if (p?.player == null) return;
|
||||
p.player.PlatformIds ??= new List<mPlatformID>();
|
||||
if (p.player.PlatformIds.Any(pid => pid.Platform == platform && pid.PlatformId == platformId))
|
||||
return;
|
||||
p.player.PlatformIds.Add(new mPlatformID { Platform = platform, PlatformId = platformId });
|
||||
col.Update(p);
|
||||
Console.WriteLine($"[account-lock] bound {platform} id {platformId} to account {accid}");
|
||||
}
|
||||
|
||||
/// <summary>Every account carrying this platform id (used by the one-account-per-Steam lock).</summary>
|
||||
public static List<ulong> AccountsWithPlatformId(Platforms platform, ulong platformId)
|
||||
{
|
||||
if (platformId == 0) return new List<ulong>();
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
return col.FindAll()
|
||||
.Where(x => x.player?.PlatformIds != null &&
|
||||
x.player.PlatformIds.Any(pid => pid.Platform == platform && pid.PlatformId == platformId))
|
||||
.Select(x => x.playerid)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ---- website signup rate limit -----------------------------------------------------
|
||||
// One successful account creation per IP per ConfigCampus.SignupRateLimitDays (default 7).
|
||||
// Persisted so a service restart does not reset the window. A tiny row per signup - the
|
||||
// volume is human-scale, not request-scale.
|
||||
public class WebSignupRecord
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Ip { get; set; }
|
||||
public ulong AccountId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The limiter's verdict: which layer refused, and when it frees up.</summary>
|
||||
public enum SignupLimit { None, Ip, Subnet, Global }
|
||||
|
||||
/// <summary>
|
||||
/// May this IP register right now? Checks, in order, the global per-hour cap, the per-IP
|
||||
/// day/week caps and the /24 subnet cap (see ConfigCampus.SignupsPer*). On refusal says
|
||||
/// which layer refused and the earliest time it frees up, so the caller can tell the
|
||||
/// player something true. Blank/loopback/allowlisted IPs skip the per-address layers.
|
||||
/// </summary>
|
||||
public static SignupLimit CheckSignupLimit(string? ip, out DateTime nextAllowedUtc)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
nextAllowedUtc = now;
|
||||
// Admin-allowlisted IPs test signup flows without tripping the caps (see
|
||||
// ConfigCampus.AdminRateLimitEnabled).
|
||||
if (AdminProtectionMiddleware.IsLimitExempt(ip)) return SignupLimit.None;
|
||||
var col = PlayerDBFile.GetCollection<WebSignupRecord>("web_signups");
|
||||
col.EnsureIndex(r => r.Ip);
|
||||
col.EnsureIndex(r => r.CreatedAt);
|
||||
|
||||
// Global cap. This is a MASS-CREATION circuit breaker, not a growth cap, and for a
|
||||
// long time it behaved as the latter: at 15/hour across every address on the internet
|
||||
// it was the ONLY limit that ever fired. In one 12-hour window it refused 399 signups
|
||||
// - 120 of them headsets retrying create_account, 34 distinct devices - while letting
|
||||
// 19 through, and not one refusal came from the per-IP or per-subnet caps. Every real
|
||||
// player who arrived after the 15th that hour was told "try again later"; on Quest
|
||||
// that is a hard login failure the client then retries every ~8 s forever.
|
||||
//
|
||||
// So volume alone no longer trips it. Mass creation is CONCENTRATED - one actor behind
|
||||
// a handful of networks - because the per-IP (1/day) and per-/24 (4/day) caps already
|
||||
// bound what any single network can produce. Organic growth is DIFFUSE: a hundred
|
||||
// strangers on a hundred networks. The breaker now fires only when an hour is both
|
||||
// over the ceiling AND coming from few enough networks to be one actor.
|
||||
if (ConfigCampus.SignupsPerHour > 0)
|
||||
{
|
||||
var hourAgo = now.AddHours(-1);
|
||||
var lastHour = col.Find(r => r.CreatedAt >= hourAgo).OrderBy(r => r.CreatedAt).ToList();
|
||||
if (lastHour.Count >= ConfigCampus.SignupsPerHour)
|
||||
{
|
||||
// Distinct networks behind the hour. An unattributable IP counts as its own
|
||||
// network rather than being pooled with the other unattributable ones, so a
|
||||
// flood cannot look diffuse just by stripping the address.
|
||||
int networks = lastHour
|
||||
.Select(r => SubnetKey(r.Ip) ?? ("ip:" + (r.Ip ?? "")))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Count();
|
||||
|
||||
if (networks < ConfigCampus.SignupBurstDistinctNetworks)
|
||||
{
|
||||
nextAllowedUtc = lastHour[lastHour.Count - ConfigCampus.SignupsPerHour].CreatedAt.AddHours(1);
|
||||
Console.WriteLine($"[signup-guard] GLOBAL cap tripped: {lastHour.Count} signups in the last hour " +
|
||||
$"from only {networks} distinct network(s) - mass creation, not growth");
|
||||
return SignupLimit.Global;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[signup-guard] {lastHour.Count} signups in the last hour across {networks} " +
|
||||
$"distinct networks - over the ceiling but diffuse, allowed");
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ip)) return SignupLimit.None;
|
||||
ip = ip.Trim();
|
||||
if (ip == "127.0.0.1" || ip == "::1" || ip == "localhost") return SignupLimit.None;
|
||||
if (ConfigCampus.SignupExemptIps != null && ConfigCampus.SignupExemptIps.Contains(ip)) return SignupLimit.None;
|
||||
|
||||
var weekAgo = now.AddDays(-7);
|
||||
var dayAgo = now.AddDays(-1);
|
||||
var mine = col.Find(r => r.Ip == ip && r.CreatedAt >= weekAgo).OrderBy(r => r.CreatedAt).ToList();
|
||||
|
||||
if (ConfigCampus.SignupsPerIpPerWeek > 0 && mine.Count >= ConfigCampus.SignupsPerIpPerWeek)
|
||||
{
|
||||
nextAllowedUtc = mine[mine.Count - ConfigCampus.SignupsPerIpPerWeek].CreatedAt.AddDays(7);
|
||||
return SignupLimit.Ip;
|
||||
}
|
||||
var mineToday = mine.Where(r => r.CreatedAt >= dayAgo).ToList();
|
||||
if (ConfigCampus.SignupsPerIpPerDay > 0 && mineToday.Count >= ConfigCampus.SignupsPerIpPerDay)
|
||||
{
|
||||
nextAllowedUtc = mineToday[mineToday.Count - ConfigCampus.SignupsPerIpPerDay].CreatedAt.AddDays(1);
|
||||
return SignupLimit.Ip;
|
||||
}
|
||||
|
||||
// Network block - /24 for IPv4, /64 for IPv6. Same window as the per-IP day cap.
|
||||
string? block = SubnetKey(ip);
|
||||
if (block != null && ConfigCampus.SignupsPerSubnetPerDay > 0)
|
||||
{
|
||||
// Compare on the computed KEY, not a text prefix: one IPv6 address can be written
|
||||
// several ways ("2001:db8::1" / "2001:db8:0:0::1"), so matching the stored string
|
||||
// missed hosts that a real /64 comparison catches.
|
||||
var inBlock = col.Find(r => r.CreatedAt >= dayAgo)
|
||||
.Where(r => string.Equals(SubnetKey(r.Ip), block, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(r => r.CreatedAt).ToList();
|
||||
if (inBlock.Count >= ConfigCampus.SignupsPerSubnetPerDay)
|
||||
{
|
||||
nextAllowedUtc = inBlock[inBlock.Count - ConfigCampus.SignupsPerSubnetPerDay].CreatedAt.AddDays(1);
|
||||
return SignupLimit.Subnet;
|
||||
}
|
||||
}
|
||||
return SignupLimit.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The network an address belongs to: the /24 for IPv4, the /64 for IPv6, null when the
|
||||
/// string is not an address at all.
|
||||
///
|
||||
/// IPv6 used to return null, so an IPv6 client had NO subnet cap - the per-/24 rule simply
|
||||
/// did not apply to it and the global hourly ceiling was the only thing covering that
|
||||
/// hole, which is part of why the ceiling had to be set so low. A /64 is what a
|
||||
/// residential customer is handed, so it is the IPv6 equivalent of the /24 here.
|
||||
/// </summary>
|
||||
private static string? SubnetKey(string? ip)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ip)) return null;
|
||||
if (!System.Net.IPAddress.TryParse(ip.Trim(), out var addr)) return null;
|
||||
var b = addr.GetAddressBytes();
|
||||
|
||||
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
return "v4:" + b[0] + "." + b[1] + "." + b[2];
|
||||
|
||||
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder("v6:");
|
||||
for (int i = 0; i < 8; i++) sb.Append(b[i].ToString("x2")); // the /64 prefix
|
||||
return sb.ToString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void RecordSignup(string? ip, ulong accountId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<WebSignupRecord>("web_signups");
|
||||
col.Insert(new WebSignupRecord
|
||||
{
|
||||
Ip = string.IsNullOrWhiteSpace(ip) ? "" : ip.Trim(),
|
||||
AccountId = accountId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
public static bool ImportPlayers(string importPath)
|
||||
{
|
||||
Console.WriteLine($"[IMPORT] Starting player import");
|
||||
Console.WriteLine($"[IMPORT] Import path: {importPath}");
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(importPath))
|
||||
{
|
||||
Console.WriteLine($"[ERROR] Import file not found: {importPath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine("[IMPORT] Reading import file...");
|
||||
var json = File.ReadAllText(importPath);
|
||||
Console.WriteLine($"[IMPORT] File size: {json.Length} bytes");
|
||||
|
||||
Console.WriteLine("[IMPORT] Deserializing player data...");
|
||||
var importedPlayers = JsonConvert.DeserializeObject<List<player_data>>(json);
|
||||
|
||||
if (importedPlayers == null)
|
||||
{
|
||||
Console.WriteLine("[ERROR] Deserialization returned null (invalid JSON?)");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (importedPlayers.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[WARN] Import file contained 0 players");
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[IMPORT] Players found in file: {importedPlayers.Count}");
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
int inserted = 0;
|
||||
int skipped = 0;
|
||||
|
||||
foreach (var player in importedPlayers)
|
||||
{
|
||||
if (player == null)
|
||||
{
|
||||
Console.WriteLine("[WARN] Encountered null player entry, skipping");
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[IMPORT] Processing player ID: {player.id}");
|
||||
|
||||
try
|
||||
{
|
||||
// Avoid duplicate IDs
|
||||
if (col.Exists(x => x.id == player.id))
|
||||
{
|
||||
Console.WriteLine($"[SKIP] Duplicate player ID {player.id}");
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
player.player_Extra.DormRoomID = RoomDB.clone_dorm_room(player.playerid);
|
||||
|
||||
col.Insert(player);
|
||||
var name = player.player.Username ?? "<unknown>";
|
||||
var displayName = player.player.DisplayName ?? "<unknown>";
|
||||
var displayEmoji = player.player.DisplayEmoji ?? "";
|
||||
Console.WriteLine($"[OK] Inserted player ({player.id}) @{name} {displayName} {displayEmoji}");
|
||||
inserted++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[ERROR] Failed to insert player ID {player.id}: {ex.Message}");
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("[IMPORT] Import completed");
|
||||
Console.WriteLine($"[IMPORT] Inserted: {inserted}");
|
||||
Console.WriteLine($"[IMPORT] Skipped: {skipped}");
|
||||
Console.WriteLine($"[IMPORT] Total processed: {importedPlayers.Count}");
|
||||
|
||||
return inserted > 0;
|
||||
}
|
||||
catch (JsonException jex)
|
||||
{
|
||||
Console.WriteLine($"[ERROR] JSON parsing error: {jex.Message}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[ERROR] Import failed with exception: {ex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ExportPlayers(string exportPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var allPlayers = col.FindAll().ToList();
|
||||
|
||||
var json = JsonConvert.SerializeObject(allPlayers, Formatting.Indented);
|
||||
File.WriteAllText(exportPath, json);
|
||||
|
||||
Console.WriteLine($"Exported {allPlayers.Count} players to {exportPath}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Export failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool CreateDormForPlayerId(ulong playerid, bool log = false)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
var player = col.FindOne(x => x.player.Id == playerid);
|
||||
|
||||
// Was: no null check, so an unknown player id threw here instead of
|
||||
// reporting failure to the caller.
|
||||
if (player == null)
|
||||
{
|
||||
Console.WriteLine($"[CreateDormForPlayerId] no player row for id {playerid}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (player.player_Extra != null)
|
||||
{
|
||||
ulong roomid = RoomDB.clone_dorm_room(playerid);
|
||||
player.player_Extra.DormRoomID = roomid;
|
||||
col.Update(player);
|
||||
if (log)
|
||||
{
|
||||
Console.WriteLine($"Room id is {roomid} and successfully updaeted player");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void EnsureDormsForAllPlayers()
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
// Get all players
|
||||
var players = col.FindAll();
|
||||
|
||||
foreach (var player in players)
|
||||
{
|
||||
if (player?.player == null || player.player_Extra == null)
|
||||
continue;
|
||||
|
||||
// Missing or invalid dorm
|
||||
if (player.player_Extra.DormRoomID == null || player.player_Extra.DormRoomID == 1)
|
||||
{
|
||||
ulong roomId = RoomDB.clone_dorm_room(player.player.Id);
|
||||
player.player_Extra.DormRoomID = roomId;
|
||||
col.Update(player);
|
||||
Console.WriteLine($"Created dorm {roomId} for player {player.player.Id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void EnsureDormsDontBeStupid(bool log = false)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var players = col.FindAll();
|
||||
int fixCount = 0;
|
||||
|
||||
foreach (var player in players)
|
||||
{
|
||||
if (player?.player == null || player.player_Extra == null)
|
||||
continue;
|
||||
|
||||
var room = RoomDB.GetRoom((ulong)player.player_Extra.DormRoomID);
|
||||
|
||||
// Check if the room is missing or simply isn't flagged as a Dorm
|
||||
if (room == null || !room.IsDorm)
|
||||
{
|
||||
if (log)
|
||||
{
|
||||
string reason = (room == null) ? "Room Missing" : "Room not a Dorm";
|
||||
Console.WriteLine($"[DormFix] Player: {player.player.Username} ({player.player.Id}) | Reason: {reason}");
|
||||
}
|
||||
|
||||
CreateDormForPlayerId(player.player.Id);
|
||||
fixCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (log && fixCount > 0)
|
||||
{
|
||||
Console.WriteLine($"[DormFix] Finished. Fixed {fixCount} player dorm records.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static ulong GetDormId(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
var player = col.FindOne(x => x.player.Id == playerid);
|
||||
|
||||
if (playerid == 1)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Was: dereferenced player.player_Extra with no null check, so an unknown
|
||||
// player id threw NullReferenceException here.
|
||||
if (player == null)
|
||||
{
|
||||
Console.WriteLine($"[GetDormId] no player row for id {playerid} - no dorm");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (player.player_Extra != null)
|
||||
{
|
||||
return (ulong)player.player_Extra.DormRoomID;
|
||||
}
|
||||
|
||||
// Was: `return 1`. That is player 1's dorm, so any player whose row had no
|
||||
// player_Extra was silently handed SOMEONE ELSE'S dorm room. 0 means "no
|
||||
// dorm yet" and makes the caller create a real one.
|
||||
Console.WriteLine($"[GetDormId] player {playerid} has no player_Extra - no dorm assigned yet");
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static bool GetLogins(Platforms platform, ulong platformId, out List<AccountLogin> accounts)
|
||||
{
|
||||
accounts = new List<AccountLogin>();
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
// Pull all players (or consider filtering by another simple field if possible)
|
||||
var allPlayers = col.FindAll().ToList();
|
||||
|
||||
// Filter in memory
|
||||
var players = allPlayers
|
||||
.Where(player => player.player.PlatformIds != null &&
|
||||
player.player.PlatformIds.Any(pid => pid.Platform == platform &&
|
||||
pid.PlatformId == platformId))
|
||||
.ToList();
|
||||
|
||||
if (players.Count == 0)
|
||||
return false;
|
||||
|
||||
foreach (var player in players)
|
||||
{
|
||||
var pid = player.player.PlatformIds.First(p => p.Platform == platform &&
|
||||
p.PlatformId == platformId);
|
||||
|
||||
accounts.Add(new AccountLogin
|
||||
{
|
||||
accountId = player.playerid,
|
||||
createdAt = player.player.created_at,
|
||||
lastLoginTime = player.player.last_login_time,
|
||||
platform = (Platforms)pid.Platform,
|
||||
platformId = pid.PlatformId.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
accounts = accounts
|
||||
.OrderByDescending(a => a.lastLoginTime)
|
||||
.ToList();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// A synthesized stand-in for an account id that has no DB row. Mirrors RecFlare's
|
||||
// defaultAccount(): a "Player{id}" placeholder so the client can render a card instead
|
||||
// of choking on a 404 / missing entry. The account_data_web getters supply the rest of
|
||||
// the coalesced defaults (profileImage -> DefaultProfileImage.jpg, banner/emoji -> "").
|
||||
private static account_data_web DefaultAccountWeb(ulong id)
|
||||
{
|
||||
// The login placeholder (see PlatformIdentity.LoginPlaceholderAccountId): the headset's
|
||||
// cached-login picker asks for this id to label the "type your password" entry. Answer
|
||||
// with a neutral identity, not "Player0" - that read as a real account failing to log in.
|
||||
if (id == Campus_live_rewrite.api.server.PlatformIdentity.LoginPlaceholderAccountId)
|
||||
return new account_data_web
|
||||
{
|
||||
accountId = id,
|
||||
createdAt = DateTime.UtcNow,
|
||||
username = Campus_live_rewrite.api.server.PlatformIdentity.LoginPlaceholderUsername,
|
||||
displayName = Campus_live_rewrite.api.server.PlatformIdentity.LoginPlaceholderDisplayName,
|
||||
profileImage = "DefaultPFP.png",
|
||||
isJunior = false,
|
||||
identityFlags = 0,
|
||||
personalPronouns = 0,
|
||||
platforms = 1 // Oculus - the platform the placeholder is served on
|
||||
};
|
||||
return new account_data_web
|
||||
{
|
||||
accountId = id,
|
||||
createdAt = DateTime.UtcNow,
|
||||
username = $"Player{id}",
|
||||
displayName = $"Player{id}",
|
||||
isJunior = false,
|
||||
identityFlags = 0,
|
||||
personalPronouns = 0,
|
||||
platforms = 0
|
||||
};
|
||||
}
|
||||
|
||||
// A valid, non-negative `platforms` value for an account DTO. The account's primary
|
||||
// platform int (Steam=0, Oculus=1, ...), or 0 if none is known. NEVER returns -1: the
|
||||
// old code seeded a Platforms.All (-1) accumulator and OR'd onto it, so it was always
|
||||
// -1, and -1 is not a real platform - the client's strict account parser rejects it and
|
||||
// then NO player card / profile renders (own or others'). Same class as statusVisibility=4
|
||||
// and deviceClass=-1.
|
||||
public static long PrimaryPlatformValue(player_data? player)
|
||||
{
|
||||
var firstReal = player?.player?.PlatformIds?
|
||||
.Select(p => (int)p.Platform)
|
||||
.FirstOrDefault(v => v >= 0) ?? -1;
|
||||
if (firstReal >= 0) return firstReal;
|
||||
if (player != null && (int)player.platform >= 0) return (int)player.platform;
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static string GetAccountsBulk(List<ulong> playerIds)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
col.EnsureIndex(x => x.id);
|
||||
|
||||
// ONE entry per requested id, in the requested set. A missing row is SYNTHESIZED
|
||||
// (a Player{id} default), never dropped or 404'd - otherwise a profile / friends
|
||||
// list / player list that references a deleted or never-existed id (e.g. the purged
|
||||
// orphan ids 2,3,5) renders that card BLANK. This is exactly why single-account
|
||||
// reads were 404ing (AccountById -> here -> empty array -> 404). Matches RecFlare's
|
||||
// bulk, which guarantees one entry per requested id via defaultAccount().
|
||||
var requested = playerIds?.Distinct().ToList() ?? new List<ulong>();
|
||||
if (requested.Count == 0) return "[]";
|
||||
|
||||
var found = col.Find(x => requested.Contains(x.playerid))
|
||||
.Where(p => p?.player != null)
|
||||
.GroupBy(p => p.playerid)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
var accounts = requested
|
||||
.Select(id =>
|
||||
{
|
||||
if (!found.TryGetValue(id, out var player))
|
||||
return DefaultAccountWeb(id);
|
||||
|
||||
// `platforms` MUST be a valid, non-negative number. It used to start at
|
||||
// Platforms.All (-1) and OR onto it, so it was ALWAYS -1 - and -1 is not a
|
||||
// real platform. The RecFlare account DTO emits a plain small number (0 by
|
||||
// default; the reference never combines PlatformIds into it). A -1 here is
|
||||
// the same out-of-range trap as statusVisibility=4 / deviceClass=-1: the
|
||||
// client's strict account parser chokes on it and NO player card / profile
|
||||
// renders (own or others'). Emit the account's primary platform int (Steam=0,
|
||||
// Oculus=1, ...), defaulting to 0, never -1.
|
||||
long platformsValue = 0;
|
||||
var firstReal = player.player.PlatformIds?
|
||||
.Select(p => (int)p.Platform)
|
||||
.FirstOrDefault(v => v >= 0) ?? -1;
|
||||
if (firstReal >= 0) platformsValue = firstReal;
|
||||
else if ((int)player.platform >= 0) platformsValue = (int)player.platform;
|
||||
|
||||
return new account_data_web
|
||||
{
|
||||
accountId = player.playerid,
|
||||
createdAt = player.player.created_at,
|
||||
displayName = player.player.DisplayName,
|
||||
displayEmoji = player.player.DisplayEmoji,
|
||||
username = player.player.Username,
|
||||
identityFlags = player.player.identityFlags,
|
||||
isJunior = player.player.IsJunior,
|
||||
personalPronouns = player.player.pronounFlags,
|
||||
platforms = platformsValue,
|
||||
profileImage = player.player.profileImage,
|
||||
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage
|
||||
};
|
||||
})
|
||||
.OrderBy(a => a.accountId)
|
||||
.ToList();
|
||||
|
||||
return JsonConvert.SerializeObject(accounts);
|
||||
}
|
||||
|
||||
public static string GetAccountsBulkAdmin(List<ulong> playerIds)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
col.EnsureIndex(x => x.id);
|
||||
|
||||
var players = col.Find(x => playerIds.Contains(x.playerid)).ToList();
|
||||
if (players.Count == 0)
|
||||
{
|
||||
return "[]";
|
||||
}
|
||||
|
||||
var accounts = players
|
||||
.Select(player =>
|
||||
{
|
||||
return new
|
||||
{
|
||||
accountId = player.playerid,
|
||||
createdAt = player.player.created_at,
|
||||
displayName = player.player.DisplayName,
|
||||
displayEmoji = player.player.DisplayEmoji,
|
||||
username = player.player.Username,
|
||||
identityFlags = player.player.identityFlags,
|
||||
isJunior = player.player.IsJunior,
|
||||
personalPronouns = player.player.pronounFlags,
|
||||
platforms = PrimaryPlatformValue(player),
|
||||
profileImage = player.player.profileImage,
|
||||
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage,
|
||||
bio = player.player_Extra.Bio,
|
||||
mbd = JsonConvert.DeserializeObject(PlayerDB.Get_moderation_block(player.playerid)),
|
||||
PlatformIDs = player.player.PlatformIds.Select(p => new
|
||||
{
|
||||
p.Platform,
|
||||
PlatformId = p.PlatformId.ToString(),
|
||||
// Username = Steam.GetAccountInfoAsync(p.PlatformId.ToString()).Result?.PersonaName ?? ""
|
||||
}),
|
||||
level = player.player.Level,
|
||||
xp = player.player.XP
|
||||
};
|
||||
})
|
||||
.OrderBy(a => a.accountId)
|
||||
.ToList();
|
||||
|
||||
return JsonConvert.SerializeObject(accounts);
|
||||
}
|
||||
|
||||
|
||||
public static player_data? getfullaccount_token(ulong playerid, ulong platformid, int platform)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
var ids = player.player.PlatformIds;
|
||||
if (ids == null || ids.Count == 0)
|
||||
return null;
|
||||
|
||||
bool match = ids.Any(p => p.PlatformId == platformid);
|
||||
|
||||
return match ? player : null;
|
||||
}
|
||||
|
||||
/*public static player_data? GetFullAccount(ulong playerid, ulong plaformid, int plaform, string device_id)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (player.platformId == plaformid && player.deviceId.Contains(device_id))
|
||||
return player;
|
||||
return null;
|
||||
}
|
||||
*/
|
||||
|
||||
public static player_data? GetFullAccount(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
|
||||
if (player == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
// ---- Currency balances ------------------------------------------------
|
||||
// Stored per player in player_extra.Balances (CurrencyType -> amount).
|
||||
|
||||
public static long GetBalance(ulong playerId, int currencyType)
|
||||
{
|
||||
var acc = GetFullAccount(playerId);
|
||||
if (acc?.player_Extra?.Balances == null) return 0;
|
||||
return acc.player_Extra.Balances.TryGetValue(currencyType, out var v) ? v : 0;
|
||||
}
|
||||
|
||||
public static Dictionary<int, long> GetAllBalances(ulong playerId)
|
||||
{
|
||||
var acc = GetFullAccount(playerId);
|
||||
return acc?.player_Extra?.Balances ?? new Dictionary<int, long>();
|
||||
}
|
||||
|
||||
/// <summary>Adds (or subtracts) an amount and returns the NEW balance. Clamped at 0.</summary>
|
||||
public static long AddBalance(ulong playerId, int currencyType, long delta)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerId);
|
||||
if (player == null) return 0;
|
||||
|
||||
player.player_Extra ??= new player_extra();
|
||||
player.player_Extra.Balances ??= new Dictionary<int, long>();
|
||||
|
||||
long current = player.player_Extra.Balances.TryGetValue(currencyType, out var v) ? v : 0;
|
||||
long updated = current + delta;
|
||||
if (updated < 0) updated = 0;
|
||||
player.player_Extra.Balances[currencyType] = updated;
|
||||
|
||||
col.Update(player);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public static player_data? GetFullAccountByName(string username)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.player.Username == username);
|
||||
|
||||
if (player == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
public static player_info? GetAccountById(ulong id)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
var player = col.FindById(id);
|
||||
return player?.player;
|
||||
}
|
||||
|
||||
public static account_data_me? GetAccountMeSafe(ulong playerid)
|
||||
{
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// See PrimaryPlatformValue: a valid, non-negative platform number, NEVER -1 (which
|
||||
// is what the old Platforms.All accumulator always produced and which breaks the
|
||||
// client's account parser - the owner's OWN /account/me card wouldn't render).
|
||||
account_data_me account = new account_data_me
|
||||
{
|
||||
accountId = player.playerid,
|
||||
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage,
|
||||
createdAt = player.player.created_at,
|
||||
displayName = player.player.DisplayName,
|
||||
email = player.player.Email,
|
||||
displayEmoji = player.player.DisplayEmoji,
|
||||
username = player.player.Username,
|
||||
identityFlags = player.player.identityFlags,
|
||||
isJunior = player.player.IsJunior,
|
||||
// Juniors are treated as juniors client-side (chat/social restrictions).
|
||||
TreatAsJunior = player.player.IsJunior,
|
||||
personalPronouns = player.player.pronounFlags,
|
||||
platforms = PrimaryPlatformValue(player),
|
||||
profileImage = player.player.profileImage,
|
||||
};
|
||||
// The client requires a present, valid birthday. Use the real one entered at signup
|
||||
// when we have it; otherwise keep the fixed placeholder the DTO defaults to.
|
||||
if (!string.IsNullOrWhiteSpace(player.player.Birthday))
|
||||
{
|
||||
account.birthday = player.player.Birthday;
|
||||
account.hasBirthday = true;
|
||||
}
|
||||
else if (string.Equals(SettingsDB.GetPlayerSettingValue("Recroom.AccountCreation.HasFinished", player.playerid), "false", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// An in-game account still in its creation flow with no birthday on record: tell
|
||||
// the client so its title-screen account-creation flow (birthday -> avatar ->
|
||||
// username -> password) has a page it cannot skip. Website accounts entered a
|
||||
// birthday at signup and never hit this.
|
||||
account.hasBirthday = false;
|
||||
}
|
||||
/*
|
||||
account_data_me account = new account_data_me
|
||||
{
|
||||
accountId = player.playerid,
|
||||
bannerImage = string.IsNullOrEmpty(player.player.bannerImage) ? Campus_live_rewrite.api.server.ConfigCampus.DefaultBannerImage : player.player.bannerImage,
|
||||
createdAt = player.player.created_at,
|
||||
displayName = player.player.DisplayName,
|
||||
displayEmoji = player.player.DisplayEmoji,
|
||||
username = player.player.Username,
|
||||
identityFlags = player.player.identityFlags,
|
||||
isJunior = player.player.IsJunior,
|
||||
personalPronouns = player.player.pronounFlags,
|
||||
platforms = (long)combinedPlatforms,
|
||||
profileImage = player.player.profileImage,
|
||||
};
|
||||
*/
|
||||
return account;
|
||||
|
||||
}
|
||||
|
||||
public static string Get_moderation_block(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player == null)
|
||||
{
|
||||
return JsonConvert.SerializeObject(new Moderation_Detail.ModerationBlockDetails());
|
||||
}
|
||||
|
||||
var block = player.player_Extra.ModerationBlockDetails;
|
||||
|
||||
// Only an ACTIVE penalty is a moderation block. A stored record with Duration <= 0 (or
|
||||
// no set-time) is NOT active - e.g. a logged report with no penalty applied
|
||||
// (IsBan=false, Duration=0). The client counts ANY non-empty moderationBlockDetails as a
|
||||
// recent moderation incident and then blocks club/room creation ("Creation Restricted -
|
||||
// too many moderation incidents"), so a non-active record MUST read back as empty.
|
||||
if (block == null || block.Duration <= 0 || block.ModerationSetUnixTime <= 0)
|
||||
return JsonConvert.SerializeObject(new Moderation_Detail.ModerationBlockDetails());
|
||||
|
||||
// Permanent ban.
|
||||
if (block.Duration == int.MaxValue)
|
||||
return JsonConvert.SerializeObject(block);
|
||||
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
int elapsed = (int)(now - block.ModerationSetUnixTime);
|
||||
int remaining = block.Duration - elapsed;
|
||||
|
||||
if (remaining <= 0)
|
||||
{
|
||||
// Timeout has run out - clear it and report no block.
|
||||
player.player_Extra.ModerationBlockDetails = null;
|
||||
col.Update(player);
|
||||
return JsonConvert.SerializeObject(new Moderation_Detail.ModerationBlockDetails());
|
||||
}
|
||||
|
||||
// Still serving time: report the block with the time that's left.
|
||||
block.Duration = remaining;
|
||||
return JsonConvert.SerializeObject(block);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static string Set_moderation_block(
|
||||
ulong playerid,
|
||||
Moderation_Detail.ReportCategory category,
|
||||
int duration,
|
||||
long gameSessionId,
|
||||
bool? isHostKick,
|
||||
string? message,
|
||||
ulong? playerIdReporter,
|
||||
bool? isBan,
|
||||
ulong bannedByPlayerId,
|
||||
bool addAppealMessage = true
|
||||
)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player == null)
|
||||
return "{}";
|
||||
if (addAppealMessage && isBan == true)
|
||||
{
|
||||
message += "\nTo appeal this ban, Make a ticket in https://discord.gg/xH8MMwK5Bk.";
|
||||
}
|
||||
var newBlock = new Moderation_Detail.ModerationBlockDetails
|
||||
{
|
||||
ReportCategory = category,
|
||||
Duration = duration,
|
||||
GameSessionId = gameSessionId,
|
||||
IsHostKick = isHostKick,
|
||||
Message = message,
|
||||
PlayerIdReporter = playerIdReporter,
|
||||
IsBan = isBan,
|
||||
ModerationSetUnixTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
BannedByPlayerId = bannedByPlayerId
|
||||
};
|
||||
|
||||
player.player_Extra.ModerationBlockDetails = newBlock;
|
||||
col.Update(player);
|
||||
|
||||
// Every moderation action is logged with WHO did it to WHOM and why. Without this
|
||||
// there is no way to tell a real moderator action from a bug calling this path -
|
||||
// and a 1-second isBan looks identical to "the game keeps closing itself" from the
|
||||
// player's side, because the block clears before anyone can inspect it.
|
||||
Console.WriteLine(
|
||||
$"[moderation] target={playerid} by={bannedByPlayerId} " +
|
||||
$"isBan={isBan} isHostKick={isHostKick} duration={duration}s " +
|
||||
$"category={category} reporter={playerIdReporter?.ToString() ?? "-"} " +
|
||||
$"msg=\"{(message ?? "").Replace("\n", " ")}\"");
|
||||
|
||||
// A ban has to actually REMOVE them from the room.
|
||||
//
|
||||
// The client drops to its dorm and then resyncs against the presence heartbeat
|
||||
// - and the heartbeat still said "you are in room X", so it put the banned
|
||||
// player straight back into the room they were just banned from. Clearing the
|
||||
// room instance means the resync has nowhere to send them.
|
||||
if (isBan == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
HeartbeatDB.UpdatePlayerHeartbeat(playerid, null, online: false);
|
||||
Console.WriteLine($"[ban] cleared room instance for banned player {playerid}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[ban] could not clear heartbeat for {playerid}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Notifications.SendToPlayer(playerid, JsonConvert.SerializeObject(WebsocketEvents.CreateBan(newBlock)));
|
||||
}
|
||||
catch { }
|
||||
|
||||
return JsonConvert.SerializeObject(newBlock);
|
||||
}
|
||||
|
||||
|
||||
public static bool HasActiveModerationBlock(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player == null)
|
||||
return false;
|
||||
|
||||
var block = player.player_Extra.ModerationBlockDetails;
|
||||
if (block == null || block.Duration <= 0 || block.ModerationSetUnixTime <= 0)
|
||||
return false;
|
||||
|
||||
if (block.Duration == int.MaxValue) // Permanent ban
|
||||
return true;
|
||||
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
int elapsed = (int)(now - block.ModerationSetUnixTime);
|
||||
int remaining = block.Duration - elapsed;
|
||||
|
||||
if (remaining <= 0)
|
||||
{
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool CheckDevFlag(ulong id)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.id == id);
|
||||
|
||||
if (player != null && (player.dev_Flag & DevFlag.dev) == DevFlag.dev)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool CheckModFlag(ulong id)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.id == id);
|
||||
|
||||
if (player != null && (player.dev_Flag & DevFlag.mod) == DevFlag.mod)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void set_player_avatar(string avatar, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
|
||||
// Was: guarded `player_Setting1.player != null` but then wrote to
|
||||
// player_Setting1.player_Extra - the wrong field. A row with a null
|
||||
// player_Extra threw NullReferenceException while saving the avatar.
|
||||
if (player_Setting1?.player_Extra != null)
|
||||
{
|
||||
player_Setting1.player_Extra.avatar = avatar;
|
||||
col.Update(player_Setting1);
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[set_player_avatar] no player_Extra for {playerid} - avatar not saved");
|
||||
}
|
||||
|
||||
public static (bool Cheered, bool Favorited) GetInteraction(ulong playerid, ulong roomid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player == null)
|
||||
return (false, false);
|
||||
|
||||
bool cheered = player.player_Extra.CheeredRooms?.Contains(roomid) ?? false;
|
||||
bool favorited = player.player_Extra.FavoritedRooms?.Contains(roomid) ?? false;
|
||||
|
||||
return (cheered, favorited);
|
||||
}
|
||||
|
||||
public static bool ToggleCheer(ulong playerid, ulong roomid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player == null) return false;
|
||||
|
||||
if (player.player_Extra.CheeredRooms == null)
|
||||
player.player_Extra.CheeredRooms = new List<ulong>();
|
||||
|
||||
bool nowCheered;
|
||||
if (player.player_Extra.CheeredRooms.Contains(roomid))
|
||||
{
|
||||
player.player_Extra.CheeredRooms.Remove(roomid);
|
||||
nowCheered = false;
|
||||
RoomDB.UpdateRoomCheerCount(roomid, -1);
|
||||
}
|
||||
else
|
||||
{
|
||||
player.player_Extra.CheeredRooms.Add(roomid);
|
||||
nowCheered = true;
|
||||
RoomDB.UpdateRoomCheerCount(roomid, +1);
|
||||
}
|
||||
|
||||
col.Update(player);
|
||||
return nowCheered;
|
||||
}
|
||||
|
||||
public static bool ToggleFavorite(ulong playerid, ulong roomid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player == null) return false;
|
||||
|
||||
if (player.player_Extra.FavoritedRooms == null)
|
||||
player.player_Extra.FavoritedRooms = new List<ulong>();
|
||||
|
||||
bool nowFavorited;
|
||||
if (player.player_Extra.FavoritedRooms.Contains(roomid))
|
||||
{
|
||||
player.player_Extra.FavoritedRooms.Remove(roomid);
|
||||
nowFavorited = false;
|
||||
RoomDB.UpdateRoomFavoriteCount(roomid, -1);
|
||||
}
|
||||
else
|
||||
{
|
||||
player.player_Extra.FavoritedRooms.Add(roomid);
|
||||
nowFavorited = true;
|
||||
RoomDB.UpdateRoomFavoriteCount(roomid, +1);
|
||||
}
|
||||
|
||||
col.Update(player);
|
||||
return nowFavorited;
|
||||
}
|
||||
|
||||
public static List<account_data> Search(string text)
|
||||
{
|
||||
// GET /Accounts/account/search with no `name` query parameter passes null
|
||||
// here, and the first thing the loop does is text.StartsWith("@") - an
|
||||
// unhandled NullReferenceException that Kestrel turns into a 500.
|
||||
// Verified live: probing the route with no argument returned HTTP 500.
|
||||
// An empty search matches nothing, so return an empty list.
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return new List<account_data>();
|
||||
|
||||
// Match on USERNAME first (the reference server prefix-matches username, and the
|
||||
// People search is by @handle), and also on DISPLAY NAME so searching the shown name
|
||||
// works too. Case-insensitive Contains, which is a superset of the reference's prefix
|
||||
// match. A leading "@" is stripped so "@eli" and "eli" behave the same.
|
||||
//
|
||||
// The previous version only checked username when the query STARTED WITH "@", and
|
||||
// otherwise matched display name ONLY - so searching a plain "eli" (which is a
|
||||
// USERNAME) never checked usernames and returned []. It also left `found=true` when a
|
||||
// row had a null DisplayName, which quietly matched every such account.
|
||||
string q = text.Trim().TrimStart('@').ToLowerInvariant();
|
||||
if (q.Length == 0)
|
||||
return new List<account_data>();
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
List<account_data> list = new List<account_data>();
|
||||
foreach (player_data room in col.FindAll())
|
||||
{
|
||||
var p = room?.player;
|
||||
if (p == null)
|
||||
continue;
|
||||
|
||||
bool matchUsername = !string.IsNullOrEmpty(p.Username)
|
||||
&& p.Username.ToLowerInvariant().Contains(q);
|
||||
bool matchDisplay = !string.IsNullOrEmpty(p.DisplayName)
|
||||
&& p.DisplayName.ToLowerInvariant().Contains(q);
|
||||
|
||||
if (!matchUsername && !matchDisplay)
|
||||
continue;
|
||||
|
||||
var room_data = GetAccountBulk(room.playerid);
|
||||
if (room_data is null)
|
||||
continue;
|
||||
list.Add(room_data);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static account_data GetAccountBulk(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new account_data
|
||||
{
|
||||
accountId = player.playerid,
|
||||
createdAt = player.player.created_at,
|
||||
displayName = player.player.DisplayName,
|
||||
username = player.player.Username,
|
||||
identityFlags = 0,
|
||||
isJunior = player.player.IsJunior,
|
||||
personalPronouns = 0,
|
||||
platforms = PrimaryPlatformValue(player),
|
||||
profileImage = player.player.profileImage,
|
||||
};
|
||||
}
|
||||
|
||||
public static bool SetPlayerUsername(string username, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
// Case-INSENSITIVE: "kyle" next to "Kyle" is the whole point of a spoofed @name.
|
||||
var existingplayer = col.FindAll().FirstOrDefault(x => x.player?.Username != null
|
||||
&& string.Equals(x.player.Username, username, StringComparison.OrdinalIgnoreCase) && x.playerid != playerid);
|
||||
if (existingplayer != null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
|
||||
if (player_Setting1 != null)
|
||||
{
|
||||
if (player_Setting1.player != null)
|
||||
{
|
||||
player_Setting1.player.Username = username;
|
||||
col.Update(player_Setting1);
|
||||
RoomDB.RenameDormForPlayer(playerid);
|
||||
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every platform id linked to this account for a platform, in the order stored.
|
||||
/// An account may hold several - a player with two Steam accounts signs in on
|
||||
/// either and lands here.
|
||||
/// </summary>
|
||||
public static List<ulong> GetPlatformIds(ulong playerid, Platforms platform)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var row = col.FindOne(x => x.playerid == playerid);
|
||||
|
||||
return row?.player?.PlatformIds?
|
||||
.Where(p => p.Platform == platform)
|
||||
.Select(p => p.PlatformId)
|
||||
.ToList()
|
||||
?? new List<ulong>();
|
||||
}
|
||||
|
||||
/// <summary>First platform id for this platform, or 0. Convenience for display.</summary>
|
||||
public static ulong GetPlatformId(ulong playerid, Platforms platform) =>
|
||||
GetPlatformIds(playerid, platform).FirstOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Every account carrying this platform id, ignoring <paramref name="ignorePlayerId"/>.
|
||||
///
|
||||
/// Informational only - the admin panel reports shared ids rather than refusing
|
||||
/// them. Sharing IS allowed by design, but it is worth knowing about: GetLogins
|
||||
/// resolves a sign-in by (platform, platformId), so when several accounts hold the
|
||||
/// same id, that lookup returns them all and the caller picks.
|
||||
/// </summary>
|
||||
public static List<ulong> FindPlayersByPlatformId(Platforms platform, ulong platformId, ulong ignorePlayerId = 0)
|
||||
{
|
||||
if (platformId == 0) return new List<ulong>();
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
return col.FindAll()
|
||||
.Where(x => x.playerid != ignorePlayerId &&
|
||||
x.player?.PlatformIds != null &&
|
||||
x.player.PlatformIds.Any(p => p.Platform == platform && p.PlatformId == platformId))
|
||||
.Select(x => x.playerid)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces every link for this platform with <paramref name="platformIds"/>.
|
||||
/// An empty list unlinks the platform entirely. Duplicates within the list are
|
||||
/// collapsed; the same id living on OTHER accounts is left alone - that is
|
||||
/// deliberate, several accounts may share one Steam id.
|
||||
/// </summary>
|
||||
public static bool SetPlatformIds(ulong playerid, Platforms platform, IEnumerable<ulong> platformIds)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var row = col.FindOne(x => x.playerid == playerid);
|
||||
if (row?.player == null) return false;
|
||||
|
||||
row.player.PlatformIds ??= new List<mPlatformID>();
|
||||
row.player.PlatformIds.RemoveAll(p => p.Platform == platform);
|
||||
|
||||
foreach (var id in platformIds.Where(i => i != 0).Distinct())
|
||||
row.player.PlatformIds.Add(new mPlatformID { Platform = platform, PlatformId = id });
|
||||
|
||||
col.Update(row);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Single-id form of <see cref="SetPlatformIds"/>; 0 unlinks.</summary>
|
||||
public static bool SetPlatformId(ulong playerid, Platforms platform, ulong platformId) =>
|
||||
SetPlatformIds(playerid, platform, platformId == 0 ? Array.Empty<ulong>() : new[] { platformId });
|
||||
|
||||
/// <summary>Sets (or with 0, clears) the linked Discord account.</summary>
|
||||
public static bool SetDiscordId(ulong playerid, ulong discordId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var row = col.FindOne(x => x.playerid == playerid);
|
||||
if (row?.player == null) return false;
|
||||
|
||||
row.player_Extra ??= new player_extra();
|
||||
row.player_Extra.DiscordId = discordId;
|
||||
|
||||
col.Update(row);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds whoever already has this Discord id linked, ignoring
|
||||
/// <paramref name="ignorePlayerId"/>. Returns 0 when nobody does.
|
||||
/// </summary>
|
||||
public static ulong FindPlayerByDiscordId(ulong discordId, ulong ignorePlayerId = 0)
|
||||
{
|
||||
if (discordId == 0) return 0;
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var owner = col.FindAll().FirstOrDefault(x =>
|
||||
x.playerid != ignorePlayerId &&
|
||||
x.player_Extra != null &&
|
||||
x.player_Extra.DiscordId == discordId);
|
||||
|
||||
return owner?.playerid ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Link a Discord user to a Campus account (website Settings > Discord tab + the /link bot
|
||||
/// command). Sets BOTH the id and the display tag. This is ONLY an association for the bot
|
||||
/// and the site - it grants NO dev/admin/moderation privilege of any kind. Pass discordId 0
|
||||
/// to unlink.
|
||||
/// </summary>
|
||||
public static bool SetDiscordLink(ulong playerid, ulong discordId, string? discordUsername)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record?.player == null) return false;
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.DiscordId = discordId;
|
||||
record.player_Extra.DiscordUsername = discordId == 0 ? null : discordUsername;
|
||||
col.Update(record);
|
||||
Console.WriteLine($"[discord-link] account {playerid} {(discordId == 0 ? "UNLINKED" : $"linked to discord {discordId} ({discordUsername})")}");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Whether a moderator has locked this player's @name / display name (admin panel).</summary>
|
||||
public static bool IsNameLocked(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
return record?.player_Extra?.NameLocked == true;
|
||||
}
|
||||
|
||||
/// <summary>Lock or unlock a player's names. Admin edits are never gated by this.</summary>
|
||||
public static bool SetNameLock(ulong playerid, bool locked)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record?.player == null) return false;
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.NameLocked = locked;
|
||||
col.Update(record);
|
||||
Console.WriteLine($"[names] account {playerid} (@{record.player.Username}) name lock {(locked ? "ON" : "off")}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SetPlayerDev(DevFlag dev_Flag, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var playerRecord = col.FindOne(x => x.playerid == playerid);
|
||||
if (playerRecord != null)
|
||||
{
|
||||
if (playerRecord.player != null)
|
||||
{
|
||||
playerRecord.dev_Flag = dev_Flag;
|
||||
col.Update(playerRecord);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool SetAllDev()
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
foreach (var playerRecord in col.FindAll())
|
||||
{
|
||||
if (playerRecord.player != null)
|
||||
{
|
||||
playerRecord.dev_Flag = DevFlag.dev;
|
||||
col.Update(playerRecord);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static account_login<account_data> GetAccountPassword(string username, string password, bool isAdminPanel)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return new account_login<account_data>
|
||||
{
|
||||
success = false,
|
||||
error = "Your username or password is empty, please enter a username and password.",
|
||||
};
|
||||
}
|
||||
|
||||
var player = col.FindOne(x => x.player.Username == username);
|
||||
if (player == null)
|
||||
{
|
||||
return new account_login<account_data>
|
||||
{
|
||||
success = false,
|
||||
error = "The account you are trying to login to does not exist.",
|
||||
};
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(player.password))
|
||||
{
|
||||
return new account_login<account_data>
|
||||
{
|
||||
success = false,
|
||||
error = "The password or username you entered don't match the account you are trying to login to."
|
||||
};
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
return new account_login<account_data>
|
||||
{
|
||||
success = false,
|
||||
error = "The password or username you entered don't match the account you are trying to login to.",
|
||||
};
|
||||
}
|
||||
|
||||
if (isAdminPanel)
|
||||
{
|
||||
var devCheckResult = PlayerDB.CheckDevFlag(player.playerid);
|
||||
if (!devCheckResult)
|
||||
{
|
||||
return new account_login<account_data>
|
||||
{
|
||||
success = false,
|
||||
error = "You are not a developer",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
bool isValid = ClientSecurity.VerifyPassword(password, player.password);
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
string oldHash = ClientSecurity.GetHashString(password);
|
||||
if (string.Equals(player.password, oldHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
player.password = ClientSecurity.HashPassword(password);
|
||||
col.Update(player);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new account_login<account_data>
|
||||
{
|
||||
success = false,
|
||||
error = "The password or username you entered don't match the account you are trying to login to."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new account_login<account_data>
|
||||
{
|
||||
success = true,
|
||||
error = "",
|
||||
accid = player.playerid,
|
||||
};
|
||||
}
|
||||
|
||||
public static void SetPlayerPassword(string pass, ulong playerid, bool log)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var playerRecord = col.FindOne(x => x.playerid == playerid);
|
||||
if (playerRecord != null && playerRecord.player != null)
|
||||
{
|
||||
playerRecord.password = ClientSecurity.HashPassword(pass);
|
||||
col.Update(playerRecord);
|
||||
if (log)
|
||||
{
|
||||
Console.WriteLine($"[Password Change] PlayerID: {playerid} has changed their password.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SetPlayerBio(string bio, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
|
||||
if (player_Setting1 != null)
|
||||
{
|
||||
if (player_Setting1.player != null)
|
||||
{
|
||||
player_Setting1.player_Extra.Bio = bio;
|
||||
col.Update(player_Setting1);
|
||||
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static async Task<bool> SetPlayerLevel(int level, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
|
||||
if (player_Setting1 != null)
|
||||
{
|
||||
if (player_Setting1.player != null)
|
||||
{
|
||||
player_Setting1.player.Level = level;
|
||||
try
|
||||
{
|
||||
await Notifications.SendToAll(JsonConvert.SerializeObject(WebsocketEvents.createResponse_levelupdate(playerid, level, Progress.CumulativeXpForLevel(level, 0))));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
col.Update(player_Setting1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static player_data? GetPlayerByDiscordId(ulong discordId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.player_Extra.DiscordId == discordId);
|
||||
return player;
|
||||
}
|
||||
|
||||
public static void SetDisplayName(string username, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
|
||||
if (player_Setting1 != null)
|
||||
{
|
||||
if (player_Setting1.player != null)
|
||||
{
|
||||
player_Setting1.player.DisplayName = username;
|
||||
col.Update(player_Setting1);
|
||||
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SetRoleFlag(ulong playerid, DevFlag flag, bool enable)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record == null) return false;
|
||||
|
||||
if (enable) record.dev_Flag |= flag;
|
||||
else record.dev_Flag &= ~flag;
|
||||
|
||||
col.Update(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets XP and/or Level directly (admin panel). Pass null to leave one unchanged.
|
||||
/// Returns the values actually stored, or null if the player does not exist.
|
||||
/// </summary>
|
||||
public static (int XP, int Level)? SetProgression(ulong playerid, int? xp, int? level)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record?.player == null) return null;
|
||||
|
||||
if (xp.HasValue) record.player.XP = xp.Value < 0 ? 0 : xp.Value;
|
||||
if (level.HasValue) record.player.Level = level.Value < 0 ? 0 : level.Value;
|
||||
|
||||
col.Update(record);
|
||||
return (record.player.XP, record.player.Level);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grants XP to a player and applies any level-ups it now pays for, using the real
|
||||
/// progression curve (Progress.LEVEL_REQUIRED_XP). XP is progress INTO the current level;
|
||||
/// a grant that fills the level carries the remainder forward. Returns the resulting
|
||||
/// (Level, XP) and how many levels were gained, or null if the player has no row.
|
||||
/// This is what a game reward calls - it was the missing piece: nothing granted XP, so
|
||||
/// players never levelled up. Mirrors RecFlare's addXp.
|
||||
/// </summary>
|
||||
public static (int Level, int XP, int LevelsGained)? AddXp(ulong playerid, int amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
{
|
||||
var cur = Progress.GetPlayerLevel(playerid);
|
||||
return (cur.Level, cur.XP, 0);
|
||||
}
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record?.player == null) return null;
|
||||
|
||||
int startLevel = record.player.Level < 1 ? 1 : record.player.Level;
|
||||
int newXp = (record.player.XP < 0 ? 0 : record.player.XP) + amount;
|
||||
|
||||
var (level, xp) = Progress.ApplyLevelUps(startLevel, newXp);
|
||||
int levelsGained = level - startLevel;
|
||||
|
||||
record.player.Level = level;
|
||||
record.player.XP = xp;
|
||||
col.Update(record);
|
||||
|
||||
// Push the new level/XP so the crown + progress bar update in real time (no relog) -
|
||||
// AddXp used to be silent, so a level earned in-game only showed after a relog. A level
|
||||
// CHANGE broadcasts (everyone's view of this player's crown updates); an XP-only gain
|
||||
// pings just the owner so their bar advances. Same PlayerProgressionLevelUpdate event
|
||||
// the admin level set + the progression GET use.
|
||||
try
|
||||
{
|
||||
// Report CUMULATIVE XP (client derives the crown level from XP - see
|
||||
// Progress.CumulativeXpForLevel), not the raw into-level XP.
|
||||
int reportXp = Progress.CumulativeXpForLevel(level, xp);
|
||||
string msg = JsonConvert.SerializeObject(WebsocketEvents.createResponse_levelupdate(playerid, level, reportXp));
|
||||
if (levelsGained > 0) Notifications.FireAndForget(Notifications.SendToAll(msg), "levelup broadcast");
|
||||
else Notifications.FireAndForget(Notifications.SendToPlayer(playerid, msg), "xp update");
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Every level crossed pays its box (reference: grantLevelUpGifts). After the level is
|
||||
// stored, so a failed roll costs a prize and not the level.
|
||||
if (levelsGained > 0)
|
||||
{
|
||||
try { Campus_live_rewrite.api.server.LevelRewards.GrantLevelUps(playerid, startLevel, level); }
|
||||
catch (Exception ex) { Console.WriteLine($"[levelrewards] {playerid}: {ex.Message}"); }
|
||||
}
|
||||
|
||||
return (level, xp, levelsGained);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Claims a once-per-day reward. Returns false if it was already claimed today,
|
||||
/// in which case nothing is written.
|
||||
/// </summary>
|
||||
public static bool TryClaimDailyReward(ulong playerid, string rewardKey)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record == null) return false;
|
||||
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.DailyRewardClaims ??= new Dictionary<string, string>();
|
||||
|
||||
string today = DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||
if (record.player_Extra.DailyRewardClaims.TryGetValue(rewardKey, out var last) && last == today)
|
||||
return false;
|
||||
|
||||
record.player_Extra.DailyRewardClaims[rewardKey] = today;
|
||||
col.Update(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Gift boxes -------------------------------------------------------
|
||||
|
||||
// ---- Avatar item ownership ------------------------------------------------
|
||||
//
|
||||
// The 2023 server treats AvatarItems.json as a CATALOG - the list of every item
|
||||
// that exists - and keeps what each player owns on the player record, seeded from
|
||||
// DefaultAvatarItems.json. The 2025 server had no ownership list at all and simply
|
||||
// served the whole catalog from /api/avatar/v4/items, so everybody owned
|
||||
// everything. These helpers restore the 2023 model.
|
||||
|
||||
/// <summary>
|
||||
/// Canonical key for comparing two descriptors. Trailing empty colour slots are
|
||||
/// dropped, so "guid,,," and "guid," compare equal, while "guid,colour,,"
|
||||
/// stays distinct from "guid,,," - every colour of an item shares a base guid, so
|
||||
/// keying on the guid alone would merge Knight Cape (Red) into Knight Cape (Green).
|
||||
/// </summary>
|
||||
public static string NormalizeAvatarItemDesc(string? desc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(desc)) return "";
|
||||
|
||||
var parts = desc.Trim().Split(',').Select(p => p.Trim()).ToList();
|
||||
while (parts.Count > 1 && string.IsNullOrEmpty(parts[^1])) parts.RemoveAt(parts.Count - 1);
|
||||
|
||||
// Compare on the EXPANDED form of every id.
|
||||
//
|
||||
// Rec Room writes ids two ways - a full UUID, or a 22-char base64 "short guid" -
|
||||
// and avatar_items.json used to mix both. The catalog has since been rewritten to
|
||||
// full UUIDs throughout (see Data/APIS/items/README-avatar-items.md), but stored
|
||||
// ownership rows, DefaultAvatarItems.json and the storefronts still carry whichever
|
||||
// spelling they were written with. Expanding here means "guid,8XajS2adOEOqzqnLy6-zcQ"
|
||||
// and "guid,4ba376f1-9d66-4338-aace-a9cbcbafb371" are the same item, so nobody
|
||||
// loses an item they own just because the catalog changed spelling.
|
||||
for (int i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var expanded = Campus_live_rewrite.api.server.Classes.ItemGuids.NormalizeGuid(parts[i]);
|
||||
if (expanded != null) parts[i] = expanded;
|
||||
}
|
||||
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The starter wardrobe every non-developer account begins with. Loaded once.
|
||||
/// </summary>
|
||||
private static List<string>? _defaultAvatarItems;
|
||||
private static readonly object _defaultAvatarItemsLock = new();
|
||||
|
||||
public static List<string> DefaultAvatarItemDescs()
|
||||
{
|
||||
if (_defaultAvatarItems != null) return _defaultAvatarItems;
|
||||
|
||||
lock (_defaultAvatarItemsLock)
|
||||
{
|
||||
if (_defaultAvatarItems != null) return _defaultAvatarItems;
|
||||
|
||||
var list = new List<string>();
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "DefaultAvatarItems.json");
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// The file is a flat array of descriptor strings.
|
||||
var arr = Newtonsoft.Json.JsonConvert.DeserializeObject<List<string>>(File.ReadAllText(path));
|
||||
foreach (var d in arr ?? new List<string>())
|
||||
if (!string.IsNullOrWhiteSpace(d)) list.Add(d.Trim());
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[items] DefaultAvatarItems.json not found at {path} - new accounts will start empty");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[items] could not read DefaultAvatarItems.json: {ex.Message}");
|
||||
}
|
||||
|
||||
_defaultAvatarItems = list;
|
||||
Console.WriteLine($"[items] loaded {list.Count} default starter avatar item(s)");
|
||||
return _defaultAvatarItems;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything this account owns, as canonical keys. Grants the starter set on
|
||||
/// first read so existing accounts need no migration.
|
||||
/// </summary>
|
||||
public static HashSet<string> OwnedAvatarItemDescs(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record == null) return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
record.player_Extra ??= new player_extra();
|
||||
|
||||
if (record.player_Extra.AvatarItems == null)
|
||||
{
|
||||
record.player_Extra.AvatarItems = new List<string>(DefaultAvatarItemDescs());
|
||||
col.Update(record);
|
||||
Console.WriteLine($"[items] granted {record.player_Extra.AvatarItems.Count} starter item(s) to {playerid}");
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(
|
||||
record.player_Extra.AvatarItems.Select(NormalizeAvatarItemDesc).Where(d => d.Length > 0),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Unioned on READ rather than written into the wardrobe row, so every existing account
|
||||
// gets it with no migration and it cannot drift out of sync with the catalog.
|
||||
set.UnionWith(Campus_live_rewrite.api.server.HairGrants.FreeHairKeys());
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>Adds an item to a player's wardrobe. Returns true if it was new.</summary>
|
||||
/// <summary>The normalised keys of every avatar item the player owns (defaults included).</summary>
|
||||
public static HashSet<string> GetOwnedAvatarItemKeys(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
var items = record?.player_Extra?.AvatarItems ?? DefaultAvatarItemDescs();
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var d in items) { var k = NormalizeAvatarItemDesc(d); if (k.Length > 0) set.Add(k); }
|
||||
set.UnionWith(Campus_live_rewrite.api.server.HairGrants.FreeHairKeys());
|
||||
return set;
|
||||
}
|
||||
|
||||
// Consumable descriptors come in short-guid and UUID spellings; own them by the UUID form.
|
||||
public static string NormalizeConsumableDesc(string? desc)
|
||||
{
|
||||
string s = (desc ?? "").Trim();
|
||||
if (s.Length == 0) return "";
|
||||
return (Campus_live_rewrite.api.server.Classes.ItemGuids.NormalizeGuid(s) ?? s).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>The player's consumable stock (desc key -> count), empty when they own none.</summary>
|
||||
public static Dictionary<string, int> OwnedConsumables(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
return new Dictionary<string, int>(record?.player_Extra?.Consumables ?? new Dictionary<string, int>(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static void GrantConsumable(ulong playerid, string? desc, int count)
|
||||
{
|
||||
string ck = NormalizeConsumableDesc(desc);
|
||||
if (ck.Length == 0 || count <= 0) return;
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record == null) return;
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.Consumables ??= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
record.player_Extra.Consumables[ck] = (record.player_Extra.Consumables.TryGetValue(ck, out var have) ? have : 0) + count;
|
||||
col.Update(record);
|
||||
}
|
||||
|
||||
public static bool GrantAvatarItem(ulong playerid, string? desc)
|
||||
{
|
||||
string key = NormalizeAvatarItemDesc(desc);
|
||||
if (key.Length == 0) return false;
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record == null) return false;
|
||||
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.AvatarItems ??= new List<string>(DefaultAvatarItemDescs());
|
||||
|
||||
if (record.player_Extra.AvatarItems.Any(d => NormalizeAvatarItemDesc(d).Equals(key, StringComparison.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
|
||||
record.player_Extra.AvatarItems.Add(desc!.Trim());
|
||||
col.Update(record);
|
||||
Console.WriteLine($"[items] granted '{desc!.Trim()}' to {playerid}");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Developers get the whole catalog - the dev locker.</summary>
|
||||
public static bool HasDevLocker(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
return record != null && (record.dev_Flag & DevFlag.dev) == DevFlag.dev;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks CONTENTLESS pending gift boxes as opened, so they stop lingering forever. An empty
|
||||
/// box (no gift drop, no avatar item, no consumable, no equipment) is filtered out of the
|
||||
/// gifts list the client is sent - it can never be opened there and would sit in the DB
|
||||
/// counting toward the gift badge as a "random gift" that won't clear. Marking it opened
|
||||
/// retires it. Returns how many were retired.
|
||||
/// </summary>
|
||||
public static int MarkEmptyGiftBoxesOpened(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
var boxes = record?.player_Extra?.PendingGiftBoxes;
|
||||
if (boxes == null || boxes.Count == 0) return 0;
|
||||
|
||||
int cleaned = 0;
|
||||
foreach (var g in boxes)
|
||||
{
|
||||
if (g.Opened) continue;
|
||||
// Currency / XP boxes count as content (a token box has no item and is real).
|
||||
if (!Campus_live_rewrite.api.server.GiftBoxes.HasContent(g)) { g.Opened = true; cleaned++; }
|
||||
}
|
||||
|
||||
if (cleaned > 0) col.Update(record);
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
public static bool AddPendingGiftBox(ulong playerid, int giftContext, string? message, ulong fromAccountId = 0)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record == null) return false;
|
||||
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.PendingGiftBoxes ??= new List<PendingGiftBox>();
|
||||
record.player_Extra.PendingGiftBoxes.Add(new PendingGiftBox
|
||||
{
|
||||
GiftContext = giftContext,
|
||||
Message = message,
|
||||
FromAccountId = fromAccountId
|
||||
});
|
||||
|
||||
col.Update(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Stores a fully-populated gift box (store purchases carry item data).</summary>
|
||||
/// <summary>Backfill a numeric Id onto any pending gift box that still has NumericId 0 (older
|
||||
/// reward/select boxes). Without it the gifts endpoint emits a guid Id the client rejects.</summary>
|
||||
// ---- gift package ids: ONE sequence for the whole server ------------------------------
|
||||
//
|
||||
// A gift box id has to be unique across PLAYERS, not just within one player's list. The
|
||||
// client networks a spawned box into the room by its package id, and "Open All" walks the
|
||||
// boxes it can see: with per-player ids (reward boxes 1001+ per player, purchases from a
|
||||
// counter that restarted at 5000 on every deploy) two players' boxes in the same room
|
||||
// shared ids. Player.log, opening gifts in the Rec Center after another player had: the
|
||||
// client posts consume for 5003 and 5002 (the other player's, already opened - "no matching
|
||||
// unopened box"), throws "Gift is missing from the list", and only then reaches its own
|
||||
// 5004 - which the other player ALSO had as 5004. The reference's received_gift table is
|
||||
// AUTOINCREMENT for the same reason.
|
||||
//
|
||||
// Seeded once above every id already stored, so nothing pending collides with a new box.
|
||||
private sealed class Counter { public int Id { get; set; } public int Value { get; set; } }
|
||||
private static readonly object _giftIdLock = new();
|
||||
|
||||
public static int NextGiftPackageId()
|
||||
{
|
||||
lock (_giftIdLock)
|
||||
{
|
||||
var counters = PlayerDBFile.GetCollection<Counter>("counters");
|
||||
var row = counters.FindById(1);
|
||||
if (row == null)
|
||||
{
|
||||
int max = 100_000;
|
||||
try
|
||||
{
|
||||
foreach (var p in PlayerDBFile.GetCollection<player_data>("players").FindAll())
|
||||
foreach (var b in p.player_Extra?.PendingGiftBoxes ?? new List<PendingGiftBox>())
|
||||
if (b.NumericId > max) max = b.NumericId;
|
||||
}
|
||||
catch { }
|
||||
row = new Counter { Id = 1, Value = max };
|
||||
Console.WriteLine($"[gifts] gift package id sequence seeded at {max}");
|
||||
}
|
||||
row.Value++;
|
||||
counters.Upsert(row);
|
||||
return row.Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when a box with this id is (or was) some OTHER player's.</summary>
|
||||
public static bool GiftBoxBelongsToSomeoneElse(ulong playerid, int numericId)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var p in PlayerDBFile.GetCollection<player_data>("players").Find(x => x.playerid != playerid))
|
||||
if ((p.player_Extra?.PendingGiftBoxes ?? new List<PendingGiftBox>()).Any(b => b.NumericId == numericId))
|
||||
return true;
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void EnsureGiftBoxNumericIds(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
var boxes = record?.player_Extra?.PendingGiftBoxes;
|
||||
if (boxes == null || boxes.Count == 0) return;
|
||||
bool changed = false;
|
||||
foreach (var b in boxes)
|
||||
if (b.NumericId == 0) { b.NumericId = NextGiftPackageId(); changed = true; }
|
||||
if (changed) col.Update(record);
|
||||
}
|
||||
|
||||
// The guid is compared on its UUID form: Equipments.json spells every skin as a full UUID,
|
||||
// but 112 of the 187 skin rows in the Watch store spell the SAME skins as 22-char short
|
||||
// guids. Compared as text they never matched, so a bought skin was stored under a key
|
||||
// that /equipment/v2/getUnlocked could never find - "Can't load item" on the box, and the
|
||||
// skin never turned up in the locker.
|
||||
public static string EquipmentSkinKey(string? prefab, string? guid)
|
||||
{
|
||||
string g = Campus_live_rewrite.api.server.Classes.ItemGuids.NormalizeGuid(guid) ?? (guid ?? "").Trim().ToLowerInvariant();
|
||||
return $"{(prefab ?? "").Trim().ToLowerInvariant()}|{g}";
|
||||
}
|
||||
|
||||
public static HashSet<string> OwnedEquipmentSkins(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
return new HashSet<string>(record?.player_Extra?.EquipmentSkins ?? new List<string>(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool OwnsCustomItem(ulong playerid, string caid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
return record?.player_Extra?.CustomItems?.Any(c => string.Equals(c, caid, StringComparison.OrdinalIgnoreCase)) == true;
|
||||
}
|
||||
|
||||
public static HashSet<string> OwnedCustomItems(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
return new HashSet<string>(record?.player_Extra?.CustomItems ?? new List<string>(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Put a UGC item in the player's wardrobe. Returns true if it was new.</summary>
|
||||
public static bool GrantCustomItem(ulong playerid, string? caid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(caid)) return false;
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record == null) return false;
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.CustomItems ??= new List<string>();
|
||||
if (record.player_Extra.CustomItems.Any(c => string.Equals(c, caid, StringComparison.OrdinalIgnoreCase))) return false;
|
||||
record.player_Extra.CustomItems.Add(caid);
|
||||
col.Update(record);
|
||||
Console.WriteLine($"[ugc] {playerid} now owns custom item {caid}");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Put a skin in the player's locker. Returns true if it was new.</summary>
|
||||
public static bool GrantEquipmentSkin(ulong playerid, string? prefab, string? guid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefab) || string.IsNullOrWhiteSpace(guid)) return false;
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record == null) return false;
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.EquipmentSkins ??= new List<string>();
|
||||
string key = EquipmentSkinKey(prefab, guid);
|
||||
if (record.player_Extra.EquipmentSkins.Any(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase))) return false;
|
||||
record.player_Extra.EquipmentSkins.Add(key);
|
||||
col.Update(record);
|
||||
Console.WriteLine($"[skins] {playerid} now owns skin {prefab} {guid}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool AddPendingGiftBoxFull(ulong playerid, PendingGiftBox box)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record == null) return false;
|
||||
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.PendingGiftBoxes ??= new List<PendingGiftBox>();
|
||||
|
||||
// Every gift box MUST have a numeric Id. The gifts endpoint emits the guid Id when
|
||||
// NumericId is 0, but the client's gift model expects a NUMBER there and rejects the whole
|
||||
// gifts array as "Malformed Response" if it sees a guid - which silently killed every
|
||||
// reward gift box (and made the reward watch open then instantly close). Purchases already
|
||||
// set NumericId; reward/select boxes didn't, so assign one here - from the server-wide
|
||||
// sequence (NextGiftPackageId), never a per-player count.
|
||||
if (box.NumericId == 0) box.NumericId = NextGiftPackageId();
|
||||
|
||||
record.player_Extra.PendingGiftBoxes.Add(box);
|
||||
|
||||
// A purchased or gifted item has to enter the wardrobe too, now that owning it
|
||||
// is no longer implied by the catalog being served to everyone. Done here, in
|
||||
// the same write, so a bought item can never be left unowned.
|
||||
if (!string.IsNullOrWhiteSpace(box.AvatarItemDesc))
|
||||
{
|
||||
string key = NormalizeAvatarItemDesc(box.AvatarItemDesc);
|
||||
record.player_Extra.AvatarItems ??= new List<string>(DefaultAvatarItemDescs());
|
||||
|
||||
if (!record.player_Extra.AvatarItems.Any(d =>
|
||||
NormalizeAvatarItemDesc(d).Equals(key, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
record.player_Extra.AvatarItems.Add(box.AvatarItemDesc!.Trim());
|
||||
Console.WriteLine($"[items] {playerid} now owns '{box.AvatarItemDesc!.Trim()}' ({box.FriendlyName})");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(box.CustomAvatarItemId))
|
||||
{
|
||||
record.player_Extra.CustomItems ??= new List<string>();
|
||||
if (!record.player_Extra.CustomItems.Any(c => string.Equals(c, box.CustomAvatarItemId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
record.player_Extra.CustomItems.Add(box.CustomAvatarItemId!);
|
||||
Console.WriteLine($"[ugc] {playerid} now owns custom item {box.CustomAvatarItemId} ({box.FriendlyName})");
|
||||
}
|
||||
}
|
||||
|
||||
// A consumable: counted into the player's own stock (was: everyone had every
|
||||
// consumable with an infinite count, so nothing was ever really "got").
|
||||
if (!string.IsNullOrWhiteSpace(box.ConsumableItemDesc))
|
||||
{
|
||||
record.player_Extra.Consumables ??= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
string ck = NormalizeConsumableDesc(box.ConsumableItemDesc);
|
||||
int add = box.ConsumableCount > 0 ? box.ConsumableCount : 1;
|
||||
record.player_Extra.Consumables[ck] = (record.player_Extra.Consumables.TryGetValue(ck, out var have) ? have : 0) + add;
|
||||
Console.WriteLine($"[consumables] {playerid} +{add} {box.FriendlyName} -> {record.player_Extra.Consumables[ck]}");
|
||||
}
|
||||
|
||||
// An equipment SKIN enters the locker the same way an avatar item enters the wardrobe -
|
||||
// in this write, so a bought skin can never be left unowned (it used to be: skins were
|
||||
// served to everyone, so nothing ever recorded a purchase).
|
||||
if (!string.IsNullOrWhiteSpace(box.EquipmentPrefabName) && !string.IsNullOrWhiteSpace(box.EquipmentModificationGuid))
|
||||
{
|
||||
record.player_Extra.EquipmentSkins ??= new List<string>();
|
||||
string skey = EquipmentSkinKey(box.EquipmentPrefabName, box.EquipmentModificationGuid);
|
||||
if (!record.player_Extra.EquipmentSkins.Any(k => string.Equals(k, skey, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
record.player_Extra.EquipmentSkins.Add(skey);
|
||||
Console.WriteLine($"[skins] {playerid} now owns skin {box.EquipmentPrefabName} {box.EquipmentModificationGuid} ({box.FriendlyName})");
|
||||
}
|
||||
}
|
||||
|
||||
col.Update(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static List<PendingGiftBox> GetPendingGiftBoxes(ulong playerid)
|
||||
{
|
||||
var acc = GetFullAccount(playerid);
|
||||
var all = acc?.player_Extra?.PendingGiftBoxes;
|
||||
if (all == null) return new List<PendingGiftBox>();
|
||||
return all.Where(g => !g.Opened).ToList();
|
||||
}
|
||||
|
||||
// ================= Daily objectives / weekly challenges (quests) =================
|
||||
// Per-player progress only - the objective/challenge CATALOG (descriptions, targets,
|
||||
// rewards) is client-side. See APIController objectives + challenge handlers.
|
||||
|
||||
public static (List<ObjectiveProgress> objectives, List<ObjectiveGroupProgress> groups)
|
||||
GetObjectivesProgress(ulong playerid)
|
||||
{
|
||||
var ex = GetFullAccount(playerid)?.player_Extra;
|
||||
return (ex?.Objectives ?? new List<ObjectiveProgress>(),
|
||||
ex?.ObjectiveGroups ?? new List<ObjectiveGroupProgress>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upsert one objective's progress on the (Group, Index) key. Returns true ONLY on the call
|
||||
/// that first completes it while its reward is unclaimed - the once-only reward latch - so
|
||||
/// the caller grants the reward exactly once. Hands back the parent group's state.
|
||||
/// </summary>
|
||||
public static bool UpdateObjective(ulong playerid, long group, int index,
|
||||
double progress, double visualProgress, bool isCompleted,
|
||||
out ObjectiveGroupProgress groupState)
|
||||
{
|
||||
groupState = new ObjectiveGroupProgress { Group = group };
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record == null) return false;
|
||||
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.Objectives ??= new List<ObjectiveProgress>();
|
||||
record.player_Extra.ObjectiveGroups ??= new List<ObjectiveGroupProgress>();
|
||||
|
||||
var o = record.player_Extra.Objectives.FirstOrDefault(x => x.Group == group && x.Index == index);
|
||||
if (o == null)
|
||||
{
|
||||
o = new ObjectiveProgress { Group = group, Index = index };
|
||||
record.player_Extra.Objectives.Add(o);
|
||||
}
|
||||
|
||||
o.Progress = progress;
|
||||
o.VisualProgress = visualProgress;
|
||||
|
||||
bool justCompleted = isCompleted && !o.HasClaimedReward;
|
||||
if (isCompleted) o.IsCompleted = true;
|
||||
if (justCompleted) o.HasClaimedReward = true;
|
||||
|
||||
var g = record.player_Extra.ObjectiveGroups.FirstOrDefault(x => x.Group == group);
|
||||
if (g == null)
|
||||
{
|
||||
g = new ObjectiveGroupProgress { Group = group };
|
||||
record.player_Extra.ObjectiveGroups.Add(g);
|
||||
}
|
||||
groupState = g;
|
||||
|
||||
col.Update(record);
|
||||
return justCompleted;
|
||||
}
|
||||
|
||||
public static ObjectiveGroupProgress ClearObjectiveGroup(ulong playerid, long group)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
var g = new ObjectiveGroupProgress { Group = group, IsCompleted = true, ClearedAt = DateTime.UtcNow.ToString("o") };
|
||||
if (record == null) return g;
|
||||
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.ObjectiveGroups ??= new List<ObjectiveGroupProgress>();
|
||||
var existing = record.player_Extra.ObjectiveGroups.FirstOrDefault(x => x.Group == group);
|
||||
if (existing == null) { record.player_Extra.ObjectiveGroups.Add(g); }
|
||||
else { existing.IsCompleted = true; existing.ClearedAt = g.ClearedAt; g = existing; }
|
||||
|
||||
col.Update(record);
|
||||
return g;
|
||||
}
|
||||
|
||||
public static HashSet<int> GetCompletedChallengeIds(ulong playerid, int mapId)
|
||||
{
|
||||
var set = new HashSet<int>();
|
||||
var st = GetFullAccount(playerid)?.player_Extra?.ChallengeStatuses;
|
||||
if (st != null)
|
||||
foreach (var s in st)
|
||||
if (s.ChallengeMapId == mapId && s.Complete) set.Add(s.ChallengeId);
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>Latch one challenge's completion within a rotation (false->true only). Returns the stored state.</summary>
|
||||
public static bool RecordChallengeProgress(ulong playerid, int mapId, int challengeId, bool complete)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record == null) return false;
|
||||
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.ChallengeStatuses ??= new List<ChallengeStatus>();
|
||||
|
||||
var s = record.player_Extra.ChallengeStatuses
|
||||
.FirstOrDefault(x => x.ChallengeMapId == mapId && x.ChallengeId == challengeId);
|
||||
if (s == null)
|
||||
{
|
||||
s = new ChallengeStatus { ChallengeMapId = mapId, ChallengeId = challengeId };
|
||||
record.player_Extra.ChallengeStatuses.Add(s);
|
||||
}
|
||||
s.Complete = s.Complete || complete;
|
||||
s.UpdatedAt = DateTime.UtcNow.ToString("o");
|
||||
|
||||
col.Update(record);
|
||||
return s.Complete;
|
||||
}
|
||||
|
||||
/// <summary>At-most-once gift claim for a challenge map. Returns true only on the FIRST claim.</summary>
|
||||
public static bool ClaimChallengeGift(ulong playerid, int mapId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record == null) return false;
|
||||
|
||||
record.player_Extra ??= new player_extra();
|
||||
record.player_Extra.ClaimedChallengeMaps ??= new List<int>();
|
||||
if (record.player_Extra.ClaimedChallengeMaps.Contains(mapId)) return false;
|
||||
|
||||
record.player_Extra.ClaimedChallengeMaps.Add(mapId);
|
||||
col.Update(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens (consumes) a gift box for a player. Returns the box that was opened, or null
|
||||
/// when the player has no such unopened box.
|
||||
///
|
||||
/// The item was already granted at PURCHASE time (see AddPendingGiftBoxFull) - opening
|
||||
/// is only the cosmetic reveal, so this just marks the box opened so it stops showing
|
||||
/// as pending. Without this the client's POST /api/avatar/v2/gifts/consume 404'd, the
|
||||
/// box was never marked opened, and GetPendingGiftBoxes handed it straight back - so a
|
||||
/// box could be "opened" over and over and never went away.
|
||||
///
|
||||
/// The client sends back whatever id we emitted in the gifts list, which is NumericId
|
||||
/// for purchases and the guid Id otherwise (see the Gifts endpoint), so match on both.
|
||||
/// </summary>
|
||||
/// <summary>Flip a box's PayOnOpen off. Returns true only for the call that flipped it.</summary>
|
||||
public static bool ClearGiftBoxPayOnOpen(ulong playerid, PendingGiftBox box)
|
||||
{
|
||||
lock (_giftPayLock)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
var stored = record?.player_Extra?.PendingGiftBoxes?.FirstOrDefault(g =>
|
||||
(box.NumericId != 0 && g.NumericId == box.NumericId) || (box.NumericId == 0 && g.Id == box.Id));
|
||||
if (stored == null || (!stored.PayOnOpen && !stored.XpOnOpen)) return false;
|
||||
stored.PayOnOpen = false; stored.XpOnOpen = false;
|
||||
box.PayOnOpen = false; box.XpOnOpen = false;
|
||||
col.Update(record);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
private static readonly object _giftPayLock = new();
|
||||
|
||||
/// <summary>Persist PayOnOpen=true on an already-stored box.</summary>
|
||||
public static void SetGiftBoxPayOnOpen(ulong playerid, PendingGiftBox box)
|
||||
{
|
||||
lock (_giftPayLock)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
var stored = record?.player_Extra?.PendingGiftBoxes?.FirstOrDefault(g =>
|
||||
(box.NumericId != 0 && g.NumericId == box.NumericId) || (box.NumericId == 0 && g.Id == box.Id));
|
||||
if (stored == null) return;
|
||||
stored.PayOnOpen = true;
|
||||
col.Update(record);
|
||||
}
|
||||
}
|
||||
|
||||
public static PendingGiftBox? ConsumeGiftBox(ulong playerid, string? id, int numericId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
var boxes = record?.player_Extra?.PendingGiftBoxes;
|
||||
if (boxes == null) return null;
|
||||
|
||||
var box = boxes.FirstOrDefault(g => !g.Opened &&
|
||||
((numericId != 0 && g.NumericId == numericId) ||
|
||||
(!string.IsNullOrEmpty(id) && g.Id == id)));
|
||||
|
||||
if (box == null) return null;
|
||||
|
||||
box.Opened = true;
|
||||
col.Update(record);
|
||||
Console.WriteLine($"[gifts] {playerid} opened box {(numericId != 0 ? numericId.ToString() : id)} ({box.FriendlyName})");
|
||||
return box;
|
||||
}
|
||||
|
||||
public static bool SetPlayerSteamId(ulong steamId, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record?.player == null) return false;
|
||||
|
||||
record.platform = Platforms.Steam;
|
||||
record.player.PlatformIds ??= new List<mPlatformID>();
|
||||
// Replace any existing Steam entry, add if missing.
|
||||
var existing = record.player.PlatformIds.FirstOrDefault(p => p != null && p.Platform == Platforms.Steam);
|
||||
if (existing != null) existing.PlatformId = steamId;
|
||||
else record.player.PlatformIds.Add(new mPlatformID { Platform = Platforms.Steam, PlatformId = steamId });
|
||||
col.Update(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The account using this email (case-insensitive), or 0.</summary>
|
||||
public static ulong GetAccountIdByEmail(string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email)) return 0;
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var hit = col.FindAll().FirstOrDefault(x => x.player?.Email != null &&
|
||||
string.Equals(x.player.Email.Trim(), email.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
return hit?.playerid ?? 0;
|
||||
}
|
||||
|
||||
public static bool SetPlayerEmail(string? email, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var record = col.FindOne(x => x.playerid == playerid);
|
||||
if (record?.player == null) return false;
|
||||
record.player.Email = email;
|
||||
col.Update(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SetLastLoginTime(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.player.Id == playerid);
|
||||
if (player != null)
|
||||
{
|
||||
player.player.last_login_time = DateTime.UtcNow;
|
||||
col.Update(player);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool ResetCheerCreditIfNewDay(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.player.Id == playerid);
|
||||
|
||||
if (player?.player?.PlayerReputation == null)
|
||||
return false;
|
||||
|
||||
DateTime lastLoginDate = player.player.last_login_time.Date;
|
||||
DateTime today = DateTime.UtcNow.Date;
|
||||
|
||||
if (lastLoginDate < today)
|
||||
{
|
||||
player.player.PlayerReputation.CheerCredit = 20;
|
||||
player.player.last_login_time = DateTime.UtcNow;
|
||||
|
||||
col.Update(player);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public static bool AddIpAddress(ulong playerid, string ip)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.player.Id == playerid);
|
||||
if (player != null)
|
||||
{
|
||||
if (player.player_Extra.IpAddresses == null)
|
||||
player.player_Extra.IpAddresses = new List<string>();
|
||||
|
||||
string encryptedIp = Utils.EncryptAes(ip);
|
||||
|
||||
if (!player.player_Extra.IpAddresses.Contains(encryptedIp))
|
||||
player.player_Extra.IpAddresses.Add(encryptedIp);
|
||||
|
||||
col.Update(player);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public static List<string> GetDecryptedIpAddresses(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.player.Id == playerid);
|
||||
|
||||
List<string> result = new();
|
||||
if (player?.player_Extra?.IpAddresses != null)
|
||||
{
|
||||
foreach (string encryptedIp in player.player_Extra.IpAddresses)
|
||||
{
|
||||
try
|
||||
{
|
||||
result.Add(Utils.DecryptAes(encryptedIp));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void SetPlayerEmoji(string emoji, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
|
||||
if (player_Setting1 != null)
|
||||
{
|
||||
if (player_Setting1.player != null)
|
||||
{
|
||||
player_Setting1.player.DisplayEmoji = emoji;
|
||||
col.Update(player_Setting1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetPlayerPronounFlags(int pronounflag, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
|
||||
if (player_Setting1 != null)
|
||||
{
|
||||
if (player_Setting1.player != null)
|
||||
{
|
||||
player_Setting1.player.pronounFlags = pronounflag;
|
||||
col.Update(player_Setting1);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
public static void AddSubscription(ulong fromPlayerId, ulong toPlayerId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var playerRecord = col.FindOne(x => x.playerid == fromPlayerId);
|
||||
if (playerRecord == null || playerRecord.player_Extra == null)
|
||||
throw new InvalidOperationException($"player_Extra for player {fromPlayerId} not found.");
|
||||
|
||||
// check if already subscribed
|
||||
if (!playerRecord.player_Extra.SubbedTo.Contains(toPlayerId))
|
||||
{
|
||||
playerRecord.player_Extra.SubbedTo.Add(toPlayerId);
|
||||
col.Update(playerRecord);
|
||||
IncrementSubscriberCount(toPlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static void IncrementSubscriberCount(ulong playerId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerId);
|
||||
if (player != null && player.player_Extra != null)
|
||||
{
|
||||
player.player_Extra.sub_count++;
|
||||
col.Update(player);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RemoveSubscription(ulong fromPlayerId, ulong toPlayerId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var playerRecord = col.FindOne(x => x.playerid == fromPlayerId);
|
||||
if (playerRecord == null || playerRecord.player_Extra == null)
|
||||
return;
|
||||
|
||||
if (playerRecord.player_Extra.SubbedTo.Remove(toPlayerId))
|
||||
{
|
||||
col.Update(playerRecord);
|
||||
DecrementSubscriberCount(toPlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static void DecrementSubscriberCount(ulong playerId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerId);
|
||||
if (player != null && player.player_Extra != null && player.player_Extra.sub_count > 0)
|
||||
{
|
||||
player.player_Extra.sub_count--;
|
||||
col.Update(player);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Everyone subscribed to <paramref name="playerId"/> (the creator's subscribers).</summary>
|
||||
public static List<ulong> GetSubscriberIds(ulong playerId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
return col.FindAll()
|
||||
.Where(x => x.player_Extra?.SubbedTo != null && x.player_Extra.SubbedTo.Contains(playerId))
|
||||
.Select(x => x.playerid)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static List<ulong> GetSubscribedToIds(ulong playerId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var playerRecord = col.FindOne(x => x.playerid == playerId);
|
||||
if (playerRecord != null && playerRecord.player_Extra != null)
|
||||
{
|
||||
return playerRecord.player_Extra.SubbedTo;
|
||||
}
|
||||
return new List<ulong>();
|
||||
}
|
||||
|
||||
public static void SetPlayerIdentityFlags(int IdentityFlag, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
|
||||
if (player_Setting1 != null)
|
||||
{
|
||||
if (player_Setting1.player != null)
|
||||
{
|
||||
player_Setting1.player.identityFlags = IdentityFlag;
|
||||
col.Update(player_Setting1);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetPlayerImageName(string imageName, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
|
||||
if (player?.player == null)
|
||||
return;
|
||||
|
||||
player.player.profileImage = imageName;
|
||||
col.Update(player);
|
||||
Campus_live_rewrite.api.server.Notifications.FireAndForget(Campus_live_rewrite.api.server.Notifications.PushAccountUpdate(playerid), $"account-update {playerid}");
|
||||
}
|
||||
|
||||
public static void SetPlayerBanner(string imageName, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
|
||||
if (player?.player == null)
|
||||
return;
|
||||
|
||||
player.player.bannerImage = imageName;
|
||||
col.Update(player);
|
||||
}
|
||||
|
||||
public static int GetAccountCountByPlatformId(ulong platformId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
return col.Count(x =>
|
||||
x.player.PlatformIds != null &&
|
||||
x.player.PlatformIds
|
||||
.Select(pid => pid.PlatformId)
|
||||
.Any(id => id == platformId)
|
||||
);
|
||||
}
|
||||
public static int GetAccountCountByDiscordId(ulong discordId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
return col.Count(x =>
|
||||
x.player_Extra != null &&
|
||||
x.player_Extra.DiscordId == discordId
|
||||
);
|
||||
}
|
||||
|
||||
public static List<ulong> GetAccountIdsByDiscordId(ulong discordId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
return col
|
||||
.Find(x =>
|
||||
x.player_Extra != null &&
|
||||
x.player_Extra.DiscordId == discordId
|
||||
)
|
||||
.Select(x => (ulong)x.playerid)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
public static ulong GetPlayerCount()
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var players = col.FindAll().ToList();
|
||||
|
||||
if (players.Count == 0)
|
||||
return 0;
|
||||
|
||||
ulong minID = players.Min(p => p.playerid);
|
||||
ulong maxID = players.Max(p => p.playerid);
|
||||
ulong expectedCount = (ulong)(maxID - minID + 1);
|
||||
|
||||
return expectedCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static List<object> Find_all_player_filtered(string? id, string? displayName, string? minLevel, string? jrState, string? platformId, string? deviceId, bool onlyBannedPlayers)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players"); List<object> list = new List<object>();
|
||||
|
||||
foreach (player_data player in col.FindAll())
|
||||
{
|
||||
if (id != null && player.playerid.ToString() != id)
|
||||
continue;
|
||||
|
||||
if (displayName != null && !player.player.DisplayName.Contains(displayName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (minLevel != null && int.TryParse(minLevel, out int minLvl))
|
||||
{
|
||||
if (player.player.Level < minLvl)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (jrState != null && jrState != "N/A")
|
||||
{
|
||||
bool expectedJunior = jrState == "true";
|
||||
if (player.player.IsJunior != expectedJunior)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(platformId) && (player.player.PlatformIds == null || !player.player.PlatformIds.Any(pid => pid.PlatformId.ToString() == platformId)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (onlyBannedPlayers)
|
||||
{
|
||||
var block = player.player_Extra.ModerationBlockDetails;
|
||||
if (block == null || block.IsBan != true || !HasActiveModerationBlock(player.playerid))
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (deviceId != null && !string.Equals(player.deviceId.ToString(), deviceId, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var room_data = GetAccountBulk_full(player.playerid);
|
||||
if (room_data != null)
|
||||
list.Add(room_data);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public static object? GetAccountBulk_full(ulong playerid)
|
||||
{
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
List<object> list = new List<object>();
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// The admin row template reads `player.PlatformID` (singular) — surface the first
|
||||
// non-zero linked platform id so accounts created via /create-account show up as
|
||||
// "Steam 76561…" instead of "Steam undefined".
|
||||
var firstPlatformId = player?.player?.PlatformIds?
|
||||
.FirstOrDefault(p => p != null && p.PlatformId != 0)?.PlatformId.ToString();
|
||||
|
||||
// PlatformID above is the first non-zero id of ANY platform, so on a Quest
|
||||
// account it is an Oculus id even though the admin table labels the column
|
||||
// "Steam". The Steam allowlist must never be fed that, so surface the real
|
||||
// Steam id separately - empty when the account has none.
|
||||
var steamId = player?.player?.PlatformIds?
|
||||
.FirstOrDefault(p => p != null && p.Platform == Platforms.Steam && p.PlatformId != 0)
|
||||
?.PlatformId.ToString() ?? "";
|
||||
|
||||
return new
|
||||
{
|
||||
AccountId = player?.playerid,
|
||||
Level = player?.player?.Level,
|
||||
xp = player?.player?.XP,
|
||||
DisplayName = player?.player?.DisplayName,
|
||||
DeviceId = player?.deviceId,
|
||||
Username = player?.player?.Username,
|
||||
IsJunior = player?.player?.IsJunior,
|
||||
ProfileImage = player?.player?.profileImage,
|
||||
LastLoginTime = player?.player?.last_login_time,
|
||||
CreatedAt = player?.player?.created_at,
|
||||
PlatformID = firstPlatformId ?? "",
|
||||
SteamID = steamId,
|
||||
PlatformIDs = player?.player?.PlatformIds,
|
||||
// Linked Discord (website Settings > Discord tab). Empty/0 when not linked.
|
||||
DiscordUsername = player?.player_Extra?.DiscordUsername ?? "",
|
||||
DiscordId = (player?.player_Extra?.DiscordId ?? 0) == 0 ? "" : (player!.player_Extra!.DiscordId).ToString(),
|
||||
dormroom_id = player?.player_Extra?.DormRoomID,
|
||||
DevFlagRaw = (int)(player?.dev_Flag ?? DevFlag.none),
|
||||
IsDev = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.dev) == DevFlag.dev,
|
||||
IsMod = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.mod) == DevFlag.mod,
|
||||
IsScreenshare = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.screenshare) == DevFlag.screenshare,
|
||||
IsSupport = ((player?.dev_Flag ?? DevFlag.none) & DevFlag.support) == DevFlag.support,
|
||||
IsInfluencer = player?.player_Extra?.Influencer?.isInfluencer == true,
|
||||
};
|
||||
}
|
||||
|
||||
public static void DeletePlayer(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
|
||||
if (player != null)
|
||||
{
|
||||
col.Delete(playerid);
|
||||
Console.WriteLine("Deleted player id " + playerid);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Player with ID {playerid} not found.");
|
||||
}
|
||||
}
|
||||
|
||||
public static int DeletePlayersWithNullLastLogin()
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
var minDate = DateTime.MinValue;
|
||||
|
||||
var playersToDelete = col
|
||||
.Find(x => x.player.last_login_time == minDate)
|
||||
.Select(x => x.playerid)
|
||||
.ToList();
|
||||
|
||||
int deleted = 0;
|
||||
|
||||
foreach (var id in playersToDelete)
|
||||
{
|
||||
if (DeletePlayerFull(id))
|
||||
deleted++;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Deleted {deleted} players with default LastLogin.");
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
|
||||
public static bool DeletePlayerFull(ulong playerid)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeletePlayer(playerid);
|
||||
RoomDB.DeleteAllRoomsFromPlayerId(playerid);
|
||||
HeartbeatDB.ClearPlayerHeartbeat(playerid);
|
||||
SettingsDB.DeleteSettings(playerid);
|
||||
LinkDB.DeleteLinksFromPlayerId(playerid);
|
||||
ImageMetadataDB.DeleteImagesFromPlayerId(playerid);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string SetRecentRooms(ulong playerid, ulong room_id)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.player.Id == playerid);
|
||||
|
||||
if (player != null)
|
||||
{
|
||||
if (player.player.recent_rooms == null)
|
||||
{
|
||||
player.player.recent_rooms = new List<ulong>();
|
||||
}
|
||||
|
||||
player.player.recent_rooms.Remove(room_id);
|
||||
player.player.recent_rooms.Insert(0, room_id);
|
||||
|
||||
col.Update(player);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
public static string GetRecentRooms(ulong playerId, int skip = 0, int take = 20)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.player.Id == playerId);
|
||||
|
||||
if (player == null || player.player.recent_rooms == null || !player.player.recent_rooms.Any())
|
||||
{
|
||||
return "[]";
|
||||
}
|
||||
|
||||
var paginatedRoomIds = player.player.recent_rooms
|
||||
.Skip(Math.Max(0, skip))
|
||||
.Take(Math.Max(1, take))
|
||||
.ToList();
|
||||
|
||||
var visitedRooms = new List<RoomDBClasses.RoomRoot>();
|
||||
|
||||
foreach (ulong roomId in paginatedRoomIds)
|
||||
{
|
||||
var room = RoomDB.GetRoom(roomId);
|
||||
if (room != null)
|
||||
{
|
||||
visitedRooms.Add(room);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip blobs the client cannot download - this listing is fetched moments
|
||||
// before every join and the client can join straight from a cached entry.
|
||||
Campus_live_rewrite.api.server.RoomBlobFilter.Strip(visitedRooms);
|
||||
return JsonConvert.SerializeObject(visitedRooms, Formatting.None);
|
||||
}
|
||||
|
||||
|
||||
public static string ClearRecentRoomsForAllPlayers()
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
var allPlayers = col.FindAll();
|
||||
|
||||
foreach (var player in allPlayers)
|
||||
{
|
||||
if (player.player.recent_rooms != null && player.player.recent_rooms.Count > 0)
|
||||
{
|
||||
player.player.recent_rooms = new List<ulong>();
|
||||
col.Update(player);
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
public static string GetFavoritedRooms(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.player.Id == playerid);
|
||||
|
||||
// Was: `player == null || player.player_Extra.FavoritedRooms == null` -
|
||||
// if player_Extra itself was null the second clause threw NRE before the
|
||||
// check could help.
|
||||
if (player?.player_Extra?.FavoritedRooms == null)
|
||||
{
|
||||
return "[]";
|
||||
}
|
||||
|
||||
var favRooms = new List<RoomDBClasses.RoomRoot>();
|
||||
|
||||
foreach (ulong roomId in player.player_Extra.FavoritedRooms)
|
||||
{
|
||||
var room = RoomDB.GetRoom(roomId);
|
||||
if (room != null)
|
||||
{
|
||||
favRooms.Add(room);
|
||||
}
|
||||
}
|
||||
|
||||
return JsonConvert.SerializeObject(favRooms);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a device this account has logged in from. An account may accumulate
|
||||
/// SEVERAL - a player is allowed to play on more than one machine, and each new
|
||||
/// one is remembered the first time they sign in on it.
|
||||
///
|
||||
/// The restriction is on CREATION, not login: see CanCreateAccountOnDevice. A
|
||||
/// device that already belongs to an account cannot be used to register another,
|
||||
/// which is what stops alt farms, while a real player moving to a second device
|
||||
/// just signs in and has it added here.
|
||||
///
|
||||
/// (This used to overwrite the list with a single id - one account, one device.
|
||||
/// That made a legitimate second machine look like a device move on every login,
|
||||
/// and it meant the creation check below had nothing reliable to test against.)
|
||||
///
|
||||
/// Blank ids are ignored rather than stored, so a login that sends no device id
|
||||
/// cannot wipe the binding (and cannot create an unbannable empty-id account).
|
||||
///
|
||||
/// Device bans still work: AccountsOnDevice matches by CONTAINMENT, so banning a
|
||||
/// device finds every account that has ever used it. Note the trade-off that comes
|
||||
/// with allowing several devices - banning one does not stop a player who has
|
||||
/// another registered.
|
||||
/// </summary>
|
||||
public static void SetPlayerDeviceId(string DeviceId, ulong playerid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(DeviceId)) return;
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player?.player == null) return;
|
||||
|
||||
string id = DeviceId.Trim();
|
||||
var current = player.deviceId ?? new List<string>();
|
||||
|
||||
// Already known for this account - nothing to write.
|
||||
if (current.Contains(id)) return;
|
||||
|
||||
current.Add(id);
|
||||
player.deviceId = current;
|
||||
col.Update(player);
|
||||
|
||||
Console.WriteLine($"[device] account {playerid} added device '{id}' "
|
||||
+ $"({current.Count} total)");
|
||||
}
|
||||
|
||||
/// <summary>Drop device bindings (the account lock replacing an idle device). The flat
|
||||
/// anti-alt deviceId list is left alone: that device stays "used" for alt purposes.</summary>
|
||||
public static void UnbindDevices(ulong playerid, IEnumerable<string> deviceIds)
|
||||
{
|
||||
var ids = new HashSet<string>(deviceIds, StringComparer.Ordinal);
|
||||
if (ids.Count == 0) return;
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player?.DeviceBindings == null) return;
|
||||
int before = player.DeviceBindings.Count;
|
||||
player.DeviceBindings.RemoveAll(b => b.DeviceId != null && ids.Contains(b.DeviceId));
|
||||
if (player.DeviceBindings.Count != before) col.Update(player);
|
||||
}
|
||||
|
||||
/// <summary>The account's device bindings (device id + the platform it signed in on).
|
||||
/// Empty for legacy rows until the next login re-binds them.</summary>
|
||||
public static List<mDeviceBind> GetDeviceBindings(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
return player?.DeviceBindings ?? new List<mDeviceBind>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record that this account signed in from this device on this platform. Writes BOTH the
|
||||
/// flat deviceId list (the anti-alt source of truth) and the platform-aware DeviceBindings
|
||||
/// list (used by the account lock's one-PC-one-Quest cap). Idempotent per (device,platform).
|
||||
/// </summary>
|
||||
public static void BindDevice(string? deviceId, Platforms platform, ulong playerid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return;
|
||||
string id = deviceId.Trim();
|
||||
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player?.player == null) return;
|
||||
|
||||
bool changed = false;
|
||||
|
||||
var flat = player.deviceId ?? new List<string>();
|
||||
if (!flat.Contains(id)) { flat.Add(id); player.deviceId = flat; changed = true; }
|
||||
|
||||
var binds = player.DeviceBindings ?? new List<mDeviceBind>();
|
||||
var mine = binds.FirstOrDefault(b => b.DeviceId == id && b.Platform == platform);
|
||||
if (mine == null)
|
||||
{
|
||||
binds.Add(new mDeviceBind { DeviceId = id, Platform = platform, LastSeen = DateTime.UtcNow });
|
||||
player.DeviceBindings = binds;
|
||||
changed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Touch: the account lock lets a NEW device of the same class take over when this
|
||||
// one has been idle (AccountLock), so every login has to stamp it.
|
||||
mine.LastSeen = DateTime.UtcNow;
|
||||
player.DeviceBindings = binds;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
col.Update(player);
|
||||
Console.WriteLine($"[device] account {playerid} bound device '{id}' on {platform} "
|
||||
+ $"({binds.Select(b => b.DeviceId).Distinct().Count()} device(s))");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear an account's device bindings so it can be signed in from fresh hardware - the
|
||||
/// operator's "reset" when a player legitimately replaces their PC or headset. Clears both
|
||||
/// the flat deviceId list and the platform-aware DeviceBindings.
|
||||
/// </summary>
|
||||
public static bool ClearDeviceBindings(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player == null) return false;
|
||||
player.deviceId = new List<string>();
|
||||
player.DeviceBindings = new List<mDeviceBind>();
|
||||
col.Update(player);
|
||||
Console.WriteLine($"[device] account {playerid} device bindings CLEARED");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// May a NEW account be created from this device?
|
||||
///
|
||||
/// False once any existing account has used it. This is the whole anti-alt rule:
|
||||
/// one account per device at creation time, while logins from further devices are
|
||||
/// unrestricted (SetPlayerDeviceId).
|
||||
///
|
||||
/// A blank device id returns TRUE - it carries no information, and refusing on it
|
||||
/// would lock out every caller that does not send one. Callers that care must
|
||||
/// require a non-empty id themselves before asking.
|
||||
/// </summary>
|
||||
public static bool CanCreateAccountOnDevice(string? deviceId, out ulong existingAccountId)
|
||||
{
|
||||
existingAccountId = 0;
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return true;
|
||||
|
||||
var owners = AccountsOnDevice(deviceId).Where(o => !AccountLock.IsExempt(o)).ToList();
|
||||
if (owners.Count == 0) return true;
|
||||
|
||||
// A device may hold one game-made account and one website account (see
|
||||
// ConfigCampus.MaxAccountsPerDevice). Creating in-game takes the game-made slot: refused
|
||||
// once the device is full, or once it already has a game-made account.
|
||||
existingAccountId = owners[0];
|
||||
if (owners.Count >= ConfigCampus.MaxAccountsPerDevice) return false;
|
||||
var gameMade = owners.FirstOrDefault(o => OriginOf(o) == AccountOrigin.InGame);
|
||||
if (gameMade != 0) { existingAccountId = gameMade; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
public enum AccountOrigin { Unknown, InGame, Website }
|
||||
|
||||
/// <summary>
|
||||
/// Where an account was made. Rows created since the flag exists say so explicitly; older
|
||||
/// rows are Unknown, and an Unknown account fits EITHER of a device's two slots (it only
|
||||
/// counts toward the cap), so nobody who existed before the rule is locked out by it.
|
||||
/// </summary>
|
||||
public static AccountOrigin OriginOf(ulong accountId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var p = col.FindOne(x => x.playerid == accountId);
|
||||
if (p?.CreatedInGame == null) return AccountOrigin.Unknown;
|
||||
return p.CreatedInGame.Value ? AccountOrigin.InGame : AccountOrigin.Website;
|
||||
}
|
||||
|
||||
/// <summary>The account's single device id, or null if it has never logged in.</summary>
|
||||
public static string? GetDeviceId(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
var list = player?.deviceId;
|
||||
if (list == null || list.Count == 0) return null;
|
||||
return string.IsNullOrWhiteSpace(list[0]) ? null : list[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cached-login picker entries for the accounts bound to this device on this platform
|
||||
/// (see ConfigCampus.QuestDeviceCachedLogin). requirePassword=false: device ownership is the
|
||||
/// credential, the cached_login grant re-checks the same binding before issuing a token.
|
||||
/// </summary>
|
||||
public static List<AccountLogin> GetDeviceLogins(string? deviceId, Platforms platform, ulong platformId)
|
||||
{
|
||||
var result = new List<AccountLogin>();
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return result;
|
||||
string id = deviceId.Trim();
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
foreach (var p in col.FindAll())
|
||||
{
|
||||
if (p?.player == null || p.deviceId == null || !p.deviceId.Contains(id)) continue;
|
||||
// Only a binding made from THIS platform class counts (a PC login on some shared
|
||||
// device id must not turn into a headset cached login).
|
||||
if (p.DeviceBindings == null || !p.DeviceBindings.Any(b => b.DeviceId == id && b.Platform == platform)) continue;
|
||||
result.Add(new AccountLogin
|
||||
{
|
||||
accountId = p.playerid,
|
||||
createdAt = p.player.created_at,
|
||||
lastLoginTime = p.player.last_login_time,
|
||||
platform = platform,
|
||||
platformId = platformId.ToString(),
|
||||
requirePassword = false
|
||||
});
|
||||
}
|
||||
return result.OrderByDescending(a => a.lastLoginTime).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Is this account bound to this device on this platform?</summary>
|
||||
public static bool IsDeviceBoundTo(ulong accountId, string? deviceId, Platforms platform)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return false;
|
||||
string id = deviceId.Trim();
|
||||
var binds = GetDeviceBindings(accountId);
|
||||
return binds.Any(b => b.DeviceId == id && b.Platform == platform);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every account bound to this device id. Surfaced by the admin panel so a device
|
||||
/// ban shows what else it takes out (alt accounts).
|
||||
/// </summary>
|
||||
public static List<ulong> AccountsOnDevice(string? deviceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return new List<ulong>();
|
||||
string id = deviceId.Trim();
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
return col.FindAll()
|
||||
.Where(x => x.deviceId != null && x.deviceId.Contains(id))
|
||||
.Select(x => x.playerid)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Clears an account-level ban. Device/IP bans are lifted separately (BanDB).</summary>
|
||||
public static bool ClearModerationBlock(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player?.player_Extra == null) return false;
|
||||
|
||||
player.player_Extra.ModerationBlockDetails = null;
|
||||
col.Update(player);
|
||||
return true;
|
||||
}
|
||||
public static void SetPlayerDiscordId(ulong DiscordId, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player != null)
|
||||
{
|
||||
if (player.player != null)
|
||||
{
|
||||
player.player_Extra.DiscordId = DiscordId;
|
||||
col.Update(player);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetPlayerNameById(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
return player?.player.Username ?? "Unknown";
|
||||
}
|
||||
|
||||
public static ulong GetPlayerIdByName(string name)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.player.Username == name);
|
||||
return player?.player.Id ?? 0;
|
||||
}
|
||||
|
||||
public static string GetAvatarSaved(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
// Was: only `player` was null-checked, then player_Extra.SavedOutfits was
|
||||
// dereferenced - NRE when player_Extra is null. And SerializeObject(null)
|
||||
// returns the literal "null", not "[]", which the client cannot parse as a
|
||||
// list. Both paths now yield a real empty array.
|
||||
var saved = player?.player_Extra?.SavedOutfits;
|
||||
if (saved == null || saved.Count == 0)
|
||||
{
|
||||
return "[]";
|
||||
}
|
||||
|
||||
// The newer client's Saved Outfits page (SavedOutfitsWatchUIFlow) deserializes each
|
||||
// outfit into the StoredOutfit envelope and THROWS "Expected LegacyData but was null"
|
||||
// (DCJDHBOIBBC.MissingLegacyData) on any entry without a `LegacyData` object - which
|
||||
// then blanks the whole page. Serializing the STRUCTURED avatar_data_saved (which has
|
||||
// no LegacyData field) is exactly that failure. So project each outfit into the shape
|
||||
// the client expects:
|
||||
// - Preferred: round-trip the RAW document the client posted (SaveOutfit stores it in
|
||||
// .Avatar); it already carries LegacyData/Selections/DataVersion verbatim.
|
||||
// - Fallback (older saves / server-made presets with no raw body): synthesize the
|
||||
// LegacyData envelope from the flat fields so LegacyData is never null.
|
||||
var arr = new Newtonsoft.Json.Linq.JArray();
|
||||
foreach (var o in saved)
|
||||
{
|
||||
Newtonsoft.Json.Linq.JObject? raw = null;
|
||||
if (!string.IsNullOrWhiteSpace(o.Avatar))
|
||||
{
|
||||
try { raw = Newtonsoft.Json.Linq.JObject.Parse(o.Avatar); } catch { raw = null; }
|
||||
}
|
||||
|
||||
if (raw != null && raw["LegacyData"] != null &&
|
||||
raw["LegacyData"]!.Type != Newtonsoft.Json.Linq.JTokenType.Null)
|
||||
{
|
||||
// Slot/Name are the authority for slot overwrite - keep them in sync with the
|
||||
// structured fields in case the stored raw body predated them.
|
||||
raw["Slot"] = (long)o.Slot;
|
||||
if (o.Name != null) raw["Name"] = o.Name;
|
||||
arr.Add(raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
arr.Add(new Newtonsoft.Json.Linq.JObject
|
||||
{
|
||||
["LegacyData"] = new Newtonsoft.Json.Linq.JObject
|
||||
{
|
||||
["SelectionsV1"] = o.OutfitSelections,
|
||||
["SelectionsV2"] = o.OutfitSelectionsV2,
|
||||
["FaceFeatures"] = o.FaceFeatures,
|
||||
["SkinColor"] = o.SkinColor,
|
||||
["HairColor"] = o.HairColor
|
||||
},
|
||||
["Selections"] = new Newtonsoft.Json.Linq.JArray(),
|
||||
["DataVersion"] = 9,
|
||||
["CustomizationSettings"] = (string?)null,
|
||||
["ThumbnailFileName"] = o.ImageName ?? o.PreviewImageName,
|
||||
["Name"] = o.Name,
|
||||
["Accessibility"] = 0,
|
||||
["Slot"] = (long)o.Slot
|
||||
});
|
||||
}
|
||||
return arr.ToString(Newtonsoft.Json.Formatting.None);
|
||||
}
|
||||
|
||||
public static string SetAvatarSaved(ulong playerid, List<avatar_data_saved> avatars)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
// Was: `player == null` only - player_Extra was dereferenced unchecked.
|
||||
if (player?.player_Extra == null)
|
||||
return "[]";
|
||||
|
||||
if (player.player_Extra.SavedOutfits == null)
|
||||
player.player_Extra.SavedOutfits = new List<avatar_data_saved>();
|
||||
|
||||
var incomingSlots = avatars.Select(a => a.Slot).ToHashSet();
|
||||
player.player_Extra.SavedOutfits.RemoveAll(a => incomingSlots.Contains(a.Slot));
|
||||
|
||||
player.player_Extra.SavedOutfits.AddRange(avatars);
|
||||
|
||||
col.Update(player);
|
||||
|
||||
return JsonConvert.SerializeObject(player.player_Extra.SavedOutfits);
|
||||
}
|
||||
|
||||
public static void SetPlayerCheer(CheerCategoryEnum cheer = CheerCategoryEnum.None, ulong playerid = 0)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
|
||||
if (player_Setting1 != null)
|
||||
{
|
||||
if (player_Setting1.player.PlayerReputation != null)
|
||||
{
|
||||
player_Setting1.player.PlayerReputation.SelectedCheer = cheer;
|
||||
col.Update(player_Setting1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public static CheerCategoryEnum? GetPlayerCheer(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
|
||||
|
||||
if (player_Setting1 != null)
|
||||
{
|
||||
if (player_Setting1.player.PlayerReputation != null)
|
||||
{
|
||||
return player_Setting1.player.PlayerReputation.SelectedCheer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<ulong> GetInfluencerIds()
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var result = col.Query().Where(x => x.player_Extra.Influencer.isInfluencer == true).Select(x => x.playerid).ToList();
|
||||
return result;
|
||||
}
|
||||
|
||||
public static player_data? GetInfluencerByCreatorCode(string creatorcode)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.player_Extra.Influencer.creatorCode == creatorcode && x.player_Extra.Influencer.isInfluencer == true);
|
||||
return player;
|
||||
}
|
||||
|
||||
public static bool IsInfluencer(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
return player?.player_Extra?.Influencer?.isInfluencer == true;
|
||||
}
|
||||
|
||||
public static void SetIsInfluencer(ulong playerid, bool IsInfluencer)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
|
||||
if (player == null)
|
||||
return;
|
||||
|
||||
if (player.player_Extra == null)
|
||||
player.player_Extra = new player_extra();
|
||||
|
||||
if (player.player_Extra.Influencer == null)
|
||||
player.player_Extra.Influencer = new Influencer();
|
||||
|
||||
player.player_Extra.Influencer.isInfluencer = IsInfluencer;
|
||||
|
||||
col.Update(player);
|
||||
}
|
||||
|
||||
public static void SetCreatorCode(ulong playerid, string creatorcode)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
|
||||
if (player == null)
|
||||
return;
|
||||
|
||||
if (player.player_Extra == null)
|
||||
player.player_Extra = new player_extra();
|
||||
|
||||
if (player.player_Extra.Influencer == null)
|
||||
player.player_Extra.Influencer = new Influencer();
|
||||
|
||||
player.player_Extra.Influencer.creatorCode = creatorcode;
|
||||
|
||||
col.Update(player);
|
||||
}
|
||||
|
||||
public static void SupportInfluencer(ulong requestplayerid, ulong SupportId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == requestplayerid);
|
||||
if (player != null)
|
||||
{
|
||||
if (player.player_Extra.Influencer == null)
|
||||
{
|
||||
player.player_Extra.Influencer = new Influencer();
|
||||
}
|
||||
|
||||
player.player_Extra.Influencer.SupportingInfluencer = SupportId;
|
||||
col.Update(player);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public static ulong? GetMyInfluencer(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
|
||||
if (player != null)
|
||||
{
|
||||
if (player.player_Extra.Influencer != null)
|
||||
{
|
||||
return player.player_Extra.Influencer.SupportingInfluencer;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// message funcs
|
||||
|
||||
public static List<MessageData> GetMessages(ulong playerId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerId);
|
||||
|
||||
if (player == null)
|
||||
return new List<MessageData>();
|
||||
|
||||
if (player.player_Extra.Messages == null)
|
||||
player.player_Extra.Messages = new List<MessageData>();
|
||||
|
||||
return player.player_Extra.Messages;
|
||||
}
|
||||
|
||||
public static MessageData? AddMessage(ulong toPlayerId, ulong fromPlayerId, WebsocketEvents.MessageType type, string? data, ulong? roomId = null, ulong? playerEventId = null)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == toPlayerId);
|
||||
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
if (player.player_Extra.Messages == null)
|
||||
player.player_Extra.Messages = new List<MessageData>();
|
||||
|
||||
var msg = new MessageData()
|
||||
{
|
||||
Id = GenerateRandomMessageId(),
|
||||
FromPlayerId = fromPlayerId,
|
||||
SentTime = DateTime.UtcNow,
|
||||
Type = type,
|
||||
Data = data,
|
||||
RoomId = roomId,
|
||||
PlayerEventId = playerEventId
|
||||
};
|
||||
|
||||
player.player_Extra.Messages.Add(msg);
|
||||
col.Update(player);
|
||||
|
||||
// Nudge the recipient's open website tabs to re-fetch the inbox (cheers, coach
|
||||
// messages, ...). No-op if they have no browser tab open.
|
||||
Campus_live_rewrite.api.server.SiteNotifications.Notify(toPlayerId);
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
public static void DeleteMessage(ulong playerId, ulong messageId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerId);
|
||||
|
||||
if (player == null)
|
||||
return;
|
||||
|
||||
if (player.player_Extra.Messages == null || player.player_Extra.Messages.Count == 0)
|
||||
return;
|
||||
|
||||
var message = player.player_Extra.Messages.FirstOrDefault(m => m.Id == messageId);
|
||||
|
||||
if (message == null)
|
||||
return;
|
||||
|
||||
player.player_Extra.Messages.Remove(message);
|
||||
|
||||
// Save changes
|
||||
col.Update(player);
|
||||
}
|
||||
|
||||
|
||||
public static ulong GenerateRandomMessageId()
|
||||
{
|
||||
lock (randLock)
|
||||
{
|
||||
byte[] buffer = new byte[8];
|
||||
random.NextBytes(buffer);
|
||||
return BitConverter.ToUInt64(buffer, 0);
|
||||
}
|
||||
}
|
||||
// message funcs
|
||||
|
||||
public static int SetCheerCredit(int cheerCredit, ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player_Setting1 = col.FindOne(x => x.playerid == playerid);
|
||||
if (player_Setting1 != null)
|
||||
{
|
||||
if (player_Setting1.player != null)
|
||||
{
|
||||
player_Setting1.player.PlayerReputation.CheerCredit = cheerCredit;
|
||||
col.Update(player_Setting1);
|
||||
return cheerCredit;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static void Setup()
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
col.EnsureIndex(x => x.playerid, true);
|
||||
col.EnsureIndex(x => x.platform);
|
||||
col.EnsureIndex(x => x.player.PlatformIds);
|
||||
col.EnsureIndex(x => x.player.Username);
|
||||
col.EnsureIndex(x => x.deviceId);
|
||||
col.EnsureIndex(x => x.authtoken);
|
||||
col.EnsureIndex(x => x.id);
|
||||
col.EnsureIndex(x => x.player_Extra.Messages);
|
||||
col.EnsureIndex("infl", "playerExtra.Influencer.isInfluencer");
|
||||
}
|
||||
|
||||
public static string? GetPlayerData(ulong playerId, ulong roomId)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerId);
|
||||
|
||||
if (player?.player_Extra?.MyRoomData?.PlayerData == null)
|
||||
return null;
|
||||
|
||||
var entry = player.player_Extra.MyRoomData.PlayerData.FirstOrDefault(p => p.RoomId == roomId);
|
||||
|
||||
return entry?.Data;
|
||||
}
|
||||
|
||||
public static bool SetPlayerData(ulong playerId, ulong roomId, string data)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerId);
|
||||
|
||||
if (player == null)
|
||||
return false;
|
||||
|
||||
if (player.player_Extra == null)
|
||||
player.player_Extra = new player_extra();
|
||||
|
||||
if (player.player_Extra.MyRoomData == null)
|
||||
player.player_Extra.MyRoomData = new MyRoomData();
|
||||
|
||||
if (player.player_Extra.MyRoomData.PlayerData == null)
|
||||
player.player_Extra.MyRoomData.PlayerData = new List<PlayerData>();
|
||||
|
||||
var existingEntry = player.player_Extra.MyRoomData.PlayerData.FirstOrDefault(p => p.RoomId == roomId);
|
||||
|
||||
if (existingEntry != null)
|
||||
{
|
||||
existingEntry.Data = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
player.player_Extra.MyRoomData.PlayerData.Add(new PlayerData
|
||||
{
|
||||
RoomId = roomId,
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
|
||||
return col.Update(player);
|
||||
}
|
||||
|
||||
public static PlayerPhotoTaggingSetting GetPlayerPhotoTaggingSetting(ulong playerid)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player != null)
|
||||
{
|
||||
if (player.player != null)
|
||||
{
|
||||
return player.player_Extra.PlayerPhotoTaggingSetting;
|
||||
}
|
||||
}
|
||||
return PlayerPhotoTaggingSetting.Anyone;
|
||||
}
|
||||
|
||||
public static bool SetPlayerPhotoTaggingSetting(ulong playerid, PlayerPhotoTaggingSetting playerPhotoTaggingSetting)
|
||||
{
|
||||
var col = PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = col.FindOne(x => x.playerid == playerid);
|
||||
if (player != null)
|
||||
{
|
||||
if (player.player != null)
|
||||
{
|
||||
player.player_Extra.PlayerPhotoTaggingSetting = playerPhotoTaggingSetting;
|
||||
col.Update(player);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using LiteDB;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
/// <summary>
|
||||
/// Player reports - the ones raised from the in-game report UI.
|
||||
///
|
||||
/// The client files them with POST /api/PlayerReporting/v3/create (form-encoded; the reporter
|
||||
/// is the bearer token, never a body field). That route did not exist here at all, so every
|
||||
/// report a player made 404'd and was lost - which is why reporting "did nothing" and why the
|
||||
/// admin panel had nothing to show. Reports now land in this store and the admin panel's
|
||||
/// Reports section reads and acts on them.
|
||||
///
|
||||
/// A report is filed OPEN. A moderator resolves it, and resolving is the only thing that
|
||||
/// changes its state - the ban / kick an action performs goes through the normal ban path so
|
||||
/// there is one ban mechanism, not two.
|
||||
/// </summary>
|
||||
public static class ReportsDB
|
||||
{
|
||||
private static readonly LiteDatabase _db =
|
||||
new(System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "DBs", "Reports.db"));
|
||||
|
||||
/// <summary>Merge this store's write-ahead log back into the data file (see DbMaintenance).</summary>
|
||||
public static void Checkpoint() => _db.Checkpoint();
|
||||
|
||||
public class Report
|
||||
{
|
||||
[BsonId]
|
||||
public long Id { get; set; }
|
||||
|
||||
public ulong ReporterPlayerId { get; set; }
|
||||
public ulong ReportedPlayerId { get; set; }
|
||||
|
||||
/// <summary>Stored verbatim, unmapped - Moderation_Detail.ReportCategory names it for display.</summary>
|
||||
public int ReportCategory { get; set; }
|
||||
|
||||
public string? Details { get; set; }
|
||||
public double? HeightReporter { get; set; }
|
||||
public double? HeightReported { get; set; }
|
||||
|
||||
/// <summary>Null when the report was raised outside a room (the client omits it).</summary>
|
||||
public ulong? RoomId { get; set; }
|
||||
public string? RoomInstanceType { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// ---- website reports -------------------------------------------------------
|
||||
// Filed from the site's Report buttons (POST /api/site/report). Source is "website";
|
||||
// Subject says what was reported (player / photo / comment), SubjectId which one,
|
||||
// SubjectLink is the site page a moderator can open, Reason the label the reporter
|
||||
// picked. In-game reports leave these null and keep using ReportCategory.
|
||||
public string? Source { get; set; }
|
||||
public string? Subject { get; set; }
|
||||
public long SubjectId { get; set; }
|
||||
public string? SubjectLink { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
|
||||
// ---- moderation state -------------------------------------------------------
|
||||
public bool Resolved { get; set; }
|
||||
public string? Action { get; set; } // what was done: dismissed / warned / banned / ...
|
||||
public ulong ResolvedByPlayerId { get; set; }
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
public string? ModeratorNote { get; set; }
|
||||
}
|
||||
|
||||
private static ILiteCollection<Report> Col
|
||||
{
|
||||
get
|
||||
{
|
||||
var c = _db.GetCollection<Report>("reports");
|
||||
c.EnsureIndex(x => x.ReportedPlayerId);
|
||||
c.EnsureIndex(x => x.CreatedAt);
|
||||
c.EnsureIndex(x => x.Resolved);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
public static Report Create(Report r)
|
||||
{
|
||||
r.CreatedAt = DateTime.UtcNow;
|
||||
r.Resolved = false;
|
||||
Col.Insert(r);
|
||||
Console.WriteLine($"[report] #{r.Id}: {r.ReporterPlayerId} reported {r.ReportedPlayerId} " +
|
||||
$"(category {r.ReportCategory}{(r.RoomId != null ? $", room {r.RoomId}" : "")})");
|
||||
return r;
|
||||
}
|
||||
|
||||
public static Report? Get(long id) => Col.FindById(id);
|
||||
|
||||
/// <summary>Newest first. openOnly leaves resolved reports out of the queue.</summary>
|
||||
public static List<Report> List(bool openOnly, int skip, int take, ulong reportedPlayerId = 0)
|
||||
{
|
||||
var q = Col.FindAll().AsEnumerable();
|
||||
if (openOnly) q = q.Where(x => !x.Resolved);
|
||||
if (reportedPlayerId != 0) q = q.Where(x => x.ReportedPlayerId == reportedPlayerId);
|
||||
return q.OrderByDescending(x => x.CreatedAt).Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, 500)).ToList();
|
||||
}
|
||||
|
||||
public static int CountOpen() => Col.Count(x => !x.Resolved);
|
||||
|
||||
/// <summary>An OPEN report by this reporter about the same thing already exists.</summary>
|
||||
public static bool HasOpenDuplicate(ulong reporter, string subject, long subjectId, ulong reported) =>
|
||||
Col.Exists(x => !x.Resolved && x.ReporterPlayerId == reporter && x.Subject == subject
|
||||
&& x.SubjectId == subjectId && x.ReportedPlayerId == reported);
|
||||
|
||||
/// <summary>How many reports this player has had filed against them, ever.</summary>
|
||||
public static int CountAgainst(ulong playerId) => Col.Count(x => x.ReportedPlayerId == playerId);
|
||||
|
||||
public static bool Resolve(long id, ulong byPlayerId, string action, string? note)
|
||||
{
|
||||
var r = Col.FindById(id);
|
||||
if (r == null) return false;
|
||||
|
||||
r.Resolved = true;
|
||||
r.Action = action;
|
||||
r.ResolvedByPlayerId = byPlayerId;
|
||||
r.ResolvedAt = DateTime.UtcNow;
|
||||
if (!string.IsNullOrWhiteSpace(note)) r.ModeratorNote = note;
|
||||
Col.Update(r);
|
||||
|
||||
Console.WriteLine($"[report] #{id} resolved by {byPlayerId}: {action}");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve every OPEN report against one player at once. Used when an action is taken on a
|
||||
/// player rather than on a single report - banning someone should clear their queue, not
|
||||
/// leave nine duplicates of the thing you just acted on.
|
||||
/// </summary>
|
||||
public static int ResolveAllAgainst(ulong reportedPlayerId, ulong byPlayerId, string action, string? note)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var r in Col.Find(x => x.ReportedPlayerId == reportedPlayerId && !x.Resolved).ToList())
|
||||
if (Resolve(r.Id, byPlayerId, action, note)) n++;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
// Comments pinned in a room's scene - the little notes players leave stuck to a spot in
|
||||
// a room. Ported from RecFlare's `roomcomments` service, which the 2025 client talks to
|
||||
// on the `/RoomComments` base URL (see WebController's service map). There were no
|
||||
// handlers for it, so reading a room's notes and leaving one both 404'd.
|
||||
//
|
||||
// Stored as a collection INSIDE Rooms.db so it rides RoomDB's existing checkpoint
|
||||
// (DbMaintenance) instead of adding another write-ahead log that never merges.
|
||||
public static class RoomCommentsDB
|
||||
{
|
||||
public const int MaxCommentLength = 1000;
|
||||
public const int DefaultCount = 100;
|
||||
public const int MaxCount = 500;
|
||||
|
||||
// Matches RecFlare's RoomCommentEntry field-for-field (PascalCase, the client's shape).
|
||||
public class RoomCommentEntry
|
||||
{
|
||||
[BsonId]
|
||||
public int CommentId { get; set; } // autoincrement; also the read cursor (minId)
|
||||
public long RoomId { get; set; }
|
||||
public long SubRoomId { get; set; }
|
||||
public ulong AccountId { get; set; } // who wrote it
|
||||
public string CreatedAt { get; set; } = ""; // ISO-8601 UTC
|
||||
public string Message { get; set; } = "";
|
||||
public int Style { get; set; }
|
||||
public bool Unread { get; set; } = true; // always true - read state, not per-viewer
|
||||
public double PositionX { get; set; }
|
||||
public double PositionY { get; set; }
|
||||
public double PositionZ { get; set; }
|
||||
}
|
||||
|
||||
private static readonly ILiteCollection<RoomCommentEntry> _col =
|
||||
RoomDB.rooms_db.GetCollection<RoomCommentEntry>("room_comments");
|
||||
|
||||
static RoomCommentsDB()
|
||||
{
|
||||
_col.EnsureIndex(x => x.RoomId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A room's comments, NEWEST FIRST. minId is an EXCLUSIVE cursor (-1 = give me
|
||||
/// everything), so a client holding up to id N polls with minId=N and gets only what
|
||||
/// was written since. count is clamped 1..500. An unknown room simply has none.
|
||||
/// </summary>
|
||||
public static List<RoomCommentEntry> Get(long roomId, int? count, int? minId, int? subRoomId)
|
||||
{
|
||||
int take = count.HasValue ? Math.Clamp(count.Value, 1, MaxCount) : DefaultCount;
|
||||
int cursor = minId ?? -1;
|
||||
|
||||
var q = _col.Query().Where(x => x.RoomId == roomId);
|
||||
// subRoomId omitted -> the whole room; given -> narrow to that subroom.
|
||||
if (subRoomId.HasValue)
|
||||
{
|
||||
int sr = subRoomId.Value;
|
||||
q = q.Where(x => x.SubRoomId == sr);
|
||||
}
|
||||
|
||||
return q.ToEnumerable()
|
||||
.Where(x => x.CommentId > cursor) // exclusive
|
||||
.OrderByDescending(x => x.CommentId) // newest first
|
||||
.Take(take)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins a comment in a subroom's scene. Author is the caller (never trusted from the
|
||||
/// body). Message is truncated to MaxCommentLength; a blank message or missing subroom
|
||||
/// is the caller's responsibility to reject (returns null on a blank message here as a
|
||||
/// safety net). Returns the stored comment so the client can draw the bubble it placed.
|
||||
/// </summary>
|
||||
public static RoomCommentEntry? Create(long roomId, ulong accountId, long subRoomId,
|
||||
string message, int style, double x, double y, double z)
|
||||
{
|
||||
message = (message ?? "").Trim();
|
||||
if (message.Length > MaxCommentLength) message = message.Substring(0, MaxCommentLength);
|
||||
if (message.Length == 0) return null;
|
||||
|
||||
int nextId = (_col.Query().OrderByDescending(c => c.CommentId).FirstOrDefault()?.CommentId ?? 0) + 1;
|
||||
|
||||
var entry = new RoomCommentEntry
|
||||
{
|
||||
CommentId = nextId,
|
||||
RoomId = roomId,
|
||||
SubRoomId = subRoomId,
|
||||
AccountId = accountId,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o"),
|
||||
Message = message,
|
||||
Style = style,
|
||||
Unread = true,
|
||||
PositionX = x,
|
||||
PositionY = y,
|
||||
PositionZ = z
|
||||
};
|
||||
_col.Insert(entry);
|
||||
Console.WriteLine($"[roomcomments] {accountId} pinned a comment in room {roomId}/{subRoomId} (id {nextId})");
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <summary>Deletes one comment - for admin moderation.</summary>
|
||||
public static bool Delete(int commentId) => _col.Delete(commentId);
|
||||
|
||||
/// <summary>Every comment across all rooms, newest first - for the admin panel.</summary>
|
||||
public static List<RoomCommentEntry> GetAll()
|
||||
=> _col.Query().OrderByDescending(x => x.CommentId).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2599 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Discord;
|
||||
using LiteDB;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using Campus_live_rewrite.api.server.Classes.db_class;
|
||||
using static Campus_live_rewrite.api.server.Classes.db_class.RoomDBClasses;
|
||||
using System.Linq;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
public class RoomDB
|
||||
{
|
||||
public static LiteDatabase rooms_db = new LiteDatabase(Environment.CurrentDirectory + $"/Data/DBs/Rooms.db");
|
||||
private static readonly object _roomLock = new();
|
||||
|
||||
public static readonly ILiteCollection<RoomRoot> col = rooms_db.GetCollection<RoomRoot>("Main_rooms");
|
||||
public static readonly ILiteCollection<SubRoom> subroom_col = rooms_db.GetCollection<SubRoom>("Main_subrooms");
|
||||
public static async Task ImportRooms(string path, ulong? ownerOverride = null)
|
||||
{
|
||||
string jsonData = await File.ReadAllTextAsync(path);
|
||||
|
||||
var hotRooms = JsonConvert.DeserializeObject<Resultslist<RoomDBClasses.RoomRoot>>(jsonData);
|
||||
|
||||
if (hotRooms?.Results == null)
|
||||
return;
|
||||
|
||||
foreach (var room in hotRooms.Results)
|
||||
{
|
||||
// Re-own every imported room to the given account (the @Importer account), so all
|
||||
// uploaded rooms land under one owner regardless of who created them originally.
|
||||
if (ownerOverride.HasValue) room.CreatorAccountId = ownerOverride.Value;
|
||||
AddRoom(room, isImportingFromJson: true);
|
||||
}
|
||||
}
|
||||
|
||||
// Rec Room exports carry JSON nulls for value-type fields (e.g. "PublishedAt": null); the
|
||||
// server's RoomRoot types those as non-nullable DateTime, so a plain ToObject throws and the
|
||||
// whole room import dies before any blob is placed. Ignoring nulls leaves such fields at
|
||||
// their default instead of throwing.
|
||||
private static readonly Newtonsoft.Json.JsonSerializer _lenientRoomSerializer =
|
||||
Newtonsoft.Json.JsonSerializer.Create(new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||
|
||||
public class RoomExportResult
|
||||
{
|
||||
public int Rooms, Blobs, Images;
|
||||
public readonly List<string> Names = new();
|
||||
public readonly List<string> Errors = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import Rec Room's OFFICIAL room-export format (the one the game's export tool produces):
|
||||
///
|
||||
/// {Room}/RoomDetails.json # a SINGLE RoomRoot object (NOT {Results:[...]})
|
||||
/// {Room}/super_room_data(.original).binpb # bytes for RoomRoot.DataBlob (a .meta)
|
||||
/// {Room}/RoomImage.jpg # bytes for RoomRoot.ImageName
|
||||
/// {Room}/SubRoom_{SubRoomId}_{Name}/persisted_room_data(.original).binpb
|
||||
/// # bytes for that subroom's CurrentSave.DataBlob (a .room)
|
||||
///
|
||||
/// The OLD ImportRooms() only understood the server's own {Results:[...]} dump AND expected the
|
||||
/// .room/.meta blobs to already exist under their content-hash names. A real export has neither:
|
||||
/// RoomDetails.json is a bare object, and the blob bytes live in the .binpb files under DIFFERENT
|
||||
/// names. So a dragged-in export imported 0 rooms (or a room with no build = bare scene).
|
||||
///
|
||||
/// The blob mapping is verified by hash: RoomRoot.DataBlob's recorded DataBlobHash matches
|
||||
/// super_room_data.binpb, and each CurrentSave.DataBlobHash matches that subroom's
|
||||
/// persisted_room_data.ORIGINAL.binpb (the pre-migration copy is the canonical blob the manifest
|
||||
/// records; the plain .binpb is a migrated variant). We pick, per blob, the .binpb whose SHA-256
|
||||
/// equals the recorded hash and write it under the DataBlob name into RoomBlobs, so the client's
|
||||
/// integrity check passes and the room actually loads its build.
|
||||
/// </summary>
|
||||
public static async Task<RoomExportResult> ImportRoomExport(string dir, ulong? ownerOverride = null)
|
||||
{
|
||||
var res = new RoomExportResult();
|
||||
string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
|
||||
string imgDir = Path.Combine(Environment.CurrentDirectory, "Data", "Images");
|
||||
Directory.CreateDirectory(blobDir);
|
||||
Directory.CreateDirectory(imgDir);
|
||||
|
||||
var manifests = Directory.EnumerateFiles(dir, "RoomDetails.json", SearchOption.AllDirectories).ToList();
|
||||
if (manifests.Count == 0) { res.Errors.Add("no RoomDetails.json found"); return res; }
|
||||
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
try
|
||||
{
|
||||
string baseDir = Path.GetDirectoryName(manifest)!;
|
||||
var tok = JToken.Parse(await File.ReadAllTextAsync(manifest));
|
||||
var roomTokens =
|
||||
tok is JObject o && o["Results"] is JArray ra ? ra.Cast<JToken>().ToList() :
|
||||
tok is JArray arr ? arr.Cast<JToken>().ToList() :
|
||||
new List<JToken> { tok };
|
||||
|
||||
foreach (var rt in roomTokens)
|
||||
{
|
||||
var room = rt.ToObject<RoomRoot>(_lenientRoomSerializer);
|
||||
if (room == null) { res.Errors.Add($"{Path.GetFileName(baseDir)}: could not parse RoomDetails.json"); continue; }
|
||||
|
||||
// Downgrade the room's declared build version to the client's level so the
|
||||
// client doesn't reject it with "playing on an older version of Rec Room than
|
||||
// this room was saved with." Working base rooms declare no minimum; the subroom
|
||||
// CurrentSave versions are zeroed separately in AddRoom. See ConfigCampus.
|
||||
if (Campus_live_rewrite.api.server.ConfigCampus.ClampImportedRoomVersionToClient)
|
||||
{
|
||||
room.MinUgcSubVersion = null;
|
||||
room.UgcSubVersion = null;
|
||||
room.PersistenceVersion = 0;
|
||||
}
|
||||
|
||||
// DataBlobHash is NOT modelled on RoomRoot/CurrentSave (commented out), so read
|
||||
// the recorded hashes straight off the raw JSON to hash-match the .binpb files.
|
||||
string? roomHash = (string?)rt["DataBlobHash"];
|
||||
var subHashById = new Dictionary<ulong, string?>();
|
||||
if (rt["SubRooms"] is JArray sra)
|
||||
foreach (var st in sra)
|
||||
if (ulong.TryParse((string?)st["SubRoomId"], out var sid))
|
||||
subHashById[sid] = (string?)st["CurrentSave"]?["DataBlobHash"];
|
||||
|
||||
// Room-level blob (super room data) -> RoomBlobs/<DataBlob>.
|
||||
if (!string.IsNullOrWhiteSpace(room.DataBlob))
|
||||
{
|
||||
var src = PickBlobByHash(baseDir, new[] { "super_room_data.original.binpb", "super_room_data.binpb" }, roomHash);
|
||||
if (src != null) { File.Copy(src, Path.Combine(blobDir, room.DataBlob), true); res.Blobs++; }
|
||||
else res.Errors.Add($"{room.Name}: super_room_data.binpb not found for DataBlob {room.DataBlob}");
|
||||
}
|
||||
|
||||
// Each subroom's build blob -> RoomBlobs/<CurrentSave.DataBlob>.
|
||||
foreach (var sub in room.SubRooms ?? new List<SubRoom>())
|
||||
{
|
||||
var cs = sub.CurrentSave;
|
||||
if (cs == null || string.IsNullOrWhiteSpace(cs.DataBlob)) continue;
|
||||
string? subDir = FindSubRoomDir(baseDir, sub.SubRoomId);
|
||||
if (subDir == null) { res.Errors.Add($"{room.Name}/{sub.Name}: SubRoom_{sub.SubRoomId}_* folder missing"); continue; }
|
||||
subHashById.TryGetValue(sub.SubRoomId, out var subHash);
|
||||
var src = PickBlobByHash(subDir, new[] { "persisted_room_data.original.binpb", "persisted_room_data.binpb" }, subHash);
|
||||
if (src != null) { File.Copy(src, Path.Combine(blobDir, cs.DataBlob), true); res.Blobs++; }
|
||||
else res.Errors.Add($"{room.Name}/{sub.Name}: persisted_room_data.binpb not found for DataBlob {cs.DataBlob}");
|
||||
}
|
||||
|
||||
// Room thumbnail -> Images/<ImageName>. Skip when the room has no custom image
|
||||
// (ImageName is the shared DefaultRoomImage.jpg) so we don't clobber the global
|
||||
// fallback every other room falls back to.
|
||||
if (!string.IsNullOrWhiteSpace(room.ImageName) &&
|
||||
!room.ImageName.StartsWith("Default", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var img = Directory.EnumerateFiles(baseDir, "RoomImage.*", SearchOption.TopDirectoryOnly).FirstOrDefault();
|
||||
if (img != null) { try { File.Copy(img, Path.Combine(imgDir, room.ImageName), true); res.Images++; } catch { } }
|
||||
}
|
||||
|
||||
if (ownerOverride.HasValue) room.CreatorAccountId = ownerOverride.Value;
|
||||
var id = AddRoom(room, isImportingFromJson: true);
|
||||
if (id != 0) { res.Rooms++; res.Names.Add(room.Name); }
|
||||
else res.Errors.Add($"{room.Name}: not added (a room with that name may already exist)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { res.Errors.Add($"{Path.GetFileName(Path.GetDirectoryName(manifest))}: {ex.Message}"); }
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clamp the declared build version of every @Importer-owned room down to the client's level
|
||||
/// (no minimum), so already-imported rooms stop tripping the client's "playing on an older
|
||||
/// version of Rec Room" gate. Idempotent; only touches imported rooms (base rooms are owned
|
||||
/// by other accounts and already declare no minimum). Returns how many rooms were changed.
|
||||
/// </summary>
|
||||
public static int ClampImportedRoomsToClient(ulong importerId)
|
||||
{
|
||||
if (!Campus_live_rewrite.api.server.ConfigCampus.ClampImportedRoomVersionToClient || importerId == 0) return 0;
|
||||
int n = 0;
|
||||
lock (_roomLock)
|
||||
{
|
||||
foreach (var room in col.Find(r => r.CreatorAccountId == importerId).ToList())
|
||||
{
|
||||
bool ch = false;
|
||||
if (room.MinUgcSubVersion != null) { room.MinUgcSubVersion = null; ch = true; }
|
||||
if (room.UgcSubVersion != null) { room.UgcSubVersion = null; ch = true; }
|
||||
if (room.PersistenceVersion != null && room.PersistenceVersion != 0) { room.PersistenceVersion = 0; ch = true; }
|
||||
if (ch) { col.Update(room); n++; }
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap a loose .room blob (a single subroom's build data, with no RoomDetails.json) in a
|
||||
/// minimal, joinable @Importer-owned room so it can actually be played. The blob must already
|
||||
/// be present in Data/CDN/RoomBlobs under `roomFile`. The room-level blob points at the shared
|
||||
/// default_room.META (empty room variables). Scene id is taken from the blob when present, else
|
||||
/// the standard MakerPen sandbox scene. Returns the new RoomId, or 0 if a room with that name
|
||||
/// already exists / creation failed.
|
||||
/// </summary>
|
||||
public static ulong CreateRoomFromBlob(string roomFile, string roomName, ulong ownerId, string? sceneId = null, int persistenceVersion = 0)
|
||||
{
|
||||
const string SandboxScene = "a75f7547-79eb-47c6-8986-6767abcb4f92";
|
||||
string scene = string.IsNullOrWhiteSpace(sceneId) ? SandboxScene : sceneId!;
|
||||
|
||||
var room = new RoomRoot
|
||||
{
|
||||
Name = roomName,
|
||||
Description = "Imported from a loose .room blob.",
|
||||
CreatorAccountId = ownerId,
|
||||
Accessibility = RoomAccessibility.Public,
|
||||
State = RoomState.Active,
|
||||
PublishState = 0,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
PublishedAt = DateTime.UtcNow,
|
||||
DataBlob = "default_room.META", // shared empty room-level (super) blob
|
||||
ImageName = "DefaultRoomImage.jpg",
|
||||
MaxPlayers = 20,
|
||||
MaxPlayerCalculationMode = 0,
|
||||
AgeRating = 0,
|
||||
MinLevel = 0,
|
||||
CloningAllowed = false,
|
||||
SupportsScreens = true,
|
||||
SupportsWalkVR = true,
|
||||
SupportsTeleportVR = true,
|
||||
SupportsVRLow = true,
|
||||
SupportsQuest2 = true,
|
||||
SupportsMobile = true,
|
||||
SupportsJuniors = false,
|
||||
IsRRO = false,
|
||||
UgcVersion = 1,
|
||||
UgcSubVersion = null, // no minimum -> passes the client version gate
|
||||
MinUgcSubVersion = null,
|
||||
PersistenceVersion = persistenceVersion, // the blob's real save-format version
|
||||
LoadScreens = new List<LoadScreen> { new LoadScreen { ImageName = "", Title = roomName, Subtitle = "" } },
|
||||
Stats = new Stats(),
|
||||
Tags = new List<TagClass>(),
|
||||
Roles = new List<RoleClass>(),
|
||||
PromoImages = new List<string>(),
|
||||
PromoExternalContent = new List<string>(),
|
||||
RestrictedCircuitsAllowListNames = new List<string>(),
|
||||
SubRooms = new List<SubRoom>
|
||||
{
|
||||
new SubRoom
|
||||
{
|
||||
Name = roomName,
|
||||
UnitySceneId = scene,
|
||||
IsSandbox = true,
|
||||
MaxPlayers = 20,
|
||||
Accessibility = RoomAccessibility.Public,
|
||||
ShouldAutoStageSaves = true,
|
||||
AllSaves = new List<CurrentSave>(),
|
||||
CurrentSave = new CurrentSave
|
||||
{
|
||||
DataBlob = roomFile,
|
||||
Description = "Imported from a loose .room blob.",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
SavedByAccountId = ownerId,
|
||||
PersistenceVersion = 0,
|
||||
OMVersion = 0,
|
||||
UgcSubVersion = 0,
|
||||
ReferencedUnityAssetIds = new List<string>()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return AddRoom(room, isImportingFromJson: true);
|
||||
}
|
||||
|
||||
/// <summary>True if any room (room-level DataBlob) or subroom (CurrentSave/AllSaves DataBlob) already points at this blob.</summary>
|
||||
public static bool AnyRoomUsesBlob(string blobName)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (col.Exists(r => r.DataBlob == blobName)) return true;
|
||||
foreach (var s in subroom_col.FindAll())
|
||||
{
|
||||
if (s.CurrentSave != null && string.Equals(s.CurrentSave.DataBlob, blobName, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (s.AllSaves != null && s.AllSaves.Any(x => string.Equals(x.DataBlob, blobName, StringComparison.OrdinalIgnoreCase))) return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove @Importer rooms that were wrapped around a loose .room blob whose save version is
|
||||
/// newer than the client can load - they only freeze the game and drop the player to the dorm.
|
||||
/// The blob file stays hosted. Only touches loose-blob rooms (our marker), never real imports.
|
||||
/// </summary>
|
||||
public static int RemoveTooNewLooseRooms(ulong importerId)
|
||||
{
|
||||
if (importerId == 0) return 0;
|
||||
int max = Campus_live_rewrite.api.server.ConfigCampus.ClientMaxRoomPersistenceVersion;
|
||||
string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
|
||||
int removed = 0;
|
||||
lock (_roomLock)
|
||||
{
|
||||
foreach (var room in col.Find(r => r.CreatorAccountId == importerId).ToList())
|
||||
{
|
||||
var subs = subroom_col.Find(s => s.RoomId == room.RoomId).ToList();
|
||||
bool loose = subs.Count == 1 && subs[0].CurrentSave != null &&
|
||||
(subs[0].CurrentSave.Description ?? "").Contains("loose .room", StringComparison.OrdinalIgnoreCase);
|
||||
if (!loose) continue;
|
||||
int ver = BlobPersistenceVersion(Path.Combine(blobDir, subs[0].CurrentSave!.DataBlob ?? ""));
|
||||
if (ver > max)
|
||||
{
|
||||
subroom_col.DeleteMany(x => x.RoomId == room.RoomId);
|
||||
col.DeleteMany(x => x.RoomId == room.RoomId);
|
||||
removed++;
|
||||
Console.WriteLine($"[room-version] removed too-new loose room '{room.Name}' (id {room.RoomId}, save v{ver} > client max {max})");
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>The .room blob's persistence version = field 1 (a varint) at the very start. 0 if unreadable.</summary>
|
||||
public static int BlobPersistenceVersion(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var fs = File.OpenRead(path);
|
||||
int first = fs.ReadByte();
|
||||
if (first != 0x08) return 0; // expect field 1, wiretype 0 (varint)
|
||||
int result = 0, shift = 0, b;
|
||||
while ((b = fs.ReadByte()) >= 0)
|
||||
{
|
||||
result |= (b & 0x7f) << shift; shift += 7;
|
||||
if ((b & 0x80) == 0) break;
|
||||
if (shift > 28) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
public enum LooseWrapStatus { Created, TooNew, AlreadyUsed, NameExists, Error }
|
||||
|
||||
/// <summary>
|
||||
/// Host a single loose .room blob in RoomBlobs and, when it's old enough for the client and not
|
||||
/// already linked to a room, wrap it in a joinable @Importer room. Reusable by the admin upload
|
||||
/// AND the staging scan. Returns what happened.
|
||||
/// </summary>
|
||||
public static (LooseWrapStatus status, ulong roomId, int version, string roomName, string? error) WrapLooseRoom(string onDiskPath, ulong ownerId)
|
||||
{
|
||||
string bn = Path.GetFileName(onDiskPath);
|
||||
try
|
||||
{
|
||||
string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs");
|
||||
Directory.CreateDirectory(blobDir);
|
||||
string dest = Path.Combine(blobDir, bn);
|
||||
if (!File.Exists(dest) || new FileInfo(dest).Length != new FileInfo(onDiskPath).Length)
|
||||
File.Copy(onDiskPath, dest, true);
|
||||
|
||||
if (AnyRoomUsesBlob(bn)) return (LooseWrapStatus.AlreadyUsed, 0, 0, bn, null);
|
||||
int ver = BlobPersistenceVersion(dest);
|
||||
if (ver > Campus_live_rewrite.api.server.ConfigCampus.ClientMaxRoomPersistenceVersion)
|
||||
return (LooseWrapStatus.TooNew, 0, ver, bn, null);
|
||||
|
||||
string roomName = Path.GetFileNameWithoutExtension(bn);
|
||||
string? scene = SceneIdFromBlob(dest);
|
||||
ulong id = CreateRoomFromBlob(bn, roomName, ownerId, scene, ver);
|
||||
return id != 0 ? (LooseWrapStatus.Created, id, ver, roomName, null)
|
||||
: (LooseWrapStatus.NameExists, 0, ver, roomName, null);
|
||||
}
|
||||
catch (Exception ex) { return (LooseWrapStatus.Error, 0, 0, bn, ex.Message); }
|
||||
}
|
||||
|
||||
/// <summary>Best-effort: pull the first GUID (the subroom's UnitySceneId) out of a .room blob's header.</summary>
|
||||
public static string? SceneIdFromBlob(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] head = new byte[Math.Min(4096, (int)new FileInfo(path).Length)];
|
||||
using (var fs = File.OpenRead(path)) fs.Read(head, 0, head.Length);
|
||||
string ascii = System.Text.Encoding.ASCII.GetString(head);
|
||||
var m = System.Text.RegularExpressions.Regex.Match(ascii, "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
|
||||
return m.Success ? m.Value : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>True if the directory looks like a Rec Room room export (has RoomDetails.json + a room blob).</summary>
|
||||
public static bool LooksLikeRoomExport(string dir)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(dir, "RoomDetails.json", SearchOption.AllDirectories).Any()
|
||||
&& Directory.EnumerateFiles(dir, "*room_data*.binpb", SearchOption.AllDirectories).Any();
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
// Pick, among candidate filenames in dir, the one whose SHA-256 matches expectedHashB64.
|
||||
// Falls back to the first existing candidate (candidates are listed best-first) when no hash
|
||||
// is recorded or none match - better to serve a plausible blob than nothing.
|
||||
private static string? PickBlobByHash(string dir, string[] candidateNames, string? expectedHashB64)
|
||||
{
|
||||
var existing = candidateNames.Select(n => Path.Combine(dir, n)).Where(File.Exists).ToList();
|
||||
if (existing.Count == 0) return null;
|
||||
if (!string.IsNullOrWhiteSpace(expectedHashB64))
|
||||
{
|
||||
foreach (var p in existing)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var fs = File.OpenRead(p);
|
||||
var hash = Convert.ToBase64String(SHA256.Create().ComputeHash(fs));
|
||||
if (string.Equals(hash, expectedHashB64, StringComparison.Ordinal)) return p;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
private static string? FindSubRoomDir(string baseDir, ulong subRoomId)
|
||||
{
|
||||
try
|
||||
{
|
||||
string prefix = $"SubRoom_{subRoomId}_";
|
||||
return Directory.EnumerateDirectories(baseDir, "SubRoom_*", SearchOption.AllDirectories)
|
||||
.FirstOrDefault(d => Path.GetFileName(d).StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public static void ExportRooms()
|
||||
{
|
||||
var allRooms = col.FindAll().ToList();
|
||||
var allSubrooms = subroom_col.FindAll().ToList();
|
||||
|
||||
// Option 1: Export rooms with their subrooms attached
|
||||
foreach (var room in allRooms)
|
||||
{
|
||||
room.SubRooms = allSubrooms.Where(s => s.RoomId == room.RoomId).ToList();
|
||||
}
|
||||
|
||||
// Build final result
|
||||
var exportData = new Resultslist<RoomDBClasses.RoomRoot>
|
||||
{
|
||||
Results = allRooms, // No mapping, just raw rooms
|
||||
TotalResults = allRooms.Count
|
||||
};
|
||||
|
||||
string outputPath = Path.Combine(Environment.CurrentDirectory, "Data/exported_rooms.json");
|
||||
string json = JsonConvert.SerializeObject(exportData, Formatting.Indented);
|
||||
File.WriteAllText(outputPath, json);
|
||||
|
||||
Console.WriteLine($"Exported {allRooms.Count} rooms with subrooms → {outputPath}");
|
||||
}
|
||||
|
||||
public static void FixDuplicateDorms()
|
||||
{
|
||||
|
||||
|
||||
|
||||
// Get all dorm rooms
|
||||
var dormRooms = col.Find(x => x.IsDorm).ToList();
|
||||
|
||||
// Group dorms by creator
|
||||
var groupedDorms = dormRooms
|
||||
.GroupBy(r => r.CreatorAccountId)
|
||||
.Where(g => g.Count() > 1);
|
||||
|
||||
foreach (var group in groupedDorms)
|
||||
{
|
||||
// Load player data to get DormRoomId
|
||||
var player = PlayerDB.GetFullAccount(group.FirstOrDefault().CreatorAccountId);
|
||||
if (player == null)
|
||||
continue;
|
||||
|
||||
// Try to keep the dorm that matches player.DormRoomId
|
||||
var dormToKeep = group.FirstOrDefault(r => r.RoomId == player.player_Extra.DormRoomID);
|
||||
|
||||
// Fallback: keep oldest dorm if DormRoomId is invalid/missing
|
||||
if (dormToKeep == null)
|
||||
{
|
||||
dormToKeep = group
|
||||
.OrderBy(r => r.CreatedAt)
|
||||
.First();
|
||||
}
|
||||
|
||||
// Delete all other dorms
|
||||
var roomsToDelete = group
|
||||
.Where(r => r.RoomId != dormToKeep.RoomId)
|
||||
.ToList();
|
||||
|
||||
foreach (var room in roomsToDelete)
|
||||
{
|
||||
subroom_col.DeleteMany(x => x.RoomId == room.RoomId);
|
||||
col.DeleteMany(x => x.RoomId == room.RoomId);
|
||||
|
||||
Console.WriteLine(
|
||||
$"Deleted duplicate dorm room: {room.Name} (ID: {room.RoomId}) for creator {room.CreatorAccountId}"
|
||||
);
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"Kept dorm room: {dormToKeep.Name} (ID: {dormToKeep.RoomId}) for creator {group.Key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public static string get_subroom_from_id(ulong room_id, ulong subroom_id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var subroom = subroom_col.FindOne(x => x.RoomId == room_id && x.SubRoomId == subroom_id);
|
||||
|
||||
return subroom?.Name ?? "Unknown Room";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error in get_subroom_from_id: {ex.Message}");
|
||||
return "Unknown Room";
|
||||
}
|
||||
}
|
||||
|
||||
public static ulong AddRoom(RoomDBClasses.RoomRoot room, bool isImportingFromJson = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (room == null)
|
||||
throw new ArgumentNullException(nameof(room));
|
||||
|
||||
lock (_roomLock)
|
||||
{
|
||||
// Skip duplicate check if DormRoom
|
||||
if (room.Name != "DormRoom")
|
||||
{
|
||||
var existingRoom = col.FindOne(x => x.Name == room.Name);
|
||||
if (existingRoom != null)
|
||||
return 0;
|
||||
}
|
||||
|
||||
var highest = col.Query().OrderByDescending(x => x.RoomId).Limit(1).FirstOrDefault();
|
||||
room.RoomId = highest != null ? highest.RoomId + 1 : 1;
|
||||
|
||||
if (room.RoomId == 0)
|
||||
{
|
||||
throw new Exception($"Imported room '{room.Name}' has RoomId = 0");
|
||||
}
|
||||
//room.DataBlobHash = null;
|
||||
if (string.IsNullOrEmpty(room.RankedEntityId))
|
||||
room.RankedEntityId = room.RoomId.ToString();
|
||||
if (room.UgcVersion == null)
|
||||
{
|
||||
room.UgcVersion = 1;
|
||||
}
|
||||
|
||||
if (room.PersistenceVersion == null)
|
||||
{
|
||||
room.PersistenceVersion = 0;
|
||||
}
|
||||
if (room.NeedsSnapshotId == false)
|
||||
{
|
||||
room.CurrentSnapshotId = null;
|
||||
}
|
||||
// Ensure required fields exist
|
||||
if (room.LoadScreens == null) throw new Exception("room.LoadScreens is null!");
|
||||
if (room.Stats == null) throw new Exception("room.Stats is null!");
|
||||
|
||||
// --- Insert the room directly, no mapping ---
|
||||
col.Insert(room);
|
||||
|
||||
// --- Insert subrooms directly, no mapping ---
|
||||
if (room.SubRooms != null && room.SubRooms.Count > 0)
|
||||
{
|
||||
// ----------------------------
|
||||
// Get next SubRoomId safely
|
||||
// ----------------------------
|
||||
var lastSubroom = subroom_col.Query()
|
||||
.OrderByDescending(x => x.SubRoomId)
|
||||
.Limit(1)
|
||||
.FirstOrDefault();
|
||||
|
||||
ulong nextSubId = lastSubroom?.SubRoomId + 1 ?? 1;
|
||||
|
||||
|
||||
// ----------------------------
|
||||
// Get next SubRoomDataSaveId globally
|
||||
// ----------------------------
|
||||
ulong nextSaveId = 1;
|
||||
|
||||
var allExistingSubrooms = subroom_col.FindAll().ToList();
|
||||
|
||||
var maxSaveId = allExistingSubrooms
|
||||
.SelectMany(x =>
|
||||
{
|
||||
var saves = new List<RoomDBClasses.CurrentSave>();
|
||||
|
||||
if (x.CurrentSave != null)
|
||||
saves.Add(x.CurrentSave);
|
||||
|
||||
if (x.AllSaves != null)
|
||||
saves.AddRange(x.AllSaves);
|
||||
|
||||
return saves;
|
||||
})
|
||||
.Select(x => x.SubRoomDataSaveId)
|
||||
.DefaultIfEmpty(0UL)
|
||||
.Max();
|
||||
|
||||
nextSaveId = maxSaveId + 1;
|
||||
|
||||
|
||||
// ----------------------------
|
||||
// Insert SubRooms
|
||||
// ----------------------------
|
||||
foreach (var sub in room.SubRooms)
|
||||
{
|
||||
try
|
||||
{
|
||||
ulong subId = nextSubId++;
|
||||
|
||||
if (isImportingFromJson && subId == 0)
|
||||
throw new Exception($"Imported subroom '{sub.Name}' has SubRoomId = 0");
|
||||
|
||||
sub.SubRoomId = subId;
|
||||
sub.RoomId = room.RoomId;
|
||||
|
||||
// Fix CurrentSave
|
||||
if (sub.CurrentSave != null)
|
||||
{
|
||||
sub.CurrentSave.SubRoomDataSaveId = nextSaveId++;
|
||||
sub.CurrentSave.SubRoomId = sub.SubRoomId;
|
||||
sub.CurrentSave.UgcSubVersion = 0;
|
||||
sub.CurrentSave.PersistenceVersion = 0;
|
||||
}
|
||||
|
||||
// ...and AllSaves the same way.
|
||||
//
|
||||
// Only CurrentSave was re-keyed. When a room is CLONED the
|
||||
// subroom objects come straight off the source room, so every
|
||||
// AllSaves entry kept the SOURCE subroom's SubRoomId and save
|
||||
// id. GetRoomSaveById looks a save up by SubRoomDataSaveId, so
|
||||
// the clone's saves pointed at another room's ids - the save
|
||||
// either could not be found or resolved to the wrong subroom.
|
||||
//
|
||||
// Harmless on this server today because no template room has a
|
||||
// save to copy, but it breaks the moment one does.
|
||||
if (sub.AllSaves != null)
|
||||
{
|
||||
foreach (var save in sub.AllSaves)
|
||||
{
|
||||
save.SubRoomDataSaveId = nextSaveId++;
|
||||
save.SubRoomId = sub.SubRoomId;
|
||||
}
|
||||
}
|
||||
|
||||
subroom_col.Insert(sub);
|
||||
|
||||
Console.WriteLine($"Added subroom: {sub.Name} ({sub.SubRoomId})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error adding subroom '{sub.Name}' for room '{room.Name}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"added room: {room.Name} ({room.RoomId})");
|
||||
return room.RoomId;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error adding room '{room.Name}': {ex.Message}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot? GetRoom(ulong room_id)
|
||||
{
|
||||
// Find the main room
|
||||
var room = col.FindOne(x => x.RoomId == room_id);
|
||||
if (room == null) return null;
|
||||
|
||||
// Fetch subrooms and attach them directly
|
||||
var subrooms = subroom_col.Find(x => x.RoomId == room_id).ToList();
|
||||
|
||||
// Attach subrooms directly, no new object mapping
|
||||
room.SubRooms = subrooms;
|
||||
|
||||
// Return the database object as-is
|
||||
return room;
|
||||
}
|
||||
|
||||
public static bool DoesPlayerDormExist(ulong playerId)
|
||||
{
|
||||
var playercol = PlayerDB.PlayerDBFile.GetCollection<player_data>("players");
|
||||
var player = playercol.FindOne(x => x.player.Id == playerId);
|
||||
|
||||
if (player?.player_Extra == null)
|
||||
return false;
|
||||
|
||||
ulong dormId = (ulong)player.player_Extra.DormRoomID;
|
||||
|
||||
if (dormId == 0 || dormId == 1)
|
||||
return false;
|
||||
|
||||
var dormRoom = RoomDB.GetRoom(dormId);
|
||||
return dormRoom != null;
|
||||
}
|
||||
|
||||
// The dorm template the client asks for by name. Rec Room added multi-dorm
|
||||
// ("My Dorms" -> Create new Dorm) after this server was written, so no room
|
||||
// called CustomDormRoomTemplate was ever seeded - the lookup 404'd and the
|
||||
// client reported "Failed to find ^CustomDormRoomTemplate from server".
|
||||
// Room 1 IS the dorm template (Name "DormRoom", IsDorm, one "Home" subroom) and
|
||||
// is already what clone_dorm_room copies, so point the new name at it.
|
||||
private const ulong DormTemplateRoomId = 1;
|
||||
|
||||
public static RoomDBClasses.RoomRoot? GetRoomByName(string roomName)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(roomName))
|
||||
{
|
||||
// The client prefixes template/base room lookups with '^' ("^Park",
|
||||
// "^CustomDormRoomTemplate") but the rooms are stored unprefixed, so an
|
||||
// exact match never hit. Strip it before comparing.
|
||||
roomName = roomName.TrimStart('^');
|
||||
|
||||
if (roomName.Equals("CustomDormRoomTemplate", StringComparison.OrdinalIgnoreCase)
|
||||
|| roomName.Equals("DormRoomTemplate", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var dormTemplate = GetRoom(DormTemplateRoomId);
|
||||
if (dormTemplate != null)
|
||||
{
|
||||
dormTemplate.DataBlob ??= "";
|
||||
return dormTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
foreach (RoomDBClasses.RoomRoot subroom in col.FindAll())
|
||||
{
|
||||
if (roomName.Equals(subroom.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var room = GetRoom(subroom.RoomId);
|
||||
if (room.DataBlob is null)
|
||||
{
|
||||
room.DataBlob = "";
|
||||
}
|
||||
return room;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// "<Player>'s DormRoom" instead of the literal "DormRoom" every dorm used to
|
||||
// get. With every dorm sharing one name the client had nothing distinguishing
|
||||
// to show and fell back to just the accessibility tag - a bare "[PRIVATE]".
|
||||
public static string DormRoomName(ulong player_id)
|
||||
{
|
||||
string owner = PlayerDB.GetPlayerNameById(player_id);
|
||||
return string.IsNullOrWhiteSpace(owner) || owner == "Unknown"
|
||||
? "DormRoom"
|
||||
: $"{owner}'s DormRoom";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keep the player's dorm ROOM named after their current @name ("kyle's DormRoom"). Called
|
||||
/// after a username change; the room's stored Name is what the room list, the in-game
|
||||
/// "go to dorm" tile and the website would otherwise keep showing under the old handle.
|
||||
/// The live dorm INSTANCE name (`@kyle's Dorm`) is rebuilt from the username on every
|
||||
/// dorm matchmake, so it follows on the next visit by itself.
|
||||
/// </summary>
|
||||
public static void RenameDormForPlayer(ulong player_id)
|
||||
{
|
||||
try
|
||||
{
|
||||
ulong dormId = PlayerDB.GetDormId(player_id);
|
||||
if (dormId == 0) return;
|
||||
var dorm = col.FindOne(x => x.RoomId == dormId);
|
||||
if (dorm == null || !dorm.IsDorm) return;
|
||||
string wanted = DormRoomName(player_id);
|
||||
if (dorm.Name == wanted) return;
|
||||
dorm.Name = wanted;
|
||||
col.Update(dorm);
|
||||
Console.WriteLine($"[dorm] renamed dorm {dormId} of {player_id} -> \"{wanted}\"");
|
||||
}
|
||||
catch (Exception ex) { Console.WriteLine($"[dorm] rename for {player_id} failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>Startup pass: bring every existing dorm's name in line with its owner's current @name.</summary>
|
||||
public static void RenameAllDormsToOwners()
|
||||
{
|
||||
int fixedCount = 0;
|
||||
foreach (var dorm in col.Find(x => x.IsDorm).ToList())
|
||||
{
|
||||
if (dorm.CreatorAccountId == 0) continue;
|
||||
string wanted = DormRoomName(dorm.CreatorAccountId);
|
||||
if (wanted == "DormRoom" || dorm.Name == wanted) continue;
|
||||
dorm.Name = wanted; col.Update(dorm); fixedCount++;
|
||||
}
|
||||
if (fixedCount > 0) Console.WriteLine($"[dorm] startup: renamed {fixedCount} dorm(s) to their owners' current @names");
|
||||
}
|
||||
|
||||
public static ulong clone_dorm_room(ulong player_id)
|
||||
{
|
||||
RoomDBClasses.RoomRoot room_data = GetRoom(1);
|
||||
var deep = JsonConvert.SerializeObject(room_data);
|
||||
var room_data2 = JsonConvert.DeserializeObject<RoomDBClasses.RoomRoot>(deep);
|
||||
if (room_data2 == null)
|
||||
{
|
||||
throw new Exception("Base dorm room not found.");
|
||||
}
|
||||
if (room_data2.SubRooms == null || room_data2.SubRooms.Count == 0)
|
||||
{
|
||||
throw new Exception("Base dorm room has no subrooms.");
|
||||
}
|
||||
if (room_data2.Stats == null)
|
||||
{
|
||||
throw new Exception("Base dorm room stats are null.");
|
||||
}
|
||||
room_data2.CreatorAccountId = player_id;
|
||||
room_data2.CloningAllowed = false;
|
||||
// Was: the literal "DormRoom" for every player, so the client had nothing to
|
||||
// show but the accessibility tag - hence a bare "[PRIVATE]". Name it after
|
||||
// the owner so it reads "<Player>'s DormRoom [Private]" instead.
|
||||
room_data2.Name = DormRoomName(player_id);
|
||||
room_data2.CreatedAt = DateTime.Now;
|
||||
room_data2.Accessibility = RoomDBClasses.RoomAccessibility.Private;
|
||||
room_data2.IsRRO = false;
|
||||
room_data2.IsDorm = true;
|
||||
room_data2.Stats.CheerCount = 0;
|
||||
room_data2.Stats.FavoriteCount = 0;
|
||||
room_data2.Stats.VisitorCount = 0;
|
||||
room_data2.Stats.VisitCount = 0;
|
||||
room_data2.Roles = new List<RoomDBClasses.RoleClass>
|
||||
{
|
||||
new RoomDBClasses.RoleClass
|
||||
{
|
||||
AccountId = (int)player_id,
|
||||
InvitedRole = 0,
|
||||
Role = RoomDBClasses.Role_data.Creator
|
||||
}
|
||||
};
|
||||
ulong id = AddRoom(room_data2);
|
||||
return id;
|
||||
}
|
||||
|
||||
public static void GiveCoOwnerToAllRooms(ulong playerId)
|
||||
{
|
||||
var allRooms = col.FindAll().ToList();
|
||||
|
||||
foreach (var room in allRooms)
|
||||
{
|
||||
if (room.CreatorAccountId == playerId)
|
||||
continue;
|
||||
|
||||
if (room.Roles == null)
|
||||
room.Roles = new List<RoleClass>();
|
||||
|
||||
var existingRole = room.Roles.FirstOrDefault(r => (ulong)r.AccountId == playerId);
|
||||
|
||||
if (existingRole != null)
|
||||
{
|
||||
if (existingRole.Role != RoomDBClasses.Role_data.Creator)
|
||||
{
|
||||
existingRole.Role = RoomDBClasses.Role_data.CoOwner;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
room.Roles.Add(new RoleClass
|
||||
{
|
||||
AccountId = (int)playerId,
|
||||
InvitedRole = 0,
|
||||
Role = RoomDBClasses.Role_data.CoOwner
|
||||
});
|
||||
}
|
||||
|
||||
col.Update(room);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Updated all rooms to grant Co-Owner to player {playerId}.");
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot? IncreaseRoomVisit(ulong roomid)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomid);
|
||||
|
||||
if (room != null)
|
||||
{
|
||||
if (room.Stats == null)
|
||||
room.Stats = new Stats();
|
||||
|
||||
if (room.Stats.VisitCount == 0)
|
||||
room.Stats.VisitCount = 1;
|
||||
else
|
||||
room.Stats.VisitCount++;
|
||||
|
||||
col.Update(room);
|
||||
return room;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The hot-rooms candidate set: every non-dorm room, WITHOUT subrooms. The list is
|
||||
/// filtered, sorted and paged by the caller, which then attaches subrooms to just the
|
||||
/// page it returns (AttachSubRooms) - loading them for all ~2,500 candidates to serve 30
|
||||
/// was what made this endpoint take over a minute.
|
||||
/// </summary>
|
||||
public static List<RoomDBClasses.RoomRoot> GetHotRooms()
|
||||
{
|
||||
return col.Query()
|
||||
.Where(r => !r.IsDorm)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populate SubRooms on the given rooms. One indexed lookup per room (RoomId is indexed),
|
||||
/// so call it on the rooms you are actually returning, not on the whole table.
|
||||
/// </summary>
|
||||
public static void AttachSubRooms(IEnumerable<RoomDBClasses.RoomRoot> rooms)
|
||||
{
|
||||
foreach (var room in rooms)
|
||||
room.SubRooms = subroom_col.Find(s => s.RoomId == room.RoomId).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every non-dorm room with subrooms attached. Subrooms are read in ONE pass and grouped in
|
||||
/// memory rather than one query per room. Admin/maintenance use - the client-facing lists
|
||||
/// should page first and use AttachSubRooms on the page.
|
||||
/// </summary>
|
||||
public static List<RoomDBClasses.RoomRoot> GetAllRooms()
|
||||
{
|
||||
var mainRooms = col.Query()
|
||||
.Where(r => !r.IsDorm)
|
||||
.ToList();
|
||||
|
||||
var byRoom = subroom_col.FindAll()
|
||||
.GroupBy(s => s.RoomId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var room in mainRooms)
|
||||
room.SubRooms = byRoom.TryGetValue(room.RoomId, out var subs) ? subs : new List<SubRoom>();
|
||||
|
||||
return mainRooms;
|
||||
}
|
||||
|
||||
|
||||
public static void UpdateRoomCheerCount(ulong roomid, int delta)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomid);
|
||||
if (room != null)
|
||||
{
|
||||
room.Stats.CheerCount = Math.Max(0, room.Stats.CheerCount + delta);
|
||||
col.Update(room);
|
||||
}
|
||||
}
|
||||
|
||||
public static void UpdateRoomFavoriteCount(ulong roomid, int delta)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomid);
|
||||
if (room != null)
|
||||
{
|
||||
room.Stats.FavoriteCount = Math.Max(0, room.Stats.FavoriteCount + delta);
|
||||
col.Update(room);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Attaches each room's LIVE subrooms from the subroom collection.
|
||||
///
|
||||
/// Rooms carry an embedded SubRooms array, but it is a STALE SNAPSHOT taken when
|
||||
/// the room was created/cloned - saves are written to the subroom collection, not
|
||||
/// back into it. GetRoom() has always overwritten it with live data; the list
|
||||
/// queries did not, so a listing reported the original subroom with
|
||||
/// CurrentSave = null even for rooms that had been saved many times.
|
||||
///
|
||||
/// That is what made the client treat saved rooms as "never been saved": it hid
|
||||
/// them from Create -> My Rooms, and prompted "Do you want to save?" on exit
|
||||
/// because as far as the listing was concerned no save existed.
|
||||
/// </summary>
|
||||
// ---- Room access control ----------------------------------------------
|
||||
//
|
||||
// RoomAccessibility: Private = 0, Public = 1, Unlisted = 2, Dev_only = 3,
|
||||
// Dev_Unlisted = 4. Private is the DEFAULT for a freshly created room, which is
|
||||
// why almost every player room is private.
|
||||
//
|
||||
// There is no separate invite system on this server - room ROLES are the invite.
|
||||
// Give someone Host/Moderator/CoOwner/Creator on a room and they can see and join
|
||||
// it; without a role a private room is invisible and unjoinable to them.
|
||||
|
||||
/// <summary>The player's role on this room, or null if they have none.</summary>
|
||||
public static RoomDBClasses.Role_data? PlayerRoleOn(RoomDBClasses.RoomRoot? room, ulong playerId)
|
||||
{
|
||||
if (room == null) return null;
|
||||
if (room.CreatorAccountId == playerId) return RoomDBClasses.Role_data.Creator;
|
||||
|
||||
var role = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == playerId);
|
||||
return role?.Role;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the player holds a role that counts as being part of the room -
|
||||
/// owner, co-owner, moderator or host. Explicitly false for Banned and None.
|
||||
/// </summary>
|
||||
public static bool PlayerIsMemberOf(RoomDBClasses.RoomRoot? room, ulong playerId)
|
||||
{
|
||||
var role = PlayerRoleOn(room, playerId);
|
||||
if (role == null) return false;
|
||||
|
||||
switch (role.Value)
|
||||
{
|
||||
case RoomDBClasses.Role_data.Creator:
|
||||
case RoomDBClasses.Role_data.CoOwner:
|
||||
case RoomDBClasses.Role_data.TemporaryCoOwner:
|
||||
case RoomDBClasses.Role_data.Contributor:
|
||||
case RoomDBClasses.Role_data.Moderator:
|
||||
case RoomDBClasses.Role_data.Host:
|
||||
return true;
|
||||
default:
|
||||
return false; // None / Banned
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Banned from this specific room (an elapsed timed ban is lifted on the way).</summary>
|
||||
public static bool PlayerIsBannedFrom(RoomDBClasses.RoomRoot? room, ulong playerId)
|
||||
=> ActiveRoomBan(room, playerId) != null;
|
||||
|
||||
/// <summary>
|
||||
/// The ban currently keeping this player out of the room, or null. A ban is the Banned ROLE
|
||||
/// on the room (which is what every join gate reads) plus a RoomBans record carrying who /
|
||||
/// when / why / until. A timed ban whose BanEndTime has passed is lifted here - the role
|
||||
/// removed and the record marked Elapsed - so a "15 minutes" restriction really ends.
|
||||
/// </summary>
|
||||
public static RoomDBClasses.RoomBanDetails? ActiveRoomBan(RoomDBClasses.RoomRoot? room, ulong playerId)
|
||||
{
|
||||
if (room == null) return null;
|
||||
var role = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == playerId);
|
||||
if (role == null || role.Role != RoomDBClasses.Role_data.Banned) return null;
|
||||
|
||||
var ban = room.RoomBans?.FirstOrDefault(b => b.AccountId == (long)playerId && b.Status == 0);
|
||||
if (ban != null && ban.BanEndTime.HasValue && ban.BanEndTime.Value <= DateTime.UtcNow)
|
||||
{
|
||||
// Elapsed: lift it on the stored document (not on whatever copy we were handed).
|
||||
var stored = col.FindOne(x => x.RoomId == room.RoomId);
|
||||
if (stored != null)
|
||||
{
|
||||
stored.Roles?.RemoveAll(r => (ulong)r.AccountId == playerId && r.Role == RoomDBClasses.Role_data.Banned);
|
||||
foreach (var b in stored.RoomBans ?? new()) if (b.AccountId == (long)playerId && b.Status == 0) b.Status = 1;
|
||||
if (stored.RoomBanDetails?.AccountId == (long)playerId) stored.RoomBanDetails = null;
|
||||
col.Update(stored);
|
||||
}
|
||||
room.Roles!.Remove(role);
|
||||
ban.Status = 1;
|
||||
Console.WriteLine($"[roomban] room {room.RoomId}: ban on {playerId} elapsed, lifted");
|
||||
return null;
|
||||
}
|
||||
// A Banned role with no record (older data): still a ban, with what we know.
|
||||
return ban ?? new RoomDBClasses.RoomBanDetails { AccountId = (long)playerId, BanStartTime = DateTime.UtcNow, Reason = "", Status = 0 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ban a player from a room: the Banned role (what the join gates refuse on) plus a record.
|
||||
/// duration null = until someone unbans them. The room's creator can never be banned.
|
||||
/// Any earlier active ban on the same player is closed as Lifted first.
|
||||
/// </summary>
|
||||
public static RoomDBClasses.RoomBanDetails? BanFromRoom(ulong roomId, ulong playerId, ulong by, string? reason, TimeSpan? duration)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null || room.CreatorAccountId == playerId) return null;
|
||||
|
||||
room.Roles ??= new List<RoomDBClasses.RoleClass>();
|
||||
var existing = room.Roles.FirstOrDefault(r => (ulong)r.AccountId == playerId);
|
||||
if (existing != null) { existing.Role = RoomDBClasses.Role_data.Banned; existing.InvitedRole = 0; existing.LastChangedByAccountId = (long)by; }
|
||||
else room.Roles.Add(new RoomDBClasses.RoleClass { AccountId = (long)playerId, Role = RoomDBClasses.Role_data.Banned, InvitedRole = 0, LastChangedByAccountId = (long)by });
|
||||
|
||||
room.RoomBans ??= new List<RoomDBClasses.RoomBanDetails>();
|
||||
foreach (var old in room.RoomBans) if (old.AccountId == (long)playerId && old.Status == 0) old.Status = 2;
|
||||
var ban = new RoomDBClasses.RoomBanDetails
|
||||
{
|
||||
AccountId = (long)playerId,
|
||||
BannedByAccountId = (long)by,
|
||||
BanStartTime = DateTime.UtcNow,
|
||||
BanEndTime = duration.HasValue ? DateTime.UtcNow + duration.Value : null,
|
||||
Reason = reason ?? "",
|
||||
Status = 0
|
||||
};
|
||||
room.RoomBans.Add(ban);
|
||||
room.RoomBanDetails = ban;
|
||||
col.Update(room);
|
||||
Console.WriteLine($"[roomban] room {roomId} '{room.Name}': {by} banned {playerId} " +
|
||||
$"{(duration.HasValue ? $"for {duration.Value.TotalMinutes:0} min" : "until unbanned")}" +
|
||||
$"{(string.IsNullOrEmpty(reason) ? "" : $" - {reason}")}");
|
||||
return ban;
|
||||
}
|
||||
|
||||
/// <summary>Lift a room ban. Returns the closed record, or null if they were not banned.</summary>
|
||||
public static RoomDBClasses.RoomBanDetails? UnbanFromRoom(ulong roomId, ulong playerId, ulong by)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null) return null;
|
||||
var role = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == playerId && r.Role == RoomDBClasses.Role_data.Banned);
|
||||
var ban = room.RoomBans?.FirstOrDefault(b => b.AccountId == (long)playerId && b.Status == 0);
|
||||
if (role == null && ban == null) return null;
|
||||
if (role != null) room.Roles!.Remove(role);
|
||||
if (ban != null) { ban.Status = 2; ban.UnbannedByAccountId = (long)by; }
|
||||
if (room.RoomBanDetails?.AccountId == (long)playerId) room.RoomBanDetails = null;
|
||||
col.Update(room);
|
||||
Console.WriteLine($"[roomban] room {roomId}: {by} unbanned {playerId}");
|
||||
return ban ?? new RoomDBClasses.RoomBanDetails { AccountId = (long)playerId, BannedByAccountId = 0, BanStartTime = DateTime.UtcNow, Status = 2, UnbannedByAccountId = (long)by, Reason = "" };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-off repair: room kicks used to be stored as room bans ("Kicked by X" / "Vote
|
||||
/// kicked ..."), so every kick was a permanent ban from the map. Lift those; a real ban
|
||||
/// (issued from the ban route, any other reason) is left alone. Returns how many.
|
||||
/// </summary>
|
||||
public static int LiftKickBans()
|
||||
{
|
||||
int lifted = 0;
|
||||
foreach (var room in col.FindAll().Where(r => r.RoomBans != null && r.RoomBans.Count > 0).ToList())
|
||||
{
|
||||
bool changed = false;
|
||||
foreach (var b in room.RoomBans!.Where(b => b.Status == 0).ToList())
|
||||
{
|
||||
string reason = b.Reason ?? "";
|
||||
if (!reason.StartsWith("Kicked by ", StringComparison.OrdinalIgnoreCase) && !reason.StartsWith("Vote kicked", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
b.Status = 2;
|
||||
room.Roles?.RemoveAll(r => r.AccountId == b.AccountId && r.Role == RoomDBClasses.Role_data.Banned);
|
||||
if (room.RoomBanDetails?.AccountId == b.AccountId) room.RoomBanDetails = null;
|
||||
Console.WriteLine($"[roomban] room {room.RoomId} '{room.Name}': lifted ban on {b.AccountId} - it was a kick ({reason})");
|
||||
changed = true; lifted++;
|
||||
}
|
||||
if (changed) col.Update(room);
|
||||
}
|
||||
return lifted;
|
||||
}
|
||||
|
||||
/// <summary>The room's active bans, newest first.</summary>
|
||||
public static List<RoomDBClasses.RoomBanDetails> ActiveRoomBans(RoomDBClasses.RoomRoot room)
|
||||
{
|
||||
var list = new List<RoomDBClasses.RoomBanDetails>();
|
||||
foreach (var r in (room.Roles ?? new()).Where(r => r.Role == RoomDBClasses.Role_data.Banned).ToList())
|
||||
{
|
||||
var b = ActiveRoomBan(room, (ulong)r.AccountId);
|
||||
if (b != null) list.Add(b);
|
||||
}
|
||||
return list.OrderByDescending(b => b.BanStartTime).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Creator, co-owner, moderator or host on the room, or a staff account.</summary>
|
||||
public static bool CanModerateRoom(RoomDBClasses.RoomRoot? room, ulong accountId)
|
||||
{
|
||||
if (room == null) return false;
|
||||
if (room.CreatorAccountId == accountId) return true;
|
||||
if (PlayerDB.CheckDevFlag(accountId) || PlayerDB.CheckModFlag(accountId)) return true;
|
||||
var role = PlayerRoleOn(room, accountId);
|
||||
return role is RoomDBClasses.Role_data.CoOwner or RoomDBClasses.Role_data.TemporaryCoOwner
|
||||
or RoomDBClasses.Role_data.Moderator or RoomDBClasses.Role_data.Host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Is the room published?
|
||||
///
|
||||
/// PublishState is the client's own field (RoomRoot.PublishState) and the save
|
||||
/// request carries AutoPublish. Neither was ever read by this server - every room
|
||||
/// in the DB sits at PublishState = 0, including the stock RRO rooms.
|
||||
///
|
||||
/// So "published" is treated as: PublishState says so, OR the room is Public /
|
||||
/// Unlisted. That second clause matters - without it every existing room (Rec
|
||||
/// Center, Park, all 54 of account 1's) would read as unpublished and vanish from
|
||||
/// everyone's listings the moment this shipped.
|
||||
/// </summary>
|
||||
public static bool RoomHasTag(RoomDBClasses.RoomRoot room, string tag) =>
|
||||
room.Tags != null && room.Tags.Any(t => string.Equals(t.Tag, tag, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Rooms 2.0 keep their REAL visibility on the "Home" subroom; the room-level Accessibility
|
||||
// is a stale default (Private) for them. Search / hot / CanPlayerSeeRoom all read the
|
||||
// ROOM-level field, so a published 2.0 room was invisible everywhere. This returns the
|
||||
// room's effective accessibility - its Home subroom's (or the first subroom's) - which the
|
||||
// room-level field is kept mirrored to (ModifySubroom + the startup SyncAllRoomAccessibility).
|
||||
public static RoomDBClasses.RoomAccessibility PrimaryAccessibility(RoomDBClasses.RoomRoot room)
|
||||
{
|
||||
var subs = room.SubRooms;
|
||||
if (subs == null || subs.Count == 0) return room.Accessibility;
|
||||
var home = subs.FirstOrDefault(s => string.Equals(s.Name, "Home", StringComparison.OrdinalIgnoreCase)) ?? subs[0];
|
||||
return home.Accessibility;
|
||||
}
|
||||
|
||||
// The room-level Accessibility a room SHOULD carry: its Home subroom's, which is what the
|
||||
// owner actually set in-game. Rooms are NOT auto-published, so the accessibility the owner
|
||||
// chose (Public / Unlisted / Private) IS the room's published state - see IsRoomPublished.
|
||||
public static RoomDBClasses.RoomAccessibility EffectiveRoomAccessibility(RoomDBClasses.RoomRoot room)
|
||||
=> PrimaryAccessibility(room);
|
||||
|
||||
// Idempotent migration: give every non-RRO room its effective accessibility, so existing
|
||||
// PUBLISHED Rooms 2.0 become Public/searchable and unpublished ones match their subroom.
|
||||
// RRO rooms already carry a correct room-level Accessibility, so leave them.
|
||||
/// <summary>
|
||||
/// Idempotent: player-made rooms cloned before the clone fix carry the template's
|
||||
/// IsDeveloperOwned=true and RankedEntityId (the client hides developer-owned rooms from a
|
||||
/// player's created list). Rec Room Originals and base templates are left alone.
|
||||
/// </summary>
|
||||
public static int FixPlayerRoomFlags()
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var room in col.FindAll().ToList())
|
||||
{
|
||||
if (room == null || room.IsRRO || room.IsDorm) continue;
|
||||
if (RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal")) continue;
|
||||
if (room.CreatorAccountId <= 1) continue; // system / owner-seeded rooms
|
||||
bool changed = false;
|
||||
if (room.IsDeveloperOwned) { room.IsDeveloperOwned = false; changed = true; }
|
||||
if (room.RankedEntityId != room.RoomId.ToString()) { room.RankedEntityId = room.RoomId.ToString(); changed = true; }
|
||||
if (changed) { col.Update(room); n++; }
|
||||
}
|
||||
if (n > 0) Console.WriteLine($"[rooms] cleared template flags (IsDeveloperOwned / RankedEntityId) on {n} player room(s)");
|
||||
return n;
|
||||
}
|
||||
|
||||
public static int SyncAllRoomAccessibility()
|
||||
{
|
||||
int n = 0;
|
||||
// Subrooms live in their own collection and are NOT on the rows FindAll hands back, so
|
||||
// EffectiveRoomAccessibility (the Home subroom's setting) used to see no subrooms and
|
||||
// answer the room's own field - this sync never changed anything. One pass over the
|
||||
// subroom collection, grouped by room, instead of a query per room.
|
||||
var subsByRoom = subroom_col.FindAll().GroupBy(s => s.RoomId).ToDictionary(g => g.Key, g => g.ToList());
|
||||
foreach (var room in col.FindAll().ToList())
|
||||
{
|
||||
if (room == null || room.IsRRO) continue;
|
||||
if (subsByRoom.TryGetValue(room.RoomId, out var subs)) room.SubRooms = subs;
|
||||
if (room.SubRooms == null || room.SubRooms.Count == 0) continue;
|
||||
var eff = EffectiveRoomAccessibility(room);
|
||||
if (room.Accessibility != eff)
|
||||
{
|
||||
room.Accessibility = eff;
|
||||
col.Update(room); n++; // the embedded subroom copy is refreshed from the subroom collection too
|
||||
Console.WriteLine($"[rooms] {room.RoomId} '{room.Name}': room-level accessibility -> {eff} (from its Home subroom)");
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
public static bool IsRoomPublished(RoomDBClasses.RoomRoot? room)
|
||||
{
|
||||
if (room == null) return false;
|
||||
|
||||
// Legacy Rec Room Originals (Rec Center, Park, the base rooms) are always listed.
|
||||
if (room.IsRRO || RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal"))
|
||||
return true;
|
||||
|
||||
// For a user room (Rooms 2.0 / beta) the ACCESSIBILITY the owner set IS the published
|
||||
// state: Public or Unlisted = visible to non-members; Private = a draft, members-only.
|
||||
// Rooms are NOT auto-published on save, so a freshly created (Private) room stays in the
|
||||
// owner's list only until they deliberately set it Public/Unlisted.
|
||||
var acc = EffectiveRoomAccessibility(room);
|
||||
return acc == RoomDBClasses.RoomAccessibility.Public
|
||||
|| acc == RoomDBClasses.RoomAccessibility.Unlisted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Does the room appear in LISTS for people who hold no role on it (another player's
|
||||
/// profile, search, hot)? Only a PUBLIC room does - Unlisted is exactly that (reachable by
|
||||
/// invite or link, never listed) and Private is a draft. Rec Room Originals are always listed.
|
||||
/// </summary>
|
||||
/// <summary>A Rec Room Original - the stock rooms, not a player's creation.</summary>
|
||||
public static bool IsRecRoomOriginal(RoomDBClasses.RoomRoot? room) =>
|
||||
room != null && (room.IsRRO || RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal"));
|
||||
|
||||
public static bool IsRoomListed(RoomDBClasses.RoomRoot? room)
|
||||
{
|
||||
if (room == null) return false;
|
||||
if (room.IsRRO || RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal"))
|
||||
return true;
|
||||
return EffectiveRoomAccessibility(room) == RoomDBClasses.RoomAccessibility.Public;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// May this player SEE the room (listings, search, someone's portfolio)?
|
||||
///
|
||||
/// Published rooms are visible to everyone, private or not - a published private
|
||||
/// room advertises itself, it just does not let strangers into your session.
|
||||
/// An UNPUBLISHED room is invisible to anyone without a role on it.
|
||||
/// </summary>
|
||||
public static bool CanPlayerSeeRoom(RoomDBClasses.RoomRoot? room, ulong playerId)
|
||||
{
|
||||
if (room == null) return false;
|
||||
if (PlayerIsBannedFrom(room, playerId)) return false;
|
||||
if (PlayerIsMemberOf(room, playerId)) return true; // own/co-own/mod/host
|
||||
|
||||
return IsRoomListed(room); // others only see what is PUBLIC; unlisted/private stay off their lists
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// May this player ENTER the room at all - in an instance of their own if need be?
|
||||
///
|
||||
/// Published rooms may be entered by anyone: a published PRIVATE room hands the
|
||||
/// visitor their own fresh session (see CanPlayerShareInstance), which is what lets
|
||||
/// them play it with their own friends without getting into the owner's session.
|
||||
/// Unpublished rooms are members-only.
|
||||
/// </summary>
|
||||
public static bool CanPlayerEnterRoom(RoomDBClasses.RoomRoot? room, ulong playerId)
|
||||
{
|
||||
if (room == null) return false;
|
||||
if (PlayerIsBannedFrom(room, playerId)) return false;
|
||||
if (PlayerIsMemberOf(room, playerId)) return true;
|
||||
|
||||
// A dorm is never open, published or not.
|
||||
if (room.IsDorm) return false;
|
||||
|
||||
return IsRoomPublished(room);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// May this player be dropped into an instance SOMEONE ELSE is already in?
|
||||
///
|
||||
/// Only for Public rooms, or when they hold a role on the room. This is the line
|
||||
/// between "published private" and "public": both are visible and enterable, but a
|
||||
/// published private room gives each visitor their own session instead of pooling
|
||||
/// strangers into the owner's.
|
||||
/// </summary>
|
||||
public static bool CanPlayerShareInstance(RoomDBClasses.RoomRoot? room, ulong playerId)
|
||||
{
|
||||
if (room == null) return false;
|
||||
if (PlayerIsBannedFrom(room, playerId)) return false;
|
||||
if (PlayerIsMemberOf(room, playerId)) return true;
|
||||
|
||||
return room.Accessibility == RoomDBClasses.RoomAccessibility.Public;
|
||||
}
|
||||
|
||||
/// <summary>Sets the room's publish state (0 = unpublished). Returns false if missing.</summary>
|
||||
public static bool SetRoomPublishState(ulong roomId, int publishState)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null) return false;
|
||||
|
||||
room.PublishState = publishState;
|
||||
if (publishState > 0) room.PublishedAt = DateTime.UtcNow;
|
||||
// Publishing makes a room Public for everyone; unpublishing drops it back to its
|
||||
// subroom's accessibility (and it becomes members-only via CanPlayerSeeRoom).
|
||||
room.Accessibility = EffectiveRoomAccessibility(room);
|
||||
|
||||
col.Update(room);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static List<RoomDBClasses.RoomRoot> AttachSubRooms(List<RoomDBClasses.RoomRoot> rooms)
|
||||
{
|
||||
if (rooms == null || rooms.Count == 0) return rooms ?? new List<RoomDBClasses.RoomRoot>();
|
||||
|
||||
foreach (var room in rooms)
|
||||
{
|
||||
if (room == null) continue;
|
||||
room.SubRooms = subroom_col.Find(s => s.RoomId == room.RoomId).ToList();
|
||||
}
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every room the player should see under "My Rooms": ones they created, plus any
|
||||
/// they hold a role on. A room stays listed until the player's role is removed or
|
||||
/// the room is deleted - it is never dropped for being unsaved or unpublished.
|
||||
/// </summary>
|
||||
public static List<RoomDBClasses.RoomRoot> GetRoomsForPlayer(ulong playerId)
|
||||
{
|
||||
// A role of Banned is still "a role", so a room the player had been BANNED from
|
||||
// was listed among their own rooms. Creator always counts; otherwise the role has
|
||||
// to be one that actually makes them part of the room.
|
||||
var rooms = col.FindAll()
|
||||
.Where(r => r != null
|
||||
&& (r.CreatorAccountId == playerId
|
||||
|| PlayerIsMemberOf(r, playerId)))
|
||||
.ToList();
|
||||
|
||||
return AttachSubRooms(rooms);
|
||||
}
|
||||
|
||||
public static List<RoomDBClasses.RoomRoot> GetRoomsByCreator(ulong playerId)
|
||||
{
|
||||
|
||||
var rooms = col.Find(x => x.CreatorAccountId == playerId).ToList();
|
||||
|
||||
return AttachSubRooms(rooms);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single PAGE of a creator's rooms, ordered newest-first, attaching subrooms ONLY to the
|
||||
/// page returned. GetRoomsByCreator loads EVERY room the creator owns and runs a subroom query
|
||||
/// per room - fine for a normal player, but @Importer owns thousands, so a profile view was
|
||||
/// loading everything at once and overloading the server. This caps it at the DB level.
|
||||
/// </summary>
|
||||
public static List<RoomDBClasses.RoomRoot> GetRoomsByCreatorPaged(ulong playerId, int skip, int take, bool attachSubrooms = true)
|
||||
{
|
||||
if (skip < 0) skip = 0;
|
||||
if (take <= 0) take = 200;
|
||||
var page = col.Query()
|
||||
.Where(x => x.CreatorAccountId == playerId)
|
||||
.OrderByDescending(x => x.RoomId)
|
||||
.Skip(skip)
|
||||
.Limit(take)
|
||||
.ToList();
|
||||
return attachSubrooms ? AttachSubRooms(page) : page;
|
||||
}
|
||||
|
||||
/// <summary>Count of a creator's rooms - cheap (no room objects loaded).</summary>
|
||||
public static int CountRoomsByCreator(ulong playerId) => col.Count(x => x.CreatorAccountId == playerId);
|
||||
|
||||
/// <summary>
|
||||
/// Rooms the player has a role on (Creator/CoOwner/etc.) but did not create -
|
||||
/// what the client's "contributed to" listing expects.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Every room this player has a hand in - the ones they CREATED plus the ones they hold a
|
||||
/// role on. This is what the profile's room list is drawn from.
|
||||
///
|
||||
/// It used to exclude the player's own rooms outright (`CreatorAccountId != playerId`),
|
||||
/// which is why a profile read "32 Rooms" above a list holding exactly one: the single
|
||||
/// room they had been invited to co-own. A creator IS a contributor to their own room -
|
||||
/// the owner is simply the first role on it - so they belong in this list ahead of
|
||||
/// everyone else's. (The Room Showcase is a separate, deliberately-picked handful; it is
|
||||
/// NOT a place to dump these. See RoomController.RoomShowcaseForPlayer.)
|
||||
///
|
||||
/// Own rooms come first, newest first; then the rooms they were invited to. Capped, because
|
||||
/// this attaches subrooms to every room it returns.
|
||||
/// </summary>
|
||||
public static List<RoomDBClasses.RoomRoot> GetRoomsContributedTo(ulong playerId)
|
||||
{
|
||||
var all = col.FindAll().ToList();
|
||||
|
||||
var mine = all.Where(r => r.CreatorAccountId == playerId)
|
||||
.OrderByDescending(r => r.CreatedAt);
|
||||
|
||||
var invited = all.Where(r => r.CreatorAccountId != playerId
|
||||
&& r.Roles != null
|
||||
&& r.Roles.Any(x => (ulong)x.AccountId == playerId));
|
||||
|
||||
return AttachSubRooms(mine.Concat(invited)
|
||||
.Take(Campus_live_rewrite.api.server.ConfigCampus.ProfileRoomsCap)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public static List<RoomDBClasses.RoomRoot> Search(string p0, bool isBase = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(p0))
|
||||
return new List<RoomDBClasses.RoomRoot>();
|
||||
|
||||
var terms = p0.ToLowerInvariant()
|
||||
.Split(new char[] { ' ', '+' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Start with the basic "Not a Dorm" filter
|
||||
var query = col.Query().Where(r => !r.IsDorm);
|
||||
|
||||
// Only enforce Public accessibility if isBase is false
|
||||
if (!isBase)
|
||||
{
|
||||
query = query.Where(r => r.Accessibility == RoomDBClasses.RoomAccessibility.Public);
|
||||
}
|
||||
|
||||
foreach (var term in terms)
|
||||
{
|
||||
if (term.StartsWith("#"))
|
||||
{
|
||||
string tag = term.Substring(1);
|
||||
|
||||
// Using BsonExpression ensures LiteDB looks directly into the array
|
||||
// This looks for any element in the 'Tags' array where the 'Tag' field matches
|
||||
query = query.Where("$.Tags[*].Tag ANY = @0", tag);
|
||||
}
|
||||
else
|
||||
{
|
||||
string nameTerm = term;
|
||||
query = query.Where(r => r.Name.ToLower().Contains(nameTerm));
|
||||
}
|
||||
}
|
||||
|
||||
return AttachSubRooms(query.ToList());
|
||||
}
|
||||
|
||||
public static ulong GetAllRoomsCount()
|
||||
{
|
||||
|
||||
return (ulong)col.Count();
|
||||
}
|
||||
|
||||
/// <summary>All non-dorm rooms WITHOUT attaching subrooms - for listings (admin table) that
|
||||
/// only need top-level fields. Avoids one subroom query per room (brutal at thousands of rooms).</summary>
|
||||
public static List<RoomDBClasses.RoomRoot> GetAllRoomsLite()
|
||||
{
|
||||
return col.Query().Where(r => !r.IsDorm).ToList();
|
||||
}
|
||||
|
||||
public static void DeleteAllRoomsFromPlayerId(ulong creatorId)
|
||||
{
|
||||
|
||||
var roomsToDelete = col.Find(x => x.CreatorAccountId == creatorId).ToList();
|
||||
|
||||
foreach (var room in roomsToDelete)
|
||||
{
|
||||
subroom_col.DeleteMany(x => x.RoomId == room.RoomId);
|
||||
col.DeleteMany(x => x.RoomId == room.RoomId);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Deleted {roomsToDelete.Count} rooms and their subrooms for creator {creatorId}.");
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot? SetRoomName(ulong roomId, string roomName)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
var existingRoom = col.FindOne(x => x.Name == roomName && x.RoomId != roomId);
|
||||
if (existingRoom != null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (room != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
room.Name = roomName;
|
||||
col.Update(room);
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.ToString());
|
||||
}
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot? es()
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == 53);
|
||||
if (room != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
room.CurrentSnapshotId = "98cfdcad-b680-4b33-e94c-08dd76d7c12b";
|
||||
col.Update(room);
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.ToString());
|
||||
}
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot? SetRoomDescription(ulong roomId, string NewDescription)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
room.Description = NewDescription;
|
||||
col.Update(room);
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.ToString());
|
||||
}
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot? SetRoomImageName(ulong roomId, string imageName)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
room.ImageName = imageName;
|
||||
col.Update(room);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.ToString());
|
||||
}
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot? SetRoomAccessibility(ulong roomId, RoomDBClasses.RoomAccessibility roomAccessibility)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
room.Accessibility = roomAccessibility;
|
||||
// The effective accessibility is read from the Home subroom (PrimaryAccessibility),
|
||||
// so the subrooms must follow or "publish" changed nothing anyone could see.
|
||||
foreach (var sr in subroom_col.Find(x => x.RoomId == roomId).ToList())
|
||||
{
|
||||
sr.Accessibility = roomAccessibility;
|
||||
subroom_col.Update(sr);
|
||||
}
|
||||
bool published = roomAccessibility == RoomDBClasses.RoomAccessibility.Public
|
||||
|| roomAccessibility == RoomDBClasses.RoomAccessibility.Unlisted;
|
||||
room.PublishState = published ? 1 : 0;
|
||||
if (published && (room.PublishedAt == default || room.PublishedAt < room.CreatedAt)) room.PublishedAt = DateTime.UtcNow;
|
||||
col.Update(room);
|
||||
Console.WriteLine($"[rooms] room {roomId} ({room.Name}) accessibility -> {roomAccessibility} ({(published ? "published" : "unpublished")})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.ToString());
|
||||
}
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot? SetRoomCloning(ulong roomId, bool cloningEnabled)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
room.CloningAllowed = cloningEnabled;
|
||||
col.Update(room);
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.ToString());
|
||||
}
|
||||
}
|
||||
return room;
|
||||
}
|
||||
// supportsScreens=True&supportsWalkVR=False&supportsTeleportVR=True&supportsJuniors=True
|
||||
public static RoomDBClasses.RoomRoot? SetRoomRestrictions(ulong roomId, bool supportsScreens, bool supportsWalkVR, bool supportsTeleportVR, bool supportsJuniors)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
room.SupportsScreens = supportsScreens;
|
||||
room.SupportsWalkVR = supportsWalkVR;
|
||||
room.SupportsTeleportVR = supportsTeleportVR;
|
||||
room.SupportsJuniors = supportsJuniors;
|
||||
col.Update(room);
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.ToString());
|
||||
}
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
public static bool UserCanEditRoom(ulong roomId, ulong accountId)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null)
|
||||
return false;
|
||||
|
||||
if (room.CreatorAccountId == accountId)
|
||||
return true;
|
||||
|
||||
return IsCoOwner(room, accountId);
|
||||
}
|
||||
|
||||
|
||||
public static RoomDBClasses.RoomRoot? SetRoomLoadscreen(ulong roomid, string? imageName, string? title, string? subtitle)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomid);
|
||||
if (room == null)
|
||||
return null;
|
||||
|
||||
room.LoadScreens = new List<LoadScreen>
|
||||
{
|
||||
new LoadScreen
|
||||
{
|
||||
ImageName = imageName ?? "",
|
||||
Title = title ?? "",
|
||||
Subtitle = subtitle ?? ""
|
||||
}
|
||||
};
|
||||
|
||||
col.Update(room);
|
||||
return room;
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot SetRoomWarningMask(ulong roomId, WarningMaskType warningMask)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
room.WarningMask = warningMask;
|
||||
col.Update(room);
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.ToString());
|
||||
}
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reference's room tag edit (rooms.app.ts / applyRoomTagEdit). Two request shapes reach
|
||||
/// PUT /rooms/{id}/tags and the FIELDS say which:
|
||||
/// - a lone `tag=<name>`: TOGGLE that owner tag (the five "main" tags pvp/quest/game/
|
||||
/// hangout/art act as radio buttons);
|
||||
/// - anything else is the whole-state save from room settings
|
||||
/// (`autoTag=limitsv2&tag=roleplay&tag=social&primaryGenreTag=roleplay`): `tag` is the
|
||||
/// COMPLETE set of owner (Type 0) tags, `autoTag` ADDS derived Type 1 tags (limitsv2,
|
||||
/// beta - the ones Beta rooms and Limits V2 need to be recognised), `primaryGenreTag`
|
||||
/// flags the genre.
|
||||
/// The previous implementation toggled every posted tag, so a settings save with three
|
||||
/// tags flipped them, and `autoTag` wiped the list to just "beta" as an owner tag.
|
||||
/// </summary>
|
||||
public static RoomDBClasses.RoomRoot? ApplyRoomTagEdit(ulong roomId, string? toggle, List<string>? tags, List<string> autoTags, string? primaryGenre)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null) return null;
|
||||
var next = (room.Tags ?? new List<TagClass>()).Where(t => t != null && !string.IsNullOrWhiteSpace(t.Tag)).ToList();
|
||||
string Key(TagClass t) => t.Tag.Trim().ToLowerInvariant();
|
||||
var mainTags = new HashSet<string> { "pvp", "quest", "game", "hangout", "art" };
|
||||
|
||||
if (tags != null)
|
||||
{
|
||||
var posted = tags.Select(t => t.Trim().ToLowerInvariant()).Where(t => t.Length > 0).Distinct().ToList();
|
||||
var kept = next.Where(t => t.Type != 0 && !posted.Contains(Key(t))).ToList();
|
||||
foreach (var tag in posted)
|
||||
kept.Add(next.FirstOrDefault(t => Key(t) == tag) ?? new TagClass { Tag = tag, Type = 0 });
|
||||
next = kept;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(toggle))
|
||||
{
|
||||
string lower = toggle.Trim().ToLowerInvariant();
|
||||
var existing = next.FirstOrDefault(t => Key(t) == lower);
|
||||
if (existing != null) next.Remove(existing);
|
||||
else
|
||||
{
|
||||
if (mainTags.Contains(lower)) next = next.Where(t => !mainTags.Contains(Key(t))).ToList();
|
||||
next.Add(new TagClass { Tag = lower, Type = 0 });
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var auto in autoTags)
|
||||
{
|
||||
string lower = auto.Trim().ToLowerInvariant();
|
||||
if (lower.Length == 0) continue;
|
||||
var existing = next.FirstOrDefault(t => Key(t) == lower);
|
||||
if (existing != null) existing.Type = 1;
|
||||
else next.Add(new TagClass { Tag = lower, Type = 1 });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(primaryGenre))
|
||||
{
|
||||
string lower = primaryGenre.Trim().ToLowerInvariant();
|
||||
foreach (var t in next) t.IsPrimaryGenre = null;
|
||||
var chosen = next.FirstOrDefault(t => Key(t) == lower);
|
||||
if (chosen != null) chosen.IsPrimaryGenre = true;
|
||||
else next.Add(new TagClass { Tag = lower, Type = 0, IsPrimaryGenre = true });
|
||||
}
|
||||
|
||||
room.Tags = next;
|
||||
col.Update(room);
|
||||
return room;
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot? SetRoomTags(List<string> tags, ulong roomId, int type = 0)
|
||||
{
|
||||
|
||||
col.EnsureIndex(x => x.RoomId);
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null)
|
||||
return null;
|
||||
|
||||
if (tags == null || tags.Count == 0)
|
||||
return room;
|
||||
|
||||
// Normalize input tags: trim, lowercase, remove empty, distinct
|
||||
var newTags = tags
|
||||
.Select(t => t.Trim().ToLowerInvariant())
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (room.Tags == null)
|
||||
room.Tags = new List<TagClass>();
|
||||
|
||||
foreach (var newTag in newTags)
|
||||
{
|
||||
var existingTag = room.Tags.FirstOrDefault(t =>
|
||||
t.Tag.Equals(newTag, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingTag != null)
|
||||
{
|
||||
// TagClass exists -> remove it
|
||||
room.Tags.Remove(existingTag);
|
||||
}
|
||||
else
|
||||
{
|
||||
// TagClass does not exist -> add it
|
||||
room.Tags.Add(new TagClass
|
||||
{
|
||||
Tag = newTag,
|
||||
Type = type
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.Update(room);
|
||||
return room;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static bool DoesRoomExist(ulong roomId)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static ulong? CloneRoom(ulong roomId, string RoomName, ulong playerId)
|
||||
{
|
||||
|
||||
|
||||
// ✅ Deep clone to detach from DB references
|
||||
RoomDBClasses.RoomRoot? original = GetRoom(roomId);
|
||||
RoomDBClasses.RoomRoot? room_data = JsonConvert.DeserializeObject<RoomDBClasses.RoomRoot>(JsonConvert.SerializeObject(original));
|
||||
|
||||
if (room_data?.Name == "MakerRoom")
|
||||
goto clone_room_data;
|
||||
|
||||
if (room_data?.Name == "TheBackDoor")
|
||||
return null;
|
||||
|
||||
if (room_data?.Name == "DormRoom")
|
||||
return null;
|
||||
|
||||
if (room_data.CloningAllowed &&
|
||||
(room_data.Accessibility == RoomDBClasses.RoomAccessibility.Public ||
|
||||
room_data.Accessibility == RoomDBClasses.RoomAccessibility.Unlisted))
|
||||
goto clone_room_data;
|
||||
|
||||
if (RoomName == "MakerRoom")
|
||||
goto clone_room_data;
|
||||
|
||||
if (!room_data.CloningAllowed && room_data.CreatorAccountId != playerId && !room_data.IsDorm)
|
||||
return null;
|
||||
|
||||
var existingRoom = col.FindOne(x => x.Name == room_data.Name && x.RoomId != room_data.RoomId);
|
||||
if (existingRoom != null)
|
||||
return null;
|
||||
|
||||
clone_room_data:
|
||||
room_data.RoomId = 0;
|
||||
room_data.CreatorAccountId = playerId;
|
||||
room_data.CloningAllowed = false;
|
||||
room_data.Name = RoomName;
|
||||
room_data.ImageName = "DefaultRoomImage.jpg";
|
||||
room_data.CreatedAt = DateTime.Now;
|
||||
room_data.Accessibility = RoomDBClasses.RoomAccessibility.Private;
|
||||
room_data.PublishState = 0;
|
||||
// A player's room is not one of Rec Room's own. The template's flags came across on the
|
||||
// copy: IsDeveloperOwned=true is what kept every player-made room out of the profile's
|
||||
// created-rooms list (the client hides developer-owned rooms there), and the template's
|
||||
// RankedEntityId meant dozens of rooms shared one ranking key.
|
||||
room_data.IsDeveloperOwned = false;
|
||||
room_data.IsRecRoomApproved = false;
|
||||
room_data.PublishedAt = default;
|
||||
room_data.RankedEntityId = "";
|
||||
// The subrooms carry the accessibility the owner actually sets (PrimaryAccessibility
|
||||
// reads the Home subroom), and a template's are Public - so a fresh room was "published"
|
||||
// the moment it was created and every profile listed it. A new room is a private draft
|
||||
// until its owner publishes it from room settings.
|
||||
if (room_data.SubRooms != null)
|
||||
foreach (var sr in room_data.SubRooms) sr.Accessibility = RoomDBClasses.RoomAccessibility.Private;
|
||||
room_data.IsRRO = false;
|
||||
room_data.IsDorm = false;
|
||||
room_data.Stats.CheerCount = 0;
|
||||
room_data.Stats.FavoriteCount = 0;
|
||||
room_data.Stats.VisitorCount = 0;
|
||||
room_data.Stats.VisitCount = 0;
|
||||
room_data.Tags = new List<TagClass>();
|
||||
room_data.Roles = new List<RoleClass>
|
||||
{
|
||||
new RoleClass
|
||||
{
|
||||
AccountId = (int)playerId,
|
||||
InvitedRole = 0,
|
||||
Role = RoomDBClasses.Role_data.Creator
|
||||
}
|
||||
};
|
||||
|
||||
ulong NewRoomId = RoomDB.AddRoom(room_data);
|
||||
Console.WriteLine($"[clone_room] New room ID returned from add_room: {NewRoomId}");
|
||||
if (NewRoomId != 0)
|
||||
{
|
||||
var made = col.FindOne(x => x.RoomId == NewRoomId);
|
||||
if (made != null && made.RankedEntityId != NewRoomId.ToString())
|
||||
{
|
||||
made.RankedEntityId = NewRoomId.ToString();
|
||||
col.Update(made);
|
||||
}
|
||||
}
|
||||
|
||||
return NewRoomId;
|
||||
}
|
||||
|
||||
public static object? GetRoomSaveById(
|
||||
ulong roomId,
|
||||
ulong subroomId,
|
||||
ulong subRoomDataSaveId, int target, int version)
|
||||
{
|
||||
var subroom = subroom_col.FindOne(x =>
|
||||
x.RoomId == roomId &&
|
||||
x.SubRoomId == subroomId);
|
||||
|
||||
if (subroom == null)
|
||||
return null;
|
||||
|
||||
var save =
|
||||
(subroom.CurrentSave != null &&
|
||||
subroom.CurrentSave.SubRoomDataSaveId == subRoomDataSaveId)
|
||||
? subroom.CurrentSave
|
||||
: subroom.AllSaves?
|
||||
.FirstOrDefault(x => x.SubRoomDataSaveId == subRoomDataSaveId);
|
||||
|
||||
if (save == null)
|
||||
return null;
|
||||
|
||||
return MapSave(save, subroomId, target, version);
|
||||
}
|
||||
|
||||
private class UnityAssetInfo
|
||||
{
|
||||
public string UnityAssetId { get; set; }
|
||||
public int Target { get; set; }
|
||||
public int Version { get; set; }
|
||||
public string Filename { get; set; }
|
||||
public string Hash { get; set; }
|
||||
}
|
||||
|
||||
private static object MapSave(RoomDBClasses.CurrentSave save, ulong subroomid, int target, int version)
|
||||
{
|
||||
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "BakedBulk", $"{save.UnityAssetId}_assets.json");
|
||||
|
||||
UnityAssetInfo matchingAsset = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(save.UnityAssetId) && System.IO.File.Exists(path))
|
||||
{
|
||||
var unityAssets = JsonConvert.DeserializeObject<List<UnityAssetInfo>>(System.IO.File.ReadAllText(path));
|
||||
matchingAsset = unityAssets.FirstOrDefault(u => u.Target == target && u.Version == version);
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, object>
|
||||
{
|
||||
["UnitySubAssets"] = new List<object>(),
|
||||
["ReferencedUnityAssets"] = new List<object>(),
|
||||
["SubRoomDataSaveId"] = save.SubRoomDataSaveId,
|
||||
["SubRoomId"] = subroomid,
|
||||
["DataBlob"] = save.DataBlob,
|
||||
["ReferencedUnityAssetIds"] = save.ReferencedUnityAssetIds ?? new List<string>(),
|
||||
["PersistenceVersion"] = save.PersistenceVersion,
|
||||
["OMVersion"] = save.OMVersion,
|
||||
["UgcSubVersion"] = save.UgcSubVersion,
|
||||
["SavedByAccountId"] = save.SavedByAccountId,
|
||||
["SavedOnPlatform"] = save.SavedOnPlatform,
|
||||
["SavedOnDeviceClass"] = save.SavedOnDeviceClass,
|
||||
["Description"] = save.Description,
|
||||
["Tags"] = new List<object>(),
|
||||
["ModerationState"] = save.ModerationState,
|
||||
["CreatedAt"] = save.CreatedAt
|
||||
};
|
||||
|
||||
// Only include UnityAssetId if it exists
|
||||
if (!string.IsNullOrEmpty(save.UnityAssetId))
|
||||
{
|
||||
result["UnityAssetId"] = save.UnityAssetId;
|
||||
}
|
||||
|
||||
// Only include UnityAsset if matching asset exists
|
||||
if (matchingAsset != null)
|
||||
{
|
||||
result["UnityAsset"] = matchingAsset.Filename;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a subroom's UnitySceneId - the scene the CLIENT loads for that subroom.
|
||||
///
|
||||
/// This is what decides which map you actually walk into, and it is not the same
|
||||
/// thing as the .room save blob:
|
||||
///
|
||||
/// - a Rec Room Original's geometry ships INSIDE the client and is selected by
|
||||
/// this id. Such rooms have no save at all and never needed one.
|
||||
/// - a player-built room loads a template scene by this id, then applies its
|
||||
/// save blob on top.
|
||||
///
|
||||
/// Imported room data carries the id the ORIGINAL service used, which does not
|
||||
/// necessarily mean the same scene in this client build. Rooms whose id resolves to
|
||||
/// a template the client happens to have - a75f7547-79eb-47c6-8986-6767abcb4f92 is
|
||||
/// "Basement" here - load that blank template instead of their real map, which looks
|
||||
/// exactly like a missing save but has nothing to do with blobs.
|
||||
///
|
||||
/// Changing this only rewrites one field; nothing is downloaded and no file moves, so
|
||||
/// a wrong value costs nothing but another attempt.
|
||||
/// </summary>
|
||||
public static RoomDBClasses.SubRoom? GetSubRoom(ulong roomId, ulong subRoomId) =>
|
||||
subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
|
||||
|
||||
public static bool SetSubRoomPermissions(ulong roomId, ulong subRoomId, List<Campus_live_rewrite.api.server.RoomPermissions.Entry> table)
|
||||
{
|
||||
var sub = GetSubRoom(roomId, subRoomId);
|
||||
if (sub == null) return false;
|
||||
sub.Permissions = table;
|
||||
subroom_col.Update(sub);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SetSubRoomScene(ulong roomId, ulong subRoomId, string sceneId)
|
||||
{
|
||||
var sub = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
|
||||
if (sub == null) return false;
|
||||
|
||||
string before = sub.UnitySceneId ?? "(none)";
|
||||
sub.UnitySceneId = sceneId;
|
||||
subroom_col.Update(sub);
|
||||
|
||||
Console.WriteLine($"[rooms] room {roomId} subroom {subRoomId} scene {before} -> {sceneId}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot? SetRoomSubroomRoomFile(
|
||||
ulong roomId,
|
||||
ulong subRoomId,
|
||||
string roomFile,
|
||||
string roomDesc,
|
||||
ulong playerId,
|
||||
Platforms currentplatform,
|
||||
int persistenceVersion = 0,
|
||||
int omVersion = 0,
|
||||
int ugcSubVersion = 0,
|
||||
string? roomLevelBlob = null)
|
||||
=> SetRoomSubroomRoomFile(roomId, subRoomId, roomFile, roomDesc, playerId, currentplatform, persistenceVersion, omVersion, ugcSubVersion, roomLevelBlob, true, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Record a save. PUBLISHED and UNPUBLISHED saves are kept apart (reference rooms
|
||||
/// POST /subrooms/{id}/data): a save lands in the subroom's history and, unless the
|
||||
/// client asked to AutoPublish, is only STAGED (StagedSubRoomDataSaveId) - players keep
|
||||
/// loading the last published CurrentSave until the owner publishes it
|
||||
/// (publish_save). Dorms, and a subroom that has never been published, publish at once.
|
||||
/// </summary>
|
||||
public static RoomDBClasses.RoomRoot? SetRoomSubroomRoomFile(
|
||||
ulong roomId,
|
||||
ulong subRoomId,
|
||||
string roomFile,
|
||||
string roomDesc,
|
||||
ulong playerId,
|
||||
Platforms currentplatform,
|
||||
int persistenceVersion,
|
||||
int omVersion,
|
||||
int ugcSubVersion,
|
||||
string? roomLevelBlob,
|
||||
bool autoPublish,
|
||||
out CurrentSave? created)
|
||||
{
|
||||
created = null;
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null)
|
||||
return null;
|
||||
|
||||
var subroom = subroom_col.FindOne(x =>
|
||||
x.RoomId == roomId &&
|
||||
x.SubRoomId == subRoomId);
|
||||
|
||||
if (subroom == null)
|
||||
return room;
|
||||
|
||||
subroom.AllSaves ??= new List<CurrentSave>();
|
||||
|
||||
// The history holds EVERY save, the published one included (older rows kept the
|
||||
// published save only on CurrentSave - fold it in once).
|
||||
if (subroom.CurrentSave != null && !subroom.AllSaves.Any(s => s.SubRoomDataSaveId == subroom.CurrentSave.SubRoomDataSaveId))
|
||||
subroom.AllSaves.Add(subroom.CurrentSave);
|
||||
|
||||
long maxId = subroom.AllSaves.Count > 0 ? subroom.AllSaves.Max(s => (long)s.SubRoomDataSaveId) : 0;
|
||||
if (subroom.CurrentSave != null) maxId = Math.Max(maxId, (long)subroom.CurrentSave.SubRoomDataSaveId);
|
||||
|
||||
// 💡 Create fresh save object
|
||||
var newSave = new CurrentSave
|
||||
{
|
||||
SubRoomDataSaveId = (ulong)(maxId + 1),
|
||||
SubRoomId = subroom.SubRoomId,
|
||||
DataBlob = roomFile,
|
||||
Description = roomDesc ?? "",
|
||||
SavedByAccountId = playerId,
|
||||
SavedOnPlatform = currentplatform,
|
||||
SavedOnDeviceClass = DeviceClasses.Screen,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
|
||||
// These arrive on every save request and used to be dropped, so every
|
||||
// save was stored as 0/0/0. The client compares the versions it wrote
|
||||
// against what the room reports back; 0 never matched, so the room looked
|
||||
// modified the moment it loaded and leaving always prompted
|
||||
// "do you want to save?" even with no changes made.
|
||||
PersistenceVersion = persistenceVersion,
|
||||
OMVersion = omVersion,
|
||||
UgcSubVersion = ugcSubVersion
|
||||
};
|
||||
|
||||
subroom.AllSaves.Add(newSave);
|
||||
created = newSave;
|
||||
|
||||
bool publishNow = autoPublish || room.IsDorm || subroom.CurrentSave == null;
|
||||
if (publishNow)
|
||||
{
|
||||
subroom.CurrentSave = newSave;
|
||||
subroom.StagedSubRoomDataSaveId = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
subroom.StagedSubRoomDataSaveId = (long)newSave.SubRoomDataSaveId;
|
||||
}
|
||||
subroom.CreatorAccountId ??= (long)playerId; // client NREs on a null creator
|
||||
|
||||
subroom_col.Update(subroom);
|
||||
Console.WriteLine($"[rooms] save {newSave.SubRoomDataSaveId} on room {roomId} subroom {subRoomId} by {playerId}: {(publishNow ? "PUBLISHED" : "staged (unpublished)")}");
|
||||
|
||||
// Mirror onto the embedded copy the room document carries, so a room read that does
|
||||
// not re-attach subrooms still shows the staged / published state.
|
||||
var emb = room.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId);
|
||||
if (emb != null)
|
||||
{
|
||||
emb.AllSaves = subroom.AllSaves;
|
||||
emb.CurrentSave = subroom.CurrentSave;
|
||||
emb.StagedSubRoomDataSaveId = subroom.StagedSubRoomDataSaveId;
|
||||
col.Update(room);
|
||||
}
|
||||
|
||||
// The save request also carries a ROOM-level blob (RoomData.Filename), which
|
||||
// was never read - so RoomRoot.DataBlob kept pointing at whatever stale .meta
|
||||
// it shipped with and never picked up the freshly uploaded one.
|
||||
bool roomChanged = false;
|
||||
if (!string.IsNullOrWhiteSpace(roomLevelBlob) && room.DataBlob != roomLevelBlob)
|
||||
{
|
||||
room.DataBlob = roomLevelBlob;
|
||||
roomChanged = true;
|
||||
}
|
||||
|
||||
if (ugcSubVersion > 0 && room.UgcSubVersion != ugcSubVersion)
|
||||
{
|
||||
room.UgcSubVersion = ugcSubVersion;
|
||||
roomChanged = true;
|
||||
}
|
||||
|
||||
if (roomChanged)
|
||||
col.Update(room);
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot? d(
|
||||
ulong roomId,
|
||||
ulong subRoomId,
|
||||
string roomFile)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null)
|
||||
return null;
|
||||
|
||||
var subroom = subroom_col.FindOne(x =>
|
||||
x.RoomId == roomId &&
|
||||
x.SubRoomId == subRoomId);
|
||||
|
||||
subroom.CurrentSave.DataBlob = "80c450a999404dbcb792d6d3d2f62a56.room";
|
||||
|
||||
subroom_col.Update(subroom);
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot? CreateSubroomForRoom(ulong roomId, string newSubroomName)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null)
|
||||
{
|
||||
Console.WriteLine("Room not found.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var roomSubrooms = subroom_col.Find(x => x.RoomId == roomId).ToList();
|
||||
ulong nextSubRoomId = roomSubrooms.Count == 0 ? 1 : roomSubrooms.Max(sr => sr.SubRoomId) + 1;
|
||||
var lastSubroom = roomSubrooms
|
||||
.OrderByDescending(sr => sr.SubRoomId)
|
||||
.FirstOrDefault();
|
||||
var newSubroom = new SubRoom
|
||||
{
|
||||
RoomId = roomId,
|
||||
Name = newSubroomName,
|
||||
SubRoomId = nextSubRoomId,
|
||||
AllSaves = new List<CurrentSave>(),
|
||||
CurrentSave = null,
|
||||
Accessibility = RoomDBClasses.RoomAccessibility.Public,
|
||||
UnitySceneId = lastSubroom.UnitySceneId,
|
||||
MaxPlayers = lastSubroom.MaxPlayers,
|
||||
};
|
||||
|
||||
subroom_col.Insert(newSubroom);
|
||||
|
||||
roomSubrooms.Add(newSubroom);
|
||||
col.Update(room);
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
/// <summary>A copy of a subroom under a fresh SubRoomId: same scene, settings and saves.</summary>
|
||||
public static SubRoom? CloneSubroom(ulong roomId, ulong subRoomId)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
var src = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
|
||||
if (room == null || src == null) return null;
|
||||
var all = subroom_col.Find(x => x.RoomId == roomId).ToList();
|
||||
ulong nextId = all.Count == 0 ? 1 : all.Max(sr => sr.SubRoomId) + 1;
|
||||
var copy = new SubRoom
|
||||
{
|
||||
RoomId = roomId,
|
||||
SubRoomId = nextId,
|
||||
Name = (src.Name ?? "Subroom") + " (copy)",
|
||||
Accessibility = src.Accessibility,
|
||||
CreatorAccountId = src.CreatorAccountId,
|
||||
IsSandbox = src.IsSandbox,
|
||||
MaxPlayers = src.MaxPlayers,
|
||||
ShouldAutoStageSaves = src.ShouldAutoStageSaves,
|
||||
UnitySceneId = src.UnitySceneId,
|
||||
CurrentSave = src.CurrentSave,
|
||||
AllSaves = new List<CurrentSave>(src.AllSaves ?? new List<CurrentSave>())
|
||||
};
|
||||
subroom_col.Insert(copy);
|
||||
room.SubRooms ??= new List<SubRoom>();
|
||||
room.SubRooms.Add(copy);
|
||||
col.Update(room);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>Put the room's embedded subroom list in the given order (ids not named keep their relative order at the end).</summary>
|
||||
public static void ReorderSubrooms(ulong roomId, List<ulong> order)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room?.SubRooms == null) return;
|
||||
var rank = order.Select((id, i) => (id, i)).ToDictionary(t => t.id, t => t.i);
|
||||
room.SubRooms = room.SubRooms.OrderBy(s => rank.TryGetValue(s.SubRoomId, out var r) ? r : int.MaxValue).ToList();
|
||||
col.Update(room);
|
||||
}
|
||||
|
||||
/// <summary>Make one of a subroom's saved versions the live one (restore). False when unknown.</summary>
|
||||
public static bool PublishSubroomSave(ulong roomId, ulong subRoomId, ulong saveId)
|
||||
{
|
||||
var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
|
||||
if (subroom == null) return false;
|
||||
var save = (subroom.AllSaves ?? new List<CurrentSave>()).FirstOrDefault(s => s.SubRoomDataSaveId == saveId)
|
||||
?? (subroom.CurrentSave?.SubRoomDataSaveId == saveId ? subroom.CurrentSave : null);
|
||||
if (save == null) return false;
|
||||
subroom.CurrentSave = save;
|
||||
if (subroom.StagedSubRoomDataSaveId == (long)saveId) subroom.StagedSubRoomDataSaveId = null;
|
||||
subroom_col.Update(subroom);
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
var emb = room?.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId);
|
||||
if (room != null && emb != null) { emb.CurrentSave = save; emb.StagedSubRoomDataSaveId = subroom.StagedSubRoomDataSaveId; col.Update(room); }
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool DeleteSubroom(ulong roomId, ulong subRoomId)
|
||||
{
|
||||
|
||||
var subroom = subroom_col.FindOne(x =>
|
||||
x.RoomId == roomId &&
|
||||
x.SubRoomId == subRoomId);
|
||||
|
||||
if (subroom == null)
|
||||
return false;
|
||||
|
||||
subroom_col.Delete(subroom.Id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ModifySubroom(
|
||||
ulong roomId,
|
||||
ulong subRoomId,
|
||||
string name,
|
||||
RoomDBClasses.RoomAccessibility accessibility,
|
||||
int maxPlayers)
|
||||
{
|
||||
|
||||
var subroom = subroom_col.FindOne(x =>
|
||||
x.RoomId == roomId &&
|
||||
x.SubRoomId == subRoomId);
|
||||
|
||||
if (subroom == null)
|
||||
return false;
|
||||
|
||||
subroom.Name = name;
|
||||
subroom.Accessibility = accessibility;
|
||||
subroom.MaxPlayers = maxPlayers;
|
||||
|
||||
subroom_col.Update(subroom);
|
||||
|
||||
// Mirror the change onto the room's embedded subroom copy AND the room-level
|
||||
// Accessibility (what Search / hot / visibility read), so setting a Rooms 2.0 room to
|
||||
// Public in-game actually makes it appear in listings/search without a stale field.
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room != null)
|
||||
{
|
||||
var emb = room.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId);
|
||||
if (emb != null) { emb.Name = name; emb.Accessibility = accessibility; emb.MaxPlayers = maxPlayers; }
|
||||
// Published rooms stay Public no matter what the subroom is set to; unpublished
|
||||
// rooms mirror the subroom.
|
||||
room.Accessibility = EffectiveRoomAccessibility(room);
|
||||
col.Update(room);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void DeleteRoom(ulong roomId)
|
||||
{
|
||||
subroom_col.DeleteMany(x => x.RoomId == roomId);
|
||||
col.DeleteMany(x => x.RoomId == roomId);
|
||||
}
|
||||
|
||||
public static RoomDBClasses.SubroomSaveResults? GetRoomSaves(ulong roomId, ulong subroomId, int skip = 0, int take = 20)
|
||||
{
|
||||
|
||||
var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subroomId);
|
||||
if (subroom == null)
|
||||
return null;
|
||||
|
||||
var saves = new List<RoomDBClasses.CurrentSave>();
|
||||
|
||||
if (subroom.CurrentSave != null)
|
||||
saves.Add(subroom.CurrentSave);
|
||||
|
||||
if (subroom.AllSaves != null && subroom.AllSaves.Count > 0)
|
||||
saves.AddRange(subroom.AllSaves);
|
||||
|
||||
saves = saves
|
||||
.GroupBy(x => x.SubRoomDataSaveId)
|
||||
.Select(g => g.First())
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
var paged = saves
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToList();
|
||||
|
||||
return new SubroomSaveResults
|
||||
{
|
||||
Results = paged,
|
||||
TotalCount = saves.Count
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// <summary>A room's role list (by id). Empty if the room or its Roles are unset.</summary>
|
||||
public static List<RoomDBClasses.RoleClass> GetRoomRoles(ulong roomId)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
return room?.Roles ?? new List<RoomDBClasses.RoleClass>();
|
||||
}
|
||||
|
||||
/// <summary>Can this player manage a room's roles? Creator, a co-owner, or a dev/admin.</summary>
|
||||
public static bool PlayerCanManageRoles(ulong roomId, ulong callerId)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null) return false;
|
||||
if (room.CreatorAccountId == callerId) return true;
|
||||
if (PlayerDB.CheckDevFlag(callerId)) return true;
|
||||
return IsCoOwner(room, callerId);
|
||||
}
|
||||
|
||||
/// <summary>Co-owner or temporary co-owner (both edit like the creator does).</summary>
|
||||
public static bool IsCoOwner(RoomDBClasses.RoomRoot room, ulong accountId) =>
|
||||
room.Roles?.Any(r => (ulong)r.AccountId == accountId
|
||||
&& (r.Role == RoomDBClasses.Role_data.Creator
|
||||
|| r.Role == RoomDBClasses.Role_data.CoOwner
|
||||
|| r.Role == RoomDBClasses.Role_data.TemporaryCoOwner)) ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Does the player hold at least <paramref name="role"/> (a Role_data value: 10 Host,
|
||||
/// 20 Moderator, 30 CoOwner) in the room? The creator always passes; a banned entry never
|
||||
/// does (reference: verifyRole).
|
||||
/// </summary>
|
||||
public static bool PlayerHasRoleAtLeast(ulong roomId, ulong accountId, int role)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null) return false;
|
||||
if (room.CreatorAccountId == accountId) return true;
|
||||
var mine = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == accountId);
|
||||
if (mine == null || mine.Role == RoomDBClasses.Role_data.Banned || mine.Role == RoomDBClasses.Role_data.None) return false;
|
||||
return (int)mine.Role >= role;
|
||||
}
|
||||
|
||||
/// <summary>Add/update/remove a player's role on a room (by id). None removes it. Returns success.</summary>
|
||||
/// <summary>Creator, co-owner (temporary too) or contributor: may save the room's contents.</summary>
|
||||
public static bool CanEditContent(RoomDBClasses.RoomRoot? room, ulong accountId)
|
||||
{
|
||||
if (room == null) return false;
|
||||
if (room.CreatorAccountId == accountId || IsCoOwner(room, accountId)) return true;
|
||||
return room.Roles?.Any(r => (ulong)r.AccountId == accountId && r.Role == RoomDBClasses.Role_data.Contributor) ?? false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a PENDING role invite: InvitedRole = the offered role, Role untouched. Nothing is
|
||||
/// granted until the invitee accepts (AcceptRoleInvite). A non-zero InvitedRole is what the
|
||||
/// client renders as "invited, not yet accepted".
|
||||
/// </summary>
|
||||
public static bool InviteRoomRole(ulong roomId, long accountId, RoomDBClasses.Role_data role, long invitedBy)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null) return false;
|
||||
room.Roles ??= new List<RoomDBClasses.RoleClass>();
|
||||
var existing = room.Roles.FirstOrDefault(r => r.AccountId == accountId);
|
||||
if (existing != null) { existing.InvitedRole = (int)role; existing.LastChangedByAccountId = invitedBy; }
|
||||
else room.Roles.Add(new RoomDBClasses.RoleClass { AccountId = accountId, Role = RoomDBClasses.Role_data.None, InvitedRole = (int)role, LastChangedByAccountId = invitedBy });
|
||||
col.Update(room);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The role this player has been invited to on the room, or None.</summary>
|
||||
public static RoomDBClasses.Role_data PendingRoleInvite(ulong roomId, long accountId)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
var e = room?.Roles?.FirstOrDefault(r => r.AccountId == accountId);
|
||||
if (e == null || e.InvitedRole == 0) return RoomDBClasses.Role_data.None;
|
||||
return Enum.IsDefined(typeof(RoomDBClasses.Role_data), (byte)e.InvitedRole) ? (RoomDBClasses.Role_data)(byte)e.InvitedRole : RoomDBClasses.Role_data.None;
|
||||
}
|
||||
|
||||
/// <summary>Accept (grant the invited role) or decline (drop the invite) a pending invite.</summary>
|
||||
public static bool ResolveRoleInvite(ulong roomId, long accountId, bool accept)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
var e = room?.Roles?.FirstOrDefault(r => r.AccountId == accountId);
|
||||
if (room == null || e == null || e.InvitedRole == 0) return false;
|
||||
if (accept)
|
||||
{
|
||||
e.Role = Enum.IsDefined(typeof(RoomDBClasses.Role_data), (byte)e.InvitedRole) ? (RoomDBClasses.Role_data)(byte)e.InvitedRole : RoomDBClasses.Role_data.None;
|
||||
e.InvitedRole = 0;
|
||||
if (e.Role == RoomDBClasses.Role_data.None) room.Roles!.Remove(e);
|
||||
}
|
||||
else
|
||||
{
|
||||
e.InvitedRole = 0;
|
||||
if (e.Role == RoomDBClasses.Role_data.None) room.Roles!.Remove(e);
|
||||
}
|
||||
col.Update(room);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SetRoomRole(ulong roomId, long accountId, RoomDBClasses.Role_data role, long changedBy)
|
||||
{
|
||||
var room = col.FindOne(x => x.RoomId == roomId);
|
||||
if (room == null) return false;
|
||||
room.Roles ??= new List<RoomDBClasses.RoleClass>();
|
||||
var existing = room.Roles.FirstOrDefault(r => r.AccountId == accountId);
|
||||
if (role == RoomDBClasses.Role_data.None)
|
||||
{
|
||||
if (existing != null) room.Roles.Remove(existing);
|
||||
}
|
||||
// Granted directly (no pending-invite step here), so InvitedRole stays 0: a non-zero
|
||||
// InvitedRole is what the client reads as "invited, not yet accepted".
|
||||
else if (existing != null)
|
||||
{
|
||||
existing.Role = role; existing.InvitedRole = 0; existing.LastChangedByAccountId = changedBy;
|
||||
}
|
||||
else
|
||||
{
|
||||
room.Roles.Add(new RoomDBClasses.RoleClass { AccountId = accountId, Role = role, InvitedRole = 0, LastChangedByAccountId = changedBy });
|
||||
}
|
||||
col.Update(room);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool AddRoleToRoom(string name, int accountId, RoomDBClasses.Role_data roleData, int invitedRole = 0)
|
||||
{
|
||||
|
||||
var room = col.FindOne(x => x.Name == name);
|
||||
if (room == null)
|
||||
return false;
|
||||
|
||||
room.Roles ??= new List<RoleClass>();
|
||||
|
||||
room.Roles.Add(new RoleClass
|
||||
{
|
||||
AccountId = accountId,
|
||||
Role = roleData,
|
||||
InvitedRole = invitedRole
|
||||
});
|
||||
|
||||
col.Update(room);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool RemoveRoleFromRoom(string name, int accountId)
|
||||
{
|
||||
|
||||
|
||||
var room = col.FindOne(x => x.Name == name);
|
||||
if (room == null)
|
||||
return false;
|
||||
|
||||
if (room.Roles == null || room.Roles.Count == 0)
|
||||
return false;
|
||||
|
||||
|
||||
room.Roles.RemoveAll(r => r.AccountId == accountId);
|
||||
|
||||
col.Update(room);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static RoomDBClasses.RoomRoot SetRoomCreatorId(ulong playerid, string roomname)
|
||||
{
|
||||
col.EnsureIndex(x => x.Name);
|
||||
|
||||
var room = col.FindOne(x => x.Name == roomname);
|
||||
|
||||
if (room != null)
|
||||
{
|
||||
room.CreatorAccountId = playerid;
|
||||
|
||||
var ownerRole = room.Roles.FirstOrDefault(r => r.Role == Role_data.Creator);
|
||||
|
||||
if (ownerRole != null)
|
||||
{
|
||||
ownerRole.AccountId = (long)playerid;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
Console.WriteLine($"Setting the creator id to {playerid}...");
|
||||
col.Update(room);
|
||||
Console.WriteLine($"Success!");
|
||||
return room;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void Setup()
|
||||
{
|
||||
col.EnsureIndex(x => x.CreatorAccountId);
|
||||
col.EnsureIndex(x => x.RoomId);
|
||||
col.EnsureIndex(x => x.IsDorm);
|
||||
subroom_col.EnsureIndex(x => x.SubRoomId);
|
||||
// Every "subrooms of room X" lookup filters on RoomId. Without this index each one was a
|
||||
// full scan of the subroom table, and the hot-rooms list did one per room - 2,500 scans
|
||||
// per request, 30-70 s each on the VPS.
|
||||
subroom_col.EnsureIndex(x => x.RoomId);
|
||||
col.EnsureIndex(x => x.Name);
|
||||
col.EnsureIndex("TagIndex", "$.Tags[*].Tag");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
/// <summary>
|
||||
/// The room economy a creator sets up in Room Settings: room KEYS (a one-off pass sold for
|
||||
/// tokens or for a room currency), room CURRENCIES (with the purchase offers that sell them
|
||||
/// for tokens), and room CONSUMABLES (stackable items sold for tokens or a room currency).
|
||||
/// Plus who owns what: keys per player, currency balances per player, consumable counts per
|
||||
/// player. Every route that used to answer "[]" or an ack reads and writes this.
|
||||
///
|
||||
/// DTO shapes follow the wire-shape notes (roomkeys-v1-room, roomcurrencies-createcurrency,
|
||||
/// roomcurrencies-purchaseoffersbatch, roomconsumables-by-room).
|
||||
/// </summary>
|
||||
public static class RoomEconDB
|
||||
{
|
||||
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "RoomEcon.db")));
|
||||
|
||||
public class RoomKey
|
||||
{
|
||||
[BsonId] public long RoomKeyId { get; set; }
|
||||
public Guid ReplicationId { get; set; } = Guid.NewGuid();
|
||||
public long RoomId { get; set; }
|
||||
public ulong CreatorId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public int Price { get; set; }
|
||||
public Guid? PurchaseCurrencyId { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public int Type { get; set; }
|
||||
public bool Deleted { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class RoomCurrency
|
||||
{
|
||||
[BsonId] public Guid CurrencyId { get; set; } = Guid.NewGuid();
|
||||
public long RoomId { get; set; }
|
||||
public ulong CreatorId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public long Limit { get; set; } = 1_000_000;
|
||||
public int Shape { get; set; }
|
||||
public int Color { get; set; } = -1;
|
||||
public string? ImageName { get; set; }
|
||||
public bool Deleted { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class CurrencyOffer
|
||||
{
|
||||
[BsonId] public Guid CurrencyPurchaseOfferId { get; set; } = Guid.NewGuid();
|
||||
public Guid CurrencyId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public int Order { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public long CurrencyAmount { get; set; }
|
||||
public long Price { get; set; }
|
||||
public bool Deleted { get; set; }
|
||||
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class RoomConsumable
|
||||
{
|
||||
[BsonId] public Guid RoomConsumableId { get; set; } = Guid.NewGuid();
|
||||
public long RoomId { get; set; }
|
||||
public ulong CreatorId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string? ImageName { get; set; }
|
||||
public long Price { get; set; }
|
||||
public Guid? PurchaseCurrencyId { get; set; }
|
||||
public int MaximumCountPerPurchase { get; set; } = 10;
|
||||
public bool Deleted { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class KeyOwnership
|
||||
{
|
||||
[BsonId] public string Key { get; set; } = ""; // "{player}|{roomKeyId}"
|
||||
public ulong PlayerId { get; set; }
|
||||
public long RoomKeyId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public DateTime At { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class CurrencyBalance
|
||||
{
|
||||
[BsonId] public string Key { get; set; } = ""; // "{player}|{currencyId}"
|
||||
public ulong PlayerId { get; set; }
|
||||
public Guid CurrencyId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public long Balance { get; set; }
|
||||
}
|
||||
|
||||
public class ConsumableOwnership
|
||||
{
|
||||
[BsonId] public string Key { get; set; } = ""; // "{player}|{consumableId}"
|
||||
public ulong PlayerId { get; set; }
|
||||
public Guid RoomConsumableId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public int Count { get; set; }
|
||||
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static ILiteCollection<RoomKey> Keys() { var c = _db.Value.GetCollection<RoomKey>("keys"); c.EnsureIndex(x => x.RoomId); return c; }
|
||||
private static ILiteCollection<RoomCurrency> Currencies() { var c = _db.Value.GetCollection<RoomCurrency>("currencies"); c.EnsureIndex(x => x.RoomId); return c; }
|
||||
private static ILiteCollection<CurrencyOffer> Offers() { var c = _db.Value.GetCollection<CurrencyOffer>("offers"); c.EnsureIndex(x => x.CurrencyId); return c; }
|
||||
private static ILiteCollection<RoomConsumable> Consumables() { var c = _db.Value.GetCollection<RoomConsumable>("consumables"); c.EnsureIndex(x => x.RoomId); return c; }
|
||||
private static ILiteCollection<KeyOwnership> KeysOwned() { var c = _db.Value.GetCollection<KeyOwnership>("keys_owned"); c.EnsureIndex(x => x.PlayerId); c.EnsureIndex(x => x.RoomKeyId); return c; }
|
||||
private static ILiteCollection<CurrencyBalance> Balances() { var c = _db.Value.GetCollection<CurrencyBalance>("balances"); c.EnsureIndex(x => x.PlayerId); return c; }
|
||||
private static ILiteCollection<ConsumableOwnership> ConsOwned() { var c = _db.Value.GetCollection<ConsumableOwnership>("consumables_owned"); c.EnsureIndex(x => x.PlayerId); c.EnsureIndex(x => x.RoomConsumableId); return c; }
|
||||
|
||||
// ---- keys ----------------------------------------------------------------------------
|
||||
|
||||
public static RoomKey CreateKey(RoomKey k)
|
||||
{
|
||||
var col = Keys();
|
||||
if (k.RoomKeyId == 0)
|
||||
{
|
||||
var last = col.Query().OrderByDescending(x => x.RoomKeyId).Limit(1).FirstOrDefault();
|
||||
k.RoomKeyId = Math.Max(900_000, (last?.RoomKeyId ?? 0) + 1);
|
||||
}
|
||||
col.Insert(k);
|
||||
return k;
|
||||
}
|
||||
public static RoomKey? GetKey(long id) { var k = Keys().FindById(id); return k == null || k.Deleted ? null : k; }
|
||||
public static List<RoomKey> KeysForRoom(long roomId) => Keys().Query().Where(x => x.RoomId == roomId).ToList().Where(x => !x.Deleted).OrderBy(x => x.CreatedAt).ToList();
|
||||
public static RoomKey? UpdateKey(long id, Action<RoomKey> edit) { var col = Keys(); var k = col.FindById(id); if (k == null) return null; edit(k); col.Update(k); return k; }
|
||||
public static bool OwnsKey(ulong player, long keyId) => KeysOwned().FindById($"{player}|{keyId}") != null;
|
||||
public static bool GrantKey(ulong player, RoomKey k)
|
||||
{
|
||||
var col = KeysOwned();
|
||||
string key = $"{player}|{k.RoomKeyId}";
|
||||
if (col.FindById(key) != null) return false;
|
||||
col.Insert(new KeyOwnership { Key = key, PlayerId = player, RoomKeyId = k.RoomKeyId, RoomId = k.RoomId });
|
||||
return true;
|
||||
}
|
||||
public static bool RevokeKey(ulong player, long keyId) => KeysOwned().Delete($"{player}|{keyId}");
|
||||
public static List<RoomKey> KeysOwnedBy(ulong player)
|
||||
{
|
||||
var ids = KeysOwned().Find(x => x.PlayerId == player).Select(x => x.RoomKeyId).ToList();
|
||||
return ids.Select(GetKey).Where(k => k != null).Select(k => k!).ToList();
|
||||
}
|
||||
public static int KeyOwnerCount(long keyId) => KeysOwned().Count(x => x.RoomKeyId == keyId);
|
||||
|
||||
// ---- currencies ----------------------------------------------------------------------
|
||||
|
||||
public static RoomCurrency CreateCurrency(RoomCurrency c) { Currencies().Insert(c); return c; }
|
||||
public static RoomCurrency? GetCurrency(Guid id) { var c = Currencies().FindById(id); return c == null || c.Deleted ? null : c; }
|
||||
public static List<RoomCurrency> CurrenciesForRoom(long roomId) => Currencies().Query().Where(x => x.RoomId == roomId).ToList().Where(x => !x.Deleted).OrderBy(x => x.CreatedAt).ToList();
|
||||
public static RoomCurrency? UpdateCurrency(Guid id, Action<RoomCurrency> edit) { var col = Currencies(); var c = col.FindById(id); if (c == null) return null; edit(c); c.ModifiedAt = DateTime.UtcNow; col.Update(c); return c; }
|
||||
|
||||
public static CurrencyOffer CreateOffer(CurrencyOffer o) { Offers().Insert(o); return o; }
|
||||
public static CurrencyOffer? GetOffer(Guid id) { var o = Offers().FindById(id); return o == null || o.Deleted ? null : o; }
|
||||
public static List<CurrencyOffer> OffersFor(Guid currencyId) => Offers().Query().Where(x => x.CurrencyId == currencyId).ToList().Where(x => !x.Deleted).OrderBy(x => x.Order).ThenBy(x => x.Price).ToList();
|
||||
public static CurrencyOffer? UpdateOffer(Guid id, Action<CurrencyOffer> edit) { var col = Offers(); var o = col.FindById(id); if (o == null) return null; edit(o); o.ModifiedAt = DateTime.UtcNow; col.Update(o); return o; }
|
||||
|
||||
public static long GetCurrencyBalance(ulong player, Guid currencyId) => Balances().FindById($"{player}|{currencyId}")?.Balance ?? 0;
|
||||
public static List<CurrencyBalance> BalancesForRoom(ulong player, long roomId) => Balances().Find(x => x.PlayerId == player && x.RoomId == roomId).ToList();
|
||||
/// <summary>Add (or take, negative) room currency. Returns the new balance, or null when it would go negative / over the limit.</summary>
|
||||
public static long? AddCurrency(ulong player, RoomCurrency cur, long delta)
|
||||
{
|
||||
var col = Balances();
|
||||
string key = $"{player}|{cur.CurrencyId}";
|
||||
var b = col.FindById(key) ?? new CurrencyBalance { Key = key, PlayerId = player, CurrencyId = cur.CurrencyId, RoomId = cur.RoomId, Balance = 0 };
|
||||
long next = b.Balance + delta;
|
||||
if (next < 0) return null;
|
||||
if (cur.Limit > 0 && next > cur.Limit) next = cur.Limit;
|
||||
b.Balance = next;
|
||||
col.Upsert(b);
|
||||
return next;
|
||||
}
|
||||
|
||||
// ---- consumables ---------------------------------------------------------------------
|
||||
|
||||
public static RoomConsumable CreateConsumable(RoomConsumable c) { Consumables().Insert(c); return c; }
|
||||
public static RoomConsumable? GetConsumable(Guid id) { var c = Consumables().FindById(id); return c == null || c.Deleted ? null : c; }
|
||||
public static List<RoomConsumable> ConsumablesForRoom(long roomId) => Consumables().Query().Where(x => x.RoomId == roomId).ToList().Where(x => !x.Deleted).OrderBy(x => x.CreatedAt).ToList();
|
||||
public static RoomConsumable? UpdateConsumable(Guid id, Action<RoomConsumable> edit) { var col = Consumables(); var c = col.FindById(id); if (c == null) return null; edit(c); c.ModifiedAt = DateTime.UtcNow; col.Update(c); return c; }
|
||||
|
||||
public static int ConsumableCount(ulong player, Guid id) => ConsOwned().FindById($"{player}|{id}")?.Count ?? 0;
|
||||
public static List<ConsumableOwnership> ConsumablesOwnedInRoom(ulong player, long roomId) => ConsOwned().Find(x => x.PlayerId == player && x.RoomId == roomId).Where(x => x.Count > 0).ToList();
|
||||
/// <summary>Add (or consume, negative) copies. Returns the new count, or null when there were not enough.</summary>
|
||||
public static int? AddConsumable(ulong player, RoomConsumable c, int delta)
|
||||
{
|
||||
var col = ConsOwned();
|
||||
string key = $"{player}|{c.RoomConsumableId}";
|
||||
var o = col.FindById(key) ?? new ConsumableOwnership { Key = key, PlayerId = player, RoomConsumableId = c.RoomConsumableId, RoomId = c.RoomId, Count = 0 };
|
||||
int next = o.Count + delta;
|
||||
if (next < 0) return null;
|
||||
o.Count = next; o.ModifiedAt = DateTime.UtcNow;
|
||||
col.Upsert(o);
|
||||
return next;
|
||||
}
|
||||
public static int ConsumableSoldCount(Guid id) => ConsOwned().Find(x => x.RoomConsumableId == id).Sum(x => x.Count);
|
||||
|
||||
// ---- DTOs ----------------------------------------------------------------------------
|
||||
|
||||
public static object KeyDto(RoomKey k) => new
|
||||
{
|
||||
k.RoomKeyId, k.ReplicationId, k.RoomId, k.Name, k.Description, k.Price, k.PurchaseCurrencyId,
|
||||
CreatedAt = k.CreatedAt.ToString("o"), ImageName = k.ImageName ?? "", k.Type
|
||||
};
|
||||
public static object CurrencyDto(RoomCurrency c) => new
|
||||
{
|
||||
c.CurrencyId, RoomId = (long?)c.RoomId, c.Name, c.Description, CurrencyType = 300, c.Limit, c.Shape, c.Color,
|
||||
ImageName = c.ImageName ?? "", CreatedAt = c.CreatedAt.ToString("o"), ModifiedAt = c.ModifiedAt.ToString("o")
|
||||
};
|
||||
public static object OfferDto(CurrencyOffer o) => new
|
||||
{
|
||||
o.CurrencyPurchaseOfferId, o.CurrencyId, o.Order, o.Name, o.CurrencyAmount, o.Price, ModifiedAt = o.ModifiedAt.ToString("o")
|
||||
};
|
||||
public static object ConsumableDto(RoomConsumable c) => new
|
||||
{
|
||||
c.RoomConsumableId, c.RoomId, c.Name, c.Description, ImageName = c.ImageName ?? "", c.Price, c.PurchaseCurrencyId,
|
||||
ModifiedAt = c.ModifiedAt.ToString("o"), c.MaximumCountPerPurchase, CreatedAt = c.CreatedAt.ToString("o")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using LiteDB;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Campus_live_rewrite.api.server.Classes.db_class;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
public class SettingsDB
|
||||
{
|
||||
private static readonly string _dbPath = Path.Combine(Environment.CurrentDirectory, "Data", "DBs", "Settings.db");
|
||||
private static readonly LiteDatabase _db = new LiteDatabase(_dbPath);
|
||||
|
||||
// Merge this store's write-ahead log back into the data file.
|
||||
// See DbMaintenance for why this must be called explicitly.
|
||||
public static void Checkpoint() => _db.Checkpoint();
|
||||
|
||||
private static ILiteCollection<PlayerSetting> PlayerSettings => _db.GetCollection<PlayerSetting>("players_settings");
|
||||
|
||||
public static List<Setting> LoadSettings(ulong playerId)
|
||||
{
|
||||
return PlayerSettings.FindById(playerId)?.Settings ?? new List<Setting>();
|
||||
}
|
||||
|
||||
public static void SaveSettings(List<Setting> settings, ulong playerId)
|
||||
{
|
||||
if (playerId == 0) return;
|
||||
|
||||
var player = new PlayerSetting
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Settings = settings
|
||||
};
|
||||
|
||||
PlayerSettings.Upsert(player); // ✅ SAFE
|
||||
}
|
||||
|
||||
|
||||
public static void SetPlayerSetting(string key, string value, ulong playerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || playerId == 0)
|
||||
return;
|
||||
|
||||
if (key is "SplitTestAssignedSegments" or "Growth.LastEmailPromptTime")
|
||||
return;
|
||||
|
||||
var col = PlayerSettings;
|
||||
|
||||
var player = col.FindById(playerId) ?? new PlayerSetting
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Settings = CreateDefaultSettings()
|
||||
};
|
||||
|
||||
var setting = player.Settings.FirstOrDefault(s => s.Key == key);
|
||||
if (setting != null)
|
||||
setting.Value = value;
|
||||
else
|
||||
player.Settings.Add(new Setting { Key = key, Value = value });
|
||||
|
||||
col.Upsert(player); // 🔥 KEY LINE
|
||||
}
|
||||
|
||||
/// <summary>Every step of the new-user / Orientation flow, marked done.</summary>
|
||||
private static readonly (string Key, string Value)[] OrientationDone =
|
||||
{
|
||||
("Recroom.AccountCreation.HasStarted", "true"),
|
||||
("Recroom.AccountCreation.AcceptedCodeOfConduct", "true"),
|
||||
("Recroom.AccountCreation.HasChosenUsername", "true"),
|
||||
("Recroom.AccountCreation.HasCreatedPassword", "true"),
|
||||
("Recroom.AccountCreation.HasImportedFriends", "true"),
|
||||
("Recroom.AccountCreation.FinishedAvatarCustomization", "true"),
|
||||
("Recroom.AccountCreation.HasFinished", "true"),
|
||||
// Every bit set = every tutorial/orientation step complete.
|
||||
("TUTORIAL_COMPLETE_MASK", "4294967295"),
|
||||
("Recroom.OOBE", "1"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Mark this player as having finished Orientation. One upsert, not nine - SetPlayerSetting
|
||||
/// reads and writes the whole settings document per call, which on a full backfill is a
|
||||
/// read-modify-write per key per account.
|
||||
/// </summary>
|
||||
public static bool MarkOrientationComplete(ulong playerId)
|
||||
{
|
||||
if (playerId == 0) return false;
|
||||
|
||||
var col = PlayerSettings;
|
||||
var player = col.FindById(playerId) ?? new PlayerSetting
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Settings = CreateDefaultSettings()
|
||||
};
|
||||
player.Settings ??= CreateDefaultSettings();
|
||||
|
||||
bool changed = false;
|
||||
foreach (var (key, value) in OrientationDone)
|
||||
{
|
||||
var setting = player.Settings.FirstOrDefault(s => s.Key == key);
|
||||
if (setting == null) { player.Settings.Add(new Setting { Key = key, Value = value }); changed = true; }
|
||||
else if (setting.Value != value) { setting.Value = value; changed = true; }
|
||||
}
|
||||
|
||||
if (changed) col.Upsert(player);
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill: mark Orientation complete for every account that already has a settings row.
|
||||
/// Accounts with no row inherit CreateDefaultSettings(), which already says complete.
|
||||
/// Returns how many rows were actually changed.
|
||||
/// </summary>
|
||||
public static int SkipOrientationForAllPlayers()
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var id in PlayerSettings.FindAll().Select(p => p.PlayerId).ToList())
|
||||
{
|
||||
try { if (MarkOrientationComplete(id)) n++; }
|
||||
catch (Exception ex) { Console.WriteLine($"[orientation] {id}: skip failed - {ex.Message}"); }
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
public static void DeletePlayerKey(string key, ulong playerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || playerId == 0)
|
||||
return;
|
||||
|
||||
var col = PlayerSettings;
|
||||
|
||||
var player = col.FindById(playerId);
|
||||
if (player == null || player.Settings == null)
|
||||
return;
|
||||
|
||||
player.Settings.RemoveAll(s => s.Key == key);
|
||||
|
||||
col.Upsert(player);
|
||||
}
|
||||
|
||||
public static string? GetPlayerSettingValue(string key, ulong playerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || playerId == 0)
|
||||
return null;
|
||||
|
||||
// ignored keys
|
||||
if (key == "SplitTestAssignedSegments")
|
||||
return null;
|
||||
|
||||
if (key == "Growth.LastEmailPromptTime")
|
||||
return null;
|
||||
|
||||
var settings = LoadSettings(playerId);
|
||||
var setting = settings.Find(s => s.Key == key);
|
||||
|
||||
return setting?.Value;
|
||||
}
|
||||
|
||||
public static void SetPlayerSettingFromJson(string jsonData, ulong playerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jsonData) || playerId == 0)
|
||||
return;
|
||||
|
||||
Setting? setting;
|
||||
try
|
||||
{
|
||||
setting = JsonConvert.DeserializeObject<Setting>(jsonData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to parse setting JSON: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (setting == null || string.IsNullOrEmpty(setting.Key) || setting.Key == "SplitTestAssignedSegments")
|
||||
return;
|
||||
|
||||
SetPlayerSetting(setting.Key, setting.Value ?? "", playerId);
|
||||
}
|
||||
|
||||
public static void DeleteSettings(ulong playerId)
|
||||
{
|
||||
if (playerId == 0) return;
|
||||
|
||||
var col = PlayerSettings;
|
||||
col.DeleteMany(x => x.PlayerId == playerId);
|
||||
}
|
||||
|
||||
public static List<Setting> CreateDefaultSettings() => new List<Setting>
|
||||
{
|
||||
new Setting { Key = "MOD_BLOCKED_TIME", Value = "0" },
|
||||
new Setting { Key = "MOD_BLOCKED_DURATION", Value = "0" },
|
||||
new Setting { Key = "PlayerSessionCount", Value = "0" },
|
||||
new Setting { Key = "ShowRoomCenter", Value = "1" },
|
||||
new Setting { Key = "QualitySettings", Value = "1" },
|
||||
new Setting { Key = "Recroom.OOBE", Value = "1" },
|
||||
new Setting { Key = "VoiceFilter", Value = "0" },
|
||||
new Setting { Key = "VIGNETTED_TELEPORT_ENABLED", Value = "0" },
|
||||
new Setting { Key = "CONTINUOUS_ROTATION_MODE", Value = "0" },
|
||||
new Setting { Key = "ROTATION_INCREMENT", Value = "0" },
|
||||
new Setting { Key = "ROTATE_IN_PLACE_ENABLED", Value = "0" },
|
||||
new Setting { Key = "TeleportBuffer", Value = "0" },
|
||||
new Setting { Key = "VoiceChat", Value = "1" },
|
||||
new Setting { Key = "PersonalBubble", Value = "0" },
|
||||
new Setting { Key = "ShowNames", Value = "1" },
|
||||
new Setting { Key = "H.264 plugin", Value = "1" },
|
||||
// Mark account creation as fully done so the client doesn't get stuck in first-boot
|
||||
// flows that expect Steam / Meta OAuth pages we don't serve.
|
||||
new Setting { Key = "Recroom.AccountCreation.HasStarted", Value = "true" },
|
||||
new Setting { Key = "Recroom.AccountCreation.HasFinished", Value = "true" },
|
||||
new Setting { Key = "Recroom.AccountCreation.HasChosenUsername", Value = "true" },
|
||||
new Setting { Key = "Recroom.AccountCreation.HasCreatedPassword", Value = "true" },
|
||||
// The client re-enters the flow on ANY step that reads false, so the three that were
|
||||
// missing here have to be stated too - not left to whatever the client defaults to.
|
||||
new Setting { Key = "Recroom.AccountCreation.AcceptedCodeOfConduct", Value = "true" },
|
||||
new Setting { Key = "Recroom.AccountCreation.HasImportedFriends", Value = "true" },
|
||||
new Setting { Key = "Recroom.AccountCreation.FinishedAvatarCustomization", Value = "true" },
|
||||
// Every bit set = every tutorial/orientation step marked complete. Prevents the
|
||||
// client from trying to load SocialOrientation, which currently loops → logout.
|
||||
new Setting { Key = "TUTORIAL_COMPLETE_MASK", Value = "4294967295" },
|
||||
new Setting { Key = "BACKPACK_FAVORITE_TOOL", Value = "-1" }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
/// <summary>
|
||||
/// Website-only per-account preferences: the nickname a player gives a friend and which
|
||||
/// friends are pinned to their home page. Neither exists in the game, so they live here
|
||||
/// (Data/DBs/SitePrefs.db) instead of on the relationship row, keyed by the OWNER's account.
|
||||
/// </summary>
|
||||
public static class SitePrefsDB
|
||||
{
|
||||
private static readonly Lazy<LiteDatabase> _db = new(() =>
|
||||
new LiteDatabase(Path.Combine(Environment.CurrentDirectory, "Data", "DBs", "SitePrefs.db")));
|
||||
|
||||
public class Prefs
|
||||
{
|
||||
[BsonId] public ulong AccountId { get; set; }
|
||||
public Dictionary<string, string> Nicknames { get; set; } = new(); // other account id -> nickname
|
||||
public List<ulong> Pinned { get; set; } = new();
|
||||
}
|
||||
|
||||
private static ILiteCollection<Prefs> Col() => _db.Value.GetCollection<Prefs>("prefs");
|
||||
|
||||
public static Prefs Get(ulong accountId) => Col().FindById(accountId) ?? new Prefs { AccountId = accountId };
|
||||
|
||||
public static string? NicknameFor(ulong owner, ulong other) =>
|
||||
Get(owner).Nicknames.TryGetValue(other.ToString(), out var n) && !string.IsNullOrWhiteSpace(n) ? n : null;
|
||||
|
||||
public static bool IsPinned(ulong owner, ulong other) => Get(owner).Pinned.Contains(other);
|
||||
|
||||
public static string? SetNickname(ulong owner, ulong other, string? nickname)
|
||||
{
|
||||
var p = Get(owner);
|
||||
nickname = (nickname ?? "").Trim();
|
||||
if (nickname.Length > 24) nickname = nickname[..24];
|
||||
if (nickname.Length == 0) p.Nicknames.Remove(other.ToString()); else p.Nicknames[other.ToString()] = nickname;
|
||||
Col().Upsert(p);
|
||||
return nickname.Length == 0 ? null : nickname;
|
||||
}
|
||||
|
||||
public static bool SetPinned(ulong owner, ulong other, bool on)
|
||||
{
|
||||
var p = Get(owner);
|
||||
p.Pinned.Remove(other);
|
||||
if (on) { p.Pinned.Insert(0, other); if (p.Pinned.Count > 12) p.Pinned = p.Pinned.Take(12).ToList(); }
|
||||
Col().Upsert(p);
|
||||
return on;
|
||||
}
|
||||
|
||||
/// <summary>Drop a friend's nickname and pin when the friendship ends.</summary>
|
||||
public static void Forget(ulong owner, ulong other)
|
||||
{
|
||||
var p = Col().FindById(owner);
|
||||
if (p == null) return;
|
||||
bool changed = p.Nicknames.Remove(other.ToString()) | p.Pinned.Remove(other);
|
||||
if (changed) Col().Update(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
/// <summary>
|
||||
/// Website support tickets. A signed-in player opens a ticket (from the Support page bot's
|
||||
/// "talk to a real person" flow); staff (dev / mod / owner) see EVERY ticket and can reply,
|
||||
/// close and reopen. Stored in its own LiteDB file; checkpointed by DbMaintenance.
|
||||
/// </summary>
|
||||
public static class SupportDB
|
||||
{
|
||||
private static readonly LiteDatabase DB =
|
||||
new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/Support.db");
|
||||
|
||||
public static void Checkpoint() => DB.Checkpoint();
|
||||
|
||||
private static ILiteCollection<Ticket> Tickets => DB.GetCollection<Ticket>("tickets");
|
||||
|
||||
static SupportDB()
|
||||
{
|
||||
Tickets.EnsureIndex(x => x.AccountId);
|
||||
Tickets.EnsureIndex(x => x.Status);
|
||||
}
|
||||
|
||||
public sealed class TicketMessage
|
||||
{
|
||||
public ulong FromAccountId { get; set; }
|
||||
public string FromName { get; set; } = "";
|
||||
public bool Staff { get; set; }
|
||||
public string Body { get; set; } = "";
|
||||
public DateTime At { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class Ticket
|
||||
{
|
||||
public int Id { get; set; } // LiteDB auto-increment PK
|
||||
public ulong AccountId { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public string Subject { get; set; } = "";
|
||||
public string Status { get; set; } = "open"; // "open" | "closed"
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public List<TicketMessage> Messages { get; set; } = new();
|
||||
}
|
||||
|
||||
public static Ticket Create(ulong accountId, string username, string subject, string body)
|
||||
{
|
||||
var t = new Ticket
|
||||
{
|
||||
AccountId = accountId,
|
||||
Username = username,
|
||||
Subject = string.IsNullOrWhiteSpace(subject) ? "Support request" : subject,
|
||||
Status = "open",
|
||||
};
|
||||
t.Messages.Add(new TicketMessage { FromAccountId = accountId, FromName = username, Staff = false, Body = body });
|
||||
Tickets.Insert(t); // Id auto-assigned
|
||||
return t;
|
||||
}
|
||||
|
||||
public static Ticket? Get(int id) => Tickets.FindById(id);
|
||||
|
||||
public static List<Ticket> ForAccount(ulong accountId) =>
|
||||
Tickets.Find(x => x.AccountId == accountId).OrderByDescending(x => x.UpdatedAt).ToList();
|
||||
|
||||
public static List<Ticket> All(string? status)
|
||||
{
|
||||
var rows = status == "open" ? Tickets.Find(x => x.Status == "open")
|
||||
: status == "closed" ? Tickets.Find(x => x.Status == "closed")
|
||||
: Tickets.FindAll();
|
||||
return rows.OrderByDescending(x => x.UpdatedAt).ToList();
|
||||
}
|
||||
|
||||
public static bool AddMessage(int id, ulong fromId, string fromName, bool staff, string body)
|
||||
{
|
||||
var t = Tickets.FindById(id);
|
||||
if (t == null) return false;
|
||||
t.Messages.Add(new TicketMessage { FromAccountId = fromId, FromName = fromName, Staff = staff, Body = body });
|
||||
t.UpdatedAt = DateTime.UtcNow;
|
||||
// A reply on a closed ticket reopens it, so nobody's follow-up is lost in a closed thread.
|
||||
if (t.Status == "closed") t.Status = "open";
|
||||
Tickets.Update(t);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SetStatus(int id, string status)
|
||||
{
|
||||
var t = Tickets.FindById(id);
|
||||
if (t == null) return false;
|
||||
t.Status = status;
|
||||
t.UpdatedAt = DateTime.UtcNow;
|
||||
Tickets.Update(t);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static int OpenCount() => Tickets.Count(x => x.Status == "open");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Campus_live_rewrite.api.dbs
|
||||
{
|
||||
/// <summary>
|
||||
/// Item wishlists (POST /api/itemWishlists/v1/wishlist/add|remove). The client's add 405'd
|
||||
/// (only the reads existed); the row is recorded here so the add answers and the panel can
|
||||
/// read it back. The wishlist DTO the client renders is not pinned yet, so the reads stay
|
||||
/// empty until it is (see the wishlist routes) - nothing is lost, the rows are here.
|
||||
/// </summary>
|
||||
public static class WishlistDB
|
||||
{
|
||||
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(Path.Combine("Data", "DBs", "Wishlists.db")));
|
||||
|
||||
public class Row
|
||||
{
|
||||
[BsonId] public string Key { get; set; } = ""; // "{account}|{storefront}|{item}"
|
||||
public ulong AccountId { get; set; }
|
||||
public int StorefrontType { get; set; }
|
||||
public int PurchasableItemId { get; set; }
|
||||
// A UGC (custom avatar item) wish: PurchasableItemId is -1 and this is the item's GUID.
|
||||
public string? CustomAvatarItemId { get; set; }
|
||||
public string RawBody { get; set; } = "";
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static ILiteCollection<Row> Col()
|
||||
{
|
||||
var c = _db.Value.GetCollection<Row>("items");
|
||||
c.EnsureIndex(x => x.AccountId);
|
||||
return c;
|
||||
}
|
||||
|
||||
private static string KeyFor(ulong account, int item, string? custom) =>
|
||||
string.IsNullOrWhiteSpace(custom) ? $"{account}|{item}" : $"{account}|ugc|{custom.ToLowerInvariant()}";
|
||||
|
||||
/// <summary>Record a wish; returns the row (existing or new) so the add can answer it.</summary>
|
||||
public static Row Add(ulong account, int storefront, int item, string? custom, string raw)
|
||||
{
|
||||
var key = KeyFor(account, item, custom);
|
||||
try
|
||||
{
|
||||
var existing = Col().FindById(key);
|
||||
if (existing != null) return existing;
|
||||
var row = new Row { Key = key, AccountId = account, StorefrontType = storefront, PurchasableItemId = item, CustomAvatarItemId = string.IsNullOrWhiteSpace(custom) ? null : custom, RawBody = raw };
|
||||
Col().Upsert(row);
|
||||
return row;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[wishlist] add failed: {ex.Message}");
|
||||
return new Row { Key = key, AccountId = account, StorefrontType = storefront, PurchasableItemId = item, CustomAvatarItemId = custom };
|
||||
}
|
||||
}
|
||||
|
||||
public static void Remove(ulong account, int item, string? custom = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(custom))
|
||||
{
|
||||
string c = custom.ToLowerInvariant();
|
||||
Col().DeleteMany(x => x.AccountId == account && x.CustomAvatarItemId != null && x.CustomAvatarItemId.ToLower() == c);
|
||||
}
|
||||
else Col().DeleteMany(x => x.AccountId == account && x.PurchasableItemId == item && x.CustomAvatarItemId == null);
|
||||
}
|
||||
catch (Exception ex) { Console.WriteLine($"[wishlist] remove failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>The wire entry (real RecNet: WishlistItemId guid, AccountId, PurchasableItemId, CreatedAt;
|
||||
/// the 2025 client adds CustomAvatarItemId for UGC wishes). The guid is derived from the key so it is stable.</summary>
|
||||
public static Guid WireId(Row r)
|
||||
{
|
||||
using var md5 = System.Security.Cryptography.MD5.Create();
|
||||
return new Guid(md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes("wish|" + r.Key)));
|
||||
}
|
||||
|
||||
public static object ToWire(Row r)
|
||||
{
|
||||
Guid id = WireId(r);
|
||||
return new
|
||||
{
|
||||
WishlistItemId = id,
|
||||
AccountId = r.AccountId,
|
||||
PurchasableItemId = r.PurchasableItemId,
|
||||
CustomAvatarItemId = r.CustomAvatarItemId,
|
||||
CreatedAt = r.CreatedAt
|
||||
};
|
||||
}
|
||||
public static List<object> WireFor(ulong account) => For(account).Select(ToWire).ToList();
|
||||
|
||||
public static List<Row> For(ulong account) => Col().Find(x => x.AccountId == account).OrderBy(x => x.CreatedAt).ToList();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
}
|
||||
+111
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
namespace Campus_live_rewrite.api.server.Classes.db_class
|
||||
{
|
||||
public class ClubDBClasses
|
||||
{
|
||||
}
|
||||
}
|
||||
+134
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -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
|
||||
}
|
||||
}
|
||||
+101
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+552
@@ -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,
|
||||
}
|
||||
}
|
||||
+283
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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 <code>` 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<->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
|
||||
/// <genre>_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":"<64 chars>","AppId":"1295523863648168","Source":"logged in user"}
|
||||
/// platform_id = <the Meta user id>
|
||||
/// 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
Reference in New Issue
Block a user