the files

This commit is contained in:
niko
2026-10-14 06:59:51 +02:00
parent f6175893fc
commit 2040419525
1855 changed files with 6735668 additions and 0 deletions
@@ -0,0 +1,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,87 @@
namespace Campus_live_rewrite.api.server.Classes
{
/// <summary>
/// Storefront ids, taken from the 2025 client's own enum (PNOBDCBDLNP, build 20250718.01).
/// /api/storefronts/v3/giftdropstore/{id} uses these, and buyItem sends "StorefrontType": N.
///
/// This used to be the 2023 client's list, which numbers everything from 1400 up
/// differently: 2000-2004 were called PopUpShop_1..5 there, but in 2025 they are the
/// PROGRESSION-EVENT pop-up shops, and the real pop-up shops are 1700 up. (Source:
/// skills-main wire-shapes giftdropstore-storefront-type.md, carved from the 2025 metadata.)
/// </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,
RoomCurrencies = 1200,
Wishlist = 1300,
Friendotron = 1400,
RoomConsumables = 1500,
RecRally = 1600,
// The pop-up shop kiosks a room can place. A room asks for each one it has while
// loading, so an unstocked one must answer promptly with an empty store.
PopUpShop_1 = 1700,
PopUpShop_2 = 1701,
PopUpShop_3 = 1702,
PopUpShop_4 = 1703,
PopUpShop_5 = 1704,
PopUpShop_6 = 1705,
PopUpShop_7 = 1706,
PopUpShop_8 = 1707,
PopUpShop_9 = 1708,
PopUpShop_10 = 1709,
PopUpShop_11 = 1710,
PopUpShop_12 = 1711,
PopUpShop_13 = 1712,
PopUpShop_14 = 1713,
PopUpShop_15 = 1714,
PopUpShop_16 = 1715,
PopUpShop_17 = 1716,
PopUpShop_18 = 1717,
PopUpShop_19 = 1718,
PopUpShop_20 = 1719,
CustomAvatarItems = 1800,
ProgressionEventPurchasableXpBoosts = 1900,
// The Limited Time Challenge's shops (the Dorm Mirror customize screen asks for these).
PopUpShop_ProgressionEvent_1 = 2000,
PopUpShop_ProgressionEvent_2 = 2001,
PopUpShop_ProgressionEvent_3 = 2002,
PopUpShop_ProgressionEvent_4 = 2003,
PopUpShop_ProgressionEvent_5 = 2004,
RoomOffers = 2100,
MyLittleMonsters = 2200,
Destiny = 2201,
RunTheBlock = 2202,
}
}
@@ -0,0 +1,9 @@
namespace Campus_live_rewrite.api.server.Classes
{
public class Sub
{
public ulong creatorAccountId { get; set; }
public ulong clubId { get; set; }
public int membershipType { get; set; }
}
}
@@ -0,0 +1,9 @@
namespace Campus_live_rewrite.api.server.Classes
{
public class SuccessResponse
{
public bool success { get; set; }
public object? value { get; set; }
}
}
@@ -0,0 +1,26 @@
namespace Campus_live_rewrite.api.server.Classes
{
public class VersionCheck
{
public VersionStatus ValidVersion { get; set; }
public VersionStatus VersionStatus { get; set; }
public UpdateNoti UpdateNotificationStage { get; set; }
public bool IsVersionIslanded { get; set; }
public bool IsCrossPlayDisabled { get; set; }
}
public enum VersionStatus
{
ValidForPlay,
UpdateRequired
}
public enum UpdateNoti
{
None,
Silent,
Warn,
Prompt,
Require
}
}
@@ -0,0 +1,111 @@
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class ChatDBClasses
{
public class ChatThread
{
public List<ChatMessage> Messages { get; set; }
public long ChatThreadId { get; set; }
public List<ulong> PlayerIds { get; set; }
public long LastReadMessageId { get; set; }
public string ChatThreadName { get; set; }
public DateTime? SnoozedUntil { get; set; }
public bool IsFavorited { get; set; }
// 0 = a normal DM / group thread, 1 = a PARTY thread (the party's chat; the client
// finds it through /chat/thread/party and shows it in-room).
public int ChatThreadType { get; set; } = 0;
// Per-member read pointer / snooze / favorite. These are PER PLAYER in the real
// service; the three thread-level fields above are the pre-existing shared ones and
// are only read when a player has no entry here yet.
public Dictionary<string, ChatMemberState> MemberStates { get; set; } = new();
// Who created the party thread (party leader) - only meaningful for ChatThreadType 1.
public ulong CreatorPlayerId { get; set; }
// Nullable on purpose: a thread stored before these existed deserialises with null
// (an initialiser would stamp "now" on every old thread and sort them above new ones).
public DateTime? CreatedAt { get; set; }
public DateTime? LastActivityAt { get; set; }
}
public class ChatMemberState
{
public long LastReadMessageId { get; set; }
public DateTime? SnoozedUntil { get; set; }
public bool IsFavorited { get; set; }
}
public class ChatMessage
{
public long ChatMessageId { get; set; }
public long ChatThreadId { get; set; }
public int SenderPlayerId { get; set; }
public DateTime TimeSent { get; set; }
public string Contents { get; set; }
public int ModerationState { get; set; } // todo find the enum
}
public class MessageJson
{
public MessageContentType Type { get; set; }
public int Version { get; set; } // default ver is 2
public string Data { get; set; }
}
public struct SendMessageResponse
{
public ChatResults ChatResult { get; set; }
public ChatThread ChatThread { get; set; }
}
public class ChatPrivacySettings
{
public ulong playerId { get; set; }
public ChatPrivacy directMessagePrivacySetting { get; set; } = ChatPrivacy.Friends;
public ChatPrivacy groupChatPrivacySetting { get; set; } = ChatPrivacy.Friends;
}
public enum ChatResults
{
Success,
InvalidArguments,
ThreadNotFound,
MembershipNotFound,
PlayerAlreadyOnThread,
CannotMessagePlayer,
InvalidCharacters,
RecentlyLeftThread,
ThreadTooLarge
}
public enum ModerationState : byte
{
Active,
Junior_Pending = 11,
Moderation_Pending = 100,
Moderation_Closed,
Moderation_Banned,
MarkedForDelete = 255
}
public enum QueryMode
{
Latest,
NewerThan,
OlderThan
}
public enum MessageContentType
{
Text,
PartyInvite,
Photo
}
public enum ChatPrivacy
{
Friends,
Favorites,
NoOne
}
}
}
@@ -0,0 +1,6 @@
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class ClubDBClasses
{
}
}
@@ -0,0 +1,134 @@
using LiteDB;
using Microsoft.AspNetCore.Http.HttpResults;
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class EventDBClasses
{
public class CreateEventRequest
{
// {"RoomId":708,"SubRoomId":1444,"ClubId":null,"name":"awdadwdawdadwdawdadwd","Description":"awdadwdawdadwdawdadwdawdadwdawdadwdawdadwdawdadwdawdadwdawdadwdawdadwd","Tags":["grandopening"],"ImageName":"J9W8A0FUGWXG4CMBKFDJ46CUZHJOHP.png","StartTime":"2025-12-13T14:20:41.6244242Z","EndTime":"2025-12-13T14:50:41.6244242Z","Accessibility":1,"IsMultiInstance":false,"SupportMultiInstanceRoomChat":false,"DefaultBroadcastPermissions":0,"CanRequestBroadcastPermissions":0}
public ulong RoomId { get; set; }
public ulong? SubRoomId { get; set; }
public ulong? ClubId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public List<string>? Tags { get; set; }
public string? ImageName { get; set; }
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public PlayerEventAccessibility Accessibility { get; set; }
public bool IsMultiInstance { get; set; } // this is just multi instance room
public bool SupportMultiInstanceRoomChat { get; set; } // this let player talk betweeen room instance using room chat
public int DefaultBroadcastPermissions { get; set; } // idk what this is used for
public int CanRequestBroadcastPermissions { get; set; } // idk what this is used for
}
public class FullPlayerEvent
{
public List<FullTag>? Tags { get; set; }
public List<FullEventResponses>? Responses { get; set; }
[BsonId]
public ulong PlayerEventId { get; set; }
public ulong? CreatorPlayerId { get; set; }
public string? ImageName { get; set; }
public ulong RoomId { get; set; }
public ulong? SubRoomId { get; set; }
public ulong? ClubId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public int AttendeeCount { get; set; }
public int State { get; set; }
public PlayerEventAccessibility Accessibility { get; set; }
public bool IsMultiInstance { get; set; } // this is just multi instance room
public bool SupportMultiInstanceRoomChat { get; set; } // this let player talk betweeen room instance using room chat
public BroadcastPermissionsEnum DefaultBroadcastPermissions { get; set; } // idk what this is used for
public BroadcastPermissionsEnum CanRequestBroadcastPermissions { get; set; } // idk what this is used for
public ulong BroadcastingRoomInstanceId { get; set; }
public DateTime? RecurrenceSchedule { get; set; }
}
public class FullTag
{
public string? Tag { get; set; }
public TagType Type { get; set; }
}
public class FullEventResponses
{
public ulong PlayerEventResponseId { get; set; }
public ulong PlayerEventId { get; set; }
public ulong PlayerId { get; set; }
public DateTime CreatedAt { get; set; }
public PlayerEventResponseType Type { get; set; } = PlayerEventResponseType.None;
}
public enum PlayerEventResponseType
{
None = -1,
Yes,
Interested,
No,
Pending
}
public enum BroadcastPermissionsEnum
{
None,
RoomOwners = 256,
All = 2147483647
}
public enum PlayerEventAccessibility
{
Private,
Public,
Unlisted
}
public enum TagType
{
Unknown1 = 0,
Unknown2 = 1,
Unknown3 = 2
}
public enum EventResults
{
Success,
HasModeratorClosedEvent,
DoesNotExist,
PlayerDoesNotExist,
RoomDoesNotExist,
StatusUnchanged,
PrivateEvent,
SomethingWentWrong,
DoesNotOwnRoom,
ResponseDoesNotExist,
PlayerAlreadyInvited,
EventDatesInvalid,
EventTooLong,
EventTooShort,
InappropriateName,
InappropriateDescription,
SomeInvitesFailed,
CannotInviteJunior,
EventCountLimitReached,
DoesNotOwnEvent,
UnregisteredOrJuniorNotAllowed,
InvalidClubPermissions,
ImageDoesNotExist,
SubRoomDoesNotExist,
DoesNotOwnSubRoom,
ModifyTagsFailed,
RoomCapacityTooLow,
BroadcastEventNotMultiInstance,
PlayerNotAllowedToCreateMultiInstanceEvents,
PlayerBannedFromEventCreation,
EventIsModerationClosed,
EventIsModerationPendingReview
}
}
}
@@ -0,0 +1,61 @@
using System;
using LiteDB;
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class YourFriendData
{
[BsonId]
public ObjectId? Id { get; set; }
public ulong PlayerID { get; set; }
public RelationshipDetail Relationship { get; set; }
}
public class FriendData
{
[BsonId]
public ObjectId? Id { get; set; }
public string? FriendId { get; set; }
public RelationshipType Relationship { get; set; }
public FriendInfo FriendFlags { get; set; }
public ReciprocalStatus Status { get; set; }
}
public class RelationshipDetail
{
public ulong Id { get; set; }
public ulong PlayerID { get; set; }
public ulong OtherPlayerID { get; set; }
public RelationshipType RelationshipType { get; set; }
public ReciprocalStatus Muted { get; set; }
public ReciprocalStatus Ignored { get; set; }
public ReciprocalStatus Favorited { get; set; }
public int VoiceVolume { get; set; } = 100;
}
public enum RelationshipType
{
None,
FriendRequestSent,
FriendRequestReceived,
Friend,
}
[Flags]
public enum FriendInfo
{
None = 0,
Favorited = 1,
Muted = 2,
Ignored = 4,
Blocked = 5
}
public enum ReciprocalStatus
{
None,
Local,
Remote,
Mutual
}
}
@@ -0,0 +1,101 @@
using System.Text.Json.Serialization;
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class LeaderboardDBClasses
{
public class GetNearbyScoresRequest
{
// {"PlayerId":2,"StatChannel":2,"RoomId":29,"FilterType":1,"SortAscending":false,"WindowSize":10}
public ulong PlayerId { get; set; }
public int StatChannel { get; set; }
public ulong RoomId { get; set; }
public FilterTypeEnum FilterType { get; set; }
public bool SortAscending { get; set; }
public int WindowSize { get; set; }
}
public class GetRanksRequest
{
// {"PlayerId":2,"StatChannel":2,"RoomId":29,"FilterType":0,"SortAscending":false,"RankStart":0,"RankEnd":9}
public ulong PlayerId { get; set; }
public int StatChannel { get; set; }
public ulong RoomId { get; set; }
public FilterTypeEnum FilterType { get; set; }
public bool SortAscending { get; set; }
public int RankStart { get; set; } // skip
public int RankEnd { get; set; } // take
}
public class CheckAndSetStatRequest
{
// {"StatChannel":2,"RoomId":29,"StatValue":1,"CurrentStatValue":null}
public int StatChannel { get; set; }
public ulong RoomId { get; set; }
public int StatValue { get; set; }
public int? CurrentStatValue { get; set; }
}
public class Entry
{
[Newtonsoft.Json.JsonIgnore] // serialized with Newtonsoft (JsonConvert); STJ's JsonIgnore is ignored there and leaked Id/StatChannel/RoomId
public int Id { get; set; } // LiteDB PK
public int PlayerId { get; set; }
[Newtonsoft.Json.JsonIgnore] // serialized with Newtonsoft (JsonConvert); STJ's JsonIgnore is ignored there and leaked Id/StatChannel/RoomId
public int StatChannel { get; set; }
[Newtonsoft.Json.JsonIgnore] // serialized with Newtonsoft (JsonConvert); STJ's JsonIgnore is ignored there and leaked Id/StatChannel/RoomId
public long RoomId { get; set; }
public int Score { get; set; }
public int Rank { get; set; }
// LeaderboardGuard bookkeeping. Not on the wire.
[Newtonsoft.Json.JsonIgnore] public DateTime UpdatedAt { get; set; }
[Newtonsoft.Json.JsonIgnore] public DateTime DayGainDate { get; set; }
[Newtonsoft.Json.JsonIgnore] public long DayGain { get; set; }
}
public class SingleLeaderboard
{
public List<Entry> Rows { get; set; }
}
public class FullLeaderboard
{
public List<Entry> GlobalOverall { get; set; }
public List<Entry> GlobalPeriodic { get; set; }
public List<Entry> FriendsOverall { get; set; }
public List<Entry> FriendsPeriodic { get; set; }
public DateTime NextResetUTC { get; set; }
}
public enum RequestResult
{
Success,
InvalidStat,
RedisConnectionError
}
public enum FilterType
{
Global,
Friends
}
public enum Timeframe
{
AllTime,
TwoWeeks
}
public enum FilterTypeEnum
{
NearbyScoresGlobal,
NearbyScoresFriends,
Champions,
NUM_VALUES // wat
}
}
}
@@ -0,0 +1,562 @@
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; }
// Moderator locks on the rest of the profile (admin panel, Edit Player): the player can't
// change it themselves - in-game, on the website or through the Discord bot. Admin edits
// are never gated. See PlayerDB.IsProfileLocked.
public bool BioLocked { get; set; }
public bool PictureLocked { get; set; }
public bool BannerLocked { 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; }
// MIC BAN (token claim rn.privilege BanVChat - see PlayerDB "MIC BAN / ROOM-CHAT BAN").
// Kept apart from ModerationBlockDetails so it never overwrites a real ban/kick (or the
// reverse) and never counts as "banned" for login/matchmaking.
public Moderation_Detail.ModerationBlockDetails? VoiceBan { 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)
studio = 512, // CampusStudio: may publish recoloured items from the app (StudioController)
unused_4 = 1024,
unused_5 = 2048,
unused_6 = 4096,
unused_7 = 8192,
}
}
@@ -0,0 +1,302 @@
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
}
/// <summary>Writes JSON null whatever the value is (reads normally).</summary>
public class NeverSentToClient : Newtonsoft.Json.JsonConverter
{
public override bool CanRead => false;
public override bool CanConvert(Type objectType) => true;
public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) => writer.WriteNull();
public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) => throw new NotSupportedException();
}
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; }
// Set from the admin panel's "Dev room" tick (RoomDB.SetDevRoom): it is what keeps IsDeveloperOwned on
// a player-made room, because FixPlayerRoomFlags clears that flag at every start. Server-side only.
[Newtonsoft.Json.JsonIgnore]
public bool IsDevRoom { 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; }
// The room-wide "last ban" record, kept in the db for the admin tools but NEVER sent to the game. The
// client reads this key as THE VIEWER's own ban on the room and shows "Unable to Join Room - you have
// been banned" from it; sent to everyone, one ban made the room look banned to every player who opened
// it (and any room with a ban in its history, which is why it seemed to happen everywhere). The ban
// itself is enforced at join (BannedFromRoom), so nothing is lost by sending null.
[Newtonsoft.Json.JsonConverter(typeof(NeverSentToClient))]
public RoomBanDetails RoomBanDetails { get; set; }
// Every ban ever issued on this room (active, elapsed and lifted). RoomBanDetails above
// is the single "current" record older code reads; this is the real list. Not part of
// the room DTO the client gets.
[Newtonsoft.Json.JsonIgnore]
public List<RoomBanDetails>? RoomBans { get; set; }
[BsonId]
public ulong RoomId { get; set; }
public RoomState State { get; set; }
public Stats Stats { get; set; }
public List<SubRoom> SubRooms { get; set; }
public bool SupportsJuniors { get; set; }
public bool SupportsLevelVoting { get; set; }
public bool SupportsMobile { get; set; }
public bool SupportsQuest2 { get; set; }
public bool SupportsScreens { get; set; }
public bool SupportsTeleportVR { get; set; }
public bool SupportsVRLow { get; set; }
public bool SupportsWalkVR { get; set; }
public List<TagClass> Tags { get; set; }
public bool ToxmodEnabled { get; set; }
public int? UgcSubVersion { get; set; }
public int? UgcVersion { get; set; }
public WarningMaskType WarningMask { get; set; }
}
public class LoadScreen
{
public string ImageName { get; set; }
public string Subtitle { get; set; }
public string Title { get; set; }
}
public class LocalizationContext
{
public List<string> LocalizedFields { get; set; }
public string Scope { get; set; }
public string TargetLocale { get; set; }
}
public class RoleClass
{
public long AccountId { get; set; }
public int InvitedRole { get; set; }
public long? LastChangedByAccountId { get; set; }
public RoomDBClasses.Role_data Role { get; set; }
}
public class RoomBanDetails
{
public long AccountId { get; set; }
public DateTime? BanEndTime { get; set; }
public DateTime BanStartTime { get; set; }
public long BannedByAccountId { get; set; }
public string Reason { get; set; }
public int Status { get; set; }
public long? UnbannedByAccountId { get; set; }
}
public class Stats
{
public int CheerCount { get; set; }
public int FavoriteCount { get; set; }
public int VisitCount { get; set; }
public int VisitorCount { get; set; }
}
public class SubRoom
{
[BsonId]
[Newtonsoft.Json.JsonIgnore]
[System.Text.Json.Serialization.JsonIgnore]
public ObjectId? Id { get; set; }
public CurrentSave? CurrentSave { get; set; }
[JsonIgnore]
public List<CurrentSave> AllSaves { get; set; } = new();
public RoomAccessibility Accessibility { get; set; }
public long? CreatorAccountId { get; set; }
public bool IsSandbox { get; set; }
public int LastModeratedSaveModerationState { get; set; }
public int MaxPlayers { get; set; }
public string Name { get; set; }
public ulong RoomId { get; set; }
public bool ShouldAutoStageSaves { get; set; }
public long? StagedSubRoomDataSaveId { get; set; }
public ulong SubRoomId { get; set; }
public string UnitySceneId { get; set; }
// The permission overrides a manager saved for THIS subroom (RoomPermissions). Not on
// the wire - the client reads them merged into GET /photon_access_token, never here.
[Newtonsoft.Json.JsonIgnore]
[System.Text.Json.Serialization.JsonIgnore]
public List<Campus_live_rewrite.api.server.RoomPermissions.Entry>? Permissions { get; set; }
}
public class CurrentSave
{
public DateTime CreatedAt { get; set; }
public string DataBlob { get; set; }
//public string DataBlobHash { get; set; }
public string Description { get; set; }
public int ModerationState { get; set; }
public int OMVersion { get; set; }
public int PersistenceVersion { get; set; }
public List<string> ReferencedUnityAssetIds { get; set; }
public ulong? SavedByAccountId { get; set; }
public DeviceClasses? SavedOnDeviceClass { get; set; }
public Platforms? SavedOnPlatform { get; set; }
public ulong SubRoomDataSaveId { get; set; }
public ulong SubRoomId { get; set; }
public int UgcSubVersion { get; set; }
public string UnityAssetId { get; set; } // optional, may be null
}
public class TagClass
{
public string Tag { get; set; }
public int Type { get; set; } // 0 owner-set, 1 client-derived (autoTag: limitsv2 / beta), 2 server-derived (rro)
// Present ONLY on the room's primary genre tag (never false) - the client keys on presence.
[Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public bool? IsPrimaryGenre { get; set; }
}
public class SubroomSaveResults
{
public List<CurrentSave> Results { get; set; } = new();
public int TotalCount { get; set; }
}
public class RoomFileData
{
public RoomData? RoomData { get; set; }
public SubRoomData? SubRoomData { get; set; }
public string? InventionUsage { get; set; }
public int PersistenceVersion { get; set; }
public int OMVersion { get; set; }
public int UgcSubVersion { get; set; }
public string? Description { get; set; }
public bool AutoPublish { get; set; }
}
public class RoomData
{
public string? Filename { get; set; }
public string? Hash { get; set; }
public string? OwnershipProof { get; set; }
}
public class SubRoomData
{
public string? Filename { get; set; }
public string? Hash { get; set; }
public string? OwnershipProof { get; set; }
}
public enum RoomAccessibility
{
Private,
Public,
Unlisted,
Dev_only,
Dev_Unlisted,
}
public enum RoomState
{
Active,
PendingJunior = 11,
Moderation_PendingReview = 100,
Moderation_Closed,
MarkedForDelete = 1000
}
public class Tags
{
public string? Tag { get; set; }
public int Type { get; set; }
}
public class LoadScreens
{
public string? ImageName { get; set; }
public string? Title { get; set; }
public string? Subtitle { get; set; }
}
public enum Role_data : byte
{
None,
Banned,
Host = 10,
Moderator = 20,
Contributor = 25, // the client's value (was missing -> a contributor invite landed as CoOwner)
CoOwner = 30,
TemporaryCoOwner,
Creator = 255
}
}
}
@@ -0,0 +1,17 @@
using LiteDB;
namespace Campus_live_rewrite.api.server.Classes.db_class
{
public class PlayerSetting
{
[BsonId]
public ulong PlayerId { get; set; }
public List<Setting> Settings { get; set; } = new List<Setting>();
}
public class Setting
{
public string? Key { get; set; }
public string? Value { get; set; }
}
}