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,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,114 @@
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 camprec.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);
}
/// <summary>The caller's account id when the request carries a valid GAME token, otherwise 0. Never an error:
/// the account lookups are open to anyone, and knowing the viewer only changes WHAT they are shown (their own
/// friend nicknames), never whether they are answered.</summary>
public static ulong OptionalViewerId(HttpContext httpContext)
{
try { var (a, err) = ValidateToken(httpContext); return err == null && a != null ? (ulong)a.Id : 0UL; }
catch { return 0UL; }
}
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);
}
/// <summary>Like ValidateTokenDev, but also accepts a mod-flagged (not just dev-flagged) account. Use
/// this ONLY on the specific endpoints mods are meant to reach (the admin page shell/script, Dashboard,
/// Players view, Reports, Ban Logs/moderation actions, Chat view) - every other admin endpoint keeps
/// calling ValidateTokenDev so a mod account gets exactly that slice, not the whole panel.</summary>
public static (player_info? account, ContentResult? error) ValidateTokenDevOrMod(HttpContext httpContext)
{
string? temp2 = httpContext.Request.Cookies["Authorization"];
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);
if (!PlayerDB.CheckDevOrModFlag(account.Id))
return (null, new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
});
return (account, null);
}
}
@@ -0,0 +1,35 @@
using Campus_live_rewrite.api.dbs;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Automatic enforcement for things only a tampered client does (a Maker Pen check the
/// server refuses, text that skipped the chat filter): the player is thrown out with a
/// timeout. A timeout is PlayerDB.Set_moderation_block with isBan=false - the moderation
/// frame makes the client leave, matchmaking refuses them until it runs out, and the
/// admin panel's Ban Logs show it (logKind "auto-timeout"). Their presence is cleared so
/// the heartbeat resync does not drop them straight back into the room. Staff are never
/// auto-actioned.
/// </summary>
public static class AutoEnforce
{
public static bool IsStaff(ulong id) => PlayerDB.CheckDevFlag(id) || PlayerDB.CheckModFlag(id);
public static void DisconnectAndTimeout(ulong playerId, Moderation_Detail.ReportCategory category, int seconds, string message, string note)
{
if (IsStaff(playerId))
{
Console.WriteLine($"[auto-enforce] {playerId} is staff - not actioned ({note})");
return;
}
try
{
PlayerDB.Set_moderation_block(playerId, category, Math.Max(1, seconds), 0, false, message, null, false, 0,
addAppealMessage: false, logKind: "auto-timeout", logNote: note);
HeartbeatDB.UpdatePlayerHeartbeat(playerId, null, online: false);
Console.WriteLine($"[auto-enforce] {playerId} disconnected + timed out {seconds}s: {note}");
}
catch (Exception ex) { Console.WriteLine($"[auto-enforce] could not action {playerId}: {ex.Message}"); }
}
}
}
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// A searchable in-memory index of Data/APIS/items/avatar_items.json - every avatar item that
/// exists, ~3.6k of them.
///
/// The admin Gifts search only ever looked at the STOREFRONT catalogs
/// (Data/APIS/GiftDropStores), which between them list a few thousand PURCHASABLE drops. Most
/// of the avatar catalog is not sold in any storefront, so those items simply could not be
/// found - searching "thief hat" returned the three that happened to be on the Watch store and
/// nothing else. This index is what makes the rest giftable.
///
/// Loaded once, lazily, and re-read if the file changes on disk (a Data deploy replaces it).
/// The file is ~1.5 MB; parsing it per keystroke would be absurd once the search box is live.
/// </summary>
public static class AvatarCatalog
{
public sealed class Entry
{
public string Desc = ""; // AvatarItemDesc, RAW - the client parses this
public string Name = "";
public int AvatarItemId;
public int AvatarItemType;
public int Rarity;
public string? Thumbnail;
}
private static List<Entry>? _items;
private static DateTime _stamp;
private static readonly object _lock = new();
private static string Path_ =>
Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "avatar_items.json");
public static IReadOnlyList<Entry> All()
{
try
{
string path = Path_;
if (!File.Exists(path))
{
if (_items == null) Console.WriteLine($"[catalog] {path} not found - gift search falls back to storefronts only");
return _items ?? (IReadOnlyList<Entry>)Array.Empty<Entry>();
}
var stamp = File.GetLastWriteTimeUtc(path);
if (_items != null && stamp == _stamp) return _items;
lock (_lock)
{
if (_items != null && stamp == _stamp) return _items;
var list = new List<Entry>();
foreach (var e in JArray.Parse(File.ReadAllText(path)))
{
string desc = (string?)e["AvatarItemDesc"] ?? "";
if (string.IsNullOrWhiteSpace(desc)) continue;
list.Add(new Entry
{
Desc = desc,
Name = (string?)e["FriendlyName"] ?? "",
AvatarItemId = (int?)e["AvatarItemId"] ?? 0,
AvatarItemType = (int?)e["AvatarItemType"] ?? 0,
Rarity = (int?)e["Rarity"] ?? 0,
Thumbnail = (string?)e["ThumbnailImage"]
});
}
_items = list;
_stamp = stamp;
Console.WriteLine($"[catalog] indexed {list.Count} avatar item(s) for the admin gift search");
return _items;
}
}
catch (Exception ex)
{
Console.WriteLine($"[catalog] index failed: {ex.Message}");
return _items ?? (IReadOnlyList<Entry>)Array.Empty<Entry>();
}
}
/// <summary>
/// Name or descriptor match, case-insensitive. Items whose name STARTS with the query come
/// first - typing "thief" should lead with "Thief Hat", not with everything that merely
/// contains the word - then the rest alphabetically so the order is stable between
/// keystrokes rather than jumping around as results stream in.
/// </summary>
public static List<Entry> Search(string query, int max)
{
if (string.IsNullOrWhiteSpace(query)) return new List<Entry>();
string q = query.Trim().ToLowerInvariant();
return All()
.Where(e => e.Name.ToLowerInvariant().Contains(q) || e.Desc.ToLowerInvariant().Contains(q))
.OrderBy(e => e.Name.ToLowerInvariant().StartsWith(q) ? 0 : 1)
.ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
.Take(Math.Clamp(max, 1, 500))
.ToList();
}
/// <summary>How many items match, ignoring the page cap - so the UI can say "showing 60 of 214".</summary>
public static int CountMatches(string query)
{
if (string.IsNullOrWhiteSpace(query)) return 0;
string q = query.Trim().ToLowerInvariant();
return All().Count(e => e.Name.ToLowerInvariant().Contains(q) || e.Desc.ToLowerInvariant().Contains(q));
}
/// <summary>Exact descriptor lookup (the id the gift form sends back for a catalog item).</summary>
public static Entry? ByDesc(string? desc)
{
if (string.IsNullOrWhiteSpace(desc)) return null;
string key = dbs.PlayerDB.NormalizeAvatarItemDesc(desc);
return All().FirstOrDefault(e =>
string.Equals(dbs.PlayerDB.NormalizeAvatarItemDesc(e.Desc), key, StringComparison.OrdinalIgnoreCase));
}
}
}
@@ -0,0 +1,85 @@
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Anti-farm throttle for the one currency-minting endpoint, POST /api/storefronts/v2/balance.
///
/// It answers Allow or Deny per award attempt, keyed on the player's LIVE room instance
/// (decided server-side from the heartbeat, never the request body):
///
/// * RATE LIMIT -> Deny. At most one award per player per BalanceRateLimitSeconds. A second
/// award that arrives sooner - e.g. the client glitching and firing twice within seconds -
/// simply doesn't go through. No currency, no ban: the player is not punished for a glitch.
///
/// * LONG SESSION -> Deny. A player parked in ONE instance past BalanceMaxInstanceMinutes, or
/// already awarded BalanceMaxAwardsPerInstance times in it, is refused further awards. The
/// counters reset the moment they move to a different room instance (legit players move;
/// farmers park).
///
/// All in-memory and self-pruning. No bans are issued here.
/// </summary>
public static class BalanceGuard
{
public enum Decision { Allow, Deny }
private sealed class State
{
public long InstanceId;
public DateTime FirstSeenUtc;
public DateTime LastAwardUtc;
public int Awards;
}
private static readonly object _lock = new();
private static readonly Dictionary<ulong, State> _state = new();
/// <summary>
/// Decide what to do with an award attempt BEFORE any currency is granted. On Allow the
/// attempt is recorded (advances the rate-limit clock and the per-instance total).
/// </summary>
public static (Decision decision, string reason) Evaluate(ulong playerId, long roomInstanceId)
{
if (!ConfigCampus.BalanceGuardEnabled)
return (Decision.Allow, "");
var now = DateTime.UtcNow;
lock (_lock)
{
// Fresh session whenever the player is in a different instance than last time
// (or we have never seen them) - this is what resets the long-session counters.
if (!_state.TryGetValue(playerId, out var st) || st.InstanceId != roomInstanceId)
{
st = new State { InstanceId = roomInstanceId, FirstSeenUtc = now, LastAwardUtc = DateTime.MinValue };
_state[playerId] = st;
}
// RATE LIMIT -> deny (the glitch-guard). Not an award, so nothing is recorded.
double sinceLast = (now - st.LastAwardUtc).TotalSeconds;
if (sinceLast < Math.Max(1, ConfigCampus.BalanceRateLimitSeconds))
{
return (Decision.Deny,
$"rate-limited: {sinceLast:F0}s since last award (< {ConfigCampus.BalanceRateLimitSeconds}s) in instance {roomInstanceId}");
}
// LONG SESSION -> deny.
double mins = (now - st.FirstSeenUtc).TotalMinutes;
if (mins >= ConfigCampus.BalanceMaxInstanceMinutes ||
st.Awards >= ConfigCampus.BalanceMaxAwardsPerInstance)
{
return (Decision.Deny,
$"parked in instance {roomInstanceId} for {mins:F0} min / {st.Awards} awards - denying");
}
// Allow: record the award.
st.LastAwardUtc = now;
st.Awards++;
return (Decision.Allow, "");
}
}
/// <summary>Forget a player's live session (e.g. on disconnect).</summary>
public static void ClearSession(ulong playerId)
{
lock (_lock) { _state.Remove(playerId); }
}
}
}
@@ -0,0 +1,150 @@
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; }
});
}
private static readonly string QuestRoot = Path.Combine(Root, "quest");
private static readonly ConcurrentDictionary<string, string?> _questHashes = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// base64 sha256 of the QUEST (Android) conversion of this bundle, AvatarBlobs/quest/&lt;name&gt;,
/// or null when we don't hold it. The client hash-checks what it downloads, so a Quest client
/// sent quest/&lt;name&gt; must also get this hash, not the PC file's (QuestBundleMiddleware):
/// with the PC hash it downloads the file, rejects it, retries and gives up (2026-09-26).
/// </summary>
public static string? OfQuest(string? fileName)
{
if (string.IsNullOrWhiteSpace(fileName) || fileName.Contains('/') || fileName.Contains(Path.DirectorySeparatorChar)) return null;
return _questHashes.GetOrAdd(fileName, name =>
{
var path = Path.Combine(QuestRoot, name);
if (!File.Exists(path)) return null;
try
{
using var fs = File.OpenRead(path);
return Convert.ToBase64String(SHA256.HashData(fs));
}
catch (Exception ex) { Console.WriteLine($"[bundlehash] quest/{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");
if (Directory.Exists(QuestRoot))
{
int q = 0; sw.Restart();
foreach (var f in Directory.EnumerateFiles(QuestRoot, "*.assetbundle", SearchOption.TopDirectoryOnly)) { OfQuest(Path.GetFileName(f)); q++; }
Console.WriteLine($"[bundlehash] warmed {q} quest 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,60 @@
using System.Collections.Concurrent;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Avatar-bundle download diagnostics ("[cdn-diag]" in the journal). Answers "does clothing
/// load on platform X?" without a device log: a client that loads a bundle caches it
/// (BestHTTP disk cache) and downloads it ONCE; a client whose load fails re-downloads the
/// same bundle over and over (133 times an hour before the CDN-host fix, see
/// ConfigCampus.CdnBaseUrl). Bundle requests carry no auth, so the player is recovered from
/// the client IP their heartbeat last came from.
///
/// Logged per (client IP, bundle): the first download, then repeats 2, 3, 5, 10, 25, 50...
/// A repeat line is the failure signal. 304s (revalidation of a cached copy) are success.
/// </summary>
public static class CdnDiag
{
private sealed record Client(ulong Id, string Name, string Platform, string Device, DateTime Seen);
private static readonly ConcurrentDictionary<string, Client> _byIp = new();
private static readonly ConcurrentDictionary<string, int> _counts = new();
public static void NoteClient(HttpContext ctx, player_info account, int platform, int deviceClass)
{
try
{
string ip = LoginGuard.ClientIp(ctx);
if (string.IsNullOrEmpty(ip)) return;
string name = account.Username ?? account.DisplayName ?? account.Id.ToString();
string plat = Enum.IsDefined(typeof(Platforms), platform) ? ((Platforms)platform).ToString() : platform.ToString();
string dev = Enum.IsDefined(typeof(DeviceClasses), deviceClass) ? ((DeviceClasses)deviceClass).ToString() : deviceClass.ToString();
_byIp[ip] = new Client(account.Id, name, plat, dev, DateTime.UtcNow);
}
catch { }
}
public static void OnBundle(HttpContext ctx, string bundle, bool notModified)
{
try
{
string ip = LoginGuard.ClientIp(ctx);
int n = _counts.AddOrUpdate(ip + "|" + bundle, 1, (_, c) => c + 1);
if (!(n <= 3 || n == 5 || n == 10 || n == 25 || n % 50 == 0)) return;
string who = _byIp.TryGetValue(ip, out var c) && DateTime.UtcNow - c.Seen < TimeSpan.FromMinutes(10)
? $"{c.Name}#{c.Id} platform={c.Platform} device={c.Device}"
: "unknown player (no recent heartbeat from this IP)";
string ua = ctx.Request.Headers.UserAgent.ToString();
string range = ctx.Request.Headers.Range.ToString();
string verdict = notModified ? "304 cached copy revalidated (OK)"
: n == 1 ? "first download"
: $"REPEAT #{n} - this client is not keeping the bundle (load likely failing)";
Console.WriteLine($"[cdn-diag] {who} host={ctx.Request.Host.Host} bundle={bundle} {verdict}" +
(range.Length > 0 ? $" range={range}" : "") + (ua.Length > 0 ? $" ua=\"{ua}\"" : ""));
}
catch { }
}
}
}
@@ -0,0 +1,87 @@
using System.Net;
using System.Net.Sockets;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Which CDN base /ns hands the client: ConfigCampus.CdnBaseUrl (its own host - needed for
/// avatar bundles to load, see the comment there) once that host is proven to work, else
/// ConfigCampus.CdnFallbackUrl. A CDN URL the client cannot use stops the game at "Launch
/// validation failed" (it fetches /cdn/config/... during boot), so the switch is never
/// taken on trust: every 5 minutes the host must (1) resolve in DNS to one of the API
/// host's addresses and (2) answer a /cdn request with 200 over https with a certificate
/// that validates for that name. The https check is dialled to the local Caddy (127.0.0.1)
/// with the real hostname as SNI/Host, so it does not depend on the VPS reaching its own
/// public IP.
/// </summary>
public static class CdnHost
{
private static volatile string _current = ConfigCampus.CdnFallbackUrl;
private static string? _lastState;
public static string Current => _current;
public static void Start()
{
_ = Task.Run(async () =>
{
while (true)
{
try { await Probe(); }
catch (Exception ex) { Report($"probe error: {ex.Message}", ConfigCampus.CdnFallbackUrl); }
await Task.Delay(TimeSpan.FromMinutes(5));
}
});
}
private static async Task Probe()
{
var cdn = new Uri(ConfigCampus.CdnBaseUrl);
var api = new Uri(ConfigCampus.CdnFallbackUrl);
if (cdn.Scheme != Uri.UriSchemeHttps) { Report("CdnBaseUrl is not https (the client rejects it)", api.ToString()); return; }
if (string.Equals(cdn.Host, api.Host, StringComparison.OrdinalIgnoreCase)) { Report("CdnBaseUrl is on the API host", ConfigCampus.CdnBaseUrl); return; }
IPAddress[] cdnIps, apiIps;
try { cdnIps = await Dns.GetHostAddressesAsync(cdn.Host); }
catch (Exception ex) { Report($"{cdn.Host} does not resolve ({ex.Message.Trim()})", ConfigCampus.CdnFallbackUrl); return; }
apiIps = await Dns.GetHostAddressesAsync(api.Host);
if (!cdnIps.Any(a => apiIps.Contains(a)))
{
Report($"{cdn.Host} resolves to {string.Join(",", cdnIps.Select(a => a.ToString()))}, not this server", ConfigCampus.CdnFallbackUrl);
return;
}
var handler = new SocketsHttpHandler
{
ConnectCallback = async (ctx, ct) =>
{
var s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try { await s.ConnectAsync(IPAddress.Loopback, cdn.Port, ct); return new NetworkStream(s, ownsSocket: true); }
catch { s.Dispose(); throw; }
},
};
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
var probe = new Uri(cdn, cdn.AbsolutePath.TrimEnd('/') + "/config/LoadingScreenTipData");
try
{
using var resp = await http.GetAsync(probe, HttpCompletionOption.ResponseHeadersRead);
if (resp.StatusCode != HttpStatusCode.OK) { Report($"{probe} answered {(int)resp.StatusCode}", ConfigCampus.CdnFallbackUrl); return; }
}
catch (Exception ex)
{
Report($"{probe} failed over https ({(ex.InnerException ?? ex).Message.Trim()})", ConfigCampus.CdnFallbackUrl);
return;
}
Report($"{cdn.Host} is live", ConfigCampus.CdnBaseUrl);
}
private static void Report(string why, string url)
{
_current = url;
string state = url + " | " + why;
if (state == _lastState) return;
_lastState = state;
Console.WriteLine($"[cdn-host] advertising {url} - {why}");
}
}
}
@@ -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; }
}
}
@@ -0,0 +1,410 @@
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" }
};
// Mic ban: the rn.privilege claim ["BanVChat"], absent otherwise (reference: packages/jwt).
// A timed ban ends the token when the ban ends, so the client re-authenticates then and
// the ban lifts on time; a permanent one keeps the normal lifetime.
try
{
var (privileges, firstEnds) = Campus_live_rewrite.api.dbs.PlayerDB.TokenPrivileges(userId);
if (privileges.Count > 0)
{
payload["rn.privilege"] = privileges;
if (firstEnds != null && firstEnds.Value.ToUnixTimeSeconds() + 5 < (long)payload["exp"])
payload["exp"] = firstEnds.Value.ToUnixTimeSeconds() + 5;
}
}
catch (Exception ex) { Console.WriteLine($"[token] chat-ban claim for {userId} skipped: {ex.Message}"); }
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" },
// A real, numeric expiry. It was the hardcoded STRING "1754316353" (Aug 2025), which
// nothing checked - a Photon token, once copied, worked forever. CampusNet now refuses
// a numeric exp that has passed. 24 h outlives any session; the client fetches a fresh
// token with every matchmake / connection-info anyway.
{ "exp", now.AddHours(24).ToUnixTimeSeconds() },
{ "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.
// CampusStudio tokens work on StudioController only, launcher tokens on LauncherController only.
if (surface == "website" || surface == "studio" || surface == "launcher")
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)
{
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,698 @@
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>
/// Exclusive: tagged "adminonly" (Tags[].Value). Never listed in any store / profile and
/// never sold - only the admin panel's gift gives it. Owners still see and wear it normally.
/// </summary>
public bool AdminOnly;
/// <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,
AdminOnly = o["Tags"] is JArray tags && tags.Any(t => string.Equals((string?)t?["Value"], "adminonly", StringComparison.OrdinalIgnoreCase)),
};
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);
// The official first-party items (FirstPartyUGC.json) - the Coach's stock, account 1.
private static readonly HashSet<string> _firstPartyIds = 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();
// CAMPUSSTUDIO items (recolours published from the CampusStudio app through
// StudioController): their own file, loaded after FirstPartyUGC.json and treated as
// first-party (Coach) stock. Written only by the server, so a deploy never clobbers it.
public static string StudioPath =>
Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "StudioUGC.json");
private static readonly List<Rec> _studio = 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;
lock (_lock) _firstPartyIds.Clear();
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);
_firstPartyIds.Add(id);
}
}
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) _studio.Clear();
if (File.Exists(StudioPath))
{
try
{
int n = 0;
foreach (var o in ReadResults(StudioPath)) if (AddStudio(o, persist: false) != null) n++;
Console.WriteLine($"[ugc] StudioUGC.json: {n} CampusStudio item(s)");
}
catch (Exception sex) { Console.WriteLine($"[ugc] StudioUGC.json load failed: {sex.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>
/// Upsert a CampusStudio item: indexed exactly like a first-party item (so the store, the
/// dev locker and gifting all see it) and persisted to StudioUGC.json. Live - no restart.
/// </summary>
public static Rec? AddStudio(JObject item, bool persist = true)
{
string? id = (string?)item["CustomAvatarItemId"];
if (string.IsNullOrEmpty(id)) return null;
if (!PassesCutoff(item)) return null;
SanitizeForClient(item);
var rec = Rec.From(item);
lock (_lock)
{
if (_byId.TryGetValue(id, out var existing))
{
_all.Remove(existing);
if (_imported.Remove(existing)) _imported.Add(rec);
}
_byId[id] = rec;
_all.Add(rec);
_firstPartyIds.Add(id);
_studio.RemoveAll(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
_studio.Add(rec);
}
if (persist) PersistStudio();
return rec;
}
/// <summary>
/// Delete a CampusStudio item from every index and from StudioUGC.json (live - no restart). Only items that
/// were published through Studio are in that list; anything else answers null. Returns the removed record.
/// </summary>
public static JObject? RemoveStudio(string? id)
{
if (string.IsNullOrEmpty(id)) return null;
Rec? rec;
lock (_lock)
{
if (!_studio.Any(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase))) return null;
if (!_byId.TryGetValue(id!, out rec)) return null;
_byId.Remove(id!);
_all.Remove(rec);
_firstPartyIds.Remove(id!);
_studio.RemoveAll(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
if (_byCreator.TryGetValue(rec.Creator, out var list)) list.Remove(rec);
_importedIds.Remove(id!);
_imported.Remove(rec);
_created.Remove(rec);
}
PersistStudio();
return rec.ToJObject();
}
/// <summary>Does any catalog record's JSON still contain this text (a bundle or thumbnail file name)?</summary>
public static bool AnyRecordMentions(string needle)
{
var nb = Encoding.UTF8.GetBytes(needle);
lock (_lock) foreach (var r in _all) if (r.Json.AsSpan().IndexOf(nb) >= 0) return true;
return false;
}
public static bool IsStudio(string? id)
{
if (string.IsNullOrEmpty(id)) return false;
lock (_lock) return _studio.Any(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
}
private static void PersistStudio()
{
try
{
List<Rec> list;
lock (_lock) list = new List<Rec>(_studio);
WriteResults(StudioPath, list);
}
catch (Exception ex) { Console.WriteLine($"[ugc] persist studio failed: {ex.Message}"); }
}
/// <summary>First-party (Coach + CampusStudio) modelled items, admin-only included - the CampusStudio library.</summary>
public static List<Rec> StudioLibraryRecs()
{
List<Rec> all; HashSet<string> ids;
lock (_lock) { all = new List<Rec>(_all); ids = new HashSet<string>(_firstPartyIds, StringComparer.OrdinalIgnoreCase); }
return all.Where(r => ids.Contains(r.Id) && r.BundleBacked).OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
/// <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) =>
!r.AdminOnly && (!ConfigCampus.HiddenItemsStorefronts || !r.BundleBacked)
|| (!r.AdminOnly && ConfigCampus.ServeFirstPartyClothing && IsFirstParty(r));
/// <summary>An official first-party item (FirstPartyUGC.json) - the Coach's stock.</summary>
public static bool IsFirstParty(Rec r) { lock (_lock) return _firstPartyIds.Contains(r.Id); }
/// <summary>The Coach's stock (account 1): every published first-party item, newest first.</summary>
/// <summary>Published, modelled custom items whose name contains q (admin gift search).</summary>
public static List<Rec> SearchRecs(string q, int cap)
{
if (string.IsNullOrWhiteSpace(q)) return new List<Rec>();
List<Rec> all; lock (_lock) all = _all.ToList();
return all.Where(r => r.Accessibility != 0 && r.BundleBacked && r.Name.Contains(q, StringComparison.OrdinalIgnoreCase))
.OrderBy(r => r.Name.Length).ThenBy(r => r.Name).Take(cap).ToList();
}
public static List<Rec> FirstPartyRecs()
{
HashSet<string> ids; lock (_lock) ids = new HashSet<string>(_firstPartyIds, StringComparer.OrdinalIgnoreCase);
return _all.Where(r => ids.Contains(r.Id) && r.Accessibility != 0 && !r.AdminOnly)
.OrderByDescending(r => r.Created).ThenBy(r => r.Id).ToList();
}
// ---- the developer locker (UGC half) --------------------------------------------------
private static List<Rec>? _devLocker;
private static DateTime _devLockerAt;
private static int _devLockerSource = -1;
private static readonly object _devLockerLock = new();
/// <summary>
/// Every custom item a developer owns: every first-party (Coach) item and every published
/// player-made / imported item - modelled ones whose bundles we don't host included (they
/// show but can't load). Rebuilt every 10 minutes (or when the item count changes).
/// </summary>
public static List<Rec> DevLockerRecs()
{
lock (_devLockerLock)
{
int count; lock (_lock) count = _all.Count;
if (_devLocker != null && count == _devLockerSource && DateTime.UtcNow - _devLockerAt < TimeSpan.FromMinutes(10))
return new List<Rec>(_devLocker);
List<Rec> all; HashSet<string> firstParty;
lock (_lock) { all = new List<Rec>(_all); firstParty = new HashSet<string>(_firstPartyIds, StringComparer.OrdinalIgnoreCase); }
var sw = System.Diagnostics.Stopwatch.StartNew();
var locker = new List<Rec>();
int skipped = 0;
// EVERY published item (owner's call 2026-09-26: "every single item") - including
// imported modelled items whose bundles we don't host; `skipped` only counts those
// for the log. Other players' unpublished drafts stay private.
foreach (var r in all)
{
if (firstParty.Contains(r.Id)) { locker.Add(r); continue; }
if (r.Accessibility == 0) continue; // someone's unpublished draft
if (r.BundleBacked && !BundlesHeld(r)) skipped++;
locker.Add(r);
}
// First-party first (the Coach's stock), then the rest newest first.
locker = locker.OrderByDescending(r => firstParty.Contains(r.Id)).ThenByDescending(r => r.Created).ToList();
Console.WriteLine($"[ugc] dev locker: {locker.Count} custom item(s) ({skipped} of them modelled items without hosted bundles) in {sw.ElapsedMilliseconds} ms");
_devLocker = locker; _devLockerAt = DateTime.UtcNow; _devLockerSource = count;
return new List<Rec>(locker);
}
}
/// <summary>Does this server hold every bundle this item's saves name?</summary>
private static bool BundlesHeld(Rec r)
{
try
{
var saves = ParseRecord(r.Json)["CurrentSaves"] as JArray;
if (saves == null || saves.Count == 0) return true;
foreach (var s in saves)
foreach (var k in new[] { "UnityAsset", "UnityAsset2" })
{
var n = (string?)s[k];
if (!string.IsNullOrWhiteSpace(n) && BundleHashes.Resolve(n) == null) return false;
}
return true;
}
catch { return false; }
}
/// <summary>The Coach's account - who first-party items are filed under (reference COACH_ACCOUNT_ID).</summary>
public const ulong CoachAccountId = 1;
/// <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).Where(r => !r.AdminOnly).Select(r => r.ToJObject()).ToList(); // profile lists: exclusives stay hidden
}
}
@@ -0,0 +1,214 @@
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The daily challenges (the watch's Challenges > Daily tab): which three objectives are served
/// in GET /api/config/v2 "DailyObjectives", when the day rolls over, and what finishing all of
/// them pays - a game request (the three-card reward picker), once per day.
///
/// The set used to be types 20/21/22, which are the ONBOARDING objectives
/// (OOBE_GoToLockerRoom / OOBE_GoToActivity / OOBE_FinishActivity - the 2025 client's enum has
/// the same order as the reference table, read from its metadata). The client only fires those
/// during the tutorial, so no one's challenges ever progressed: the live log has 3,000+
/// myprogress reads and not one updateobjective. The defaults below are the everyday versions.
///
/// Edited from the admin panel (Challenges tab), stored in Data/APIS/daily-objectives.json:
/// { "rewardEnabled": true, "xpPerObjective": 25, "sets": [[{"type":30,"score":1}, ...], ...] }
/// A bare array (one set, or one set per day) is still read. Type ids are the ObjectiveType enum
/// (reference .agents/skills/daily-objectives-config/SKILL.md). Players pick a change up on
/// their next login (config/v2 is read then).
///
/// Weekly challenges: NOT in this client build. It has no /api/challenge route at all (its API
/// path table has objectives/v1/* and nothing for challenges; the live log has never seen one),
/// only leftover enum names. api/server/WeeklyChallenge.cs serves a route nothing calls.
/// </summary>
public static class DailyChallenges
{
// 30 GoToRecCenter, 32 VisitACustomRoom, 31 FinishActivity.
private static readonly int[][] DefaultSet = { new[] { 30, 1 }, new[] { 32, 1 }, new[] { 31, 1 } };
private static readonly string File1 =
System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "daily-objectives.json");
private static readonly object _lock = new();
private static List<List<Classes.DailyObjective>>? _sets;
/// <summary>The config/v2 "DailyObjectives" value: seven day-sets (the client indexes by day).</summary>
public static List<List<Classes.DailyObjective>> Sets { get { lock (_lock) return _sets ??= Load(); } }
/// <summary>"file" when Data/APIS/daily-objectives.json supplied the sets, else "default".</summary>
public static string Source { get; private set; } = "default";
/// <summary>Finishing every challenge sends a game request. Off = XP per objective only.</summary>
public static bool RewardEnabled { get; private set; } = true;
/// <summary>How many objectives a group has to finish before its reward is paid.</summary>
public static int RequiredFor(long group)
{
var s = Sets;
if (s.Count == 0) return int.MaxValue;
var set = group >= 0 && group < s.Count ? s[(int)group] : s[0];
return set.Count == 0 ? int.MaxValue : set.Count;
}
/// <summary>The start of the current challenge day (UTC midnight). Progress from before it is stale.</summary>
public static DateTime PeriodStartUtc => DateTime.UtcNow.Date;
/// <summary>True when a group last reset (ClearedAt) before today - its progress belongs to an earlier day.</summary>
public static bool IsStale(string? clearedAt) =>
!DateTime.TryParse(clearedAt, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var t)
|| t < PeriodStartUtc;
/// <summary>
/// Pay the daily reward: a game request (three token cards in the tokens box) pushed to the
/// player's watch. The caller has already latched the group complete for today, so this runs
/// once per player per day.
/// </summary>
public static void GrantReward(ulong accountId, long group)
{
if (!RewardEnabled)
{
Console.WriteLine($"[objectives] {accountId} finished every daily challenge (group {group}) - game request is switched off (admin panel)");
return;
}
try
{
var sel = QuestRewards.Loaded
? RewardSelections.Offer(accountId, QuestRewards.GiftContextGameRewardTokens, null, "Daily Challenges complete!")
: null;
Console.WriteLine(sel != null
? $"[objectives] {accountId} finished every daily challenge (group {group}) -> game request {sel.RewardSelectionId}"
: $"[objectives] {accountId} finished every daily challenge (group {group}) but no game request could be made (reward table not loaded)");
}
catch (Exception ex) { Console.WriteLine($"[objectives] {accountId} daily reward failed: {ex.Message}"); }
}
/// <summary>Re-read the file (after the admin panel saved it or reset it).</summary>
public static void Reload() { lock (_lock) _sets = Load(); }
/// <summary>Store new settings and apply them. Sets are validated by the caller.</summary>
public static void Save(List<List<Classes.DailyObjective>> sets, bool rewardEnabled, int xpPerObjective)
{
var o = new JObject
{
["rewardEnabled"] = rewardEnabled,
["xpPerObjective"] = xpPerObjective,
["sets"] = new JArray(sets.Select(set => new JArray(set.Select(d => new JObject { ["type"] = d.Type, ["score"] = d.Score }))))
};
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(File1)!);
System.IO.File.WriteAllText(File1, o.ToString());
Reload();
}
/// <summary>Back to the built-in defaults (deletes the file).</summary>
public static void ResetToDefaults()
{
try { if (System.IO.File.Exists(File1)) System.IO.File.Delete(File1); } catch { }
ConfigCampus.ObjectiveCompletionXp = DefaultXp;
Reload();
}
private static readonly int DefaultXp = ConfigCampus.ObjectiveCompletionXp;
private static List<List<Classes.DailyObjective>> Load()
{
List<List<Classes.DailyObjective>>? days = null;
Source = "default";
RewardEnabled = true;
try
{
if (System.IO.File.Exists(File1))
{
var tok = JToken.Parse(System.IO.File.ReadAllText(File1));
JArray? arr = tok as JArray;
if (tok is JObject obj)
{
if (obj.TryGetValue("rewardEnabled", StringComparison.OrdinalIgnoreCase, out var re) && re.Type == JTokenType.Boolean)
RewardEnabled = re.Value<bool>();
if (obj.TryGetValue("xpPerObjective", StringComparison.OrdinalIgnoreCase, out var xp) && xp.Type == JTokenType.Integer)
ConfigCampus.ObjectiveCompletionXp = Math.Clamp(xp.Value<int>(), 0, 100000);
arr = obj.GetValue("sets", StringComparison.OrdinalIgnoreCase) as JArray;
}
if (arr != null && arr.Count > 0)
{
days = arr[0] is JArray
? arr.OfType<JArray>().Select(ParseSet).Where(x => x.Count > 0).ToList()
: new List<List<Classes.DailyObjective>> { ParseSet(arr) };
if (days.Count == 0) days = null;
}
if (days != null) Source = "file";
Console.WriteLine(days != null
? $"[objectives] daily challenges from Data/APIS/daily-objectives.json ({days.Count} set(s), reward {(RewardEnabled ? "on" : "off")}, {ConfigCampus.ObjectiveCompletionXp} XP each)"
: "[objectives] Data/APIS/daily-objectives.json has no usable sets - using the defaults");
}
}
catch (Exception ex) { Console.WriteLine($"[objectives] could not read daily-objectives.json ({ex.Message}) - using the defaults"); days = null; }
days ??= new List<List<Classes.DailyObjective>>
{
DefaultSet.Select(d => new Classes.DailyObjective { Type = d[0], Score = d[1] }).ToList()
};
foreach (var set in days) foreach (var d in set) d.Xp = ConfigCampus.ObjectiveCompletionXp;
// Seven entries, one per weekday, like the reference config; a shorter list repeats.
var week = new List<List<Classes.DailyObjective>>();
for (int i = 0; i < 7; i++) week.Add(days[i % days.Count]);
return week;
}
private static List<Classes.DailyObjective> ParseSet(JArray set) => set.OfType<JObject>()
.Select(o => new Classes.DailyObjective
{
Type = (int?)(o.GetValue("type", StringComparison.OrdinalIgnoreCase)) ?? 0,
Score = Math.Clamp((int?)(o.GetValue("score", StringComparison.OrdinalIgnoreCase)) ?? 1, 1, 1000)
})
.Where(o => Types.ContainsKey(o.Type))
.ToList();
// ---- the ObjectiveType enum (2025 client: same members and order as the reference table) ----
/// <summary>Tutorial-only objectives: the client fires them during the new-player tutorial, never after.</summary>
public static readonly HashSet<int> TutorialOnly = new() { 20, 21, 22, 25, 26 };
/// <summary>Every usable objective type, id -> name. The rollups (10-15) and Default (-1) are left out.</summary>
public static readonly SortedDictionary<int, string> Types = new()
{
[1] = "FirstSessionOfDay", [2] = "AddAFriend", [3] = "PartyUp", [5] = "LevelUp", [6] = "CheerAPlayer",
[7] = "PointedAtPlayer", [8] = "CheerARoom", [9] = "SubscribeToPlayer",
[20] = "OOBE_GoToLockerRoom", [21] = "OOBE_GoToActivity", [22] = "OOBE_FinishActivity",
[25] = "NUX_PunchcardObjective", [26] = "NUX_AllPunchcardObjectives",
[30] = "GoToRecCenter", [31] = "FinishActivity", [32] = "VisitACustomRoom", [33] = "CreateACustomRoom",
[35] = "ScoreBasketInRecCenter", [36] = "UploadPhotoToRecNet", [37] = "UpdatePlayerBio", [38] = "SaveOutfitSlot",
[39] = "PurchaseClothingItem", [40] = "PurchaseNonClothingItem", [41] = "DrinkWater", [42] = "ColorOnWhiteboard",
[43] = "SetBasketballSkin", [44] = "ThrowBasketball", [45] = "PlaceInventionInDorm", [46] = "ChangeDormRoomSkin",
[47] = "ToggleOwnedClothes", [48] = "EquipHat", [49] = "LoadOutfit", [50] = "SaveNewOutfitSlot", [51] = "SpawnCamera",
[52] = "TakeSelfie", [53] = "PrintSelfie", [54] = "TakePictureOfPlayer", [55] = "PrintPictureOfPlayer",
[56] = "PublishSelfieWithPlayer", [57] = "SpawnFoodWithOtherPlayers", [58] = "EmoteInRecCenter",
[59] = "SendRoomChatInRecCenter", [60] = "UseFrendotron", [61] = "GoToDormRoom", [62] = "VisitSpecificRoom",
[63] = "VisitPublicRRO", [64] = "VisitPublicRoomBySource", [65] = "FavoriteARoom", [66] = "TakePhotoWithFilter",
[67] = "OpenYourPlayerProfile", [68] = "OpenOnlineStatusModal", [69] = "ChangeProfilePicture",
[70] = "ChangePlayerDisplayName", [71] = "ChangePlayerDescriptionText", [72] = "OpenPlayerPronounsModal",
[73] = "OpenOtherPlayersProfile", [74] = "VisitPlayersPortfolio", [75] = "FavoriteAFriend",
[100] = "CharadesGames", [101] = "CharadesWinsPerformer", [102] = "CharadesWinsGuesser",
[200] = "DiscGolfWins", [201] = "DiscGolfGames", [202] = "DiscGolfHolesUnderPar",
[300] = "DodgeballWins", [301] = "DodgeballGames", [302] = "DodgeballHits",
[400] = "PaddleballGames", [401] = "PaddleballWins", [402] = "PaddleballScores",
[500] = "PaintballAnyModeGames", [501] = "PaintballAnyModeWins", [502] = "PaintballAnyModeHits",
[600] = "PaintballCTFWins", [601] = "PaintballCTFGames", [602] = "PaintballCTFHits", [603] = "PaintballFlagCaptures",
[700] = "PaintballTeamBattleWins", [701] = "PaintballTeamBattleGames", [702] = "PaintballTeamBattleHits",
[710] = "PaintballFreeForAllWins", [711] = "PaintballFreeForAllGames", [712] = "PaintballFreeForAllHits",
[800] = "SoccerWins", [801] = "SoccerGames", [802] = "SoccerGoals",
[900] = "BowlingGames", [901] = "BowlingWins", [902] = "BowlingStrike",
[1000] = "QuestGames", [1001] = "QuestWins", [1002] = "QuestPlayerRevives", [1003] = "QuestEnemyKills",
[1010] = "QuestGames_Goblin1", [1011] = "QuestWins_Goblin1", [1012] = "QuestPlayerRevives_Goblin1", [1013] = "QuestEnemyKills_Goblin1",
[1020] = "QuestGames_Goblin2", [1021] = "QuestWins_Goblin2", [1022] = "QuestPlayerRevives_Goblin2", [1023] = "QuestEnemyKills_Goblin2",
[1030] = "QuestGames_Scifi1", [1031] = "QuestWins_Scifi1", [1032] = "QuestPlayerRevives_Scifi1", [1033] = "QuestEnemyKills_Scifi1",
[1040] = "QuestGames_Pirate1", [1041] = "QuestWins_Pirate1", [1042] = "QuestPlayerRevives_Pirate1", [1043] = "QuestEnemyKills_Pirate1",
[1050] = "QuestGames_Dracula1", [1051] = "QuestWins_Dracula1", [1052] = "QuestPlayerRevives_Dracula1", [1053] = "QuestEnemyKills_Dracula1",
[2000] = "ArenaGames", [2001] = "ArenaWins", [2002] = "ArenaPlayerRevives", [2003] = "ArenaHeroTags", [2004] = "ArenaBotTags",
[3000] = "RecRoyaleGames", [3001] = "RecRoyaleWins", [3002] = "RecRoyaleTags",
[4000] = "StuntRunnerGames", [4001] = "StuntRunnerWins", [5000] = "RecRallyGames", [5001] = "RecRallyWins",
};
}
}
@@ -0,0 +1,76 @@
using Newtonsoft.Json.Linq;
using Campus_live_rewrite.api.dbs;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The developer locker's extra avatar items: every avatar item any storefront sells (the
/// Watch store as generated AND its raw file - the generator hides some captured rows - every
/// other store file, admin-added rows) that avatar_items.json does
/// NOT list - 136 on 2026-09-26, mostly captured Watch rows ("Masquerade Peacock Gown", "UFO
/// Hat"...). /api/avatar/v4/items gives a developer the whole catalog; these are appended so a
/// developer owns every single item in the game. Shaped like avatar_items.json entries and
/// rebuilt at most every 10 minutes.
/// </summary>
public static class DevLocker
{
private static List<JObject>? _extra;
private static DateTime _builtAt;
private static readonly object _lock = new();
public static List<JObject> StoreOnlyAvatarItems(JArray catalog)
{
lock (_lock)
{
if (_extra != null && DateTime.UtcNow - _builtAt < TimeSpan.FromMinutes(10))
return _extra.Select(e => (JObject)e.DeepClone()).ToList();
var known = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var e in catalog)
{
string k = PlayerDB.NormalizeAvatarItemDesc((string?)e["AvatarItemDesc"]);
if (k.Length > 0) known.Add(k);
}
var rows = new List<JToken>();
try { rows.AddRange(StoreGenerator.MergedWatchStore()["StoreItems"] as JArray ?? new JArray()); } catch { }
try
{
if (Directory.Exists(StoreCatalog.StoreDirectory))
foreach (var f in Directory.GetFiles(StoreCatalog.StoreDirectory, "*.json"))
{
try { rows.AddRange(JObject.Parse(File.ReadAllText(f))["StoreItems"] as JArray ?? new JArray()); } catch { }
}
} catch { }
foreach (int store in Enum.GetValues(typeof(Classes.StorefrontType)).Cast<int>().Distinct())
rows.AddRange(StoreEdits.Rows(store));
var extra = new List<JObject>();
foreach (var r in rows)
{
if (r["GiftDrop"] is not JObject gd) continue;
string desc = (string?)gd["AvatarItemDesc"] ?? "";
string key = PlayerDB.NormalizeAvatarItemDesc(desc);
if (key.Length == 0 || !known.Add(key)) continue;
extra.Add(new JObject
{
["AvatarItemDesc"] = desc,
["AvatarItemType"] = (int?)gd["AvatarItemType"] ?? 0,
["PlatformMask"] = -1,
["FriendlyName"] = (string?)gd["FriendlyName"] ?? "",
["Tooltip"] = (string?)gd["Tooltip"] ?? "",
["Rarity"] = (int?)gd["Rarity"] ?? 0,
["TagList"] = gd["TagList"]?.DeepClone(),
["AvatarItemId"] = (int?)gd["AvatarItemId"] ?? 0,
["IsBaseAvatarItem"] = false,
["CreatedAt"] = "2020-01-01T00:00:00Z",
["ThumbnailImage"] = (string?)gd["ThumbnailImageName"]
});
}
Console.WriteLine($"[items] dev locker: {extra.Count} store-only avatar item(s) added to the catalog's {catalog.Count}");
_extra = extra; _builtAt = DateTime.UtcNow;
return _extra.Select(e => (JObject)e.DeepClone()).ToList();
}
}
}
}
@@ -0,0 +1,83 @@
using System.Collections.Concurrent;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Short-lived codes for linking a Discord account to a Campus account.
///
/// FLOW: the website Settings > Discord tab (SiteController, site-cookie authed) calls
/// <see cref="Generate"/> for the signed-in account and shows the code; the player runs
/// `/link &lt;code&gt;` with the Discord bot (BotCommands), which calls <see cref="TryResolve"/>
/// then <see cref="Consume"/> to bind their Discord id.
///
/// This is DELIBERATELY its own single-purpose store, isolated from the admin session gate
/// (AdminGate) and everything else: redeeming a code ONLY records a Discord&lt;-&gt;account link
/// (PlayerDB.SetDiscordLink) and can never mint an admin session or grant any privilege. That is
/// the whole point of keeping it separate - linking Discord must never be a path into the admin
/// panel.
/// </summary>
public static class DiscordLinkCodes
{
private const int TtlSeconds = 600; // 10 minutes
// Unambiguous alphabet (no 0/O/1/I) so a 6-char code is easy to read off a screen and type.
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private static readonly ConcurrentDictionary<string, (ulong accountId, long expires)> _codes = new();
private static readonly Random _rng = new();
/// <summary>Mint a fresh code for this account (replacing any it already had), valid for 10 min.</summary>
public static (string code, int ttlSeconds) Generate(ulong accountId)
{
Sweep();
// Drop any outstanding code for this account so only the newest one works.
foreach (var kv in _codes)
if (kv.Value.accountId == accountId) _codes.TryRemove(kv.Key, out _);
string code;
do
{
var chars = new char[6];
lock (_rng)
for (int i = 0; i < chars.Length; i++) chars[i] = Alphabet[_rng.Next(Alphabet.Length)];
code = new string(chars);
} while (_codes.ContainsKey(code));
long expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + TtlSeconds;
_codes[code] = (accountId, expires);
return (code, TtlSeconds);
}
/// <summary>Look up (without consuming) the account a live code belongs to. False if unknown/expired.</summary>
public static bool TryResolve(string? code, out ulong accountId)
{
accountId = 0;
if (string.IsNullOrWhiteSpace(code)) return false;
code = code.Trim().ToUpperInvariant();
if (!_codes.TryGetValue(code, out var entry)) return false;
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > entry.expires)
{
_codes.TryRemove(code, out _);
return false;
}
accountId = entry.accountId;
return true;
}
/// <summary>Burn a code after a successful link so it can't be reused.</summary>
public static void Consume(string? code)
{
if (string.IsNullOrWhiteSpace(code)) return;
_codes.TryRemove(code.Trim().ToUpperInvariant(), out _);
}
private static long _lastSweep;
private static void Sweep()
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now - Interlocked.Read(ref _lastSweep) < 60) return;
Interlocked.Exchange(ref _lastSweep, now);
foreach (var kv in _codes)
if (now > kv.Value.expires) _codes.TryRemove(kv.Key, out _);
}
}
}
@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The room lists behind the Play tab, built from THIS server's rooms.
///
/// The Play page (Data/APIS/WatchPages/PlayHighlight.json) was captured from live Rec Room and
/// names its carousels by algorithmic-list endpoint: foryou, HotList, staffpicks, new,
/// inkshowcase, recentlyupdated and the genre rows (battle_algoendpoint, horror_algoendpoint,
/// ...). GET /lists/algorithmiclists/{name}?type=1 answered each from a file under
/// Data/APIS/AlgorithmicLists, and only two files existed - so "Recommended For You",
/// "Featured This Week", "Trending", "Rooms We Love" and every genre row were blank. Now each
/// name is a query over RoomDB, cached for a minute:
///
/// HotList / foryou live player count first, then cheers + visits (foryou reshuffles
/// hourly so the row isn't the same as Trending)
/// staffpicks the admin panel's featured rooms, padded from the top cheered
/// new newest published rooms
/// recentlyupdated most recently changed
/// inkshowcase player-made rooms with a picture, seeded shuffle
/// &lt;genre&gt;_algoendpoint rooms tagged with that genre (see GenreTags), padded from hot
///
/// Only rooms IsRoomListed says are public are ever listed; dorms never are.
/// </summary>
public static class DiscoveryLists
{
private static readonly object _lock = new();
private static readonly Dictionary<string, (DateTime at, List<string> ids)> _cache = new(StringComparer.OrdinalIgnoreCase);
private const int CacheSeconds = 60;
private const int PageSize = 30;
private static readonly Dictionary<string, string[]> GenreTags = new(StringComparer.OrdinalIgnoreCase)
{
["battle"] = new[] { "battle", "pvp", "shooter", "fps", "combat", "war", "gunfight", "paintball" },
["roleplay"] = new[] { "roleplay", "rp", "role-play", "story" },
["quests"] = new[] { "quest", "quests", "adventure", "rpg", "coop", "co-op" },
["horror"] = new[] { "horror", "scary", "spooky", "creepy", "backrooms" },
["hangout"] = new[] { "hangout", "hang out", "social", "chill", "chat", "lounge", "hangouts" },
["casual"] = new[] { "casual", "party", "minigame", "minigames", "fun", "game" },
["explore"] = new[] { "explore", "exploration", "showcase", "art", "museum", "parkour", "obby" },
};
/// <summary>Whether this list name is one the server builds (vs a file / store row).</summary>
public static bool Handles(string name)
{
string n = name.ToLowerInvariant();
return n is "hotlist" or "foryou" or "staffpicks" or "new" or "recentlyupdated" or "inkshowcase"
|| n.EndsWith("_algoendpoint");
}
public static List<string> RoomIds(string name)
{
lock (_lock)
{
if (_cache.TryGetValue(name, out var hit) && (DateTime.UtcNow - hit.at).TotalSeconds < CacheSeconds)
return hit.ids;
}
var ids = Build(name.ToLowerInvariant());
lock (_lock) _cache[name] = (DateTime.UtcNow, ids);
return ids;
}
private static List<RoomDBClasses.RoomRoot> Listed() =>
RoomDB.GetHotRooms().Where(r => !r.IsDorm && RoomDB.IsRoomListed(r) && !r.ExcludeFromLists).ToList();
private static long Score(RoomDBClasses.RoomRoot r, Dictionary<long, int> live)
{
long ccu = live.TryGetValue((long)r.RoomId, out var n) ? n : 0;
long cheers = r.Stats?.CheerCount ?? 0;
long visits = r.Stats?.VisitCount ?? 0;
return ccu * 100_000 + cheers * 50 + visits;
}
private static List<string> Ids(IEnumerable<RoomDBClasses.RoomRoot> rooms) =>
rooms.Select(r => r.RoomId.ToString()).Distinct().Take(PageSize).ToList();
private static List<string> Build(string name)
{
var rooms = Listed();
var live = HeartbeatDB.LivePlayerCountsByRoom();
var hot = rooms.OrderByDescending(r => Score(r, live)).ThenByDescending(r => r.CreatedAt).ToList();
int hourSeed = HashCode.Combine(name, DateTime.UtcNow.Year, DateTime.UtcNow.DayOfYear, DateTime.UtcNow.Hour);
switch (name)
{
case "hotlist":
return Ids(hot);
case "foryou":
{
// The top of the hot list in a per-hour order, then the rest shuffled in, so
// it reads as a different row from Trending without being random junk.
var rng = new Random(hourSeed);
var top = hot.Take(12).OrderBy(_ => rng.Next());
var rest = hot.Skip(12).OrderBy(_ => rng.Next());
return Ids(top.Concat(rest));
}
case "staffpicks":
{
var picked = new List<RoomDBClasses.RoomRoot>();
foreach (var rid in FeaturedRoomsStore.GetIds())
{
var r = RoomDB.GetRoom(rid);
if (r != null && !r.IsDorm) picked.Add(r);
}
var pad = hot.Where(r => !RoomDB.IsRecRoomOriginal(r)).OrderByDescending(r => r.Stats?.CheerCount ?? 0);
return Ids(picked.Concat(pad));
}
case "new":
return Ids(rooms.Where(r => !RoomDB.IsRecRoomOriginal(r)).OrderByDescending(r => r.CreatedAt));
case "recentlyupdated":
return Ids(rooms.Where(r => !RoomDB.IsRecRoomOriginal(r))
.OrderByDescending(r => r.PublishedAt > r.CreatedAt ? r.PublishedAt : r.CreatedAt));
case "inkshowcase":
{
var rng = new Random(hourSeed);
var made = rooms.Where(r => !RoomDB.IsRecRoomOriginal(r) && !string.IsNullOrEmpty(r.ImageName)).OrderBy(_ => rng.Next());
return Ids(made.Concat(hot));
}
}
if (name.EndsWith("_algoendpoint"))
{
string genre = name[..^"_algoendpoint".Length];
var tags = GenreTags.TryGetValue(genre, out var t) ? t : new[] { genre };
var tagged = hot.Where(r => tags.Any(tag => RoomDB.RoomHasTag(r, tag))
|| tags.Any(tag => (r.Name ?? "").Contains(tag, StringComparison.OrdinalIgnoreCase)));
return Ids(tagged.Concat(hot));
}
return Ids(hot);
}
/// <summary>
/// The Play page's "Featured Creator" tile: the captured page names a live-Rec-Room account
/// (730029). The local creator with the most public, player-made rooms takes its place -
/// never the system accounts (1-3) or the room importer.
/// </summary>
private static (DateTime at, ulong id) _creator;
public static ulong FeaturedCreator()
{
lock (_lock)
{
if (_creator.id != 0 && (DateTime.UtcNow - _creator.at).TotalSeconds < 600) return _creator.id;
}
ulong best = 0;
// The admin panel's Featured tab decides first; then whatever the community board
// file names; only then the most-rooms heuristic.
best = FeaturedStore.FeaturedPlayer();
if (best == 0)
{
try
{
string cb = System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "communityboard.json");
if (System.IO.File.Exists(cb))
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(System.IO.File.ReadAllText(cb));
ulong fp = (ulong?)jo["FeaturedPlayer"]?["Id"] ?? 0;
if (fp != 0 && PlayerDB.GetAccountById(fp) != null) best = fp;
}
}
catch { }
}
if (best != 0) { lock (_lock) _creator = (DateTime.UtcNow, best); return best; }
try
{
var byCreator = Listed()
.Where(r => !RoomDB.IsRecRoomOriginal(r) && r.CreatorAccountId > 3)
.GroupBy(r => r.CreatorAccountId)
.Select(g => (id: g.Key, n: g.Count(), cheers: g.Sum(r => r.Stats?.CheerCount ?? 0)))
.Where(x =>
{
var acc = PlayerDB.GetAccountById(x.id);
return acc != null && !string.Equals(acc.Username, "Importer", StringComparison.OrdinalIgnoreCase);
})
.OrderByDescending(x => x.n).ThenByDescending(x => x.cheers)
.FirstOrDefault();
best = byCreator.id;
}
catch { }
lock (_lock) _creator = (DateTime.UtcNow, best);
return best;
}
/// <summary>The room the Play page's banner shows: the first admin-featured room, else the hottest.</summary>
public static ulong BannerRoom()
{
foreach (var rid in FeaturedRoomsStore.GetIds())
{
var r = RoomDB.GetRoom(rid);
if (r != null && !r.IsDorm) return rid;
}
var top = RoomIds("hotlist").FirstOrDefault();
return ulong.TryParse(top, out var id) ? id : 0;
}
}
}
@@ -0,0 +1,50 @@
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Do Not Disturb, set on the website for a chosen length of time. While it is on:
/// - direct game invites from anyone the player has NOT favourited are refused (the sender is told the player isn't taking
/// invites right now), so favourites can always still reach them;
/// - the player isn't sent "friend X is online" notices.
/// It expires by itself (the time is stored, nothing has to switch it off) and is capped at <see cref="MaxMinutes"/>.
/// Party moves and room follows are deliberately NOT covered: those are a party leader taking their party along, not an invite.
/// </summary>
public static class DoNotDisturb
{
public const int MaxMinutes = 24 * 60;
public static DateTime? Until(ulong accountId)
{
long unix = SitePrefsDB.Get(accountId).DndUntilUnix;
if (unix <= 0) return null;
var until = DateTimeOffset.FromUnixTimeSeconds(unix).UtcDateTime;
return until > DateTime.UtcNow ? until : null;
}
public static bool Active(ulong accountId) => accountId != 0 && Until(accountId) != null;
/// <summary>Start (minutes &gt; 0, capped) or stop (0) it. Returns when it ends, or null when it is off.</summary>
public static DateTime? Set(ulong accountId, int minutes)
{
if (minutes <= 0) { SitePrefsDB.SetDnd(accountId, null); return null; }
var until = DateTime.UtcNow.AddMinutes(Math.Min(minutes, MaxMinutes));
SitePrefsDB.SetDnd(accountId, until);
return until;
}
/// <summary>May <paramref name="sender"/> invite <paramref name="target"/> right now? True unless the target has it on and
/// has not favourited the sender.</summary>
public static bool InviteAllowed(ulong target, ulong sender)
{
if (!Active(target)) return true;
try
{
return FriendsDB.GetRelationships(target).Any(r =>
r.PlayerID == sender && (r.Favorited == ReciprocalStatus.Local || r.Favorited == ReciprocalStatus.Mutual));
}
catch { return true; } // never lock a player out because a lookup failed
}
}
}
+1478
View File
@@ -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,118 @@
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
{
/// <summary>
/// JOIN AN EVENT: the one place that decides which room instance a player lands in. The game's
/// POST matchmake/event/{id} and the website's Join button both use it, so they can never disagree.
///
/// Entry rules (EventDB.CanPlayerJoin): the creator any time, an invited player any time, everyone
/// else only once it has started and before it ended. Everyone attending belongs in the SAME instance
/// (the one the event is broadcasting to); a full main instance overflows into another instance of
/// the event; the first one in opens the instance the rest land in. On success the player's heartbeat
/// is pointed at that instance, so their client walks into it.
/// </summary>
public static class EventJoin
{
public sealed class Outcome
{
public RoomInstance? Session;
public MatchmakingErrorCode Code = MatchmakingErrorCode.Success;
public string Why = "";
public EventDBClasses.FullPlayerEvent? Event;
public string RoomName = "";
public string SubRoomName = "";
}
public static Outcome Resolve(ulong eventId, ulong accountId)
{
Outcome Refuse(MatchmakingErrorCode code, string why, EventDBClasses.FullPlayerEvent? found = null)
{
Console.WriteLine($"[matchmake/event] REFUSED {accountId} -> event {eventId}: {why} (code {(int)code} {code})");
return new Outcome { Code = code, Why = why, Event = found };
}
if (PlayerDB.HasActiveModerationBlock(accountId))
return Refuse(MatchmakingErrorCode.Banned, "account is banned or timed out");
var ev = EventDB.GetFullEventById(eventId);
if (ev == null)
return Refuse(MatchmakingErrorCode.NoSuchGame, "no such event");
if (!EventDB.CanPlayerJoin(ev, accountId, out string reason))
return Refuse(MatchmakingErrorCode.EventNotStarted, reason, ev);
var room = RoomDB.GetRoom(ev.RoomId);
if (room == null)
return Refuse(MatchmakingErrorCode.NoSuchRoom, $"event room {ev.RoomId} no longer exists", ev);
// The event's room still applies its own rules - a private room the player has no
// role on is not opened up just because an event points at it.
if (!RoomDB.CanPlayerEnterRoom(room, accountId))
return Refuse(MatchmakingErrorCode.RoomIsPrivate, $"cannot enter room {ev.RoomId} (private / banned)", ev);
string roomName = room.Name ?? "";
// No subroom on the event -> the room's Home (else first) subroom, not "Unknown Room".
string subroomname = ev.SubRoomId.HasValue && ev.SubRoomId.Value != 0
? RoomDB.get_subroom_from_id(ev.RoomId, ev.SubRoomId.Value)
: (room.SubRooms?.FirstOrDefault(sr => string.Equals(sr.Name, "Home", StringComparison.OrdinalIgnoreCase)) ?? room.SubRooms?.FirstOrDefault())?.Name ?? "";
RoomInstance? session = null;
if (ev.BroadcastingRoomInstanceId != 0)
session = HeartbeatDB.FindInstanceById((long)ev.BroadcastingRoomInstanceId);
if (session != null && InstanceBlocks.IsBlocked(accountId, session))
return Refuse(MatchmakingErrorCode.BlockedFromRoom, $"kicked from the event's instance {session.roomInstanceId}", ev);
// The event's instance is FULL: overflow into another instance of the event instead of
// cramming past the cap (the overflow instances receive the broadcast over Photon Chat).
if (session != null && HeartbeatDB.InstanceIsFull(session)
&& HeartbeatDB.GetPlayerHeartbeat(accountId)?.roomInstance?.roomInstanceId != session.roomInstanceId)
{
var main = session;
var overflow = HeartbeatDB.LiveInstancesOfRoom(ev.RoomId)
.Where(t => t.inst.roomInstanceId != main.roomInstanceId && t.inst.eventId == (long)eventId
&& !t.inst.isPrivate && !HeartbeatDB.InstanceIsFull(t.inst) && !InstanceBlocks.IsBlocked(accountId, t.inst))
.OrderByDescending(t => t.players.Count)
.Select(t => t.inst)
.FirstOrDefault()
?? Sessions.CreateRoom(roomName, subroomname);
if (overflow != null)
{
Console.WriteLine($"[matchmake/event] event {eventId}: main instance {main.roomInstanceId} is full - {accountId} goes to overflow instance {overflow.roomInstanceId} (sees the broadcast)");
session = overflow;
}
}
if (session == null)
{
session = HeartbeatDB.FindJoinableInstance(ev.RoomId, accountId)
?? Sessions.CreateRoom(roomName, subroomname);
if (session != null)
{
// First one in defines the instance the rest will land in.
EventDB.SetBroadcastingInstance(eventId, (ulong)session.roomInstanceId, true);
Console.WriteLine($"[matchmake/event] event {eventId} is now live in instance {session.roomInstanceId}");
}
}
if (session == null)
return Refuse(MatchmakingErrorCode.InsufficientSpace, "could not create a room instance", ev);
// Mark the instance as this event's so the client shows the "This Event" tab.
session.eventId = (long)eventId;
// ClubId is nullable: `null != 0` is TRUE, and the cast then threw "Nullable object must
// have a value" - every join of a non-club event 500'd after the instance was created.
if (ev.ClubId.HasValue && ev.ClubId.Value != 0) session.clubId = (long)ev.ClubId.Value;
RoomDB.IncreaseRoomVisit(ev.RoomId);
PlayerDB.SetRecentRooms(accountId, ev.RoomId);
HeartbeatDB.UpdatePlayerHeartbeat(accountId, session);
Console.WriteLine($"[matchmake/event] {accountId} joined event {eventId} '{ev.Name}' -> ^{roomName}.{subroomname} instance {session.roomInstanceId}");
return new Outcome { Session = session, Event = ev, RoomName = roomName, SubRoomName = subroomname };
}
}
}
@@ -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,84 @@
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 + "|" + ConfigCampus.SkipOrientation + "|" + ConfigCampus.ClientDebugLogPaths;
/// <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.InRoomChat.AccountCreationCutoff" && !string.IsNullOrWhiteSpace(ConfigCampus.InRoomChatAccountCreationCutoff))
{
e["Value"] = ConfigCampus.InRoomChatAccountCreationCutoff; rewritten++;
}
else if (key == "Debug.Logs" &&!string.IsNullOrWhiteSpace(ConfigCampus.ClientDebugLogPaths))
{
// the value is a comma / newline separated list of log paths (GLOBAL_ENABLE, Network.TachyonClient, ...)
string cur = ((string?)e["Value"] ?? "").TrimEnd();
var extra = ConfigCampus.ClientDebugLogPaths.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
e["Value"] = cur + (cur.EndsWith(",") ? "\n" : ",\n") + string.Join(",\n", extra);
rewritten++;
}
else 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 = ItemImages.ThumbFor(g), // the item's own picture, never a missing file (ItemImages)
GiftRarity = g.Rarity,
EquipmentPrefabName = g.EquipmentPrefabName ?? "",
EquipmentModificationGuid = SkinOwnership.CanonicalGuid(g.EquipmentPrefabName, g.EquipmentModificationGuid) ?? "", // the locker's spelling - one skin, not two
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 = SkinOwnership.CanonicalGuid(g.EquipmentPrefabName, g.EquipmentModificationGuid) ?? "", // the locker's spelling - one skin, not two
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 = ItemImages.ThumbFor(g), // the item's own picture, never a missing file (ItemImages)
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,43 @@
using System.Collections.Concurrent;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// One gift of the same item from the same buyer to the same player per Window (2026-09-27
/// request). The gift button is easy to hit twice, and two taps both passed the "they already
/// have it" check before either box existed - consumables are never checked at all - so the
/// buyer paid twice. The reservation is taken atomically before anything is charged; a line
/// that ends up unbought gives it back.
/// </summary>
public static class GiftGuard
{
public static TimeSpan Window = TimeSpan.FromSeconds(60);
public const string Message = "You just gifted them that - wait a minute before sending it again.";
private static readonly ConcurrentDictionary<string, DateTime> _recent = new();
public static string Key(ulong buyer, ulong recipient, string item) => $"{buyer}>{recipient}:{item.ToLowerInvariant()}";
/// <summary>True when this gift may go ahead (and it is now reserved), false inside the window.</summary>
public static bool TryReserve(string key)
{
var now = DateTime.UtcNow;
if (_recent.Count > 5000)
foreach (var kv in _recent)
if (now - kv.Value >= Window) _recent.TryRemove(kv.Key, out _);
while (true)
{
if (_recent.TryAdd(key, now)) return true;
if (!_recent.TryGetValue(key, out var at)) continue;
if (now - at < Window) return false;
if (_recent.TryUpdate(key, now, at)) return true;
}
}
/// <summary>Give a reservation back (the gift was not bought after all).</summary>
public static void Release(string? key)
{
if (!string.IsNullOrEmpty(key)) _recent.TryRemove(key, out _);
}
}
}
@@ -0,0 +1,84 @@
using System.Collections.Concurrent;
using Campus_live_rewrite.api.dbs;
using static Campus_live_rewrite.api.dbs.HeartbeatDB;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Keeps a GROUP MOVE together: when the game moves everyone in an instance on at once (a
/// Laser Tag round ends and the whole lobby goes to a new instance), every client matchmakes
/// on its own, a few seconds apart. Seen 2026-09-21 22:02 in ^LaserTag: 617 posted
/// room/18/28 JoinMode=2 AdditionalPlayersAutoFollow=True AdditionalPlayerIds=[618] and got a
/// new private instance; 618 posted the same 10 s later with AdditionalPlayerIds=[] and got a
/// DIFFERENT new instance, so the lobby split up.
///
/// Two records, each good for <see cref="Window"/>:
/// - a reservation per player the first mover listed in AdditionalPlayerIds;
/// - the instance the group came FROM + the room it asked for, for an auto-follow move,
/// so it works whichever client asks first and even when the list is empty.
/// The next of them to ask for that room lands in the same instance while it has space.
/// </summary>
public static class GroupMoves
{
public static readonly TimeSpan Window = TimeSpan.FromSeconds(90);
private static readonly ConcurrentDictionary<ulong, (RoomInstance inst, DateTime at)> _reserved = new();
private static readonly ConcurrentDictionary<string, (RoomInstance inst, DateTime at)> _fromInstance = new();
private static string Key(long fromInstanceId, ulong roomId, long subRoomId) => $"{fromInstanceId}|{roomId}|{subRoomId}";
/// <summary>
/// Where this player's group went, if they are part of one: the room (and subroom, when
/// the request names one; -1 = any) must match, the instance must still have space and
/// must not be one they were kicked from. Null = matchmake normally.
/// </summary>
public static RoomInstance? TakeFor(ulong player, ulong roomId, long subRoomId, long fromInstanceId, bool autoFollow)
{
var now = DateTime.UtcNow;
bool Fits(RoomInstance i) =>
i.roomId == (long)roomId
&& (subRoomId < 0 || i.subRoomId == subRoomId)
&& i.roomInstanceId != fromInstanceId
&& !InstanceIsFull(i)
&& !IsInstanceBroken(i)
&& !InstanceBlocks.IsBlocked(player, i);
if (_reserved.TryRemove(player, out var r) && now - r.at <= Window && Fits(r.inst))
{
Console.WriteLine($"[group-move] {player} follows their group into instance {r.inst.roomInstanceId} of room {roomId} (listed by the first mover)");
return r.inst;
}
if (autoFollow && fromInstanceId != 0)
{
foreach (var sub in subRoomId < 0 ? new long[] { -1 } : new[] { subRoomId, -1 })
if (_fromInstance.TryGetValue(Key(fromInstanceId, roomId, sub), out var g) && now - g.at <= Window && Fits(g.inst))
{
Console.WriteLine($"[group-move] {player} follows their group from instance {fromInstanceId} into instance {g.inst.roomInstanceId} of room {roomId}");
return g.inst;
}
}
return null;
}
/// <summary>Remember where a mover landed, so the rest of their group can follow.</summary>
public static void Record(ulong mover, long fromInstanceId, RoomInstance landed, IEnumerable<long>? additional, bool autoFollow, long requestedSubRoomId)
{
if (landed == null || landed.roomInstanceId == 0) return;
var now = DateTime.UtcNow;
int listed = 0;
foreach (var id in additional ?? Enumerable.Empty<long>())
if (id > 0 && (ulong)id != mover) { _reserved[(ulong)id] = (landed, now); listed++; }
if (autoFollow && fromInstanceId != 0 && fromInstanceId != landed.roomInstanceId)
_fromInstance.TryAdd(Key(fromInstanceId, (ulong)landed.roomId, requestedSubRoomId), (landed, now));
if (listed > 0 || autoFollow)
Console.WriteLine($"[group-move] {mover} moved from instance {fromInstanceId} to {landed.roomInstanceId} (room {landed.roomId}); {listed} listed player(s) will follow{(autoFollow ? ", auto-follow group" : "")}");
Sweep(now);
}
private static void Sweep(DateTime now)
{
foreach (var kv in _reserved) if (now - kv.Value.at > Window) _reserved.TryRemove(kv.Key, out _);
foreach (var kv in _fromInstance) if (now - kv.Value.at > Window) _fromInstance.TryRemove(kv.Key, out _);
}
}
}
@@ -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);
}
}
+196
View File
@@ -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,172 @@
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>
/// <summary>
/// Every block in force as (player, Photon room) - for CampusNet, which refuses a Photon join
/// into a room the player was kicked from. A kicked client reconnects to its old Photon room
/// by itself, straight through the Photon master, without asking the API - so the
/// matchmaking check alone never saw it (2026-09-27).
/// </summary>
public static List<(ulong player, string photonRoom)> ActivePhotonBlocks()
{
EnsureLoaded();
var now = DateTime.UtcNow;
var list = new List<(ulong, string)>();
foreach (var kv in _blocks)
{
if (kv.Value.Until <= now) { _blocks.TryRemove(kv.Key, out _); continue; }
string photon = kv.Value.PhotonRoom;
if (photon.Length == 0 && kv.Value.RoomInstanceId != 0)
photon = HeartbeatDB.FindInstanceById(kv.Value.RoomInstanceId)?.photonRoomIdYeee ?? "";
if (photon.Length > 0) list.Add((kv.Value.PlayerId, photon));
}
return list;
}
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,116 @@
using Newtonsoft.Json.Linq;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Which picture a skin / consumable / plain gift-drop box (the N-Star Unique Boxes) and its
/// store tile carry.
///
/// The real-store import (2026-09-17..22) gave those store rows Rec Room's own thumbnail names,
/// and bought boxes copy them. Almost none of those files exist on this server (0 of 88 skin
/// thumbnails, 30 of 168 consumable ones, 0 of 13 box ones) and img.rec.net is gone, so every
/// one of them was answered with the fallback room picture - where, with NO thumbnail name,
/// the client draws the item's own picture, which is what they showed before the import.
///
/// So: a consumable uses the image from the consumables catalog (items/Consumables.json /
/// AllUnlockableConsumables.json - 160 of those files are here) when there is one; otherwise
/// a thumbnail name is only sent when the file really exists, and is left EMPTY when it does
/// not, so the client falls back to the item itself. Avatar items, tokens and XP boxes are
/// left alone.
/// </summary>
public static class ItemImages
{
private static readonly string ImagesRoot = Path.Combine(Environment.CurrentDirectory, "Data", "Images");
// The same folders the /img route looks in (ImagesController.ImgServer).
private static readonly string[] Folders =
{ "", "rro_images", Path.Combine("rro_images", "base_rros"), "player_images", "polaroid_images",
"DefaultImgs", "rec_net", "custom_shirt_imgs", "LoadingScreenTips" };
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, (bool ok, DateTime at)> _exists = new(StringComparer.OrdinalIgnoreCase);
private static readonly TimeSpan ExistsTtl = TimeSpan.FromMinutes(10);
/// <summary>Is there a file of this name that /img will serve?</summary>
public static bool Exists(string? name)
{
if (string.IsNullOrWhiteSpace(name) || name.IndexOfAny(new[] { '/', '\\' }) >= 0 || name.Contains("..")) return false;
if (_exists.TryGetValue(name, out var hit) && DateTime.UtcNow - hit.at < ExistsTtl) return hit.ok;
bool ok = false;
try { ok = Folders.Any(f => File.Exists(Path.Combine(ImagesRoot, f, name))); } catch { }
_exists[name] = (ok, DateTime.UtcNow);
return ok;
}
// ---- consumables catalog -------------------------------------------------------------
private static Dictionary<string, string>? _consumableImages;
private static readonly object _lock = new();
private static Dictionary<string, string> ConsumableImages()
{
lock (_lock)
{
if (_consumableImages != null) return _consumableImages;
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var file in new[] { "Consumables.json", "AllUnlockableConsumables.json" })
{
try
{
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", file);
if (!File.Exists(path)) continue;
foreach (var e in JArray.Parse(File.ReadAllText(path)).OfType<JObject>())
{
string key = PlayerDB.NormalizeConsumableDesc((string?)e["ConsumableItemDesc"]);
string? img = (string?)e["ThumbnailImageName"];
if (key.Length > 0 && !string.IsNullOrWhiteSpace(img) && !map.ContainsKey(key)) map[key] = img!;
}
}
catch (Exception ex) { Console.WriteLine($"[item-images] {file} unreadable: {ex.Message}"); }
}
return _consumableImages = map;
}
}
/// <summary>The consumables catalog's image for this consumable, if that file is here.</summary>
public static string? ConsumableImage(string? consumableItemDesc)
{
string key = PlayerDB.NormalizeConsumableDesc(consumableItemDesc);
return key.Length > 0 && ConsumableImages().TryGetValue(key, out var img) && Exists(img) ? img : null;
}
/// <summary>
/// The thumbnail to send for an item. <paramref name="stored"/> is what the box / row
/// carries. Only skins, consumables and item-less gift drops are touched.
/// </summary>
public static string ThumbFor(string? stored, string? consumableDesc, string? equipmentPrefab, string? equipmentGuid,
string? avatarDesc, string? customAvatarItemId, bool isCurrencyOrXp)
{
stored ??= "";
bool avatar = !string.IsNullOrWhiteSpace(avatarDesc) || !string.IsNullOrWhiteSpace(customAvatarItemId);
if (avatar || isCurrencyOrXp) return stored;
if (!string.IsNullOrWhiteSpace(consumableDesc))
return ConsumableImage(consumableDesc) ?? (Exists(stored) ? stored : "");
// A skin, or a gift drop with no item of its own (the N-Star Unique Boxes).
return Exists(stored) ? stored : "";
}
public static string ThumbFor(PendingGiftBox g) =>
ThumbFor(g.ThumbnailImageName, g.ConsumableItemDesc, g.EquipmentPrefabName, g.EquipmentModificationGuid,
g.AvatarItemDesc, g.CustomAvatarItemId,
(g.Currency > 0 && g.CurrencyType != 0) || GiftBoxes.IsXpOnly(g));
/// <summary>Fix a store row's GiftDrop.ThumbnailImageName in place (served stores).</summary>
public static bool FixStoreRow(JObject row)
{
if (row["GiftDrop"] is not JObject gd) return false;
string? before = (string?)gd["ThumbnailImageName"];
bool currency = (((int?)gd["CurrencyType"]) ?? 0) != 0 && (((long?)gd["Currency"]) ?? 0) > 0;
string after = ThumbFor(before, (string?)gd["ConsumableItemDesc"], (string?)gd["EquipmentPrefabName"],
(string?)gd["EquipmentModificationGuid"], (string?)gd["AvatarItemDesc"],
(string?)gd["CustomAvatarItemId"], currency);
if (after == (before ?? "")) return false;
gd["ThumbnailImageName"] = after.Length > 0 ? after : null;
return true;
}
}
}
@@ -0,0 +1,119 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The "launched by the current launcher" gate. The PATCH proves it is the current DLL
/// (PatchProofs); THIS proves the current Campus launcher was the one that started the
/// game. The launcher mints an HMAC-signed token at Play time ("launch|accountId|steamId|ts"
/// signed with the per-build LauncherKey secret) and passes it to the game via the
/// CAMPUS_LAUNCH_TOKEN environment variable. The patch reads that env var, forwards it to
/// the server with /api/patch/proof. A good token is remembered here for
/// ConfigCampus.PatchProofMinutes by the caller's IP and Steam id - the same window as
/// patch proofs. The login gate then refuses PC logins without a fresh LAUNCHER-launch
/// proof, and when a legit patch proof is seen WITHOUT a launcher-launch proof (classic
/// "someone cloned the DLL into their own tool") it yanks the Steam id from the normal-
/// player whitelist so they have to come back through the launcher.
/// </summary>
public static class LauncherLaunchProofs
{
private static readonly ConcurrentDictionary<string, DateTime> _byIp = new();
private static readonly ConcurrentDictionary<ulong, DateTime> _bySteam = new();
public readonly record struct Check(bool Ok, string Why, ulong AccountId, ulong SteamId);
// Per-build secret. The launcher's release_app.py mints one into Deploy\launcher-app
// as <sha>.launchkey (next to the exe), uploaded with the exe. Fallback: a long-lived
// one in ConfigCampus.LauncherLaunchKey - fine for dev, used in prod only if an upload
// ever ships without the .launchkey file next to it.
private static string _keySha = "", _key = "";
private static readonly object _keyLock = new();
private static string KeyFor(string sha)
{
lock (_keyLock)
{
if (sha == _keySha) return _key;
string k = "";
try
{
string f = Path.Combine(Environment.CurrentDirectory, "Data", "Launcher", "app", sha + ".launchkey");
if (sha.Length == 64 && File.Exists(f)) k = File.ReadAllText(f).Trim();
}
catch { }
if (k.Length == 0) k = ConfigCampus.LauncherLaunchKey ?? "";
_keySha = sha; _key = k;
return k;
}
}
private static string CurrentLauncherSha()
{
try
{
string f = Path.Combine(Environment.CurrentDirectory, "Data", "Launcher", "app", "latest.json");
if (!File.Exists(f)) return "";
var j = JObject.Parse(File.ReadAllText(f));
return (j.Value<string>("sha256") ?? "").ToLowerInvariant();
}
catch { return ""; }
}
/// <summary>
/// Verify a launch token "accountId|steamId|ts|hmac". Returns Ok=true + the token's
/// accountId / steamId on success so the caller can cross-check (the login endpoint
/// confirms it matches the actual Steam ticket id and the account that's signing in).
/// </summary>
public static Check Verify(string token)
{
if (string.IsNullOrEmpty(token)) return new(false, "no token", 0, 0);
var parts = token.Split('|');
if (parts.Length != 4) return new(false, "malformed token", 0, 0);
if (!ulong.TryParse(parts[0], out ulong acc)) return new(false, "bad accountId", 0, 0);
if (!ulong.TryParse(parts[1], out ulong steam)) return new(false, "bad steamId", 0, 0);
if (!long.TryParse(parts[2], out long ts)) return new(false, "bad ts", 0, 0);
string mac = (parts[3] ?? "").ToLowerInvariant();
string sha = CurrentLauncherSha();
if (sha.Length == 0) return new(false, "no launcher staged on the server", acc, steam);
string key = KeyFor(sha);
if (key.Length == 0) return new(false, "no launcher-launch key configured", acc, steam);
long skew = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts;
if (Math.Abs(skew) > ConfigCampus.ProofMaxSkewSeconds)
return new(false, $"clock off by {Math.Abs(skew) / 3600.0:0.#} hours - set the PC clock to automatic time", acc, steam);
string msg = $"launch|{acc}|{steam}|{ts}";
using var h = new HMACSHA256(Encoding.UTF8.GetBytes(key));
string want = Convert.ToHexString(h.ComputeHash(Encoding.UTF8.GetBytes(msg))).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(want), Encoding.UTF8.GetBytes(mac)))
return new(false, "bad launch signature", acc, steam);
return new(true, "", acc, steam);
}
public static void Record(string ip, ulong steamId)
{
var now = DateTime.UtcNow;
if (!string.IsNullOrEmpty(ip)) _byIp[ip] = now;
if (steamId != 0) _bySteam[steamId] = now;
if (_byIp.Count > 20000) Prune();
}
public static bool HasFresh(string? ip, ulong verifiedSteamId)
{
var cutoff = DateTime.UtcNow.AddMinutes(-Math.Max(1, ConfigCampus.PatchProofMinutes));
if (!string.IsNullOrEmpty(ip) && _byIp.TryGetValue(ip, out var a) && a >= cutoff) return true;
if (verifiedSteamId != 0 && _bySteam.TryGetValue(verifiedSteamId, out var b) && b >= cutoff) return true;
return false;
}
private static void Prune()
{
var cutoff = DateTime.UtcNow.AddMinutes(-Math.Max(1, ConfigCampus.PatchProofMinutes));
foreach (var kv in _byIp) if (kv.Value < cutoff) _byIp.TryRemove(kv.Key, out _);
foreach (var kv in _bySteam) if (kv.Value < cutoff) _bySteam.TryRemove(kv.Key, out _);
}
}
}
@@ -0,0 +1,142 @@
using System.Collections.Concurrent;
using Newtonsoft.Json;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The NORMAL PLAYER whitelist: every Steam id that has been through the Campus launcher.
///
/// The launcher proves a player's Steam account with a ticket from Steam itself
/// (LauncherController /api/launcher/v1/steam, SteamTicket checks Steam's signature), and that
/// Steam id is added here automatically. With Required on, a PC (Steam) game login is let in
/// only when its - Steam-verified - id is on this list or on the hand-made allowlist
/// (SteamAllowlist, Data/steam_allowlist.txt: staff / special entries). So playing on PC means
/// running Campus through our launcher at least once. Quest/Meta is never affected.
///
/// Stored in Data/steam_launcher_players.json; managed from the admin panel (Settings).
/// </summary>
public static class LauncherPlayers
{
public class Entry
{
public string SteamId { get; set; } = "";
public ulong AccountId { get; set; }
public string Username { get; set; } = "";
public DateTime AddedAt { get; set; }
public DateTime LastSeen { get; set; }
}
private class FileData
{
public bool Required { get; set; } = true;
public List<Entry> Players { get; set; } = new();
}
private static string Path_ => Path.Combine(Environment.CurrentDirectory, "Data", "steam_launcher_players.json");
private static readonly object _lock = new();
private static FileData? _data;
private static ConcurrentDictionary<ulong, Entry> _byId = new();
private static FileData Data()
{
if (_data != null) return _data;
lock (_lock)
{
if (_data != null) return _data;
FileData d;
try { d = File.Exists(Path_) ? JsonConvert.DeserializeObject<FileData>(File.ReadAllText(Path_)) ?? new FileData() : new FileData(); }
catch (Exception ex)
{
// Unreadable: keep the gate on (Required defaults true) but start empty - the
// players re-add themselves on their next launcher start. Not saved over the
// broken file, so it can still be recovered by hand.
Console.WriteLine($"[launcher-players] could not read {Path_}: {ex.Message}");
d = new FileData();
}
var map = new ConcurrentDictionary<ulong, Entry>();
foreach (var e in d.Players)
if (ulong.TryParse(e.SteamId, out ulong id) && id != 0) map[id] = e;
_byId = map;
_data = d;
Console.WriteLine($"[launcher-players] {map.Count} Steam id(s) whitelisted through the launcher; " +
$"launcher required for Steam logins: {(d.Required ? "YES" : "no")}");
return d;
}
}
private static void Save()
{
try
{
var d = Data();
d.Players = _byId.Values.OrderBy(e => e.AddedAt).ToList();
Directory.CreateDirectory(Path.GetDirectoryName(Path_)!);
string tmp = Path_ + ".tmp";
File.WriteAllText(tmp, JsonConvert.SerializeObject(d, Formatting.Indented));
File.Move(tmp, Path_, true);
}
catch (Exception ex) { Console.WriteLine($"[launcher-players] could not write {Path_}: {ex.Message}"); }
}
/// <summary>Must a Steam login come through the launcher (this list or the hand-made allowlist)?</summary>
public static bool Required => !ConfigCampus.LocalDev && Data().Required;
public static void SetRequired(bool on)
{
lock (_lock) { Data().Required = on; Save(); }
Console.WriteLine($"[launcher-players] launcher required for Steam logins: {(on ? "YES" : "no")}");
}
public static bool Contains(ulong steamId) { Data(); return steamId != 0 && _byId.ContainsKey(steamId); }
/// <summary>Add (or refresh) a Steam id the launcher just proved with a Steam ticket.</summary>
public static bool Add(ulong steamId, ulong accountId, string username)
{
if (steamId == 0) return false;
Data();
lock (_lock)
{
bool added = !_byId.TryGetValue(steamId, out var e);
e ??= new Entry { SteamId = steamId.ToString(), AddedAt = DateTime.UtcNow };
e.AccountId = accountId; e.Username = username ?? ""; e.LastSeen = DateTime.UtcNow;
_byId[steamId] = e;
Save();
if (added) Console.WriteLine($"[launcher-players] whitelisted Steam {steamId} (@{username}, {accountId}) - {_byId.Count} total");
return added;
}
}
public static bool Remove(ulong steamId)
{
Data();
lock (_lock)
{
if (!_byId.TryRemove(steamId, out _)) return false;
Save();
}
Console.WriteLine($"[launcher-players] removed Steam {steamId} - {_byId.Count} left");
return true;
}
public static List<Entry> All() { Data(); return _byId.Values.OrderByDescending(e => e.AddedAt).ToList(); }
/// <summary>The whitelisted Steam ids an account added through the launcher (the launcher's /me).</summary>
public static List<string> ForAccount(ulong accountId) { Data(); return _byId.Values.Where(e => e.AccountId == accountId).Select(e => e.SteamId).ToList(); }
}
/// <summary>
/// The one question the login asks about a Steam id: may it play? With the launcher required,
/// only ids on the normal-player whitelist (LauncherPlayers) or the hand-made allowlist
/// (SteamAllowlist) - an empty hand-made list does NOT open the gate then. Without it, the old
/// allowlist rule (empty = everyone).
/// </summary>
public static class SteamGate
{
public static bool Active => LauncherPlayers.Required || SteamAllowlist.IsActive;
public static bool Allowed(ulong steamId) =>
LauncherPlayers.Required
? steamId != 0 && (SteamAllowlist.All().Contains(steamId) || LauncherPlayers.Contains(steamId))
: SteamAllowlist.IsAllowed(steamId);
}
}
@@ -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,122 @@
using LiteDB;
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>
/// Daily login streak. The first login of each day extends the player's streak (a missed day starts it over at day 1) and
/// pays a token gift box that grows with the streak: a 7-day table that repeats, plus a bonus for every full week, capped.
/// The box is the same pay-on-open kind level-ups use, so it arrives in the player's notifications and pays when opened.
///
/// "Day" is the server's calendar day shifted by <see cref="ConfigCampus.StreakDayOffsetHours"/>. A login is credited once
/// per day however many times the client re-posts player/login (it does on every reconnect), and the day is only recorded
/// after the box is safely stored, so a failed store is retried by the next login instead of losing the day.
/// </summary>
public static class LoginStreaks
{
public class Streak
{
[BsonId] public ulong AccountId { get; set; }
public string LastDay { get; set; } = ""; // yyyy-MM-dd of the last credited day
public int Current { get; set; }
public int Best { get; set; }
public int TotalDays { get; set; }
}
private static readonly Lazy<LiteDatabase> _db = new(() =>
new LiteDatabase(Path.Combine(Environment.CurrentDirectory, "Data", "DBs", "LoginStreaks.db")));
private static ILiteCollection<Streak> Col() => _db.Value.GetCollection<Streak>("streaks");
private static readonly object _lock = new();
/// <summary>The clock (replaceable so a test can move between days).</summary>
public static Func<DateTime> UtcNow = () => DateTime.UtcNow;
public static string Today() => UtcNow().AddHours(ConfigCampus.StreakDayOffsetHours).ToString("yyyy-MM-dd");
/// <summary>Tokens for streak day N (1-based): the table entry for the day within its week, plus the capped weekly bonus.</summary>
public static long RewardFor(int day)
{
var table = ConfigCampus.StreakRewards;
if (day < 1 || table.Length == 0) return 0;
long baseTokens = table[(day - 1) % table.Length];
int weeks = Math.Min((day - 1) / table.Length, ConfigCampus.StreakBonusMaxWeeks);
return baseTokens + (long)weeks * ConfigCampus.StreakBonusPerWeek;
}
/// <summary>Called on every player/login. Never throws: a streak problem must not break a login.</summary>
public static void OnLogin(ulong accountId)
{
try
{
if (!ConfigCampus.StreaksEnabled || accountId == 0) return;
if (PlayerDB.HasActiveModerationBlock(accountId)) return; // banned / timed out players earn nothing
PendingGiftBox box;
int day;
lock (_lock)
{
string today = Today();
var s = Col().FindById(accountId) ?? new Streak { AccountId = accountId };
if (s.LastDay == today) return; // already credited today
int gap = DaysBetween(s.LastDay, today); // 1 = consecutive; int.MaxValue = never logged in
day = (gap >= 1 && gap <= 1 + ConfigCampus.StreakGraceDays) ? s.Current + 1 : 1;
long tokens = RewardFor(day);
box = new PendingGiftBox
{
GiftContext = QuestRewards.GiftContextGameRewardTokens,
Message = day == 1 ? "Day 1 of your login streak! Come back tomorrow for a bigger reward."
: $"Day {day} of your login streak! Come back tomorrow to keep it going.",
FromAccountId = 0,
FriendlyName = $"Day {day} Streak Box",
ThumbnailImageName = QuestRewards.TokenThumbnail,
CurrencyType = (int)CurrencyType.RecCenterTokens,
Currency = tokens,
Rarity = QuestRewards.TokenRarity(tokens),
PayOnOpen = true
};
if (!PlayerDB.AddPendingGiftBoxFull(accountId, box))
{
Console.WriteLine($"[streak] {accountId}: could not store the day {day} box - will retry on the next login");
return;
}
s.LastDay = today;
s.Current = day;
s.Best = Math.Max(s.Best, day);
s.TotalDays++;
Col().Upsert(s);
Console.WriteLine($"[streak] {accountId} day {day} (best {s.Best}): {tokens} tokens in box {box.NumericId}");
}
// The client is still loading when player/login arrives; a box pushed that early is dropped. It is already stored,
// so a missed push only means it shows at the next login - the delay just makes the live notification land.
var pushBox = box;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(ConfigCampus.StreakPushDelaySeconds));
GiftBoxes.Push(accountId, pushBox, GiftBoxes.FrameReceivedImmediate, $"login streak day {day}");
}
catch (Exception ex) { Console.WriteLine($"[streak] {accountId} push failed: {ex.Message}"); }
});
}
catch (Exception ex)
{
Console.WriteLine($"[streak] {accountId} failed: {ex.Message}");
}
}
public static Streak? Get(ulong accountId) => Col().FindById(accountId);
private static int DaysBetween(string from, string to)
{
if (!DateTime.TryParse(from, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var a)) return int.MaxValue;
if (!DateTime.TryParse(to, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var b)) return int.MaxValue;
return (int)(b.Date - a.Date).TotalDays;
}
}
}
@@ -0,0 +1,109 @@
using System.Globalization;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Verification of a Meta (Oculus) login's platform_auth nonce against Meta's graph API.
/// Port of the reference recflare server's apps/auth/src/meta-nonce.ts.
///
/// A Meta login posts
/// platform_auth = {"Nonce":"&lt;64 chars&gt;","AppId":"1295523863648168","Source":"logged in user"}
/// platform_id = &lt;the Meta user id&gt;
/// The nonce means nothing on its own; asking Meta "was this nonce issued to this user, for
/// this app?" is what binds the two together. So the posted platform_id is an INPUT here, and a
/// spoofed one fails validation instead of being trusted. Only an id that comes back verified
/// is ever bound or linked to an account.
///
/// Needs the app secret (ConfigCampus.MetaAppSecret): the access token is OC|appId|secret,
/// which authenticates the APP - which is why the secret never leaves the server.
/// </summary>
public static class MetaNonce
{
private const string NonceValidateUrl = "https://graph.oculus.com/user_nonce_validate";
private const int MaxAttempts = 3;
private static readonly HashSet<int> TransientErrorCodes = new() { 1, 2 };
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(8) };
public readonly record struct Result(bool Ok, string Reason, ulong UserId, string AppId);
/// <summary>The {Nonce, AppId} a Meta platform_auth carries, or null when it isn't one.</summary>
public static (string nonce, string appId)? Parse(string? platformAuth)
{
if (string.IsNullOrEmpty(platformAuth)) return null;
JObject o;
try { o = JObject.Parse(System.Net.WebUtility.UrlDecode(platformAuth)); }
catch { try { o = JObject.Parse(platformAuth); } catch { return null; } }
string? nonce = o["Nonce"]?.Type == JTokenType.String ? o["Nonce"]!.Value<string>() : null;
string? appId = o["AppId"]?.Type == JTokenType.String ? o["AppId"]!.Value<string>() : null;
if (string.IsNullOrEmpty(nonce)) return null;
// AppId is interpolated into the access token below - keep a non-numeric string out of it.
if (string.IsNullOrEmpty(appId) || !appId.All(char.IsDigit)) return null;
return (nonce, appId);
}
/// <summary>
/// Verify platform_auth against the user id it is claimed for. Only succeeds for a nonce Meta
/// itself confirms was issued to that user for that app. Reasons are for the server log; the
/// client is only ever told the proof was rejected.
/// </summary>
public static async Task<Result> VerifyAsync(string? platformAuth, ulong userId, string appSecret)
{
if (string.IsNullOrEmpty(appSecret)) return new(false, "no app secret configured", 0, "");
if (userId == 0) return new(false, "missing platform_id", 0, "");
var parsed = Parse(platformAuth);
if (parsed == null) return new(false, "malformed platform_auth payload", 0, "");
var (nonce, appId) = parsed.Value;
var form = new Dictionary<string, string>
{
["nonce"] = nonce,
["user_id"] = userId.ToString(CultureInfo.InvariantCulture),
["access_token"] = $"OC|{appId}|{appSecret}",
};
string reason = "not attempted";
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
var (ok, retryable, why) = await ValidateOnce(form);
if (ok) return new(true, "", userId, appId);
reason = why;
if (!retryable || attempt == MaxAttempts) break;
await Task.Delay(attempt * attempt * 250);
}
return new(false, reason, 0, appId);
}
private static async Task<(bool ok, bool retryable, string reason)> ValidateOnce(Dictionary<string, string> form)
{
HttpResponseMessage res;
try
{
res = await Http.PostAsync(NonceValidateUrl, new FormUrlEncodedContent(form));
}
catch (Exception ex)
{
return (false, true, $"request failed: {ex.Message}");
}
JObject body;
try { body = JObject.Parse(await res.Content.ReadAsStringAsync()); }
catch
{
// A non-JSON body is Meta's edge (a 5xx page, a rate-limit page), not a verdict.
return (false, true, $"HTTP {(int)res.StatusCode} with a non-JSON body");
}
if (body["error"] is JObject err)
{
int? code = err["code"]?.Type == JTokenType.Integer ? err["code"]!.Value<int>() : null;
bool transient = err["is_transient"]?.Type == JTokenType.Boolean && err["is_transient"]!.Value<bool>();
bool retry = transient || (code.HasValue && TransientErrorCodes.Contains(code.Value));
return (false, retry, $"graph error {(code?.ToString() ?? "?")}: {err["message"]?.ToString() ?? "no message"}");
}
if (body["is_valid"]?.Type != JTokenType.Boolean || !body["is_valid"]!.Value<bool>())
return (false, false, "nonce rejected");
return (true, false, "");
}
}
}
@@ -0,0 +1,53 @@
using Newtonsoft.Json;
namespace Campus_live_rewrite.api.server
{
public class Moderation_Detail
{
public class ModerationBlockDetails
{
public Moderation_Detail.ReportCategory ReportCategory { get; set; } = Moderation_Detail.ReportCategory.Unknown;
public int Duration { get; set; } = 0;
public long GameSessionId { get; set; } = 0;
public bool? IsHostKick { get; set; } = false;
public string? Message { get; set; } = "";
public ulong? PlayerIdReporter { get; set; } = null;
public bool? IsBan { get; set; } = false;
// The rest of the 16-key 2025 DTO (wire-shapes: playerreporting-moderationblockdetails).
// IsWarning is what turns the kick frame into a warning dialog instead of a removal.
public bool IsVoiceModAutoban { get; set; } = false;
public bool IsDeviceBan { get; set; } = false;
public bool IsWarning { get; set; } = false;
public string? VoteKickReason { get; set; } = null;
public DateTime? TimeoutStartedAt { get; set; } = null;
[JsonIgnore]
public long ModerationSetUnixTime { get; set; } = 0;
[JsonIgnore]
public ulong BannedByPlayerId { get; set; } = 0;
}
public enum ReportCategory
{
Moderator = -1,
Unknown,
DEPRECATED_MicrophoneAbuse,
Harassment,
Cheating,
DEPRECATED_ImmatureBehavior,
AFK,
Misc,
Underage,
VoteKick = 10,
MisleadingPurchases = 11,
CoC_Underage = 100,
CoC_Sexual,
CoC_Discrimination,
CoC_Trolling,
CoC_NameOrProfile,
InappropriateClothing = 200,
IssuingInaccurateReports = 1000,
RoomInventoryItems = 1100,
InappropriateRooms = 1101
}
}
}
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// One set of rules for every place a name can be set - the website signup, the client's
/// first-run "choose a username" (acc/account/me/username), the in-game @name change
/// (account/me/username) and the in-game display-name change (account/me/displayname).
/// Before this each path had its own checks and the gaps between them were the spoofing
/// holes: the first-run path took ANY string (spaces, control characters, zero-width joiners,
/// unlimited length, no swear filter), @name uniqueness was case-sensitive (so "kyle" sat next
/// to "Kyle"), and a display name could be blank, all punctuation, or an admin's name.
///
/// @username 3-20 chars, [A-Za-z0-9_], unique case-insensitively, not a reserved word,
/// passes the swear filter.
/// display name 1-15 chars of printable ASCII, whitespace collapsed, at least one letter
/// or digit, passes the swear filter, and may not impersonate an owner /
/// staff account or a reserved word once look-alike noise is stripped
/// ("K y l e", "Coach_", "c0ach" are all "the same name" for this check).
/// </summary>
public static class NameRules
{
public const int UsernameMin = 3, UsernameMax = 20, DisplayNameMax = 15;
// Names nobody may hold, in any casing or with look-alike decoration. Owner/staff accounts
// themselves are protected by AccountLock's exemption list (their real usernames).
private static readonly HashSet<string> Reserved = new(StringComparer.OrdinalIgnoreCase)
{
"admin", "administrator", "moderator", "mod", "campus", "system", "root", "staff",
"support", "null", "undefined", "everyone", "here", "recroom", "official", "owner",
"developer", "dev", "coach", "campusteam", "campusstaff", "campusmod"
};
private static readonly Regex UsernameShape = new("^[A-Za-z0-9_]+$", RegexOptions.Compiled);
/// <summary>Fold a name to what a reader perceives: lowercase, letters and digits only,
/// common digit-for-letter swaps undone. Two names that fold alike are "the same name".</summary>
public static string Fold(string s)
{
var sb = new System.Text.StringBuilder(s.Length);
foreach (char ch in s.ToLowerInvariant())
{
char c = ch switch { '0' => 'o', '1' => 'l', '3' => 'e', '4' => 'a', '5' => 's', '7' => 't', '@' => 'a', '$' => 's', '|' => 'l', _ => ch };
if (char.IsLetterOrDigit(c)) sb.Append(c);
}
return sb.ToString();
}
/// <summary>Does this name fold to a reserved word or to an owner/staff account's name
/// (other than <paramref name="self"/>'s own)?</summary>
public static bool Impersonates(string name, ulong self)
{
string f = Fold(name);
if (f.Length == 0) return false;
foreach (var staff in ConfigCampus.LockExemptUsernames ?? Array.Empty<string>())
{
if (Fold(staff) != f) continue;
ulong id = PlayerDB.GetAccountIdByUsernameCI(staff);
if (id != 0 && id == self) return false; // it IS that account - its own name is fine
return true;
}
return Reserved.Any(r => Fold(r) == f);
}
/// <summary>Null = acceptable (and <paramref name="clean"/> is the value to store); otherwise the player-facing reason.</summary>
public static string? CheckUsername(string? raw, ulong self, out string clean)
{
clean = (raw ?? "").Trim();
if (clean.Length < UsernameMin) return $"Your @name must have at least {UsernameMin} characters.";
if (clean.Length > UsernameMax) return $"Your @name must not exceed {UsernameMax} characters.";
if (!UsernameShape.IsMatch(clean)) return "Your @name can only use letters, numbers and underscores.";
if (Sanitize.ContainsSwears(clean)) return "Your @name contains a word that isn't allowed.";
if (Impersonates(clean, self)) return "That @name isn't available.";
ulong taken = PlayerDB.GetAccountIdByUsernameCI(clean);
if (taken != 0 && taken != self) return "That @name is already taken.";
return null;
}
public static string? CheckDisplayName(string? raw, ulong self, out string clean)
{
clean = Regex.Replace((raw ?? "").Trim(), @"\s+", " ");
if (clean.Length == 0) return "Your display name can't be empty.";
if (clean.Length > DisplayNameMax) return $"Your display name must not exceed {DisplayNameMax} characters.";
if (clean.Any(c => c < 0x20 || c > 0x7E)) return "Your display name can only use letters, numbers and basic punctuation.";
if (!clean.Any(char.IsLetterOrDigit)) return "Your display name needs at least one letter or number.";
if (Sanitize.ContainsSwears(clean)) return "Your display name contains a word that isn't allowed.";
if (Impersonates(clean, self)) return "That display name isn't available.";
return null;
}
}
}
@@ -0,0 +1,130 @@
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;
// Accounts just made by create_account, and when. The client answers a new account by PUTting five
// AccountCreation flags True itself a second or two after login (before it shows a single page), which
// ends the creation flow. An account that is "started, not finished" instead gets the client's own
// "CONTINUE CREATING ACCOUNT" flow (customize, username, password, ...), so those first PUTs are dropped.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, DateTime> _fresh = new();
// short on purpose: the burst lands within a few seconds of login, while the real steps (name, password) come later
private static readonly TimeSpan FreshWindow = TimeSpan.FromSeconds(15);
private static readonly string[] BurstKeys = { "HasStarted", "HasFinished", "HasChosenUsername", "HasCreatedPassword", "AcceptedCodeOfConduct" };
/// <summary>True when this PUT is the client's own auto-completion of a brand-new account and should be dropped.</summary>
public static bool ShouldIgnoreCreationPut(ulong accountId, string key, string value)
{
if (!ConfigCampus.HoldNewAccountCreation || !_fresh.TryGetValue(accountId, out var at)) return false;
if (DateTime.UtcNow - at > FreshWindow) { _fresh.TryRemove(accountId, out _); return false; }
if (!string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)) return false;
const string pre = "Recroom.AccountCreation.";
return key.StartsWith(pre, StringComparison.OrdinalIgnoreCase) && Array.Exists(BurstKeys, k => string.Equals(k, key.Substring(pre.Length), StringComparison.OrdinalIgnoreCase));
}
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
{
// started + not finished is the state the client answers with "CONTINUE CREATING ACCOUNT" (verified locally on account 33)
// HasStarted MUST be true: with false the client completes the account itself (PUTs every flag True a second after login, shows PLAY with a random
// name and never shows a page - verified 2026-10-13). "Started, not finished" is what makes it offer "CONTINUE CREATING ACCOUNT" and run the pages.
SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasStarted", "true", accountId);
// (the early-PUT guard is no longer armed: with HasStarted=true seeded the client sends no early burst, and the guard swallowed real steps)
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);
// Recroom.OOBE is the client's "out-of-box experience done" marker. Every account's default settings
// carry OOBE=1 (= done), and a new account that still reads done made the client mark every
// AccountCreation step complete ITSELF a few seconds after login (PUT HasStarted/HasFinished/
// HasChosenUsername/HasCreatedPassword/AcceptedCodeOfConduct = True) and never show the avatar /
// name / password steps. 0 = not done, so the client walks the flow.
SettingsDB.SetPlayerSetting("Recroom.OOBE", "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,557 @@
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);
int sent = await PushPresenceToViewers(subjectId, friendIds, viewers);
if (sent > 0) Console.WriteLine($"[presence-push] {subjectId} -> {sent} viewer(s)");
}
catch (Exception ex)
{
Console.WriteLine($"[presence-push] subject {subjectId} failed: {ex.Message}");
}
}
private static async Task<int> PushPresenceToViewers(ulong subjectId, HashSet<ulong> friendIds, HashSet<ulong> viewers)
{
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++;
}
return sent;
}
// A presence change is sent again 3 s and 10 s later. The one push alone could be overtaken:
// after a server restart (2026-09-27) Kyle's client subscribed at 07:33:12 and was answered
// poly's stale "Dorm" row; poly landed in ^RecCenter at 07:33:14, and the friends list and
// Here Now kept showing the private dorm (Party Up and the voice slider greyed out) with poly
// standing in the room, because nothing re-sends an unchanged presence. A newer change
// cancels the older echo - it schedules its own.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, long> _echoVersion = new();
private static readonly int[] EchoDelaysMs = { 3000, 7000 };
public static async Task EchoPresence(ulong subjectId)
{
long ver = _echoVersion.AddOrUpdate(subjectId, 1, (_, v) => v + 1);
foreach (int delay in EchoDelaysMs)
{
await Task.Delay(delay);
if (!_echoVersion.TryGetValue(subjectId, out var cur) || cur != ver) return;
var friendIds = FriendsDB.GetRelationships(subjectId)
.Where(r => r.RelationshipType == RelationshipType.Friend)
.Select(r => r.PlayerID)
.ToHashSet();
var viewers = new HashSet<ulong>(friendIds);
foreach (var v in NotificationsController.SubscribersOf(subjectId)) viewers.Add(v);
viewers.Remove(subjectId);
await PushPresenceToViewers(subjectId, friendIds, viewers);
}
}
// 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!,
// Same default /account/bulk serves. An empty BannerImage here blanked the banner
// on the Portfolio of anyone with no banner stored (563 players) the moment they
// changed their picture / bio / name - ProfileBannerImageImpl redraws from it.
["BannerImage"] = string.IsNullOrEmpty(p.player?.bannerImage) ? ConfigCampus.DefaultBannerImage : 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.
// The PUBLIC account DTO as an AccountUpdate notification. `nickname` (the viewer's own name for this player) replaces the
// display name when given - nothing else about the account changes, and only that one viewer's client ever receives it.
private static string PublicAccountUpdateJson(dynamic self, string? nickname)
{
var pub = new
{
accountId = self.accountId,
username = self.username,
displayName = nickname ?? (string)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,
};
return JsonConvert.SerializeObject(new WebsocketEvents.Reponse { Id = "AccountUpdate", Msg = pub });
}
/// <summary>Tell ONE viewer's client to redraw a player under that viewer's nickname (or the real name once cleared) - used when
/// the nickname is set or removed on the website, so the game updates without a relog.</summary>
public static async Task PushAccountUpdateToViewer(ulong viewerId, ulong accountId)
{
var self = PlayerDB.GetAccountMeSafe(accountId);
if (self == null) return;
await SendToPlayer(viewerId, PublicAccountUpdateJson(self, SitePrefsDB.NicknameFor(viewerId, accountId)));
}
public static async Task PushAccountUpdate(ulong accountId, bool broadcast = true)
{
var self = PlayerDB.GetAccountMeSafe(accountId);
if (self == null) return;
// 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 = PublicAccountUpdateJson(self, null);
await SendToPlayer(accountId, pubJson); // owner: redraw their own profile
if (broadcast)
{
// everyone: redraw THIS player's profile - each viewer gets their OWN nickname for them where they set one
var tasks = new List<Task>();
foreach (var kv in NotificationsController.WebSockets)
{
string? nick = kv.Key != accountId ? SitePrefsDB.NicknameFor(kv.Key, accountId) : null;
tasks.Add(SendAsync(kv.Value, nick == null ? pubJson : PublicAccountUpdateJson(self, nick), kv.Key));
}
await Task.WhenAll(tasks);
}
}
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,102 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The login patch gate (see ConfigCampus.PatchProofKey). The PC patch proves it is the CURRENT
/// build by POSTing /api/patch/proof - the SHA-256 of its own DLL file, its Steam id and a time,
/// HMAC-signed with the shared key - at startup and right before every game login. A good proof
/// is remembered here for ConfigCampus.PatchProofMinutes by the caller's IP and by that Steam id;
/// /Auth/connect/token then lets a PC login through only with a fresh one. In memory only: the
/// patch re-proves before each login, so a restart costs nothing.
/// </summary>
public static class PatchProofs
{
private static readonly ConcurrentDictionary<string, DateTime> _byIp = new();
private static readonly ConcurrentDictionary<ulong, DateTime> _bySteam = new();
public readonly record struct Check(bool Ok, string Why, string Channel = "");
// Every patch release has its OWN key (ReleaseGame.bat makes a new one): it is uploaded next to
// the DLL as Data/Launcher/patch/<sha256>.key. Without one, ConfigCampus.PatchProofKey.
private static string _keySha = "", _key = "", _keyCh = "";
private static string KeyFor(string sha, string channel = "")
{
lock (_keyLock)
{
if (sha == _keySha && channel == _keyCh) return _key;
string k = "";
try
{
string f = Path.Combine(Campus_live_rewrite.Controllers.LauncherController.PatchDirFor(channel), sha + ".key");
if (sha.Length == 64 && File.Exists(f)) k = File.ReadAllText(f).Trim();
}
catch { }
if (k.Length == 0) k = ConfigCampus.PatchProofKey ?? "";
_keySha = sha; _key = k; _keyCh = channel; _keyCh = channel;
return k;
}
}
private static readonly object _keyLock = new();
/// <summary>Verify a proof: signature, clock, and that it is the current patch.</summary>
public static Check Verify(long ts, string sha, ulong steamId, string mac)
{
sha = (sha ?? "").ToLowerInvariant();
// A developer's own server (CAMPUS_LOCAL_DEV=1) has no uploaded patch to compare with: any patch is fine there.
if (ConfigCampus.LocalDev) return new(true, "");
// The patch must be the newest of ANY channel: public, or the beta / dev patch a developer was handed.
string current = "", channel = "";
foreach (string ch in Campus_live_rewrite.Controllers.LauncherController.Channels)
{
string c = Campus_live_rewrite.Controllers.LauncherController.CurrentPatchSha(ch);
if (c.Length > 0 && c == sha) { current = c; channel = ch; break; }
}
if (current.Length == 0)
{
// not any channel's newest: an old patch (or no patch uploaded at all)
current = Campus_live_rewrite.Controllers.LauncherController.CurrentPatchSha();
if (current.Length == 0) return new(false, "no patch uploaded to compare with");
}
string key = KeyFor(current, channel);
if (key.Length == 0) return new(false, "no proof key configured");
long skew = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts;
if (Math.Abs(skew) > ConfigCampus.ProofMaxSkewSeconds)
return new(false, $"clock off by {Math.Abs(skew) / 3600.0:0.#} hours - set the PC clock to automatic time");
string msg = $"{ts}|{sha}|{steamId}";
using var h = new HMACSHA256(Encoding.UTF8.GetBytes(key));
string want = Convert.ToHexString(h.ComputeHash(Encoding.UTF8.GetBytes(msg))).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(want), Encoding.UTF8.GetBytes((mac ?? "").ToLowerInvariant())))
// an older build signs with its own (older) key: tell it apart from a forgery
return new(false, sha != current ? "old patch " + (sha.Length >= 12 ? sha.Substring(0, 12) : sha) : "bad signature");
if (sha != current) return new(false, "old patch " + (sha.Length >= 12 ? sha.Substring(0, 12) : sha));
return new(true, "", channel);
}
public static void Record(string ip, ulong steamId)
{
var now = DateTime.UtcNow;
if (!string.IsNullOrEmpty(ip)) _byIp[ip] = now;
if (steamId != 0) _bySteam[steamId] = now;
if (_byIp.Count > 20000) Prune();
}
/// <summary>Is there a fresh proof from this IP or (when verified) this Steam id?</summary>
public static bool HasFresh(string? ip, ulong verifiedSteamId)
{
var cutoff = DateTime.UtcNow.AddMinutes(-Math.Max(1, ConfigCampus.PatchProofMinutes));
if (!string.IsNullOrEmpty(ip) && _byIp.TryGetValue(ip, out var a) && a >= cutoff) return true;
if (verifiedSteamId != 0 && _bySteam.TryGetValue(verifiedSteamId, out var b) && b >= cutoff) return true;
return false;
}
private static void Prune()
{
var cutoff = DateTime.UtcNow.AddMinutes(-Math.Max(1, ConfigCampus.PatchProofMinutes));
foreach (var kv in _byIp) if (kv.Value < cutoff) _byIp.TryRemove(kv.Key, out _);
foreach (var kv in _bySteam) if (kv.Value < cutoff) _bySteam.TryRemove(kv.Key, out _);
}
}
}
@@ -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,440 @@
using LiteDB;
using Newtonsoft.Json.Linq;
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>
/// Limited-time progression events - the watch's "Limited Time Challenge" (Summer Party and
/// friends): a reward track with a free row and an RR+ row, filled by XP earned from time spent
/// in public rooms, capped per day. The client has the whole feature (RRUI.Data.
/// ProgressionEventsModel); Rec Room's servers supplied the events, so Campus defines them in
/// the admin panel (Challenges > Limited Time Events), stored in Data/APIS/progression-events.json.
///
/// The wire shapes were read out of the running 2025 client (the DTO formatters' name tables):
/// RecNet.ProgressionEventDTO / ProgressionEventRewardDTO / ProgressionEventRecordDTO. Which DTO
/// owns which name is only partly resolved, so each object is sent as a SUPERSET of the
/// candidate names - the client's decoder drops unknown keys silently. Every other call the
/// client makes under /api/progressionEvents is logged (ProgressionEventsController) so the
/// exact routes name themselves on the first test. An event can be set Developers-only so a
/// wrong guess only ever reaches staff.
///
/// XP rule (the client's own gameconfig: ProgressionEvents.* keys in Data/APIS/Gameconfigs.json):
/// every XpMinutes minutes in a public room pays XpPerConversion XP, at most MaxDailyXp per UTC day.
/// </summary>
public static class ProgressionEvents
{
// ---------------- definitions ----------------
public sealed class Reward
{
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>"tokens", "item" or "none".</summary>
public string Kind { get; set; } = "tokens";
public long Tokens { get; set; } = 50;
public int GiftDropId { get; set; } // store gift drop, for "item"
public string? AvatarItemDesc { get; set; }
public int AvatarItemType { get; set; }
public string? FriendlyName { get; set; }
public int Rarity { get; set; }
public string? ImageName { get; set; }
}
/// <summary>One column of the track: the XP to reach it, the free reward and the RR+ reward.</summary>
public sealed class Step
{
public int Xp { get; set; } = 100;
public Reward? Free { get; set; }
public Reward? Premium { get; set; }
}
public sealed class EventDef
{
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// The id the GAME uses. The client reads GET /api/progressionEvents/active as a NUMBER
/// (Player.log: "expected:'Number Token', actual:'['" for both our [] and our event
/// list), and its reward model is Set(long, int) - so events (and rewards) are
/// addressed by small integers, starting at 1. Assigned on save, never reused.
/// </summary>
public long NumericId { get; set; }
public string Name { get; set; } = "Summer Party!";
public DateTime StartTime { get; set; } = DateTime.UtcNow.Date;
public DateTime EndTime { get; set; } = DateTime.UtcNow.Date.AddDays(14);
/// <summary>Rewards can still be collected (no more XP) until this; null = EndTime.</summary>
public DateTime? CollectionEndTime { get; set; }
public bool Enabled { get; set; }
/// <summary>Only players with the developer flag see it - for testing a new event.</summary>
public bool DevelopersOnly { get; set; } = true;
/// <summary>RR+ row collectable by everyone (Campus has no RR+ purchase).</summary>
public bool PremiumForEveryone { get; set; } = true;
public string? ChallengesIconImageName { get; set; }
public string? EventInfoImageName { get; set; }
public List<Step> Steps { get; set; } = new();
}
private static readonly object _lock = new();
private static readonly string File1 = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "progression-events.json");
private static List<EventDef>? _defs;
public static List<EventDef> All()
{
lock (_lock)
{
if (_defs != null) return _defs;
try
{
_defs = File.Exists(File1)
? Newtonsoft.Json.JsonConvert.DeserializeObject<List<EventDef>>(File.ReadAllText(File1)) ?? new()
: new();
}
catch (Exception ex) { Console.WriteLine($"[progression-events] could not read progression-events.json: {ex.Message}"); _defs = new(); }
AssignNumericIds(_defs);
return _defs;
}
}
/// <summary>Give every event without one a numeric id (max + 1, starting at 1).</summary>
private static void AssignNumericIds(List<EventDef> defs)
{
long next = Math.Max(defs.Count == 0 ? 0 : defs.Max(d => d.NumericId), _highestNumericId) + 1;
foreach (var d in defs.Where(d => d.NumericId <= 0)) d.NumericId = next++;
_highestNumericId = Math.Max(_highestNumericId, next - 1);
}
private static long _highestNumericId;
public static EventDef? FindNumeric(long id) => All().FirstOrDefault(e => e.NumericId == id);
/// <summary>A reward's game-facing id: event id * 1000 + its position in the track (free, RR+ per step).</summary>
public static long RewardNumericId(EventDef e, int stepIndex, bool premium) => e.NumericId * 1000 + stepIndex * 2 + (premium ? 1 : 0);
public static void SaveAll(List<EventDef> defs)
{
lock (_lock)
{
AssignNumericIds(defs);
Directory.CreateDirectory(Path.GetDirectoryName(File1)!);
File.WriteAllText(File1, Newtonsoft.Json.JsonConvert.SerializeObject(defs, Newtonsoft.Json.Formatting.Indented));
_defs = defs;
}
}
public static EventDef? Find(string? id) =>
id == null ? null : All().FirstOrDefault(e => string.Equals(e.Id, id, StringComparison.OrdinalIgnoreCase));
private static DateTime CollectEnd(EventDef e) => e.CollectionEndTime ?? e.EndTime;
/// <summary>Events this player is shown right now (running, or in their collection window).</summary>
public static List<EventDef> VisibleTo(ulong accountId)
{
var now = DateTime.UtcNow;
bool dev = false; bool devChecked = false;
var list = new List<EventDef>();
foreach (var e in All())
{
if (!e.Enabled || now < e.StartTime || now > CollectEnd(e)) continue;
if (e.DevelopersOnly)
{
if (!devChecked) { dev = accountId != 0 && PlayerDB.CheckDevFlag(accountId); devChecked = true; }
if (!dev) continue;
}
list.Add(e);
}
return list;
}
// ---------------- game config (the client's own numbers) ----------------
private static (int minutes, int xp, int cap)? _rule;
public static (int minutes, int xp, int cap) Rule()
{
if (_rule is { } r) return r;
int minutes = 5, xp = 10, cap = 120;
try
{
var arr = JArray.Parse(File.ReadAllText(Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "Gameconfigs.json")));
string? V(string key) => arr.OfType<JObject>().FirstOrDefault(o => (string?)o["Key"] == key)?["Value"]?.ToString();
if (int.TryParse(V("ProgressionEvents.Game minutes for xp conversion"), out var m) && m > 0) minutes = m;
if (int.TryParse(V("ProgressionEvents.Xp amount per conversion"), out var x) && x > 0) xp = x;
if (int.TryParse(V("ProgressionEvents.Max daily Xp"), out var c) && c > 0) cap = c;
}
catch { }
_rule = (minutes, xp, cap);
return _rule.Value;
}
// ---------------- per-player records ----------------
public sealed class Record
{
[BsonId] public string Id { get; set; } = ""; // "{eventId}:{accountId}"
public string EventId { get; set; } = "";
public long AccountId { get; set; }
public int Xp { get; set; }
public DateTime Day { get; set; } // UTC date XpToday / GameMinutesToday belong to
public int XpToday { get; set; }
public double SecondsToday { get; set; }
public double SecondsUnconverted { get; set; }
public List<string> Collected { get; set; } = new();
public DateTime UpdatedAt { get; set; }
}
private static readonly Lazy<LiteDatabase> _db = new(() =>
{
Directory.CreateDirectory(Path.Combine("Data", "DBs"));
return new LiteDatabase(Path.Combine("Data", "DBs", "ProgressionEvents.db"));
});
private static ILiteCollection<Record> Records => _db.Value.GetCollection<Record>("records");
public static Record GetRecord(string eventId, ulong accountId)
{
string id = $"{eventId}:{accountId}";
var r = Records.FindById(id) ?? new Record { Id = id, EventId = eventId, AccountId = (long)accountId, Day = DateTime.UtcNow.Date };
if (r.Day != DateTime.UtcNow.Date) { r.Day = DateTime.UtcNow.Date; r.XpToday = 0; r.SecondsToday = 0; }
return r;
}
private static void Put(Record r) { r.UpdatedAt = DateTime.UtcNow; Records.Upsert(r); }
public static void SaveRecord(Record r) => Put(r);
public static int Participants(string eventId) => Records.Count(x => x.EventId == eventId);
public static void ResetPlayer(string eventId, ulong accountId) => Records.Delete($"{eventId}:{accountId}");
// ---------------- XP from time in public rooms ----------------
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, DateTime> _lastBeat = new();
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, DateTime> _lastSave = new();
/// <summary>
/// Called from every presence heartbeat. Time between two heartbeats (capped, so a gap or a
/// sleeping client never pays) spent in a PUBLIC room counts toward every running event.
/// </summary>
public static void OnHeartbeat(ulong accountId)
{
try
{
var now = DateTime.UtcNow;
var prev = _lastBeat.TryGetValue(accountId, out var p) ? p : (DateTime?)null;
_lastBeat[accountId] = now;
if (prev == null) return;
double secs = (now - prev.Value).TotalSeconds;
if (secs <= 0 || secs > 90) return;
var events = VisibleTo(accountId).Where(e => now <= e.EndTime).ToList();
if (events.Count == 0) return;
var inst = HeartbeatDB.GetPlayerHeartbeat(accountId)?.roomInstance;
if (inst == null || inst.isPrivate || inst.roomId <= 0) return;
var room = RoomDB.GetRoom((ulong)inst.roomId);
if (room == null || room.IsDorm) return;
var (minutes, perConv, cap) = Rule();
foreach (var e in events)
{
var r = GetRecord(e.Id, accountId);
r.SecondsToday += secs;
bool gained = false;
if (r.XpToday < cap)
{
r.SecondsUnconverted += secs;
while (r.SecondsUnconverted >= minutes * 60 && r.XpToday < cap)
{
r.SecondsUnconverted -= minutes * 60;
int add = Math.Min(perConv, cap - r.XpToday);
r.Xp += add; r.XpToday += add; gained = true;
}
}
// Persist on every XP gain, otherwise at most once a minute per player.
bool due = !_lastSave.TryGetValue(accountId, out var ls) || (now - ls).TotalSeconds >= 60;
if (gained || due) { Put(r); _lastSave[accountId] = now; }
if (gained) PushRecord(e, r);
}
}
catch (Exception ex) { Console.WriteLine($"[progression-events] heartbeat {accountId}: {ex.Message}"); }
}
// ---------------- collecting ----------------
private static IEnumerable<(int index, int cumXp, Reward reward, bool premium)> Track(EventDef e)
{
int cum = 0;
for (int i = 0; i < e.Steps.Count; i++)
{
cum += Math.Max(0, e.Steps[i].Xp);
if (e.Steps[i].Free is { } f && f.Kind != "none") yield return (i, cum, f, false);
if (e.Steps[i].Premium is { } pr && pr.Kind != "none") yield return (i, cum, pr, true);
}
}
/// <summary>
/// Collect rewards the player has reached. <paramref name="rewardId"/> = one reward (when
/// the client names it), else every reached, uncollected reward. Returns how many were paid.
/// </summary>
public static int Collect(EventDef e, ulong accountId, string? rewardId)
{
if (DateTime.UtcNow > CollectEnd(e)) return 0;
var r = GetRecord(e.Id, accountId);
int paid = 0;
foreach (var (index, cum, reward, premium) in Track(e))
{
if (rewardId != null && !string.Equals(reward.Id, rewardId, StringComparison.OrdinalIgnoreCase)) continue;
if (r.Collected.Contains(reward.Id) || r.Xp < cum) continue;
if (premium && !e.PremiumForEveryone) continue;
if (!Grant(accountId, e, reward, index)) continue;
r.Collected.Add(reward.Id); paid++;
}
if (paid > 0) { Put(r); PushRecord(e, r); }
Console.WriteLine($"[progression-events] {accountId} collect on '{e.Name}'{(rewardId != null ? $" reward {rewardId}" : "")}: {paid} reward(s) paid (xp {r.Xp})");
return paid;
}
private static bool Grant(ulong accountId, EventDef e, Reward reward, int index)
{
string msg = $"{e.Name} - reward {index + 1}";
PendingGiftBox box = reward.Kind == "item" && !string.IsNullOrWhiteSpace(reward.AvatarItemDesc)
? new PendingGiftBox
{
GiftContext = GiftContextLimitedTimeEvent, Message = msg, FromAccountId = 0,
AvatarItemDesc = reward.AvatarItemDesc, AvatarItemType = reward.AvatarItemType,
FriendlyName = reward.FriendlyName, GiftDropId = reward.GiftDropId, Rarity = reward.Rarity
}
: new PendingGiftBox
{
GiftContext = GiftContextLimitedTimeEvent, Message = msg, FromAccountId = 0,
FriendlyName = $"{reward.Tokens} Tokens",
ThumbnailImageName = QuestRewards.TokenThumbnail,
CurrencyType = (int)CurrencyType.RecCenterTokens, Currency = reward.Tokens,
Rarity = QuestRewards.TokenRarity(reward.Tokens), PayOnOpen = true
};
if (!PlayerDB.AddPendingGiftBoxFull(accountId, box)) return false;
GiftBoxes.Push(accountId, box, GiftBoxes.FrameReceivedImmediate, $"event '{e.Name}' reward");
return true;
}
// GiftContext "LimitedTimeEvent": the client's enum lists it (read from the live metadata)
// but its number is unverified, so boxes go out under the plain token-rewards context,
// which is known to draw.
public const int GiftContextLimitedTimeEvent = QuestRewards.GiftContextGameRewardTokens;
// ---------------- wire shapes (superset of the candidate names) ----------------
private static string Iso(DateTime t) => DateTime.SpecifyKind(t, DateTimeKind.Utc).ToString("o");
public static JObject RewardDto(EventDef e, Step step, Reward reward, int index, bool premium) => new()
{
["ProgressionEventRewardId"] = RewardNumericId(e, index, premium),
["ProgressionEventId"] = e.NumericId,
["RewardIndex"] = index,
["Index"] = index,
["Xp"] = step.Xp,
["RequiredXp"] = step.Xp,
["GiftDropId"] = reward.Kind == "item" ? reward.GiftDropId : TokenGiftDropBase + index * 2 + (premium ? 1 : 0),
["ImageName"] = string.IsNullOrWhiteSpace(reward.ImageName)
? (reward.Kind == "item" ? "" : ConfigCampus.LevelRewardImage) : reward.ImageName,
["IsRRPlusExclusive"] = premium,
["IsBonus"] = false
};
/// <summary>Synthetic gift drop ids for token rewards (items use their real store id).</summary>
public const int TokenGiftDropBase = 8_800_000;
public static JObject EventDto(EventDef e)
{
var rewards = new JArray();
for (int i = 0; i < e.Steps.Count; i++)
{
var s = e.Steps[i];
if (s.Free is { } f && f.Kind != "none") rewards.Add(RewardDto(e, s, f, i, false));
if (s.Premium is { } p && p.Kind != "none") rewards.Add(RewardDto(e, s, p, i, true));
}
var (minutes, perConv, cap) = Rule();
return new JObject
{
["ProgressionEventId"] = e.NumericId,
["Id"] = e.NumericId,
["Name"] = e.Name,
["StartTime"] = Iso(e.StartTime),
["EndTime"] = Iso(e.EndTime),
["CollectionEndTime"] = Iso(CollectEnd(e)),
["Rewards"] = rewards,
["ActiveExperiments"] = new JArray(),
["KeepsakeRooms"] = new JArray(),
["KeepsakeRoomLists"] = new JArray(),
["CollectionRecords"] = new JArray(),
["UsesBoost"] = false,
["BoostXpMultiplier"] = 1,
["BoostDailyGameplayMinutesLimit"] = 0,
["ProgressionEventPurchasableXpBoostId"] = null,
["ChallengesIconImageName"] = e.ChallengesIconImageName ?? "",
["EventInfoImageName"] = e.EventInfoImageName ?? "",
["XpCap"] = cap,
["DailyLimit"] = cap
};
}
public static JObject RecordDto(EventDef e, Record r)
{
int free = 0, premium = 0;
foreach (var (_, _, reward, prem) in Track(e))
if (r.Collected.Contains(reward.Id)) { if (prem) premium++; else free++; }
return new JObject
{
["ProgressionEventId"] = e.NumericId,
["AccountId"] = r.AccountId,
["Xp"] = r.Xp,
["TotalXp"] = r.Xp,
["GameMinutesToday"] = (int)(r.SecondsToday / 60),
["RewardsCollected"] = free,
["PremiumRewardsCollected"] = premium,
["BonusRewardsCollected"] = 0,
["XpBoostLastPurchasedAt"] = null
};
}
public static void PushRecord(EventDef e, Record r)
{
try
{
var frame = Newtonsoft.Json.JsonConvert.SerializeObject(new WebsocketEvents.Reponse { Id = "ProgressionEventsRecordUpdate", Msg = RecordDto(e, r) });
Notifications.FireAndForget(Notifications.SendToPlayer((ulong)r.AccountId, frame), "progression record");
}
catch { }
}
// ---------------- store items for the admin picker ----------------
public sealed class StoreItem
{
public int GiftDropId; public string FriendlyName = ""; public string AvatarItemDesc = "";
public int AvatarItemType; public int Rarity; public string ThumbnailImageName = "";
}
private static List<StoreItem>? _store;
public static List<StoreItem> StoreItems()
{
if (_store != null) return _store;
var list = new List<StoreItem>();
try
{
string file = Path.Combine(StoreCatalog.StoreDirectory, "3.json");
foreach (var it in JObject.Parse(File.ReadAllText(file))["StoreItems"] as JArray ?? new JArray())
{
if (it["GiftDrop"] is not JObject gd || (bool?)gd["IsQuery"] == true) continue;
string desc = (string?)gd["AvatarItemDesc"] ?? "";
if (string.IsNullOrWhiteSpace(desc)) continue;
list.Add(new StoreItem
{
GiftDropId = (int?)gd["GiftDropId"] ?? 0, FriendlyName = (string?)gd["FriendlyName"] ?? "",
AvatarItemDesc = desc, AvatarItemType = (int?)gd["AvatarItemType"] ?? 0,
Rarity = (int?)gd["Rarity"] ?? 0, ThumbnailImageName = (string?)gd["ThumbnailImageName"] ?? ""
});
}
}
catch (Exception ex) { Console.WriteLine($"[progression-events] store read failed: {ex.Message}"); }
_store = list;
return list;
}
}
}
@@ -0,0 +1,446 @@
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 = "";
/// <summary>The store row's picture for a Watch-clothing drop; sent only when that file exists here.</summary>
public string? Thumbnail;
}
// ---- Watch-store clothing offered as a reward -----------------------------------------------
// Rarity 10/20/30 = 2/3/4 stars (same scale as TokenRarity). Cached against the served JSON string.
private static string? _wcJson;
private static List<Newtonsoft.Json.Linq.JObject> _wc = new();
private static List<Newtonsoft.Json.Linq.JObject> WatchClothing()
{
string? json = Campus_live_rewrite.api.server.StoreServe.ServedJson(Campus_live_rewrite.api.server.StoreGenerator.WatchStorefront);
lock (_lock)
{
if (json != null && !ReferenceEquals(json, _wcJson))
{
var list = new List<Newtonsoft.Json.Linq.JObject>();
foreach (var it in (Newtonsoft.Json.Linq.JObject.Parse(json)["StoreItems"] as Newtonsoft.Json.Linq.JArray) ?? new Newtonsoft.Json.Linq.JArray())
{
if (it is not Newtonsoft.Json.Linq.JObject row || row["GiftDrop"] is not Newtonsoft.Json.Linq.JObject gd) continue;
int rar = (int?)gd["Rarity"] ?? -1;
if (rar != 10 && rar != 20 && rar != 30) continue;
if (string.IsNullOrWhiteSpace((string?)gd["AvatarItemDesc"])) continue;
if (!string.IsNullOrWhiteSpace((string?)gd["CustomAvatarItemId"]) || !string.IsNullOrWhiteSpace((string?)gd["EquipmentPrefabName"])
|| !string.IsNullOrWhiteSpace((string?)gd["ConsumableItemDesc"])) continue;
if (((int?)gd["GiftDropId"] ?? 0) <= 0) continue;
list.Add(row);
}
_wc = list;
_wcJson = json;
Console.WriteLine($"[questrewards] {list.Count} Watch-store clothing rows (2-4 stars) can be offered as rewards");
}
return _wc;
}
}
/// <summary>
/// Up to <paramref name="count"/> distinct Watch-store clothing rewards (2-4 stars) the player does
/// not already own. Fewer (or none) when they own nearly everything - the caller fills with tokens.
/// </summary>
public static List<Reward> WatchClothingDrops(int giftContext, int count, HashSet<string>? ownedKeys)
{
var result = new List<Reward>();
if (count <= 0) return result;
List<Newtonsoft.Json.Linq.JObject> pool;
lock (_lock) pool = WatchClothing().ToList();
var seen = new HashSet<string>();
int guard = 0;
while (result.Count < count && pool.Count > 0 && guard++ < 400)
{
Newtonsoft.Json.Linq.JObject row;
lock (_lock) row = pool[_rng.Next(pool.Count)];
var gd = (Newtonsoft.Json.Linq.JObject)row["GiftDrop"]!;
string desc = (string?)gd["AvatarItemDesc"] ?? "";
string key = PlayerDB.NormalizeAvatarItemDesc(desc);
if (key.Length == 0 || !seen.Add(key)) continue;
if (ownedKeys != null && ownedKeys.Contains(key)) continue;
result.Add(new Reward
{
Id = (int?)gd["GiftDropId"] ?? 0,
AvatarItemDesc = desc,
GiftRarity = (int?)gd["Rarity"] ?? 10,
Message = (string?)gd["FriendlyName"],
Xp = Campus_live_rewrite.api.server.Classes.Progress.GAME_REWARD_XP,
Context = giftContext != 0 ? giftContext : GiftContextGameRewards,
Mode = "WatchClothing",
Thumbnail = (string?)gd["ThumbnailImageName"]
});
}
return result;
}
// 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 picture a token drop / token box names. Empty by default (ConfigCampus.UseClientCurrencyImages):
// the reference names none, and the client then draws its OWN token art - the picker card's
// model has a built-in CurrencyImage. Naming our campus_tokens.png overrode that with a flat
// custom PNG. (The old "blank without it" note dates from when token drops carried Currency 0.)
public static string TokenThumbnail => ConfigCampus.UseClientCurrencyImages ? "" : 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>An item drop's picture: the store row's own thumbnail when that file is hosted here, else ""
/// (an avatar item with no name makes the client draw the item itself; a name that 404s draws nothing).</summary>
public static string ItemThumbnail(Reward r) =>
!string.IsNullOrWhiteSpace(r.Thumbnail) && ItemImages.Exists(r.Thumbnail) ? r.Thumbnail! : "";
/// <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" : "",
// A token drop names NO picture (null, as the real store's "60 Tokens" drop does): the
// card then renders the game's own 3D token pile. Our PNG only while
// UseClientCurrencyImages is off. An item drop draws its own thumbnail.
ThumbnailImageName = IsTokens(r) ? (string.IsNullOrEmpty(TokenThumbnail) ? null : TokenThumbnail)
: ItemThumbnail(r),
ConsumableItemDesc = r.ConsumableItemDesc ?? "",
AvatarItemDesc = r.AvatarItemDesc ?? "",
// NULL unless the drop IS an avatar item: the client routes a box on this field first,
// and a 0 on a token / consumable / skin drop sends it looking for "avatar item type 0"
// ("can't find avatar item"). Same rule as GiftBoxes / StoreCatalog.AvatarTypeFor.
AvatarItemType = string.IsNullOrWhiteSpace(r.AvatarItemDesc) ? (int?)null : 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,
// The rest of the real GiftDrop shape (store dump, GiftDropId 4014 "60 Tokens").
// ItemSetId was 1 here - a set id sends the card's imposter renderer after "item set 1",
// which doesn't exist, so a token card with no named picture drew nothing (blank tiles).
ItemSetId = (int?)null,
ItemSetFriendlyName = "",
ItemCount = 1,
AvatarItemId = 0,
EquipmentItemId = 0,
CustomAvatarItemId = (string?)null,
TagList = ""
};
/// <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,112 @@
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Personal quick chat. The game's quick chat menu is built from four phrase sets that the server hands out as game
/// configs (Lifecycle.PSF.CannedChatOptions.CustomA / CustomB / RecRoom / Room): five phrases each, joined with a comma and a
/// line break. A player edits their own sets on the website; <see cref="ApplyToConfigs"/> swaps just THEIR copy of those
/// config values when the game asks for its configs, so nobody else sees them. The game reads configs at login, so a change
/// shows from the next start.
///
/// Safety: a quick chat phrase is sent as chat, so it is held to the same standard as typed chat - printable ASCII only, no
/// commas or line breaks (they are the list separators and would split one phrase into two), a short length cap, and the
/// chat swear filter (Sanitize.ContainsSwears). A phrase that fails is refused with a reason, never silently altered.
/// </summary>
public static class QuickChat
{
public const int PhrasesPerSet = 5; // the standard set size; a set may have more (SetDef.Defaults.Length is the real count)
public const int MaxPhraseLength = 24;
private const string ConfigPrefix = "Lifecycle.PSF.CannedChatOptions.";
public sealed class SetDef
{
public string Key = ""; // CustomA / CustomB / RecRoom / Room (also the config key suffix)
public string Label = "";
public string[] Defaults = Array.Empty<string>();
public bool TrailingBreak = true; // the stock value ends with a line break, except the RecRoom set
}
// The stock sets, exactly as Gameconfigs.json has them, in the order the in-game quick chat panel shows its four tabs:
// the "Hi!" tab, the location-pin tab, thumbs up, thumbs down (confirmed from the panel: the first tab lists Hi!/Bye!/Ready?/BRB/Let's go!).
public static readonly SetDef[] Sets =
{
new SetDef { Key = "RecRoom", Label = "Greetings (the Hi! tab)", Defaults = new[] { "Hi!", "Bye!", "Ready?", "BRB", "Let’s go!" }, TrailingBreak = false },
new SetDef { Key = "Room", Label = "Room (the location pin tab)", Defaults = new[] { "Come with me", "Help!", "Let's party up!", "Let's be friends!", "May I pick the room?" }, TrailingBreak = true },
new SetDef { Key = "CustomA", Label = "Positive (the thumbs up tab)", Defaults = new[] { "Yes", "OK", "Thanks", "LOL", "You look great!", "Nice!", "Good game!", "Wait for me!" }, TrailingBreak = true },
new SetDef { Key = "CustomB", Label = "Negative (the thumbs down tab)", Defaults = new[] { "No", "Boo", "Sorry", "I don't like this", "What the coach?!" }, TrailingBreak = true },
};
public static SetDef? Find(string? key) =>
Sets.FirstOrDefault(s => string.Equals(s.Key, key, StringComparison.OrdinalIgnoreCase));
/// <summary>One phrase, cleaned for storage, or null with the reason it was refused. An empty phrase is valid (= keep the stock one).</summary>
public static string? Clean(string? raw, out string? problem)
{
problem = null;
string s = (raw ?? "").Replace("\r", " ").Replace("\n", " ").Replace(",", " ").Replace("\t", " ");
s = string.Join(" ", s.Split(' ', StringSplitOptions.RemoveEmptyEntries)).Trim();
if (s.Length == 0) return "";
foreach (char c in s)
if (c < 32 || c > 126) { problem = "use plain letters, numbers and basic punctuation only"; return null; }
if (s.Length > MaxPhraseLength) { problem = $"keep it to {MaxPhraseLength} characters or fewer"; return null; }
if (Sanitize.ContainsSwears(s)) { problem = "that isn't allowed in chat"; return null; }
return s;
}
/// <summary>The config VALUE for a set: the player's phrases with the stock phrase wherever a slot is empty.</summary>
public static string ConfigValue(SetDef set, IReadOnlyList<string> custom)
{
var items = new string[set.Defaults.Length];
for (int i = 0; i < items.Length; i++)
{
string c = i < custom.Count ? custom[i] : "";
items[i] = string.IsNullOrWhiteSpace(c) ? set.Defaults[i] : c;
}
// "a,\r\nb,\r\n...e" - every phrase but the last is followed by a comma, as in the stock values
string joined = string.Join(",\r\n", items);
return set.TrailingBreak ? joined + "\r\n" : joined;
}
/// <summary>
/// A copy of the game configs with this viewer's own quick chat sets swapped in, or null when they have none (the common case:
/// the cached shared copy is served untouched). The parsed shared copy is kept so the per-player cost is one clone.
/// </summary>
public static string? ApplyToConfigs(string sharedConfigJson, ulong viewerId)
{
if (viewerId == 0) return null;
var mine = SitePrefsDB.Get(viewerId).QuickChat;
if (mine == null || mine.Count == 0) return null;
JArray parsed;
lock (_lock)
{
if (!ReferenceEquals(_parsedFor, sharedConfigJson))
{
_parsed = JArray.Parse(sharedConfigJson);
_parsedFor = sharedConfigJson;
}
parsed = (JArray)_parsed!.DeepClone();
}
int swapped = 0;
foreach (var entry in parsed)
{
string key = (string?)entry["Key"] ?? "";
if (!key.StartsWith(ConfigPrefix, StringComparison.Ordinal)) continue;
var set = Find(key.Substring(ConfigPrefix.Length));
if (set == null || !mine.TryGetValue(set.Key, out var phrases) || phrases == null || phrases.All(string.IsNullOrWhiteSpace)) continue;
entry["Value"] = ConfigValue(set, phrases);
swapped++;
}
if (swapped == 0) return null;
return parsed.ToString(Newtonsoft.Json.Formatting.None);
}
private static readonly object _lock = new();
private static string? _parsedFor;
private static JArray? _parsed;
}
}
@@ -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,196 @@
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
{
// Watch-store clothing (2-4 stars) the player doesn't own, then tokens for the rest.
int want = Math.Clamp(ConfigCampus.RewardWatchClothingCards, 0, 3);
drops = QuestRewards.WatchClothingDrops(giftContext, want, owned);
if (drops.Count < 3)
drops.AddRange(QuestRewards.TokenDrops(giftContext != 0 ? giftContext : QuestRewards.GiftContextGameRewardTokens)
.OrderBy(_ => Random.Shared.Next()).Take(3 - drops.Count));
}
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,51 @@
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>
/// Who may hand a player a room role (co-owner, contributor, moderator, host).
///
/// Until 2026-10-07 anyone who owned a room could offer any account a role. The invite then reached the
/// invitee as a notification, and in practice the invitee's client answered it within ~2 seconds
/// (APILog: 393 invite->answer pairs, median 2.7 s, 11 invitees with six or more all clustered at 2.0-2.5 s)
/// - so strangers were making players co-owners of rooms they had never seen. Now an offer to a player
/// who is not already on the room needs the two to be FRIENDS (or the offerer to be staff), and a
/// block either way refuses it. Taking a role away is never refused.
/// </summary>
public static class RoleOffers
{
/// <summary>True when <paramref name="a"/> and <paramref name="b"/> are friends (both rows say so).</summary>
public static bool AreFriends(ulong a, ulong b)
{
if (a == 0 || b == 0 || a == b) return false;
var col = FriendsDB.PlayerFriendDBFile.GetCollection<YourFriendData>("PlayersFriends");
var mine = col.FindOne(x => x.PlayerID == a && x.Relationship.OtherPlayerID == b);
return mine?.Relationship != null && mine.Relationship.RelationshipType == RelationshipType.Friend;
}
/// <summary>May <paramref name="from"/> give <paramref name="to"/> a role on this room? <paramref name="why"/> is the reason when not.</summary>
public static bool MayOffer(ulong roomId, ulong from, ulong to, out string why)
{
why = "";
if (!ConfigCampus.RoomRoleOffersFriendsOnly) return true;
if (PlayerDB.CheckDevFlag(from)) return true; // staff set people up on purpose
if (FriendsDB.IsBlockedEitherWay(from, to)) { why = "You can't offer that player a role."; return false; }
var room = RoomDB.GetRoom(roomId);
var held = room?.Roles?.FirstOrDefault(r => (ulong)r.AccountId == to);
if (held != null && held.Role != RoomDBClasses.Role_data.None && held.Role != RoomDBClasses.Role_data.Banned) return true; // already on the room: promote / change
if (AreFriends(to, from)) return true;
why = "You can only offer a room role to a friend. Add them as a friend first.";
return false;
}
// When each role invite was pushed, so the answer's delay can be logged (is a person pressing Accept, or the client?).
private static readonly ConcurrentDictionary<string, DateTime> _pushedAt = new();
private static string Key(ulong roomId, ulong player) => roomId + ":" + player;
public static void Pushed(ulong roomId, ulong player) { _pushedAt[Key(roomId, player)] = DateTime.UtcNow; if (_pushedAt.Count > 5000) _pushedAt.Clear(); }
/// <summary>Seconds since the invite went out, or -1 when not known (before a restart / never pushed).</summary>
public static double SecondsSincePushed(ulong roomId, ulong player) =>
_pushedAt.TryGetValue(Key(roomId, player), out var t) ? (DateTime.UtcNow - t).TotalSeconds : -1;
}
}
@@ -0,0 +1,162 @@
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;
// Once per blob per day: this runs on every room read and listing, and 106 missing
// blobs were logging ~100,000 lines a day between them (room 48 alone: 66,843).
if (WarnedToday(blob))
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' (logged once a day)");
sub!.CurrentSave = null;
}
return room;
}
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _warned = new();
private static bool WarnedToday(string blob)
{
var now = DateTime.UtcNow;
if (_warned.TryGetValue(blob, out var at) && now - at < TimeSpan.FromDays(1)) return false;
_warned[blob] = now;
return true;
}
/// <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,175 @@
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 { }
SendHome(playerId, roomId, gameSessionId);
Console.WriteLine($"[moderation] {by} removed {playerId} from room {roomId} (instance {gameSessionId}){(isBan ? " [ban]" : "")}");
}
/// <summary>
/// Point the player's presence at their own dorm if they are in that room (roomId /
/// gameSessionId 0 = whatever room they are in). The client leaves the room on any
/// moderation frame and then goes where its presence says - left on the old instance, it
/// reconnected straight back into the room it had just been warned / kicked out of.
/// </summary>
public static void SendHome(ulong playerId, ulong roomId, long gameSessionId)
{
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}"); }
}
}
}
@@ -0,0 +1,186 @@
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>
/// A role change, pushed to EVERYONE standing in the room plus the affected player(s)
/// wherever they are (reference: pushRoomUpdateToRoom). The room's Roles are what every
/// client draws the nametag crown / host / moderator badge from, so telling only the
/// affected player left everybody else seeing the old badge until they rejoined.
/// </summary>
public static void PushRoomUpdateToRoom(ulong roomId, RoomDBClasses.RoomRoot? room, params ulong[] alsoTell)
{
if (room == null) return;
foreach (var pid in PlayersInRoom(roomId).Concat(alsoTell).Where(p => p != 0).Distinct())
PushRoomUpdate(pid, room);
}
/// <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,209 @@
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)
{
// Ends on server shutdown too, so a restart isn't held up by open browser tabs.
var result = await socket.ReceiveAsync(buffer, CampusLiveSource.Controllers.NotificationsController.Stopping);
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)
{
// Send-only, 2 s cap: a full CloseAsync waits (forever) for a tab that's already gone.
try
{
using var closeCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "bye", closeCts.Token);
}
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 { }
}
}
}
@@ -0,0 +1,111 @@
using Newtonsoft.Json.Linq;
using Campus_live_rewrite.api.dbs;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Who may use an equipment skin. A skin is OWNED only when it was bought from a store (for
/// yourself or as a gift) or given through the admin panel - PlayerDB.AddPendingGiftBoxFull
/// with grantSkin, or PlayerDB.GrantEquipmentSkin. Dev-flagged accounts (the dev locker) have
/// every skin. Anything Equipments.json does not list (a tool's default look) is not a skin
/// and is always allowed.
/// </summary>
public static class SkinOwnership
{
private static readonly object _lock = new();
private static long _ticks = -1;
private static HashSet<string> _catalog = new(StringComparer.OrdinalIgnoreCase);
public static string CatalogPath =>
Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "Equipments.json");
// Skin key -> the ModificationGuid exactly as Equipments.json spells it.
private static Dictionary<string, string> _catalogGuid = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Every skin key (PlayerDB.EquipmentSkinKey) in Equipments.json.</summary>
public static HashSet<string> CatalogKeys()
{
try
{
long t = File.Exists(CatalogPath) ? File.GetLastWriteTimeUtc(CatalogPath).Ticks : 0;
lock (_lock)
{
if (t == _ticks) return _catalog;
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var guids = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (t != 0)
foreach (var e in JArray.Parse(File.ReadAllText(CatalogPath)))
{
string key = PlayerDB.EquipmentSkinKey((string?)e["PrefabName"], (string?)e["ModificationGuid"]);
set.Add(key);
guids.TryAdd(key, (string?)e["ModificationGuid"] ?? "");
}
_catalog = set;
_catalogGuid = guids;
_ticks = t;
return set;
}
}
catch (Exception ex)
{
Console.WriteLine($"[skins] Equipments.json unreadable: {ex.Message}");
lock (_lock) return _catalog;
}
}
/// <summary>
/// The skin's ModificationGuid spelled the way the locker list (Equipments.json /
/// getUnlocked) spells it. 112 of the store's skin rows spell the same skins as 22-char
/// short guids, and the client compares them as TEXT: a skin bought from such a row came
/// out of its box as a second copy of the one already in the locker (2026-09-27). Anything
/// the catalog does not know is handed back unchanged.
/// </summary>
public static string? CanonicalGuid(string? prefab, string? guid)
{
if (string.IsNullOrWhiteSpace(prefab) || string.IsNullOrWhiteSpace(guid)) return guid;
CatalogKeys();
Dictionary<string, string> map;
lock (_lock) map = _catalogGuid;
return map.TryGetValue(PlayerDB.EquipmentSkinKey(prefab, guid), out var g) && g.Length > 0 ? g : guid;
}
/// <summary>
/// Equipping a skin the database says they do not own (not bought, not given by an admin):
/// an automatic ban of ConfigCampus.UnownedSkinBanDays (30). Only a modified client can do
/// it - the locker it is served lists owned skins only. Developers are never checked
/// (CheckerFor), and a player already under a block is not banned again.
/// </summary>
public static void BanForUnownedSkin(ulong playerId, string skins)
{
if (!ConfigCampus.UnownedSkinBanEnabled || PlayerDB.HasDevLocker(playerId)) return;
if (PlayerDB.HasActiveModerationBlock(playerId)) return;
int days = Math.Max(1, ConfigCampus.UnownedSkinBanDays);
try
{
PlayerDB.Set_moderation_block(playerId, Moderation_Detail.ReportCategory.Cheating, days * 86400, 0, false,
$"Using skins you do not own. Banned for {days} days.", null, true, 0,
logKind: "skin-ban", logNote: $"equipped unowned skin(s): {skins}");
HeartbeatDB.UpdatePlayerHeartbeat(playerId, null, online: false);
Console.WriteLine($"[skins] BANNED {playerId} for {days} days - equipped unowned skin(s): {skins}");
Webhook.Send(playerId, "mod", "Skin ban", $"({playerId}) banned for {days} days: equipped skin(s) they do not own - {skins}", 10038562);
}
catch (Exception ex) { Console.WriteLine($"[skins] ban of {playerId} failed: {ex.Message}"); }
}
/// <summary>
/// The check for one player: null = everything is allowed (dev locker), otherwise a
/// predicate that is true for an owned skin or for something that is not a skin at all.
/// </summary>
public static Func<string?, string?, bool>? CheckerFor(ulong playerId)
{
if (ConfigCampus.SkinsForEveryone || PlayerDB.HasDevLocker(playerId)) return null;
var owned = PlayerDB.OwnedEquipmentSkins(playerId);
var catalog = CatalogKeys();
return (prefab, guid) =>
{
string key = PlayerDB.EquipmentSkinKey(prefab, guid);
return !catalog.Contains(key) || owned.Contains(key);
};
}
}
}
@@ -0,0 +1,543 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Campus_live_rewrite.api.dbs;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Linked social accounts (Settings > Services): the player signs in to the service
/// (OAuth 2 authorization code, Steam via OpenID), we keep only who they are there (id, handle,
/// profile url) and a per-service "show on my profile" switch. Nothing is typed in by hand,
/// so a handle on a profile is always a verified one.
///
/// Each provider needs its own developer app; the credentials live ONLY in
/// Data/social_apps.json (never in source):
/// { "youtube": {"ClientId":"…apps.googleusercontent.com","ClientSecret":"…"},
/// "tiktok": {"ClientId":"<client_key>","ClientSecret":"…"},
/// "x": {"ClientId":"…","ClientSecret":"…"},
/// "twitch": {"ClientId":"…","ClientSecret":"…"},
/// "github": {"ClientId":"…","ClientSecret":"…"},
/// "reddit": {"ClientId":"…","ClientSecret":"…"},
/// "instagram":{"ClientId":"…","ClientSecret":"…"},
/// "steam": {"ApiKey":"…"} } // optional: only for the persona name
/// A provider with no entry shows "not set up on this server yet" on its card. The redirect
/// URI to register with every app is https://camprec.net/api/site/social/{provider}/callback
/// (override with "RedirectBase" at the top level of the file).
/// Discord and Spotify keep their own link flows (DiscordController / SpotifyLink); this class
/// only holds their "show on profile" switch and projects them into the profile rows.
/// </summary>
public static class SocialLinks
{
public sealed class Link
{
[LiteDB.BsonId] public string Id { get; set; } = ""; // "{account}|{provider}"
public ulong AccountId { get; set; }
public string Provider { get; set; } = "";
public string ExternalId { get; set; } = "";
public string Handle { get; set; } = "";
public string DisplayName { get; set; } = "";
public string Url { get; set; } = "";
public bool ShowOnProfile { get; set; } = true;
public DateTime LinkedAt { get; set; } = DateTime.UtcNow;
}
public sealed record Provider(string Key, string Label, bool Oauth);
public static readonly Provider[] Providers =
{
new("discord", "Discord", false), // own flow
new("spotify", "Spotify", false), // own flow
new("youtube", "YouTube", true),
new("tiktok", "TikTok", true),
new("x", "X", true),
new("twitch", "Twitch", true),
new("steam", "Steam", true), // OpenID, no app needed
new("github", "GitHub", true),
new("reddit", "Reddit", true),
new("instagram", "Instagram", true),
};
public static Provider? Find(string key) => Providers.FirstOrDefault(p => p.Key == key);
private sealed class AppCfg { public string ClientId { get; set; } = ""; public string ClientSecret { get; set; } = ""; public string ApiKey { get; set; } = ""; }
private static readonly string ConfigPath = Path.Combine("Data", "social_apps.json");
private static Dictionary<string, AppCfg> _cfg = new(); private static string _redirectBase = "https://gameauth.ddns.net"; private static DateTime _cfgRead; // _redirectBase: the callback host registered with the services (NOT the site host, camprec.net)
private static readonly Lazy<LiteDB.LiteDatabase> _db = new(() => new LiteDB.LiteDatabase(Path.Combine("Data", "DBs", "Socials.db")));
private static LiteDB.ILiteCollection<Link> Links()
{
var c = _db.Value.GetCollection<Link>("links");
c.EnsureIndex(x => x.AccountId);
return c;
}
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
// state -> (account, provider, pkce verifier), 10 minutes, one use
private static readonly ConcurrentDictionary<string, (ulong account, string provider, string verifier, DateTime at)> _states = new();
private static void ReadConfig()
{
if (DateTime.UtcNow - _cfgRead < TimeSpan.FromMinutes(1)) return;
_cfgRead = DateTime.UtcNow;
try
{
if (!File.Exists(ConfigPath)) { _cfg = new(); return; }
using var doc = JsonDocument.Parse(File.ReadAllText(ConfigPath));
var map = new Dictionary<string, AppCfg>(StringComparer.OrdinalIgnoreCase);
foreach (var p in doc.RootElement.EnumerateObject())
{
if (p.Name.Equals("RedirectBase", StringComparison.OrdinalIgnoreCase)) { _redirectBase = (p.Value.GetString() ?? _redirectBase).TrimEnd('/'); continue; }
if (p.Value.ValueKind != JsonValueKind.Object) continue;
var a = new AppCfg();
foreach (var f in p.Value.EnumerateObject())
{
string v = f.Value.ValueKind == JsonValueKind.String ? f.Value.GetString() ?? "" : "";
if (f.Name.Equals("ClientId", StringComparison.OrdinalIgnoreCase) || f.Name.Equals("ClientKey", StringComparison.OrdinalIgnoreCase)) a.ClientId = v;
else if (f.Name.Equals("ClientSecret", StringComparison.OrdinalIgnoreCase)) a.ClientSecret = v;
else if (f.Name.Equals("ApiKey", StringComparison.OrdinalIgnoreCase)) a.ApiKey = v;
}
map[p.Name.ToLowerInvariant()] = a;
}
_cfg = map;
}
catch (Exception ex) { Console.WriteLine($"[social] could not read {ConfigPath}: {ex.Message}"); }
}
private static AppCfg? Cfg(string provider)
{
ReadConfig();
return _cfg.TryGetValue(provider, out var c) && !string.IsNullOrWhiteSpace(c.ClientId) && !string.IsNullOrWhiteSpace(c.ClientSecret) ? c : null;
}
private static string SteamApiKey() { ReadConfig(); return _cfg.TryGetValue("steam", out var c) ? c.ApiKey : ""; }
public static bool Configured(string provider) => provider == "steam" || Cfg(provider) != null;
// ---- admin panel: the app credentials, editable without touching the VPS ----------------
public static string RedirectUriFor(string provider) => RedirectUri(provider);
/// <summary>What the admin panel shows: ids in the clear, secrets only as "set".</summary>
public static List<object> AdminView()
{
_cfgRead = DateTime.MinValue; ReadConfig();
var list = new List<object>();
foreach (var p in Providers)
{
if (!p.Oauth) continue;
_cfg.TryGetValue(p.Key, out var c);
list.Add(new
{
key = p.Key, label = p.Label,
clientId = c?.ClientId ?? "",
secretSet = !string.IsNullOrEmpty(c?.ClientSecret),
apiKeySet = !string.IsNullOrEmpty(c?.ApiKey),
usesApiKey = p.Key == "steam",
configured = Configured(p.Key),
redirectUri = RedirectUri(p.Key)
});
}
return list;
}
/// <summary>Write one provider's credentials into Data/social_apps.json (blank secret = keep the stored one).</summary>
public static bool AdminSave(string provider, string? clientId, string? clientSecret, string? apiKey, out string message)
{
message = "";
var p = Find(provider);
if (p == null || !p.Oauth) { message = "Unknown provider."; return false; }
try
{
var root = new Dictionary<string, object>();
if (File.Exists(ConfigPath))
{
using var doc = JsonDocument.Parse(File.ReadAllText(ConfigPath));
foreach (var e in doc.RootElement.EnumerateObject())
{
if (e.Value.ValueKind == JsonValueKind.Object)
{
var d = new Dictionary<string, string>();
foreach (var f in e.Value.EnumerateObject()) d[f.Name] = f.Value.ValueKind == JsonValueKind.String ? f.Value.GetString() ?? "" : "";
root[e.Name.ToLowerInvariant()] = d;
}
else root[e.Name] = e.Value.ValueKind == JsonValueKind.String ? e.Value.GetString() ?? "" : "";
}
}
var cur = root.TryGetValue(provider, out var o) && o is Dictionary<string, string> dd ? dd : new Dictionary<string, string>();
if (clientId != null) cur["ClientId"] = clientId.Trim();
if (!string.IsNullOrWhiteSpace(clientSecret)) cur["ClientSecret"] = clientSecret.Trim();
if (!string.IsNullOrWhiteSpace(apiKey)) cur["ApiKey"] = apiKey.Trim();
if (clientId == "" ) { cur.Remove("ClientId"); cur.Remove("ClientSecret"); }
if (apiKey == "") cur.Remove("ApiKey");
root[provider] = cur;
Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!);
File.WriteAllText(ConfigPath, JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true }));
_cfgRead = DateTime.MinValue; ReadConfig();
message = Configured(provider) ? $"{p.Label} sign-in is set up." : $"{p.Label} saved (still needs both a client id and a secret).";
Console.WriteLine($"[social] admin updated {provider} credentials");
return true;
}
catch (Exception ex) { message = ex.Message; return false; }
}
private static string RedirectUri(string provider) => $"{(provider == "steam" ? ConfigCampus.SiteUrl : _redirectBase)}/api/site/social/{provider}/callback";
public static Link? Get(ulong account, string provider) => Links().FindById($"{account}|{provider}");
public static List<Link> For(ulong account) => Links().Find(x => x.AccountId == account).ToList();
public static void Unlink(ulong account, string provider)
{
Links().Delete($"{account}|{provider}");
Console.WriteLine($"[social] {account} unlinked {provider}");
}
/// <summary>The per-service "show on my profile" switch. Discord/Spotify rows are created on demand.</summary>
public static void SetShow(ulong account, string provider, bool show)
{
var l = Get(account, provider);
if (l == null)
{
if (provider != "discord" && provider != "spotify") return;
l = new Link { Id = $"{account}|{provider}", AccountId = account, Provider = provider };
}
l.ShowOnProfile = show;
Links().Upsert(l);
}
public static bool Shows(ulong account, string provider) => Get(account, provider)?.ShowOnProfile ?? true;
private static void Save(ulong account, string provider, string externalId, string handle, string display, string url)
{
var existing = Get(account, provider);
var l = new Link
{
Id = $"{account}|{provider}", AccountId = account, Provider = provider,
ExternalId = externalId, Handle = handle, DisplayName = display, Url = url,
ShowOnProfile = existing?.ShowOnProfile ?? true, LinkedAt = DateTime.UtcNow
};
Links().Upsert(l);
Console.WriteLine($"[social] {account} linked {provider} as '{handle}' ({externalId})");
}
// ---- start -----------------------------------------------------------------------------
private static string NewState(ulong account, string provider, string verifier = "")
{
foreach (var kv in _states) if (DateTime.UtcNow - kv.Value.at > TimeSpan.FromMinutes(10)) _states.TryRemove(kv.Key, out _);
string state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
_states[state] = (account, provider, verifier, DateTime.UtcNow);
return state;
}
private static string Q(params (string k, string v)[] kv) =>
string.Join("&", kv.Select(p => Uri.EscapeDataString(p.k) + "=" + Uri.EscapeDataString(p.v)));
/// <summary>The service's sign-in URL for this account, or null when the provider isn't set up.</summary>
public static string? StartUrl(ulong account, string provider)
{
var p = Find(provider);
if (p == null || !p.Oauth) return null;
string redirect = RedirectUri(provider);
if (provider == "steam")
{
// Plain return_to (no query string) and a realm with a trailing slash - the exact
// shape every Steam OpenID library sends. Steam's new sign-in page answered
// "Something went wrong while attempting to sign you in" to the ?state= variant.
// The account is taken from the website cookie on the way back instead.
return "https://steamcommunity.com/openid/login?" + Q(
("openid.ns", "http://specs.openid.net/auth/2.0"), ("openid.mode", "checkid_setup"),
("openid.return_to", redirect), ("openid.realm", ConfigCampus.SiteUrl + "/"),
("openid.identity", "http://specs.openid.net/auth/2.0/identifier_select"),
("openid.claimed_id", "http://specs.openid.net/auth/2.0/identifier_select"));
}
var cfg = Cfg(provider);
if (cfg == null) return null;
switch (provider)
{
case "youtube":
return "https://accounts.google.com/o/oauth2/v2/auth?" + Q(("client_id", cfg.ClientId), ("redirect_uri", redirect), ("response_type", "code"),
("scope", "https://www.googleapis.com/auth/youtube.readonly"), ("state", NewState(account, provider)), ("access_type", "online"), ("prompt", "select_account"));
case "tiktok":
return "https://www.tiktok.com/v2/auth/authorize/?" + Q(("client_key", cfg.ClientId), ("redirect_uri", redirect), ("response_type", "code"),
("scope", "user.info.basic,user.info.profile"), ("state", NewState(account, provider)));
case "x":
{
string verifier = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
string challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
return "https://twitter.com/i/oauth2/authorize?" + Q(("client_id", cfg.ClientId), ("redirect_uri", redirect), ("response_type", "code"),
("scope", "users.read tweet.read"), ("state", NewState(account, provider, verifier)), ("code_challenge", challenge), ("code_challenge_method", "S256"));
}
case "twitch":
return "https://id.twitch.tv/oauth2/authorize?" + Q(("client_id", cfg.ClientId), ("redirect_uri", redirect), ("response_type", "code"),
("scope", ""), ("state", NewState(account, provider)), ("force_verify", "true"));
case "github":
return "https://github.com/login/oauth/authorize?" + Q(("client_id", cfg.ClientId), ("redirect_uri", redirect), ("scope", "read:user"), ("state", NewState(account, provider)));
case "reddit":
return "https://www.reddit.com/api/v1/authorize?" + Q(("client_id", cfg.ClientId), ("redirect_uri", redirect), ("response_type", "code"),
("scope", "identity"), ("state", NewState(account, provider)), ("duration", "temporary"));
case "instagram":
return "https://api.instagram.com/oauth/authorize?" + Q(("client_id", cfg.ClientId), ("redirect_uri", redirect), ("response_type", "code"),
("scope", "instagram_business_basic"), ("state", NewState(account, provider)));
}
return null;
}
private static string Base64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
// ---- callback --------------------------------------------------------------------------
/// <summary>Outcome: linked / expired / denied / inuse / error / unconfigured.</summary>
public static async Task<string> Complete(string provider, IQueryCollection query, ulong cookieAccount = 0)
{
ulong account; string verifier = "";
if (provider == "steam")
{
if (cookieAccount == 0) return "expired"; // not signed in to the website any more
account = cookieAccount;
}
else
{
string state = query["state"].ToString();
if (string.IsNullOrEmpty(state) || !_states.TryRemove(state, out var st) || st.provider != provider || DateTime.UtcNow - st.at > TimeSpan.FromMinutes(10))
return "expired";
account = st.account; verifier = st.verifier;
}
try
{
string externalId = "", handle = "", display = "", url = "";
if (provider == "steam")
{
if (query["openid.mode"].ToString() == "cancel") return "denied";
// Verify with Steam: replay every openid.* parameter with mode=check_authentication.
var form = new Dictionary<string, string>();
foreach (var kv in query) if (kv.Key.StartsWith("openid.")) form[kv.Key] = kv.Value.ToString();
form["openid.mode"] = "check_authentication";
using var vreq = new HttpRequestMessage(HttpMethod.Post, "https://steamcommunity.com/openid/login") { Content = new FormUrlEncodedContent(form) };
vreq.Headers.UserAgent.ParseAdd("Campus/1.0 (+https://camprec.net)");
var vr = await _http.SendAsync(vreq);
string vb = await vr.Content.ReadAsStringAsync();
if (!vb.Contains("is_valid:true")) { Console.WriteLine($"[social] steam check_authentication for {account} failed: {vb.Trim()}"); return "error"; }
string claimed = query["openid.claimed_id"].ToString();
externalId = claimed.TrimEnd('/').Split('/').LastOrDefault() ?? "";
if (!ulong.TryParse(externalId, out _)) return "error";
url = "https://steamcommunity.com/profiles/" + externalId;
handle = externalId; display = "Steam";
string key = SteamApiKey();
if (key.Length > 0)
{
try
{
var sr = await _http.GetStringAsync($"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={Uri.EscapeDataString(key)}&steamids={externalId}");
using var sd = JsonDocument.Parse(sr);
var pl = sd.RootElement.GetProperty("response").GetProperty("players");
if (pl.GetArrayLength() > 0)
{
var p0 = pl[0];
display = p0.TryGetProperty("personaname", out var pn) ? pn.GetString() ?? "" : "";
handle = display.Length > 0 ? display : externalId;
if (p0.TryGetProperty("profileurl", out var pu)) url = pu.GetString() ?? url;
}
}
catch (Exception ex) { Console.WriteLine($"[social] steam summary for {account}: {ex.Message}"); }
}
}
else
{
if (!string.IsNullOrEmpty(query["error"].ToString())) return "denied";
string code = query["code"].ToString();
if (string.IsNullOrEmpty(code)) return "denied";
var cfg = Cfg(provider);
if (cfg == null) return "unconfigured";
string redirect = RedirectUri(provider);
string token = await ExchangeCode(provider, cfg, code, redirect, verifier);
if (token.Length == 0) return "error";
(externalId, handle, display, url) = await Identity(provider, cfg, token);
if (externalId.Length == 0) return "error";
}
// One service account per Campus account.
var other = Links().FindOne(l => l.Provider == provider && l.ExternalId == externalId && l.AccountId != account);
if (other != null) return "inuse";
Save(account, provider, externalId, handle, display, url);
SiteNotifications.ProfileChanged(account);
return "linked";
}
catch (Exception ex)
{
Console.WriteLine($"[social] {provider} link for {account} failed: {ex.Message}");
return "error";
}
}
private static async Task<string> ExchangeCode(string provider, AppCfg cfg, string code, string redirect, string verifier)
{
string tokenUrl = provider switch
{
"youtube" => "https://oauth2.googleapis.com/token",
"tiktok" => "https://open.tiktokapis.com/v2/oauth/token/",
"x" => "https://api.twitter.com/2/oauth2/token",
"twitch" => "https://id.twitch.tv/oauth2/token",
"github" => "https://github.com/login/oauth/access_token",
"reddit" => "https://www.reddit.com/api/v1/access_token",
"instagram" => "https://api.instagram.com/oauth/access_token",
_ => ""
};
if (tokenUrl.Length == 0) return "";
var form = new Dictionary<string, string> { ["grant_type"] = "authorization_code", ["code"] = code, ["redirect_uri"] = redirect };
using var req = new HttpRequestMessage(HttpMethod.Post, tokenUrl);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Headers.UserAgent.ParseAdd("Campus/1.0 (+https://camprec.net)");
switch (provider)
{
case "tiktok":
form["client_key"] = cfg.ClientId; form["client_secret"] = cfg.ClientSecret; break;
case "x":
form["client_id"] = cfg.ClientId; form["code_verifier"] = verifier;
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(cfg.ClientId + ":" + cfg.ClientSecret)));
break;
case "reddit":
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(cfg.ClientId + ":" + cfg.ClientSecret)));
break;
default:
form["client_id"] = cfg.ClientId; form["client_secret"] = cfg.ClientSecret; break;
}
req.Content = new FormUrlEncodedContent(form);
var resp = await _http.SendAsync(req);
string body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) { Console.WriteLine($"[social] {provider} token exchange: HTTP {(int)resp.StatusCode} {Trunc(body)}"); return ""; }
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (root.TryGetProperty("access_token", out var at) && at.ValueKind == JsonValueKind.String) return at.GetString() ?? "";
if (root.TryGetProperty("data", out var d) && d.TryGetProperty("access_token", out var at2)) return at2.GetString() ?? "";
}
catch { }
Console.WriteLine($"[social] {provider} token exchange: no access_token in {Trunc(body)}");
return "";
}
private static string Trunc(string s) => s.Length > 300 ? s[..300] : s;
private static async Task<(string id, string handle, string display, string url)> Identity(string provider, AppCfg cfg, string token)
{
string apiUrl = provider switch
{
"youtube" => "https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true",
"tiktok" => "https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name,username,profile_deep_link",
"x" => "https://api.twitter.com/2/users/me",
"twitch" => "https://api.twitch.tv/helix/users",
"github" => "https://api.github.com/user",
"reddit" => "https://oauth.reddit.com/api/v1/me",
"instagram" => "https://graph.instagram.com/me?fields=id,username",
_ => ""
};
using var req = new HttpRequestMessage(HttpMethod.Get, apiUrl);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.UserAgent.ParseAdd("Campus/1.0 (+https://camprec.net)");
if (provider == "twitch") req.Headers.Add("Client-Id", cfg.ClientId);
var resp = await _http.SendAsync(req);
string body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) { Console.WriteLine($"[social] {provider} identity: HTTP {(int)resp.StatusCode} {Trunc(body)}"); return ("", "", "", ""); }
using var doc = JsonDocument.Parse(body);
var r = doc.RootElement;
string S(JsonElement e, string k) => e.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
switch (provider)
{
case "youtube":
{
if (!r.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) return ("", "", "", "");
var ch = items[0]; var sn = ch.GetProperty("snippet");
string id = S(ch, "id"), title = S(sn, "title"), custom = S(sn, "customUrl");
string handle = custom.Length > 0 ? custom.TrimStart('@') : title;
string url = custom.Length > 0 ? "https://www.youtube.com/" + (custom.StartsWith('@') ? custom : "@" + custom) : "https://www.youtube.com/channel/" + id;
return (id, handle, title, url);
}
case "tiktok":
{
var u = r.GetProperty("data").GetProperty("user");
string id = S(u, "open_id"), un = S(u, "username"), dn = S(u, "display_name"), link = S(u, "profile_deep_link");
string handle = un.Length > 0 ? un : dn;
string url = un.Length > 0 ? "https://www.tiktok.com/@" + un : link;
return (id, handle, dn, url);
}
case "x":
{
var d = r.GetProperty("data");
string un = S(d, "username");
return (S(d, "id"), un, S(d, "name"), "https://x.com/" + un);
}
case "twitch":
{
var arr = r.GetProperty("data"); if (arr.GetArrayLength() == 0) return ("", "", "", "");
var d = arr[0]; string login = S(d, "login");
return (S(d, "id"), S(d, "display_name").Length > 0 ? S(d, "display_name") : login, S(d, "display_name"), "https://www.twitch.tv/" + login);
}
case "github":
{
string login = S(r, "login");
return (r.TryGetProperty("id", out var gid) ? gid.GetRawText() : "", login, S(r, "name"), S(r, "html_url").Length > 0 ? S(r, "html_url") : "https://github.com/" + login);
}
case "reddit":
{
string name = S(r, "name");
return (S(r, "id"), name, name, "https://www.reddit.com/user/" + name);
}
case "instagram":
{
string un = S(r, "username");
return (S(r, "id"), un, un, "https://www.instagram.com/" + un);
}
}
return ("", "", "", "");
}
// ---- profile projection ------------------------------------------------------------------
/// <summary>The chips a profile shows: every linked service whose switch is on, in Providers order.</summary>
public static List<object> ProfileRows(ulong account, Classes.db_class.player_extra? extra)
{
var rows = new List<object>();
var links = For(account).ToDictionary(l => l.Provider, l => l);
foreach (var p in Providers)
{
links.TryGetValue(p.Key, out var l);
if (p.Key == "discord")
{
string dn = extra?.DiscordUsername ?? "";
if ((extra?.DiscordId ?? 0) == 0 || dn.Length == 0 || l?.ShowOnProfile == false) continue;
rows.Add(new { platform = "discord", label = "Discord", handle = dn, url = "" });
continue;
}
if (p.Key == "spotify")
{
var sp = SpotifyLink.Get(account);
if (sp == null || sp.Broken || string.IsNullOrEmpty(sp.ProfileUrl) || l?.ShowOnProfile == false) continue;
rows.Add(new { platform = "spotify", label = "Spotify", handle = string.IsNullOrEmpty(sp.DisplayName) ? "Spotify" : sp.DisplayName, url = sp.ProfileUrl });
continue;
}
if (l == null || !l.ShowOnProfile || l.Handle.Length == 0) continue;
rows.Add(new { platform = p.Key, label = p.Label, handle = l.Handle, url = l.Url });
}
return rows;
}
/// <summary>The Services cards: one entry per provider with its state for the signed-in account.</summary>
public static List<object> Status(ulong account, Classes.db_class.player_extra? extra)
{
var links = For(account).ToDictionary(l => l.Provider, l => l);
var list = new List<object>();
foreach (var p in Providers)
{
links.TryGetValue(p.Key, out var l);
bool linked; string handle = "", url = "";
if (p.Key == "discord") { linked = (extra?.DiscordId ?? 0) != 0; handle = extra?.DiscordUsername ?? ""; }
else if (p.Key == "spotify") { var sp = SpotifyLink.Get(account); linked = sp != null && !sp.Broken; handle = sp?.DisplayName ?? ""; url = sp?.ProfileUrl ?? ""; }
else { linked = l != null && l.Handle.Length > 0; handle = l?.Handle ?? ""; url = l?.Url ?? ""; }
list.Add(new
{
key = p.Key, label = p.Label, oauth = p.Oauth,
configured = p.Oauth ? Configured(p.Key) : true,
linked, handle, url,
show = l?.ShowOnProfile ?? true
});
}
return list;
}
}
}
@@ -0,0 +1,425 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Spotify account linking for the website (Settings > Services > Spotify) and the "listening
/// to" line under a player's online status.
///
/// The Spotify app's credentials live OUTSIDE the source, in Data/spotify_app.json:
/// { "ClientId": "...", "ClientSecret": "...", "RedirectUri": "https://camprec.net/api/site/spotify/callback" }
/// (RedirectUri optional - that default is used - but whatever it is must be registered on the
/// app in the Spotify developer dashboard.) Nothing here works until that file exists; the
/// Settings page says so instead of showing a Link button that goes nowhere.
///
/// Storage: Data/DBs/Spotify.db, one row per account with the REFRESH token (long-lived), the
/// last access token and the player's "show my Spotify to" choice. Now-playing is polled from
/// Spotify at most once per 10 s per account, however many people are looking.
/// </summary>
public static class SpotifyLink
{
public const int VisEveryone = 0, VisFriends = 1, VisFavorites = 2, VisNoOne = 3;
public sealed class Link
{
[LiteDB.BsonId] public ulong AccountId { get; set; }
public string RefreshToken { get; set; } = "";
public string AccessToken { get; set; } = "";
public DateTime AccessExpiresAt { get; set; }
public string SpotifyUserId { get; set; } = "";
public string DisplayName { get; set; } = "";
public string ProfileUrl { get; set; } = "";
public int Visibility { get; set; } = VisFriends;
public DateTime LinkedAt { get; set; } = DateTime.UtcNow;
/// <summary>Set when Spotify refused the refresh token (revoked): the link is dead until re-linked.</summary>
public bool Broken { get; set; }
/// <summary>
/// Spotify answers 403 for this user: the Campus app is in Spotify's Development Mode,
/// which only serves the (max 25) users allow-listed under User Management on the
/// developer dashboard. The OAuth handshake still succeeds - the token is real - but
/// /v1/me and /currently-playing are refused, which is the "Connected but no name and no
/// music" symptom. Cleared the next time a call succeeds (the user got added).
/// </summary>
public bool NotAllowed { get; set; }
public DateTime NotAllowedAt { get; set; }
}
public sealed class NowPlaying
{
public bool IsPlaying;
public string Track = "";
public string Artists = "";
public string Album = "";
public string AlbumArt = "";
public string Url = "";
public string TrackId = "";
public long ProgressMs;
public long DurationMs;
public DateTime FetchedAt = DateTime.UtcNow;
}
private sealed class AppConfig { public string ClientId { get; set; } = ""; public string ClientSecret { get; set; } = ""; public string RedirectUri { get; set; } = ""; }
private static readonly string ConfigPath = Path.Combine("Data", "spotify_app.json");
private static readonly Lazy<LiteDB.LiteDatabase> _db = new(() => new LiteDB.LiteDatabase(Path.Combine("Data", "DBs", "Spotify.db")));
private static LiteDB.ILiteCollection<Link> Links() => _db.Value.GetCollection<Link>("links");
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(8) };
private static AppConfig? _cfg; private static DateTime _cfgRead;
// OAuth state -> account, 10 minutes, one use.
private static readonly ConcurrentDictionary<string, (ulong account, DateTime at)> _states = new();
// Now-playing cache + the empty ("nothing playing") cache, per account.
private static readonly ConcurrentDictionary<ulong, (NowPlaying? np, DateTime at)> _cache = new();
private static readonly TimeSpan CacheFor = TimeSpan.FromSeconds(10);
private static AppConfig? Config()
{
if (_cfg != null && DateTime.UtcNow - _cfgRead < TimeSpan.FromMinutes(1)) return _cfg;
try
{
if (!File.Exists(ConfigPath)) { _cfg = null; }
else
{
var c = JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(ConfigPath), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (c != null && string.IsNullOrWhiteSpace(c.RedirectUri)) c.RedirectUri = "https://gameauth.ddns.net/api/site/spotify/callback"; // as registered with Spotify
_cfg = (c != null && !string.IsNullOrWhiteSpace(c.ClientId) && !string.IsNullOrWhiteSpace(c.ClientSecret)) ? c : null;
}
}
catch (Exception ex) { Console.WriteLine($"[spotify] could not read {ConfigPath}: {ex.Message}"); _cfg = null; }
_cfgRead = DateTime.UtcNow;
return _cfg;
}
public static bool Configured => Config() != null;
public static Link? Get(ulong accountId) => Links().FindById(accountId);
public static void Unlink(ulong accountId)
{
Links().Delete(accountId);
_cache.TryRemove(accountId, out _);
Console.WriteLine($"[spotify] {accountId} unlinked");
}
public static void SetVisibility(ulong accountId, int visibility)
{
var l = Get(accountId);
if (l == null) return;
l.Visibility = Math.Clamp(visibility, VisEveryone, VisNoOne);
Links().Update(l);
Console.WriteLine($"[spotify] {accountId} visibility -> {l.Visibility}");
}
// ---- OAuth ------------------------------------------------------------------------
/// <summary>The Spotify authorize URL for this account, or null when the app isn't configured.</summary>
public static string? StartUrl(ulong accountId)
{
var cfg = Config();
if (cfg == null) return null;
foreach (var kv in _states) if (DateTime.UtcNow - kv.Value.at > TimeSpan.FromMinutes(10)) _states.TryRemove(kv.Key, out _);
string state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
_states[state] = (accountId, DateTime.UtcNow);
return "https://accounts.spotify.com/authorize"
+ "?client_id=" + Uri.EscapeDataString(cfg.ClientId)
+ "&response_type=code"
+ "&redirect_uri=" + Uri.EscapeDataString(cfg.RedirectUri)
+ "&scope=" + Uri.EscapeDataString("user-read-currently-playing user-read-playback-state")
+ "&state=" + state
+ "&show_dialog=true";
}
/// <summary>Finish the OAuth round trip. Returns the outcome key the Settings page toasts.</summary>
public static async Task<string> Complete(string? code, string? state, string? error)
{
var cfg = Config();
if (cfg == null) return "unconfigured";
if (string.IsNullOrEmpty(state) || !_states.TryRemove(state, out var st) || DateTime.UtcNow - st.at > TimeSpan.FromMinutes(10)) return "expired";
if (!string.IsNullOrEmpty(error) || string.IsNullOrEmpty(code)) return "denied";
try
{
using var req = new HttpRequestMessage(HttpMethod.Post, "https://accounts.spotify.com/api/token");
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(cfg.ClientId + ":" + cfg.ClientSecret)));
req.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = code!,
["redirect_uri"] = cfg.RedirectUri
});
var resp = await _http.SendAsync(req);
string body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) { Console.WriteLine($"[spotify] token exchange for {st.account} failed: HTTP {(int)resp.StatusCode} {body}"); return "error"; }
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
string access = root.GetProperty("access_token").GetString() ?? "";
string refresh = root.TryGetProperty("refresh_token", out var rt) ? rt.GetString() ?? "" : "";
int expires = root.TryGetProperty("expires_in", out var ei) ? ei.GetInt32() : 3600;
if (access.Length == 0 || refresh.Length == 0) return "error";
// Who is this? (display name for the Settings card)
string userId = "", name = "", url = "";
bool notAllowed = false;
try
{
using var me = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me");
me.Headers.Authorization = new AuthenticationHeaderValue("Bearer", access);
var mr = await _http.SendAsync(me);
if ((int)mr.StatusCode == 403)
{
notAllowed = true;
Console.WriteLine($"[spotify] /me for {st.account}: HTTP 403 - this Spotify user is not on the app's allow-list (Development Mode)");
}
if (mr.IsSuccessStatusCode)
{
using var md = JsonDocument.Parse(await mr.Content.ReadAsStringAsync());
userId = md.RootElement.TryGetProperty("id", out var idp) ? idp.GetString() ?? "" : "";
name = md.RootElement.TryGetProperty("display_name", out var dn) && dn.ValueKind == JsonValueKind.String ? dn.GetString() ?? "" : userId;
if (md.RootElement.TryGetProperty("external_urls", out var eu) && eu.TryGetProperty("spotify", out var su)) url = su.GetString() ?? "";
}
}
catch (Exception ex) { Console.WriteLine($"[spotify] /me for {st.account} failed: {ex.Message}"); }
// One Spotify account per Campus account, and vice versa.
if (userId.Length > 0)
{
var other = Links().FindOne(l => l.SpotifyUserId == userId && l.AccountId != st.account);
if (other != null) return "inuse";
}
var existing = Get(st.account);
var link = new Link
{
AccountId = st.account,
RefreshToken = refresh,
AccessToken = access,
AccessExpiresAt = DateTime.UtcNow.AddSeconds(Math.Max(60, expires - 60)),
SpotifyUserId = userId,
DisplayName = name,
ProfileUrl = url,
Visibility = existing?.Visibility ?? VisFriends,
LinkedAt = DateTime.UtcNow,
NotAllowed = notAllowed,
NotAllowedAt = notAllowed ? DateTime.UtcNow : default
};
Links().Upsert(link);
_cache.TryRemove(st.account, out _);
Console.WriteLine($"[spotify] {st.account} linked as '{name}' ({userId}){(notAllowed ? " - NOT ALLOW-LISTED" : "")}");
return notAllowed ? "notallowed" : "linked";
}
catch (Exception ex)
{
Console.WriteLine($"[spotify] link for {st.account} failed: {ex.Message}");
return "error";
}
}
private static async Task<string?> AccessToken(Link link)
{
if (link.Broken) return null;
if (DateTime.UtcNow < link.AccessExpiresAt && link.AccessToken.Length > 0) return link.AccessToken;
var cfg = Config();
if (cfg == null) return null;
try
{
using var req = new HttpRequestMessage(HttpMethod.Post, "https://accounts.spotify.com/api/token");
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(cfg.ClientId + ":" + cfg.ClientSecret)));
req.Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["grant_type"] = "refresh_token", ["refresh_token"] = link.RefreshToken });
var resp = await _http.SendAsync(req);
string body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
Console.WriteLine($"[spotify] refresh for {link.AccountId} failed: HTTP {(int)resp.StatusCode} {body}");
if ((int)resp.StatusCode == 400 && body.Contains("invalid_grant")) { link.Broken = true; Links().Update(link); }
return null;
}
using var doc = JsonDocument.Parse(body);
link.AccessToken = doc.RootElement.GetProperty("access_token").GetString() ?? "";
int expires = doc.RootElement.TryGetProperty("expires_in", out var ei) ? ei.GetInt32() : 3600;
if (doc.RootElement.TryGetProperty("refresh_token", out var rt) && rt.ValueKind == JsonValueKind.String) link.RefreshToken = rt.GetString() ?? link.RefreshToken;
link.AccessExpiresAt = DateTime.UtcNow.AddSeconds(Math.Max(60, expires - 60));
Links().Update(link);
return link.AccessToken;
}
catch (Exception ex) { Console.WriteLine($"[spotify] refresh for {link.AccountId} threw: {ex.Message}"); return null; }
}
// ---- now playing --------------------------------------------------------------------
/// <summary>What the account is playing right now (cached 10 s), or null when nothing / unlinked.</summary>
public static async Task<NowPlaying?> Fetch(ulong accountId)
{
var link = Get(accountId);
if (link == null) return null;
// A refused (not allow-listed) link is retried every 5 minutes, not every 10 s.
var cacheFor = link.NotAllowed ? TimeSpan.FromMinutes(5) : CacheFor;
if (_cache.TryGetValue(accountId, out var hit) && DateTime.UtcNow - hit.at < cacheFor) return hit.np;
NowPlaying? np = null;
try
{
string? token = await AccessToken(link);
if (token != null)
{
using var req = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me/player/currently-playing?additional_types=track,episode");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var resp = await _http.SendAsync(req);
int code = (int)resp.StatusCode;
if (code == 200)
{
string body = await resp.Content.ReadAsStringAsync();
np = Parse(body);
}
else if (code == 403)
{
if (!link.NotAllowed)
{
link.NotAllowed = true; link.NotAllowedAt = DateTime.UtcNow; Links().Update(link);
Console.WriteLine($"[spotify] now-playing for {accountId}: HTTP 403 - user not on the app's allow-list (Development Mode); polling every 5 min until they are");
}
}
else if (code != 204)
Console.WriteLine($"[spotify] now-playing for {accountId}: HTTP {code}");
if ((code == 200 || code == 204) && link.NotAllowed)
{
// They got added: clear the flag and pick up the display name we never got.
link.NotAllowed = false; Links().Update(link);
_ = FillIdentity(link);
Console.WriteLine($"[spotify] {accountId} is allow-listed now");
}
}
}
catch (Exception ex) { Console.WriteLine($"[spotify] now-playing for {accountId} threw: {ex.Message}"); }
_cache[accountId] = (np, DateTime.UtcNow);
return np;
}
/// <summary>Fetch /v1/me for a link that was made while refused, so the card gets a name.</summary>
private static async Task FillIdentity(Link link)
{
try
{
string? token = await AccessToken(link);
if (token == null) return;
using var me = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me");
me.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var mr = await _http.SendAsync(me);
if (!mr.IsSuccessStatusCode) return;
using var md = JsonDocument.Parse(await mr.Content.ReadAsStringAsync());
link.SpotifyUserId = md.RootElement.TryGetProperty("id", out var idp) ? idp.GetString() ?? "" : link.SpotifyUserId;
link.DisplayName = md.RootElement.TryGetProperty("display_name", out var dn) && dn.ValueKind == JsonValueKind.String ? dn.GetString() ?? "" : link.SpotifyUserId;
if (md.RootElement.TryGetProperty("external_urls", out var eu) && eu.TryGetProperty("spotify", out var su)) link.ProfileUrl = su.GetString() ?? "";
Links().Update(link);
}
catch (Exception ex) { Console.WriteLine($"[spotify] identity for {link.AccountId} failed: {ex.Message}"); }
}
/// <summary>Track / artist / album names go through the chat censor: a swear becomes ****.</summary>
private static string Clean(string? s) => string.IsNullOrEmpty(s) ? "" : Campus_live_rewrite.api.server.Classes.Sanitize.SanitizeMessage(s);
private static NowPlaying? Parse(string body)
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (!root.TryGetProperty("item", out var item) || item.ValueKind != JsonValueKind.Object) return null;
var np = new NowPlaying
{
IsPlaying = root.TryGetProperty("is_playing", out var ip) && ip.GetBoolean(),
ProgressMs = root.TryGetProperty("progress_ms", out var pm) && pm.ValueKind == JsonValueKind.Number ? pm.GetInt64() : 0,
Track = item.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "",
DurationMs = item.TryGetProperty("duration_ms", out var dm) && dm.ValueKind == JsonValueKind.Number ? dm.GetInt64() : 0,
TrackId = item.TryGetProperty("id", out var tid) && tid.ValueKind == JsonValueKind.String ? tid.GetString() ?? "" : ""
};
if (item.TryGetProperty("external_urls", out var eu) && eu.TryGetProperty("spotify", out var su)) np.Url = su.GetString() ?? "";
if (item.TryGetProperty("artists", out var artists) && artists.ValueKind == JsonValueKind.Array)
np.Artists = string.Join(", ", artists.EnumerateArray().Select(a => a.TryGetProperty("name", out var an) ? an.GetString() : null).Where(s => !string.IsNullOrEmpty(s)));
JsonElement images = default; bool hasImages = false;
if (item.TryGetProperty("album", out var album) && album.ValueKind == JsonValueKind.Object)
{
np.Album = album.TryGetProperty("name", out var aln) ? aln.GetString() ?? "" : "";
hasImages = album.TryGetProperty("images", out images);
}
else if (item.TryGetProperty("show", out var show) && show.ValueKind == JsonValueKind.Object) // a podcast episode
{
np.Album = show.TryGetProperty("name", out var sn) ? sn.GetString() ?? "" : "";
if (np.Artists.Length == 0 && show.TryGetProperty("publisher", out var pub)) np.Artists = pub.GetString() ?? "";
hasImages = show.TryGetProperty("images", out images);
}
if (hasImages && images.ValueKind == JsonValueKind.Array)
{
// The smallest image that is still >= 128px, else whatever is last (smallest).
string? pick = null;
foreach (var im in images.EnumerateArray())
{
int w = im.TryGetProperty("width", out var wp) && wp.ValueKind == JsonValueKind.Number ? wp.GetInt32() : 0;
string? u = im.TryGetProperty("url", out var up) ? up.GetString() : null;
if (u == null) continue;
if (pick == null || w >= 128) pick = u;
}
np.AlbumArt = pick ?? "";
}
return np.Track.Length == 0 ? null : np;
}
// ---- who may see it ------------------------------------------------------------------
/// <summary>May <paramref name="viewer"/> see <paramref name="owner"/>'s Spotify? Self always; signed-out never.</summary>
public static bool CanSee(ulong owner, ulong viewer, Link link)
{
if (viewer == owner) return true;
if (viewer == 0) return false;
switch (link.Visibility)
{
case VisEveryone: return true;
case VisNoOne: return false;
default:
{
var rel = FriendsDB.GetRelationships(owner)?.FirstOrDefault(r => r.PlayerID == viewer);
if (rel == null || rel.RelationshipType != RelationshipType.Friend) return false;
return link.Visibility == VisFriends || rel.Favorited != ReciprocalStatus.None;
}
}
}
/// <summary>The "listening to" payload for a viewer, or null when there is nothing they may see.</summary>
public static async Task<object?> PayloadFor(ulong owner, ulong viewer)
{
var link = Get(owner);
if (link == null || link.Broken || !CanSee(owner, viewer, link)) return null;
var np = await Fetch(owner);
if (np == null) return null;
// Progress as of NOW (the cached sample may be up to 10 s old), capped at the end.
long progress = np.ProgressMs + (np.IsPlaying ? (long)(DateTime.UtcNow - np.FetchedAt).TotalMilliseconds : 0);
if (np.DurationMs > 0) progress = Math.Min(progress, np.DurationMs);
return new
{
is_playing = np.IsPlaying,
track = Clean(np.Track),
artists = Clean(np.Artists),
album = Clean(np.Album),
album_art = np.AlbumArt,
url = np.Url,
track_id = np.TrackId,
progress_ms = progress,
duration_ms = np.DurationMs,
as_of = DateTime.UtcNow
};
}
/// <summary>Synchronous convenience for list rows: the cached sample only, never a Spotify call.</summary>
public static object? CachedPayloadFor(ulong owner, ulong viewer)
{
var link = Get(owner);
if (link == null || link.Broken || !CanSee(owner, viewer, link)) return null;
if (!_cache.TryGetValue(owner, out var hit) || hit.np == null) { _ = Fetch(owner); return null; }
var np = hit.np;
long progress = np.ProgressMs + (np.IsPlaying ? (long)(DateTime.UtcNow - np.FetchedAt).TotalMilliseconds : 0);
if (np.DurationMs > 0) progress = Math.Min(progress, np.DurationMs);
return new { is_playing = np.IsPlaying, track = Clean(np.Track), artists = Clean(np.Artists), album = Clean(np.Album), album_art = np.AlbumArt, url = np.Url, track_id = np.TrackId, progress_ms = progress, duration_ms = np.DurationMs, as_of = DateTime.UtcNow };
}
}
}
+45
View File
@@ -0,0 +1,45 @@
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
public class Steam
{
private static readonly HttpClient _http = new HttpClient();
public static async Task<SteamAccountInfo?> GetAccountInfoAsync(string steamId64)
{
if (string.IsNullOrWhiteSpace(steamId64))
return null;
var url = $"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key={ConfigCampus.SteamAPIKey}&steamids={steamId64}";
var response = await _http.GetAsync(url);
if (!response.IsSuccessStatusCode)
return null;
var json = JObject.Parse(await response.Content.ReadAsStringAsync());
var player = json["response"]?["players"]?.First;
if (player == null)
return null;
return new SteamAccountInfo
{
SteamId = steamId64,
PersonaName = player["personaname"]?.ToString(),
ProfileUrl = player["profileurl"]?.ToString(),
Avatar = player["avatarfull"]?.ToString()
};
}
}
public class SteamAccountInfo
{
public string SteamId { get; set; } = "";
public string? PersonaName { get; set; }
public string? ProfileUrl { get; set; }
public string? Avatar { get; set; }
}
}
@@ -0,0 +1,174 @@
using System.Globalization;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Steam-only connection allowlist, backed by Data/steam_allowlist.txt.
///
/// Enforced at /Auth/connect/token: a client authenticating on Steam must have its
/// SteamID64 on the list or it is refused. Every other platform is untouched -
/// Quest/Meta (Oculus) in particular is explicitly excluded, so headset players are
/// never gated by this.
///
/// Conventions match Data/allowlist.txt in BannedIpMiddleware, deliberately, so
/// operators only have to learn one behaviour:
/// - missing OR empty file = mode OFF, everyone allowed. It is an ALLOWLIST, so
/// an empty one must not mean "nobody may connect" - that would lock the server
/// the moment the file is created.
/// - '#' starts a comment; blank lines ignored.
/// - a read error fails OPEN. Losing the file must not lock out the playerbase.
/// - re-read at most every 5s, and only reparsed when the timestamp moves, so
/// edits apply live without a restart and without hammering the disk.
/// </summary>
public static class SteamAllowlist
{
private static string Path_ =>
System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "steam_allowlist.txt");
private static HashSet<ulong>? _ids;
private static DateTime _loadedAt = DateTime.MinValue;
private static DateTime _fileStamp = DateTime.MinValue;
private static readonly object _lock = new();
/// <summary>True when the file exists and lists at least one id.</summary>
public static bool IsActive => Load().Count > 0;
/// <summary>
/// May this Steam id connect? Always true when the list is empty (mode off).
/// Callers must only ask this for Steam logins - see ShouldCheck.
/// </summary>
public static bool IsAllowed(ulong steamId)
{
var set = Load();
if (set.Count == 0) return true; // mode off
return set.Contains(steamId);
}
/// <summary>
/// Does this login need checking at all?
///
/// Steam only. A steamId of 0 is treated as "not a Steam login" rather than as a
/// blocked one: the field is not always populated, and refusing on it would take
/// out non-Steam clients that leave it blank.
/// </summary>
public static bool ShouldCheck(Classes.db_class.Platforms platform, ulong steamId) =>
platform == Classes.db_class.Platforms.Steam && steamId != 0;
public static IReadOnlyCollection<ulong> All() => Load();
// ---- mutation (admin panel) --------------------------------------------------
public static bool Add(ulong steamId)
{
if (steamId == 0) return false;
var set = new HashSet<ulong>(Load());
if (!set.Add(steamId)) return false; // already listed
Save(set);
Console.WriteLine($"[steam-allowlist] added {steamId} ({set.Count} total)");
return true;
}
public static bool Remove(ulong steamId)
{
var set = new HashSet<ulong>(Load());
if (!set.Remove(steamId)) return false;
Save(set);
Console.WriteLine($"[steam-allowlist] removed {steamId} ({set.Count} remaining)");
return true;
}
/// <summary>Replaces the whole list. An empty set turns the mode off.</summary>
public static void Replace(IEnumerable<ulong> ids)
{
var set = new HashSet<ulong>(ids.Where(i => i != 0));
Save(set);
Console.WriteLine($"[steam-allowlist] replaced - {set.Count} id(s)"
+ (set.Count == 0 ? " (mode OFF - all Steam users allowed)" : ""));
}
private static void Save(HashSet<ulong> set)
{
try
{
string dir = System.IO.Path.GetDirectoryName(Path_)!;
Directory.CreateDirectory(dir);
var lines = new List<string>
{
"# Campus Steam allowlist.",
"# One SteamID64 per line. '#' starts a comment.",
"# An EMPTY list means the check is OFF and every Steam user may connect.",
"# Quest / Meta and all other platforms are never affected by this file.",
};
lines.AddRange(set.OrderBy(i => i).Select(i => i.ToString(CultureInfo.InvariantCulture)));
File.WriteAllLines(Path_, lines);
// Force the next Load() to reparse instead of waiting out the 5s window,
// so the admin panel reflects the change immediately.
lock (_lock) { _ids = null; _loadedAt = DateTime.MinValue; }
}
catch (Exception ex)
{
Console.WriteLine($"[steam-allowlist] could not write {Path_}: {ex.Message}");
}
}
private static HashSet<ulong> Load()
{
var now = DateTime.UtcNow;
if (_ids != null && (now - _loadedAt).TotalSeconds < 5) return _ids;
lock (_lock)
{
if (_ids != null && (now - _loadedAt).TotalSeconds < 5) return _ids;
_loadedAt = now;
try
{
if (!File.Exists(Path_))
{
_ids = new HashSet<ulong>();
return _ids;
}
var stamp = File.GetLastWriteTimeUtc(Path_);
if (_ids != null && stamp == _fileStamp) return _ids;
_fileStamp = stamp;
var set = new HashSet<ulong>();
foreach (var raw in File.ReadAllLines(Path_))
{
var line = raw.Trim();
if (line.Length == 0 || line.StartsWith("#")) continue;
// Tolerate a trailing comment: "76561198... # kyle"
int hash = line.IndexOf('#');
if (hash >= 0) line = line.Substring(0, hash).Trim();
if (line.Length == 0) continue;
if (ulong.TryParse(line, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id != 0)
set.Add(id);
else
Console.WriteLine($"[steam-allowlist] ignoring unparseable line: '{raw.Trim()}'");
}
Console.WriteLine(set.Count > 0
? $"[steam-allowlist] active - {set.Count} Steam id(s) may connect; other Steam users refused"
: "[steam-allowlist] file present but empty - check OFF");
_ids = set;
}
catch (Exception ex)
{
// Fail OPEN, same as the IP allowlist. A read error must not lock the
// playerbase out of the server.
Console.WriteLine($"[steam-allowlist] could not read {Path_} ({ex.Message}) - allowing all Steam users");
_ids = new HashSet<ulong>();
}
return _ids;
}
}
}
}
@@ -0,0 +1,117 @@
using System.Security.Cryptography;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Offline verification of a Steam login's platform_auth ticket - the SteamID64 a login may be
/// trusted as. Port of RecFlare auth src/steam-ticket.ts (reference server).
///
/// A Steam login posts platform_auth = {"Ticket":"&lt;hex&gt;","AppId":"471710"}. The ticket's
/// ownership section is signed by Steam's "System" RSA key (RSA-SHA1), so it verifies OFFLINE -
/// no publisher Web API key (which only Rec Room has for app 471710), no network call - and the
/// SteamID64 inside is one Steam itself vouched for. The posted platform_id field is NOT: it is
/// whatever the client typed, and a SteamID64 is public (it's on every Steam profile). Before
/// this, a cached_login with someone else's SteamID and account id and no device id minted a
/// token for their account. Byte layout per DoctorMcKay's steam-appticket.
/// </summary>
public static class SteamTicket
{
// Steam's "System" public key (SPKI DER), from @doctormckay/steam-crypto system.pem.
private const string SystemPublicKeySpki =
"MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQDf7BrWLBBmLBc1OhSwfFkRf53T" +
"2Ct64+AVzRkeRuh7h3SiGEYxqQMUeYKO6UWiSRKpI2hzic9pobFhRr3Bvr/WARvY" +
"gdTckPv+T1JzZsuVcNfFjrocejN1oWI0Rrtgt4Bo+hOneoo3S57G9F1fOpn5nsQ6" +
"6WOiu4gZKODnFMBCiQIBEQ==";
public readonly record struct Result(bool Ok, ulong SteamId, uint AppId, string Reason);
public readonly record struct Parsed(ulong SteamId, uint AppId, long ExpiresAtMs, int SignedStart, int SignedEnd, int SignatureAt);
/// <summary>
/// The SteamID64 a platform_auth payload proves, or Ok=false (with why) when it is missing,
/// malformed, expired, or not signed by Steam. Only ever returns an id Steam signed.
/// </summary>
public static Result Verify(string? platformAuth, DateTimeOffset? now = null)
{
if (string.IsNullOrWhiteSpace(platformAuth)) return Fail("no Steam ticket");
string? hex;
try { hex = (string?)JObject.Parse(platformAuth)["Ticket"]; }
catch { return Fail("platform_auth is not JSON"); }
if (string.IsNullOrEmpty(hex)) return Fail("no Ticket in platform_auth");
byte[] buf;
try { buf = Convert.FromHexString(hex); }
catch { return Fail("Ticket is not hex"); }
var t = Parse(buf);
if (t == null) return Fail("malformed Steam ticket");
long nowMs = (now ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds();
if (t.Value.ExpiresAtMs != 0 && t.Value.ExpiresAtMs < nowMs) return Fail("expired Steam ticket");
if (!VerifySignature(buf, t.Value)) return Fail("Steam ticket signature does not verify");
return new Result(true, t.Value.SteamId, t.Value.AppId, "");
}
private static Result Fail(string why) => new(false, 0, 0, why);
/// <summary>The ticket's fields and signed range WITHOUT checking the signature (null = malformed).</summary>
public static Parsed? Parse(byte[] buf)
{
int pos = 0, limit = buf.Length;
uint U32() { uint v = BitConverter.ToUInt32(buf, pos); pos += 4; return v; }
ushort U16() { ushort v = BitConverter.ToUInt16(buf, pos); pos += 2; return v; }
ulong U64() { ulong v = BitConverter.ToUInt64(buf, pos); pos += 8; return v; }
try
{
if (U32() == 20)
{
// full ticket: GC token + session header come before the ownership ticket
pos += 8; // gcToken
pos += 8; // steamID (read from the ownership section instead)
U32(); // tokenGenerated
if (U32() != 24) return null; // session header length
pos += 8; // unknown1, unknown2
U32(); // session external IP
pos += 4; // filler
U32(); // client connection time
U32(); // client connection count
if (U32() + pos != limit) return null; // ownership-section length check
}
else pos -= 4; // a bare ownership ticket
int ownStart = pos;
int ownLen = (int)U32(); // includes itself
if (ownStart + ownLen != limit && ownStart + ownLen + 128 != limit) return null;
U32(); // version
ulong steamId = U64();
uint appId = U32();
U32(); U32(); U32(); U32(); // ownership external / internal IP, flags, generated
long expiresAtMs = (long)U32() * 1000;
int licenses = U16();
for (int i = 0; i < licenses; i++) U32();
int dlcs = U16();
for (int i = 0; i < dlcs; i++)
{
U32(); // dlc appId
int dl = U16();
for (int j = 0; j < dl; j++) U32();
}
U16(); // reserved
if (pos + 128 != limit) return null; // a signature is required
return new Parsed(steamId, appId, expiresAtMs, ownStart, ownStart + ownLen, pos);
}
catch (ArgumentException) { return null; } // ran off the end
catch (IndexOutOfRangeException) { return null; }
}
public static bool VerifySignature(byte[] buf, Parsed t)
{
try
{
using var rsa = RSA.Create();
rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(SystemPublicKeySpki), out _);
return rsa.VerifyData(buf.AsSpan(t.SignedStart, t.SignedEnd - t.SignedStart),
buf.AsSpan(t.SignatureAt, 128), HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1);
}
catch { return false; }
}
}
}
@@ -0,0 +1,359 @@
using Campus_live_rewrite.api.server.Classes;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Reads the gift-drop store catalogs in Data/APIS/GiftDropStores/{StorefrontType}.json.
///
/// Ported from the 2023 Campus server (D:\Puck\Campus-Server), whose StoreController had
/// a working purchase flow. The comments here record what that server learned the hard
/// way - they are not guesses.
/// </summary>
public static class StoreCatalog
{
public static string StoreDirectory =>
Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "GiftDropStores");
/// <summary>One purchasable item, flattened out of a store file.</summary>
public class GiftDropInfo
{
public int Cost;
public int Currency = (int)CurrencyType.RecCenterTokens; // currency the buyer pays in
public int SubscriberCost = -1; // from SubscriberPrices; -1 = none listed
public int SubscriberCurrency = -1;
public string Reward = ""; // GiftDrop.AvatarItemDesc, RAW (see PendingGiftBox)
public string Name = "";
public int Rarity;
public int GiftDropId;
public string ConsumableItemDesc = "";
public int ItemCount = 1; // GiftDrop.ItemCount - how many of a consumable one purchase gives
public int AvatarItemType;
public int GdCurrencyType; // GiftDrop.CurrencyType
public int GdCurrency; // GiftDrop.Currency
public string EquipmentPrefabName = "";
public string EquipmentModificationGuid = "";
/// <summary>A custom (downloadable) avatar item the row sells - GiftDrop.CustomAvatarItemId.</summary>
public string CustomAvatarItemId = "";
public string ThumbnailImageName = "";
public int EquipmentItemId;
public int AvatarItemId;
public string StoreFile = "";
public int PurchasableItemId;
public int StorefrontType;
/// <summary>Does the row put an AVATAR item in the box (legacy descriptor or custom item)?</summary>
public bool IsAvatarItem => !string.IsNullOrWhiteSpace(Reward) || !string.IsNullOrWhiteSpace(CustomAvatarItemId);
}
/// <summary>
/// AvatarItemType as the box / package must carry it: the type for an avatar item, JSON NULL
/// for anything else. The client decides what a package holds by NULL checks - a skin or a
/// consumable sent with AvatarItemType 0 is read as an avatar item with nothing to load
/// ("Can't load item. It's saved in your Dorm mirror."). Every real storefront row of a skin
/// or consumable carries null here (Watch_EnumValue_3.json), so this is the wire's rule.
/// </summary>
public static int? AvatarTypeFor(GiftDropInfo i) => i.IsAvatarItem ? i.AvatarItemType : (int?)null;
/// <summary>The descriptor for the package: as written when there is one, else null (same reason).</summary>
public static string? AvatarDescFor(GiftDropInfo i) => string.IsNullOrWhiteSpace(i.Reward) ? null : i.Reward;
/// <summary>The catalog file backing a storefront id, or "" if it has none.</summary>
public static string StoreFileForId(int id)
{
string specific = Path.Combine(StoreDirectory, $"{id}.json");
return File.Exists(specific) ? specific : "";
}
public static string StorefrontName(int id) =>
Enum.IsDefined(typeof(StorefrontType), id)
? ((StorefrontType)id).ToString()
: $"Unknown({id})";
/// <summary>
/// Look up an item by PurchasableItemId.
///
/// PurchasableItemIds are only unique WITHIN a storefront, not across them - id 487 is
/// "Drum Major Hat (Yellow)" on the Watch store and "RECS Shirt (Green)" on Laser Tag,
/// and the Isle/Crescendo catalogs collide on 971/972. So search the storefront the
/// client said it was buying from FIRST, and only scan the rest when that store has no
/// such id (or the client did not say which store).
/// </summary>
public static GiftDropInfo? FindStoreItem(int purchasableItemId, int storefrontType = -1)
{
// A catalog id (10000+) is a GENERATED Watch-store row - it exists in no file. The
// same object the store was served from prices the purchase, which is the reference's
// whole point: one table, both sides, or "Price has changed" on every buy.
// Admin edits (Stores tab, StoreEdits) come first: an added row exists nowhere else,
// and an edited one carries the price the store page now shows.
if (storefrontType >= 0)
{
var own = StoreEdits.RowFor(storefrontType, purchasableItemId);
if (own != null) return ParseRow(own, purchasableItemId, storefrontType);
if (StoreEdits.IsRemoved(storefrontType, purchasableItemId)) return null;
}
else
{
var any = StoreEdits.RowsAnywhere(purchasableItemId);
if (any.Count > 0) return ParseRow(any[0].row, purchasableItemId, any[0].store);
}
var gen = StoreGenerator.FindGenerated(purchasableItemId);
if (gen != null)
return StoreEdits.IsRemoved(StoreGenerator.WatchStorefront, purchasableItemId) ? null : gen;
return FindStoreItemInFiles(purchasableItemId, storefrontType);
}
private static GiftDropInfo? FindStoreItemInFiles(int purchasableItemId, int storefrontType = -1)
{
if (!Directory.Exists(StoreDirectory)) return null;
var files = new List<string>();
if (storefrontType >= 0)
{
string preferred = StoreFileForId(storefrontType);
if (!string.IsNullOrEmpty(preferred)) files.Add(preferred);
}
foreach (var f in Directory.GetFiles(StoreDirectory, "*.json"))
if (!files.Contains(f)) files.Add(f);
foreach (var file in files)
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(file));
if (!doc.RootElement.TryGetProperty("StoreItems", out var items)) continue;
foreach (var it in items.EnumerateArray())
{
if (!it.TryGetProperty("PurchasableItemId", out var pid)
|| pid.ValueKind != System.Text.Json.JsonValueKind.Number
|| pid.GetInt32() != purchasableItemId) continue;
return ParseRow(it, purchasableItemId, Path.GetFileName(file), StorefrontTypeFromFile(file));
}
}
catch (Exception ex)
{
Console.WriteLine($"[store] could not read {Path.GetFileName(file)}: {ex.Message}");
}
}
return null;
}
/// <summary>One store row (the client's StoreItem shape) as the purchase paths price it.</summary>
private static GiftDropInfo ParseRow(System.Text.Json.JsonElement it, int purchasableItemId, string storeFile, int storefront)
{
var info = new GiftDropInfo();
if (it.TryGetProperty("Prices", out var prices)
&& prices.ValueKind == System.Text.Json.JsonValueKind.Array
&& prices.GetArrayLength() > 0)
{
var p0 = prices[0];
if (p0.TryGetProperty("Price", out var pr) && pr.ValueKind == System.Text.Json.JsonValueKind.Number)
info.Cost = pr.GetInt32();
if (p0.TryGetProperty("CurrencyType", out var cc) && cc.ValueKind == System.Text.Json.JsonValueKind.Number)
info.Currency = cc.GetInt32();
}
// Rec Room+ price. The client shows this to a subscriber and sends it back
// as RequestedPrice. Ignoring it means RR+ members (everyone here) silently
// overpay on every purchase.
if (it.TryGetProperty("SubscriberPrices", out var sp)
&& sp.ValueKind == System.Text.Json.JsonValueKind.Array
&& sp.GetArrayLength() > 0)
{
var s0 = sp[0];
if (s0.TryGetProperty("Price", out var spr) && spr.ValueKind == System.Text.Json.JsonValueKind.Number)
info.SubscriberCost = spr.GetInt32();
if (s0.TryGetProperty("CurrencyType", out var scc) && scc.ValueKind == System.Text.Json.JsonValueKind.Number)
info.SubscriberCurrency = scc.GetInt32();
}
if (it.TryGetProperty("GiftDrop", out var gd))
{
string S(string k) => gd.TryGetProperty(k, out var v)
&& v.ValueKind == System.Text.Json.JsonValueKind.String ? v.GetString() ?? "" : "";
int I(string k) => gd.TryGetProperty(k, out var v)
&& v.ValueKind == System.Text.Json.JsonValueKind.Number ? v.GetInt32() : 0;
info.Reward = S("AvatarItemDesc");
info.Name = S("FriendlyName");
info.Rarity = I("Rarity");
info.GiftDropId = I("GiftDropId");
info.ConsumableItemDesc = S("ConsumableItemDesc");
info.ItemCount = Math.Max(1, I("ItemCount"));
info.AvatarItemType = I("AvatarItemType");
info.GdCurrencyType = I("CurrencyType");
info.GdCurrency = I("Currency");
info.EquipmentPrefabName = S("EquipmentPrefabName");
// UUID form, always - the client's equipment catalog is UUIDs and a
// short-guid box could not resolve its skin (see PlayerDB.EquipmentSkinKey).
info.EquipmentModificationGuid = Classes.ItemGuids.NormalizeGuid(S("EquipmentModificationGuid")) ?? S("EquipmentModificationGuid");
info.CustomAvatarItemId = S("CustomAvatarItemId");
info.ThumbnailImageName = S("ThumbnailImageName");
info.EquipmentItemId = I("EquipmentItemId");
info.AvatarItemId = I("AvatarItemId");
}
info.StoreFile = storeFile;
info.PurchasableItemId = purchasableItemId;
info.StorefrontType = storefront;
// Hair dye is free (ConfigCampus.FreeHairDyes). Zeroed HERE, on the one
// object every purchase path prices from - buyItem and bulkpurchase both
// take their cost off this - so the store page and the till can never
// disagree about what a colour costs.
if (HairGrants.IsHairDye(info.Name))
{
info.Cost = 0;
info.SubscriberCost = 0;
}
return info;
}
/// <summary>An admin-edited row (StoreEdits) as the purchase paths price it.</summary>
private static GiftDropInfo ParseRow(Newtonsoft.Json.Linq.JObject row, int purchasableItemId, int storefront)
{
using var doc = System.Text.Json.JsonDocument.Parse(row.ToString(Newtonsoft.Json.Formatting.None));
return ParseRow(doc.RootElement, purchasableItemId, "store-edits", storefront);
}
// The storefront id is the file's basename ("100.json" -> 100).
private static int StorefrontTypeFromFile(string file)
=> int.TryParse(Path.GetFileNameWithoutExtension(file), out var t) ? t : -1;
/// <summary>
/// Search every storefront for items whose FriendlyName or descriptor contains the query,
/// newest storefronts first, capped at <paramref name="max"/>. Used by the admin gift page
/// to pick an item to give. Returns fully-resolved GiftDropInfo (with PurchasableItemId /
/// StorefrontType) so the giver can re-look-up and hand out the exact box content.
/// </summary>
public static List<GiftDropInfo> SearchItems(string query, int max = 40)
{
var results = new List<GiftDropInfo>();
if (!Directory.Exists(StoreDirectory) || string.IsNullOrWhiteSpace(query)) return results;
string q = query.Trim().ToLowerInvariant();
foreach (var file in Directory.GetFiles(StoreDirectory, "*.json"))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(file));
if (!doc.RootElement.TryGetProperty("StoreItems", out var items)) continue;
int storefront = StorefrontTypeFromFile(file);
foreach (var it in items.EnumerateArray())
{
if (!it.TryGetProperty("PurchasableItemId", out var pid)
|| pid.ValueKind != System.Text.Json.JsonValueKind.Number) continue;
if (!it.TryGetProperty("GiftDrop", out var gd)) continue;
string S(string k) => gd.TryGetProperty(k, out var v)
&& v.ValueKind == System.Text.Json.JsonValueKind.String ? v.GetString() ?? "" : "";
int I(string k) => gd.TryGetProperty(k, out var v)
&& v.ValueKind == System.Text.Json.JsonValueKind.Number ? v.GetInt32() : 0;
string name = S("FriendlyName");
string desc = S("AvatarItemDesc");
if (!name.ToLowerInvariant().Contains(q) && !desc.ToLowerInvariant().Contains(q))
continue;
results.Add(new GiftDropInfo
{
PurchasableItemId = pid.GetInt32(),
StorefrontType = storefront,
Name = name,
Reward = desc,
Rarity = I("Rarity"),
GiftDropId = I("GiftDropId"),
ConsumableItemDesc = S("ConsumableItemDesc"),
ItemCount = Math.Max(1, I("ItemCount")),
AvatarItemType = I("AvatarItemType"),
EquipmentPrefabName = S("EquipmentPrefabName"),
EquipmentModificationGuid = Classes.ItemGuids.NormalizeGuid(S("EquipmentModificationGuid")) ?? S("EquipmentModificationGuid"),
CustomAvatarItemId = S("CustomAvatarItemId"),
ThumbnailImageName = S("ThumbnailImageName"),
StoreFile = Path.GetFileName(file)
});
if (results.Count >= max) return results;
}
}
catch (Exception ex)
{
Console.WriteLine($"[store] search could not read {Path.GetFileName(file)}: {ex.Message}");
}
}
return results;
}
// The RR+ discount the CLIENT applies. Measured by the 2023 server from what the client
// sends as RequestedPrice: 3000 -> 2700, 2500 -> 2250, 1500 -> 1350 - all exactly 10%.
//
// It is NOT the store file's SubscriberDiscountPercent: the Watch store sets that to 0
// and its SubscriberPrices equal the full price, yet the client still asks to pay 10%
// less. The rate lives in the client, and 10 is the observed value.
public const int RRPlusDiscountPercent = 10;
/// <summary>
/// What an RR+ member actually pays: the better of the store's explicit SubscriberPrice
/// and the flat RR+ discount. Never more than the base price.
/// Everyone on this server is a subscriber - UpdateAndGetSubscription always reports active.
/// </summary>
public static int SubscriberPriceFor(GiftDropInfo item)
{
int best = item.Cost;
if (item.SubscriberCost >= 0
&& (item.SubscriberCurrency < 0 || item.SubscriberCurrency == item.Currency)
&& item.SubscriberCost < best)
best = item.SubscriberCost;
int pct = (int)Math.Floor(item.Cost * (100 - RRPlusDiscountPercent) / 100.0);
if (pct < best) best = pct;
return Math.Max(0, best);
}
// Gift id for the purchase-reveal payload and the stored box. Was a process counter from
// 5000 that restarted on every deploy, so every player's first purchase after a restart
// was box 5001 - the collision PlayerDB.NextGiftPackageId explains. int32, see
// PendingGiftBox.NumericId.
public static int NextPurchaseGiftId() => Campus_live_rewrite.api.dbs.PlayerDB.NextGiftPackageId();
/// <summary>Only items sold for Rec Center Tokens can be gifted (never a room-currency / ticket item).</summary>
public static bool PricedInTokens(Newtonsoft.Json.Linq.JObject row)
{
var prices = row["Prices"] as Newtonsoft.Json.Linq.JArray;
if (prices == null || prices.Count == 0) return false;
return prices.Any(p => ((int?)p["CurrencyType"] ?? 0) == (int)Classes.CurrencyType.RecCenterTokens);
}
// A file storefront with CanBeGifted set on every row (the captures never carried it, so
// the store's gift button never showed). Cached per file + mtime.
private static readonly Dictionary<string, (long ticks, string json)> _giftable = new();
public static string? StoreJsonGiftable(string path, string? baseJson = null)
{
try
{
long ticks = File.GetLastWriteTimeUtc(path).Ticks;
string key = path + (baseJson != null ? "#free" : "");
lock (_giftable)
{
if (_giftable.TryGetValue(key, out var hit) && hit.ticks == ticks) return hit.json;
}
var root = Newtonsoft.Json.Linq.JObject.Parse(baseJson ?? File.ReadAllText(path));
foreach (var it in root["StoreItems"] as Newtonsoft.Json.Linq.JArray ?? new Newtonsoft.Json.Linq.JArray())
if (it is Newtonsoft.Json.Linq.JObject o) { StoreGenerator.ShapeRow(o); o["CanBeGifted"] = PricedInTokens(o); }
string json = root.ToString(Newtonsoft.Json.Formatting.None);
lock (_giftable) _giftable[key] = (ticks, json);
return json;
}
catch (Exception ex)
{
Console.WriteLine($"[store] could not mark {Path.GetFileName(path)} giftable: {ex.Message}");
return null;
}
}
}
}
@@ -0,0 +1,319 @@
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Admin-panel edits to the gift-drop storefronts (Stores tab), kept OUT of the store files in
/// Data/APIS/store-edits.json and laid over them wherever a store is served or bought from:
/// - GET /api/storefronts/v3/giftdropstore/{id} (ApplyJson),
/// - StoreCatalog.FindStoreItem, so an added row is buyable at the admin's price,
/// - the Wishlist rows.
/// The store files themselves (3.json is re-uploaded by UploadUpdate.bat on every deploy)
/// are never written, so a deploy can't wipe the edits.
///
/// Per storefront: "Rows" = full store rows (the client's own StoreItem shape) that are either
/// NEW (their own PurchasableItemId, from AdminIdBase up) or REPLACE the base row with the same
/// id (a price / set / featured edit), and "Removed" = base PurchasableItemIds taken off sale.
/// </summary>
public static class StoreEdits
{
/// <summary>
/// Ids for admin-added rows (PurchasableItemId and GiftDropId both). Far above the captured
/// ids and the generated Watch ranges (StoreGenerator: catalog 100000+, skins 140000+), and
/// unique across ALL storefronts, since FindStoreItem scans every store when the client
/// does not say which one it is buying from.
/// </summary>
public const int AdminIdBase = 900_000;
private static readonly string FilePath =
Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "store-edits.json");
private static readonly object _lock = new();
private static JObject? _root;
private static int _version;
public static int Version { get { Load(); return _version; } }
private static JObject Load()
{
lock (_lock)
{
if (_root != null) return _root;
try
{
_root = File.Exists(FilePath) ? JObject.Parse(File.ReadAllText(FilePath)) : new JObject();
}
catch (Exception ex)
{
Console.WriteLine($"[store-edits] could not read store-edits.json ({ex.Message}) - starting empty; the bad file is kept as .broken");
try { File.Copy(FilePath, FilePath + ".broken", true); } catch { }
_root = new JObject();
}
if (_root["Stores"] is not JObject) _root["Stores"] = new JObject();
if (_root["NextId"]?.Type != JTokenType.Integer) _root["NextId"] = AdminIdBase;
MigrateTo2025StoreIds(_root);
_version++;
return _root;
}
}
/// <summary>
/// One-time fix-up: the Stores tab first used the 2023 client's storefront ids, where
/// 2000-2004 were "PopUpShop_1..5". In the 2025 client those are the progression-event
/// shops and the pop-up shops are 1700-1704, so anything stocked there moves across.
/// </summary>
private static void MigrateTo2025StoreIds(JObject root)
{
if ((int?)root["StoreIds"] == 2025) return;
// No file = nothing stocked yet: nothing to move, and writing one here would leave a
// stray store-edits.json on a dev box for a full package to ship over the live copy.
if (!File.Exists(FilePath)) { root["StoreIds"] = 2025; return; }
var stores = (JObject)root["Stores"]!;
int moved = 0;
for (int i = 0; i < 5; i++)
{
if (stores[(2000 + i).ToString()] is not JObject from) continue;
stores.Remove((2000 + i).ToString());
string to = (1700 + i).ToString();
if (stores[to] is not JObject dest) { stores[to] = from; }
else
{
foreach (var r in (from["Rows"] as JArray ?? new JArray())) ((JArray)(dest["Rows"] ??= new JArray())).Add(r);
foreach (var r in (from["Removed"] as JArray ?? new JArray())) ((JArray)(dest["Removed"] ??= new JArray())).Add(r);
}
moved += (from["Rows"] as JArray)?.Count ?? 0;
Console.WriteLine($"[store-edits] moved store {2000 + i} edits to {to} (PopUpShop_{i + 1} is {to} in the 2025 client)");
}
root["StoreIds"] = 2025;
try
{
var tmp = FilePath + ".tmp";
if (File.Exists(FilePath)) File.Copy(FilePath, FilePath + ".bak-2023ids", true);
File.WriteAllText(tmp, root.ToString(Newtonsoft.Json.Formatting.Indented));
File.Move(tmp, FilePath, true);
}
catch (Exception ex) { Console.WriteLine($"[store-edits] could not save the store id migration: {ex.Message}"); }
if (moved > 0) Console.WriteLine($"[store-edits] {moved} pop-up shop item(s) moved to the 2025 store ids");
}
private static void Save()
{
// caller holds _lock
var tmp = FilePath + ".tmp";
File.WriteAllText(tmp, _root!.ToString(Newtonsoft.Json.Formatting.Indented));
File.Move(tmp, FilePath, true);
_version++;
lock (_applied) _applied.Clear();
}
private static JObject StoreNode(int store, bool create)
{
var stores = (JObject)Load()["Stores"]!;
if (stores[store.ToString()] is JObject s) return s;
var n = new JObject { ["Removed"] = new JArray(), ["Rows"] = new JArray() };
if (create) stores[store.ToString()] = n;
return n;
}
private static int Pid(JToken row) => (int?)row["PurchasableItemId"] ?? 0;
// ---- reads --------------------------------------------------------------------------
public static bool HasEdits(int store)
{
lock (_lock)
{
var s = StoreNode(store, false);
return ((s["Rows"] as JArray)?.Count ?? 0) > 0 || ((s["Removed"] as JArray)?.Count ?? 0) > 0;
}
}
/// <summary>The admin row for this id in this store (added or replacing), cloned; null if none.</summary>
public static JObject? RowFor(int store, int pid)
{
lock (_lock)
{
var rows = StoreNode(store, false)["Rows"] as JArray;
return rows?.FirstOrDefault(r => Pid(r) == pid)?.DeepClone() as JObject;
}
}
public static bool IsRemoved(int store, int pid)
{
lock (_lock)
return (StoreNode(store, false)["Removed"] as JArray)?.Any(x => (int?)x == pid) == true;
}
/// <summary>Every admin row in any store with this id (store it belongs to first).</summary>
public static List<(int store, JObject row)> RowsAnywhere(int pid)
{
var list = new List<(int, JObject)>();
lock (_lock)
foreach (var p in (JObject)Load()["Stores"]!)
if (int.TryParse(p.Key, out int st) && p.Value?["Rows"] is JArray rows)
foreach (var r in rows)
if (Pid(r) == pid) list.Add((st, (JObject)r.DeepClone()));
return list;
}
/// <summary>(added, edited, removed) counts for one store; "edited" = rows replacing a base id.</summary>
public static (int added, int edited, int removed) Counts(int store)
{
lock (_lock)
{
var s = StoreNode(store, false);
int added = 0, edited = 0;
foreach (var r in s["Rows"] as JArray ?? new JArray())
if (Pid(r) >= AdminIdBase) added++; else edited++;
return (added, edited, (s["Removed"] as JArray)?.Count ?? 0);
}
}
public static HashSet<int> RemovedIds(int store)
{
lock (_lock)
return new HashSet<int>((StoreNode(store, false)["Removed"] as JArray ?? new JArray()).Select(x => (int?)x ?? 0));
}
public static List<JObject> Rows(int store)
{
lock (_lock)
return (StoreNode(store, false)["Rows"] as JArray ?? new JArray()).Select(r => (JObject)r.DeepClone()).ToList();
}
// ---- laying the edits over a served store -------------------------------------------
/// <summary>Apply this store's edits to a store object in place: drop removed rows, swap in edited ones, append added ones.</summary>
public static void ApplyTo(int store, JObject root)
{
List<JObject> rows; HashSet<int> removed;
lock (_lock)
{
var s = StoreNode(store, false);
rows = (s["Rows"] as JArray ?? new JArray()).Select(r => (JObject)r.DeepClone()).ToList();
removed = new HashSet<int>((s["Removed"] as JArray ?? new JArray()).Select(x => (int?)x ?? 0));
}
if (rows.Count == 0 && removed.Count == 0) return;
foreach (var r in rows)
{
StoreGenerator.ShapeRow(r);
r["CanBeGifted"] = StoreCatalog.PricedInTokens(r);
}
var byPid = rows.GroupBy(Pid).ToDictionary(g => g.Key, g => g.Last());
var used = new HashSet<int>();
var outRows = new JArray();
foreach (var it in root["StoreItems"] as JArray ?? new JArray())
{
int pid = Pid(it);
if (removed.Contains(pid)) continue;
if (byPid.TryGetValue(pid, out var rep)) { outRows.Add(rep); used.Add(pid); }
else outRows.Add(it);
}
foreach (var r in rows)
if (used.Add(Pid(r))) outRows.Add(r);
root["StoreItems"] = outRows;
}
private static readonly Dictionary<string, string> _applied = new();
/// <summary>The served store JSON with this store's edits applied (unchanged string when it has none). Cached.</summary>
public static string ApplyJson(int store, string json)
{
if (!HasEdits(store)) return json;
string key = $"{store}|{_version}|{json.Length}|{json.GetHashCode()}";
lock (_applied)
if (_applied.TryGetValue(key, out var hit)) return hit;
try
{
var root = JObject.Parse(json);
ApplyTo(store, root);
string outJson = root.ToString(Newtonsoft.Json.Formatting.None);
lock (_applied)
{
if (_applied.Count > 64) _applied.Clear();
_applied[key] = outJson;
}
return outJson;
}
catch (Exception ex)
{
Console.WriteLine($"[store-edits] could not apply edits to store {store}: {ex.Message}");
return json;
}
}
// ---- writes (admin) -----------------------------------------------------------------
public static int NewId()
{
lock (_lock)
{
var root = Load();
int id = Math.Max(AdminIdBase, (int?)root["NextId"] ?? AdminIdBase);
root["NextId"] = id + 1;
Save();
return id;
}
}
/// <summary>Store a row (added or replacing a base row), and un-remove its id.</summary>
public static void Put(int store, JObject row)
{
lock (_lock)
{
var s = StoreNode(store, true);
var rows = (JArray)s["Rows"]!;
int pid = Pid(row);
foreach (var old in rows.Where(r => Pid(r) == pid).ToList()) old.Remove();
rows.Add(row);
foreach (var x in ((JArray)s["Removed"]!).Where(x => (int?)x == pid).ToList()) x.Remove();
Save();
}
}
/// <summary>Put several rows in one write (set assignment / rename).</summary>
public static void PutMany(int store, IEnumerable<JObject> newRows)
{
lock (_lock)
{
var s = StoreNode(store, true);
var rows = (JArray)s["Rows"]!;
var removed = (JArray)s["Removed"]!;
foreach (var row in newRows)
{
int pid = Pid(row);
foreach (var old in rows.Where(r => Pid(r) == pid).ToList()) old.Remove();
rows.Add(row);
foreach (var x in removed.Where(x => (int?)x == pid).ToList()) x.Remove();
}
Save();
}
}
/// <summary>Take an id off sale: an added row is deleted, a base row is listed as removed.</summary>
public static void Remove(int store, int pid)
{
lock (_lock)
{
var s = StoreNode(store, true);
foreach (var old in ((JArray)s["Rows"]!).Where(r => Pid(r) == pid).ToList()) old.Remove();
var removed = (JArray)s["Removed"]!;
if (pid < AdminIdBase && !removed.Any(x => (int?)x == pid)) removed.Add(pid);
Save();
}
}
/// <summary>Undo every edit to a base id: back on sale, original price and set.</summary>
public static void Restore(int store, int pid)
{
lock (_lock)
{
var s = StoreNode(store, true);
foreach (var old in ((JArray)s["Rows"]!).Where(r => Pid(r) == pid && pid < AdminIdBase).ToList()) old.Remove();
foreach (var x in ((JArray)s["Removed"]!).Where(x => (int?)x == pid).ToList()) x.Remove();
Save();
}
}
}
}
@@ -0,0 +1,480 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The Watch store (storefront 3) as the 2025 client expects it - ported from the reference
/// server's generated `sf3-2025.json` (econ/src/catalog-load.ts + catalogStoreItems).
///
/// The reference serves builds after 20230414 a MERGED store: the captured catalog's rows
/// that have no avatar item behind them (consumables, the random boxes) plus ONE GENERATED
/// ROW PER SELLABLE AVATAR ITEM in the item catalog, numbered from 10000 upward so nothing
/// can collide with a captured PurchasableItemId (every real one is 2764 or below).
///
/// Why it matters here, and why the store looked broken without it:
///
/// * The store pages are algorithmic-list rows that answer BARE PurchasableItemIds, and the
/// client resolves each one against the storefront it loaded. Our 3.json was a 1161-row
/// 2023 capture; the reference's is 2950 rows in a different id space. Any row that named
/// an id our file didn't hold rendered nothing - and an id our file held under a
/// DIFFERENT item showed that item when clicked. Generating from the catalog makes the
/// ids and the storefront one thing, so a click shows what was clicked.
/// * Only 1161 of ~3300 sellable items were for sale at all.
/// * Prices: a generated row is priced by rarity from ONE table (PRICE_BY_RARITY), which is
/// the same table the purchase check uses - the reference's point being that a store which
/// prices a buy differently from the file it listed refuses every purchase as "Price has
/// changed".
///
/// One deliberate difference from the reference: captured avatar-item rows whose descriptor is
/// NOT in avatar_items.json are KEPT rather than dropped. That is the 19 permanent hair dyes,
/// which are what unlock hair colours (see HairGrants) - the reference loses them.
///
/// Ids are assigned by catalog ORDER, so they are stable across restarts for a given
/// avatar_items.json and change only if the catalog itself is re-ordered - the same
/// load-order surrogate the reference uses. Nothing may persist one.
/// </summary>
public static class StoreGenerator
{
public const int WatchStorefront = 3;
/// <summary>First generated id. Above every captured PurchasableItemId (the real Watch store
/// runs to 33691 - ImportRealStore.py; the old synthetic file topped out at 2764).</summary>
public const int CatalogIdBase = 100_000;
/// <summary>The developer / unreleased tier. In the catalog, never in a store.</summary>
public static bool IsSellableRarity(int rarity) => rarity != -1;
// Reference PRICE_BY_RARITY: 0/10/30/50 were specified; 20 sits between its neighbours.
// A rarity nobody priced costs the floor rather than vanishing - an unpriced item
// silently missing from the store is harder to notice than one that turns up cheap.
private static readonly Dictionary<int, int> PriceByRarity = new()
{
[0] = 500, [10] = 600, [20] = 700, [30] = 800, [50] = 3000 // clothing never under 500 (owner rule)
};
public const int DefaultPrice = 500;
public static int PriceForRarity(int rarity) => PriceByRarity.TryGetValue(rarity, out var p) ? p : DefaultPrice;
/// <summary>What Rec Room Plus pays: 10% off, floored - the same band StoreCatalog.SubscriberPriceFor allows.</summary>
public static int SubscriberPriceFor(int price) => (int)Math.Floor(price * (100 - StoreCatalog.RRPlusDiscountPercent) / 100.0);
private static readonly object _lock = new();
private static JObject? _merged;
private static Dictionary<int, StoreCatalog.GiftDropInfo>? _generated;
// The downloadable twin a captured row NAMED (AvatarItemInfo.DownloadableAvatarItemId),
// by PurchasableItemId - kept only so OutfitTypeOf can still read the twin's wardrobe slot
// after ShapeRow has taken AvatarItemInfo off the wire (see ShapeRow for why it must go).
private static Dictionary<int, string> _twinByPurchasable = new();
private static string _stamp = "";
private static string StorePath => Path.Combine(StoreCatalog.StoreDirectory, $"{WatchStorefront}.json");
private static string CatalogPath => Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "avatar_items.json");
private static string EquipmentPath => Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "Equipments.json");
/// <summary>First generated SKIN id - its own range above the avatar-item rows.</summary>
public const int SkinIdBase = 140_000;
/// <summary>PurchasableItemIds of every skin row in the Watch store (captured + generated), for the Skins carousel.</summary>
public static List<int> SkinPurchasableIds()
{
var store = MergedWatchStore()["StoreItems"] as JArray ?? new JArray();
return store.Where(it => !string.IsNullOrEmpty((string?)it["GiftDrop"]?["EquipmentPrefabName"]))
.Select(it => (int?)it["PurchasableItemId"] ?? 0).Where(i => i > 0).ToList();
}
private static string Stamp()
{
static string T(string p) => File.Exists(p) ? File.GetLastWriteTimeUtc(p).Ticks.ToString() : "-";
string shown = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "ShownItems.json");
string defaults = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "DefaultAvatarItems.json");
return T(StorePath) + "|" + T(CatalogPath) + "|" + T(EquipmentPath) + "|" + (ConfigCampus.HiddenItemsStorefronts ? "H" + T(shown) + T(defaults) : "-");
}
/// <summary>The merged Watch store, rebuilt when either source file changes on disk.</summary>
public static JObject MergedWatchStore()
{
string stamp = Stamp();
if (_merged != null && stamp == _stamp) return _merged;
lock (_lock)
{
if (_merged != null && stamp == _stamp) return _merged;
Build();
_stamp = stamp;
return _merged!;
}
}
public static string MergedWatchStoreJson() => MergedWatchStore().ToString(Newtonsoft.Json.Formatting.None);
/// <summary>A generated row by its PurchasableItemId (10000+), or null. What buyItem resolves against.</summary>
public static StoreCatalog.GiftDropInfo? FindGenerated(int purchasableItemId)
{
if (purchasableItemId < CatalogIdBase) return null;
MergedWatchStore();
return _generated != null && _generated.TryGetValue(purchasableItemId, out var info) ? info : null;
}
/// <summary>
/// Bring a captured row up to the wire's rules whatever capture it came from: AvatarItemType
/// is NULL unless the row sells an avatar item (a skin / consumable / box with 0 there is
/// opened as an avatar item with nothing to load), and the modern keys exist.
///
/// AvatarItemInfo is ALWAYS null on the wire. A real capture (Watch_EnumValue_3.json) carries
/// { LegacyAvatarItemDesc, DownloadableAvatarItemId } on 2,300+ legacy rows, and that twin id
/// is what makes the 20250718 client fetch the item's CAI assetbundle from this server
/// instead of drawing the legacy item from its own built-in db - and every such download
/// dies inside the client's bundle loader (RemoteProviderException / NullReferenceException
/// in CBACIMLIBPF.PNKGBHGAHHH, identical bytes and headers to the reference CDN), so the
/// item comes up "Can't load item". The reference server never emits AvatarItemInfo at all:
/// its 2025 store (sf3-2025.json, `runx storefront build`) is generated from the legacy item
/// catalog with AvatarItemDesc only, and its legacy-saves endpoint answers an empty map -
/// the client renders every store item from what it shipped with. Same here.
/// </summary>
public static void ShapeRow(JObject row)
{
if (row["GiftDrop"] is not JObject gd) return;
bool avatar = !string.IsNullOrWhiteSpace((string?)gd["AvatarItemDesc"]) || !string.IsNullOrWhiteSpace((string?)gd["CustomAvatarItemId"]);
if (!avatar) gd["AvatarItemType"] = null;
else if (gd["AvatarItemType"] == null || gd["AvatarItemType"]!.Type == JTokenType.Null) gd["AvatarItemType"] = 0;
gd["AvatarItemInfo"] = null;
gd["CustomAvatarItemId"] ??= null;
gd["ThumbnailImageName"] ??= null;
gd["TagList"] ??= null;
if (gd["AvatarItemId"] == null) gd["AvatarItemId"] = 0;
if (gd["EquipmentItemId"] == null) gd["EquipmentItemId"] = 0;
if (gd["ItemCount"] == null) gd["ItemCount"] = 1;
if (row["OnlyAvailableThroughCv2"] == null) row["OnlyAvailableThroughCv2"] = false;
}
/// <summary>
/// The wardrobe slot (RecRoom.Avatars.OutfitType) a Watch row sells into, or -1 when the
/// catalog does not record it. A custom item records its OutfitType; a legacy row that
/// carries AvatarItemInfo names its downloadable twin, which records it too. This is what
/// the store's category carousels sort by (APIController.GetAlgorithmicList); rows without
/// a slot fall back to the name needles.
/// </summary>
public static int OutfitTypeOf(JToken row)
{
var gd = row["GiftDrop"];
if (gd == null) return -1;
string? caid = (string?)gd["CustomAvatarItemId"];
if (string.IsNullOrWhiteSpace(caid) && gd["AvatarItemInfo"] is JObject info) caid = (string?)info["DownloadableAvatarItemId"];
if (string.IsNullOrWhiteSpace(caid) && (int?)row["PurchasableItemId"] is int pid)
{
MergedWatchStore();
_twinByPurchasable.TryGetValue(pid, out caid);
}
if (string.IsNullOrWhiteSpace(caid)) return -1;
return CustomItemsCache.GetRec(caid)?.OutfitType ?? -1;
}
private static void Build()
{
var root = new JObject
{
["NewUntil"] = null,
["NextUpdate"] = "2222-02-22T22:22:00Z",
["StoreItems"] = new JArray(),
["StorefrontName"] = null,
["StorefrontType"] = WatchStorefront,
["SubscriberDiscountPercent"] = StoreCatalog.RRPlusDiscountPercent
};
var items = (JArray)root["StoreItems"]!;
var generated = new Dictionary<int, StoreCatalog.GiftDropInfo>();
var twins = new Dictionary<int, string>();
try
{
// ---- the catalog: every avatar item, in file order ------------------------------
var catalog = File.Exists(CatalogPath) ? JArray.Parse(File.ReadAllText(CatalogPath)) : new JArray();
var inCatalog = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var e in catalog)
{
string k = dbs.PlayerDB.NormalizeAvatarItemDesc((string?)e["AvatarItemDesc"]);
if (k.Length > 0) inCatalog.Add(k);
}
// ---- captured rows worth keeping -----------------------------------------------
// Kept: anything with no avatar item behind it (consumables, IsQuery random boxes),
// and an avatar item the catalog does not know (the hair dyes). Dropped: an avatar
// item the catalog DOES know - it is regenerated below under a catalog id, so the
// same item cannot be listed twice under two ids at two prices.
int kept = 0, dropped = 0, hidden = 0;
var capturedSkins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Descriptors the captured store already sells. A REAL capture (ImportRealStore.py)
// carries the real PurchasableItemId, price, thumbnail and AvatarItemInfo for an
// item, which beats a row generated from the catalog - so those rows are KEPT and
// the catalog only fills in what the capture does not sell. (The old synthetic
// 3.json was the other way round: its rows were dropped and regenerated.)
var capturedDescs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (File.Exists(StorePath))
{
var captured = JObject.Parse(File.ReadAllText(StorePath));
if (root["NewUntil"] != null) root["NewUntil"] = captured["NewUntil"];
foreach (var it in captured["StoreItems"] as JArray ?? new JArray())
{
string rawDesc = (string?)it["GiftDrop"]?["AvatarItemDesc"];
string desc = dbs.PlayerDB.NormalizeAvatarItemDesc(rawDesc);
if (desc.Length > 0 && inCatalog.Contains(desc))
{
if (!ConfigCampus.PreferCapturedStoreRows || !capturedDescs.Add(desc)) { dropped++; continue; }
}
if (!Classes.ItemGuids.StoreCanList(rawDesc)) { hidden++; continue; }
// A custom-item row is art the client fetches from this server too - the
// same TEMPORARY gate as the descriptor rows (ConfigCampus.HiddenItemsStorefronts).
if (ConfigCampus.HiddenItemsStorefronts && !string.IsNullOrWhiteSpace((string?)it["GiftDrop"]?["CustomAvatarItemId"])) { hidden++; continue; }
var row = (JObject)it.DeepClone();
// Remember the twin BEFORE ShapeRow drops it from the wire (OutfitTypeOf).
if (it["GiftDrop"]?["AvatarItemInfo"] is JObject twinInfo && (int?)it["PurchasableItemId"] is int twinPid
&& !string.IsNullOrWhiteSpace((string?)twinInfo["DownloadableAvatarItemId"]))
twins[twinPid] = (string)twinInfo["DownloadableAvatarItemId"]!;
ShapeRow(row);
// Skin rows: spell the guid as the UUID Equipments.json uses, so the client's
// owned check and the purchase both resolve the same skin.
if (row["GiftDrop"] is JObject gdRow)
{
string? eg = (string?)gdRow["EquipmentModificationGuid"];
if (!string.IsNullOrWhiteSpace(eg))
{
gdRow["EquipmentModificationGuid"] = Classes.ItemGuids.NormalizeGuid(eg) ?? eg;
capturedSkins.Add(((string?)gdRow["EquipmentModificationGuid"] ?? "").ToLowerInvariant());
}
}
// Token-priced rows can be sent as a gift (the store's gift button reads
// this; the level gate is at the till - ConfigCampus.GiftMinLevel).
row["CanBeGifted"] = StoreCatalog.PricedInTokens(row);
items.Add(row);
kept++;
}
}
// ---- one generated row per sellable catalog item ---------------------------------
// Numbered by catalog POSITION (base + index over every row, sellable or not) so an
// item's id does not shift when a neighbour's rarity changes.
int made = 0;
for (int i = 0; i < catalog.Count; i++)
{
var e = catalog[i];
string desc = (string?)e["AvatarItemDesc"] ?? "";
if (string.IsNullOrWhiteSpace(desc)) continue;
int rarity = (int?)e["Rarity"] ?? 0;
if (!IsSellableRarity(rarity)) continue;
if (capturedDescs.Contains(dbs.PlayerDB.NormalizeAvatarItemDesc(desc))) continue; // the capture sells it
if (!Classes.ItemGuids.StoreCanList(desc)) { hidden++; continue; } // HiddenItemsStorefronts
int id = CatalogIdBase + i;
int price = PriceForRarity(rarity);
int sub = SubscriberPriceFor(price);
string name = (string?)e["FriendlyName"] ?? "";
int type = (int?)e["AvatarItemType"] ?? 0;
items.Add(new JObject
{
["GiftDrop"] = new JObject
{
["AvatarItemDesc"] = desc, // RAW - the client parses this
["AvatarItemType"] = type,
["AvatarItemId"] = (int?)e["AvatarItemId"] ?? 0,
["AvatarItemInfo"] = null, // legacy path: the client's own item db
["ConsumableItemDesc"] = "",
["Context"] = 0,
["Currency"] = 0,
["CurrencyType"] = 0,
["CustomAvatarItemId"] = null,
["EquipmentItemId"] = 0,
["EquipmentModificationGuid"] = "",
["EquipmentPrefabName"] = "",
["FriendlyName"] = name,
["GiftDropId"] = id,
["IsQuery"] = false,
["ItemCount"] = 1,
["ItemSetFriendlyName"] = "",
["ItemSetId"] = null,
["QueryRedirectContext"] = null,
["QueryRedirectRarity"] = null,
["QueryRedirectTag"] = null,
["Rarity"] = rarity,
["SubscribersOnly"] = false,
["TagList"] = null,
["ThumbnailImageName"] = (string?)e["ThumbnailImage"],
["Tooltip"] = (string?)e["Tooltip"] ?? "",
["Unique"] = false
},
["IsFeatured"] = false,
["CanBeGifted"] = true,
["Prices"] = new JArray { new JObject { ["CurrencyType"] = (int)Classes.CurrencyType.RecCenterTokens, ["Price"] = price, ["StorefrontSaleData"] = null, ["Type"] = 0 } },
["PurchasableItemId"] = id,
["SubscriberPrices"] = new JArray { new JObject { ["CurrencyType"] = (int)Classes.CurrencyType.RecCenterTokens, ["Price"] = sub, ["StorefrontSaleData"] = null, ["Type"] = 0 } },
["Type"] = 0
});
generated[id] = new StoreCatalog.GiftDropInfo
{
Cost = price,
Currency = (int)Classes.CurrencyType.RecCenterTokens,
SubscriberCost = sub,
SubscriberCurrency = (int)Classes.CurrencyType.RecCenterTokens,
Reward = desc,
Name = name,
Rarity = rarity,
GiftDropId = id,
ConsumableItemDesc = "",
AvatarItemType = type,
GdCurrencyType = 0,
GdCurrency = 0,
EquipmentPrefabName = "",
EquipmentModificationGuid = "",
ThumbnailImageName = (string?)e["ThumbnailImage"] ?? "",
AvatarItemId = (int?)e["AvatarItemId"] ?? 0,
StoreFile = "generated",
PurchasableItemId = id,
StorefrontType = WatchStorefront
};
made++;
}
// ---- one generated row per equipment SKIN the captured store does not sell ------
// Equipments.json is the whole skin inventory (340); the captured Watch store only
// sells 187 of them. The rest are listed here under SkinIdBase + index, priced by
// rarity like the avatar items, so the Skins carousel and the locker agree.
int skins = 0;
if (File.Exists(EquipmentPath))
{
var equip = JArray.Parse(File.ReadAllText(EquipmentPath));
for (int i = 0; i < equip.Count; i++)
{
var e = equip[i];
string prefab = (string?)e["PrefabName"] ?? "";
string guid = Classes.ItemGuids.NormalizeGuid((string?)e["ModificationGuid"]) ?? ((string?)e["ModificationGuid"] ?? "");
if (prefab.Length == 0 || guid.Length == 0) continue;
if (capturedSkins.Contains(guid)) continue;
int rarity = (int?)e["Rarity"] ?? 0;
if (!IsSellableRarity(rarity)) continue;
string name = (string?)e["FriendlyName"] ?? prefab;
int id = SkinIdBase + i;
int price = PriceForRarity(rarity);
int sub = SubscriberPriceFor(price);
items.Add(new JObject
{
["GiftDrop"] = new JObject
{
["AvatarItemDesc"] = "",
["AvatarItemType"] = null, // a skin is NOT an avatar item (see StoreCatalog.AvatarTypeFor)
["AvatarItemId"] = 0,
["AvatarItemInfo"] = null,
["ConsumableItemDesc"] = "",
["Context"] = 0,
["Currency"] = 0,
["CurrencyType"] = 0,
["CustomAvatarItemId"] = null,
["EquipmentItemId"] = 0,
["EquipmentModificationGuid"] = guid,
["EquipmentPrefabName"] = prefab,
["FriendlyName"] = name,
["GiftDropId"] = id,
["IsQuery"] = false,
["ItemCount"] = 1,
["ItemSetFriendlyName"] = "",
["ItemSetId"] = null,
["QueryRedirectContext"] = null,
["QueryRedirectRarity"] = null,
["QueryRedirectTag"] = null,
["Rarity"] = rarity,
["SubscribersOnly"] = false,
["TagList"] = "",
["ThumbnailImageName"] = null,
["Tooltip"] = (string?)e["Tooltip"] ?? "",
["Unique"] = false
},
["IsFeatured"] = false,
["CanBeGifted"] = true,
["Prices"] = new JArray { new JObject { ["CurrencyType"] = (int)Classes.CurrencyType.RecCenterTokens, ["Price"] = price, ["StorefrontSaleData"] = null, ["Type"] = 0 } },
["PurchasableItemId"] = id,
["SubscriberPrices"] = new JArray { new JObject { ["CurrencyType"] = (int)Classes.CurrencyType.RecCenterTokens, ["Price"] = sub, ["StorefrontSaleData"] = null, ["Type"] = 0 } },
["Type"] = 0
});
generated[id] = new StoreCatalog.GiftDropInfo
{
Cost = price,
Currency = (int)Classes.CurrencyType.RecCenterTokens,
SubscriberCost = sub,
SubscriberCurrency = (int)Classes.CurrencyType.RecCenterTokens,
Reward = "",
Name = name,
Rarity = rarity,
GiftDropId = id,
ConsumableItemDesc = "",
AvatarItemType = 0,
GdCurrencyType = 0,
GdCurrency = 0,
EquipmentPrefabName = prefab,
EquipmentModificationGuid = guid,
StoreFile = "generated-skin",
PurchasableItemId = id,
StorefrontType = WatchStorefront
};
skins++;
}
}
Console.WriteLine($"[store] Watch store: {kept} captured row(s) kept, {dropped} replaced by catalog rows, {made} generated from the catalog, {skins} skin(s) from Equipments.json" +
(hidden > 0 ? $", {hidden} hidden (HiddenItemsStorefronts)" : "") + $" -> {items.Count} for sale");
}
catch (Exception ex)
{
Console.WriteLine($"[store] could not generate the Watch store ({ex.Message}) - serving the captured file as-is");
try
{
if (File.Exists(StorePath))
{
root = JObject.Parse(File.ReadAllText(StorePath));
generated.Clear();
}
}
catch { }
}
// Hair dye is free (HairGrants) - applied to the merged object so the badge and the
// till agree here exactly as they do for the other store files.
// LOCAL EXPERIMENT (HoldNewAccountCreation): the client's starter avatar wears two custom items we have no record of and logs
// "[Economy.Store] Can't get purchase info for item <id>" for each - the exception that follows stops the creation screen's
// clothing tabs and its DONE button. A free store row each gives the Store something to answer with.
if (ConfigCampus.HoldNewAccountCreation)
{
int pid = 9900001;
foreach (var cid in Campus_live_rewrite.api.Controllers.APIController.StarterCustomItemIds)
{
items.Add(new JObject
{
["GiftDrop"] = new JObject
{
["GiftDropId"] = pid, ["FriendlyName"] = "(Starter item)", ["Tooltip"] = "", ["TagList"] = "", ["ConsumableItemDesc"] = "",
["AvatarItemDesc"] = "", ["CustomAvatarItemId"] = cid, ["AvatarItemType"] = 0, ["EquipmentPrefabName"] = "", ["EquipmentModificationGuid"] = "",
["IsQuery"] = false, ["QueryRedirectContext"] = null, ["QueryRedirectTag"] = null, ["QueryRedirectRarity"] = null, ["Unique"] = false,
["SubscribersOnly"] = false, ["Rarity"] = 0, ["Context"] = 1003, ["Currency"] = 0, ["CurrencyType"] = 0, ["ItemCount"] = 1, ["ItemSetId"] = null,
["ItemSetFriendlyName"] = "", ["AvatarItemId"] = 0, ["EquipmentItemId"] = 0, ["ThumbnailImageName"] = null, ["AvatarItemInfo"] = null
},
["PurchasableItemId"] = pid, ["Type"] = 0,
["Prices"] = new JArray(new JObject { ["CurrencyType"] = 2, ["Price"] = 0, ["StorefrontSaleData"] = null }),
["SubscriberPrices"] = new JArray(new JObject { ["CurrencyType"] = 2, ["Price"] = 0, ["StorefrontSaleData"] = null }),
["IsFeatured"] = false, ["NewUntil"] = "2020-01-01T00:00:00Z", ["AvailableAt"] = "2020-01-01T00:00:00Z", ["AvailableUntil"] = null,
["CanBeGifted"] = false, ["OnlyAvailableThroughCv2"] = false
});
pid++;
}
Console.WriteLine("[store] local experiment: 2 free starter-item rows added for the creation screen");
}
HairGrants.ZeroDyePrices(root);
_merged = root;
_generated = generated;
_twinByPurchasable = twins;
}
}
}
@@ -0,0 +1,122 @@
using Newtonsoft.Json.Linq;
using Campus_live_rewrite.api.server.Classes;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// One Rec Center Token price per item across every store (2026-09-27 request: Thriller Suit
/// (Orange) was 4000 in the Watch and 4500 in Pop Up Shop 1).
///
/// The price an item sells for is the one the admin most recently SET for it in the Stores tab,
/// in whichever store (rows carry "PriceSetAt" from then on; older edits count as set before any
/// stamped one, newest admin row first among them). An item never priced there keeps the Watch
/// store's price. Every served store row priced in tokens is rewritten to it (subscriber price
/// = the usual 10% off), and a purchase is charged it. Items priced in room currencies or other
/// currencies are left alone.
///
/// An item is its GiftDropId plus the pack size (ItemCount), so a 1-pack and a 5-pack of the
/// same consumable keep their own prices.
/// </summary>
public static class StorePrices
{
public const string PriceSetAtKey = "PriceSetAt";
private const int Tokens = (int)CurrencyType.RecCenterTokens;
public static string? ItemKey(JToken row)
{
var gd = row["GiftDrop"];
int drop = (int?)gd?["GiftDropId"] ?? 0;
if (drop == 0) return null;
int count = Math.Max(1, (int?)gd?["ItemCount"] ?? 1);
return $"{drop}x{count}";
}
/// <summary>The row's token price, or null when it is not sold for tokens.</summary>
public static int? TokenPrice(JToken row)
{
foreach (var p in row["Prices"] as JArray ?? new JArray())
if (((int?)p["CurrencyType"] ?? 0) == Tokens) return (int?)p["Price"];
return null;
}
// ---- the canonical price table ------------------------------------------------------------
private static readonly object _lock = new();
private static string _key = "";
private static Dictionary<string, int> _prices = new();
private static Dictionary<string, int> _watchBase = new();
private static string _watchKey = "";
/// <summary>Called while the Watch store is built, BEFORE unification: its own prices are the fallback.</summary>
public static void SetWatchBase(JObject watchRoot, string watchKey)
{
var map = new Dictionary<string, int>();
foreach (var r in watchRoot["StoreItems"] as JArray ?? new JArray())
if (ItemKey(r) is { } k && TokenPrice(r) is { } p && !map.ContainsKey(k)) map[k] = p;
lock (_lock) { _watchBase = map; _watchKey = watchKey; }
}
public static string WatchKey { get { lock (_lock) return _watchKey; } }
private static Dictionary<string, int> Table()
{
string key = $"{StoreEdits.Version}|{WatchKey}";
lock (_lock)
{
if (key == _key) return _prices;
// Every admin price, newest first: stamped rows by stamp, unstamped (older) rows by
// admin id (a higher id was added later).
var edits = new List<(string item, int price, DateTime at, int pid)>();
foreach (int store in Enum.GetValues(typeof(StorefrontType)).Cast<int>().Distinct())
{
if (StoreServe.IsPerPlayer(store)) continue;
foreach (var r in StoreEdits.Rows(store))
{
if (ItemKey(r) is not { } item || TokenPrice(r) is not { } price) continue;
DateTime at = DateTime.TryParse((string?)r[PriceSetAtKey], null, System.Globalization.DateTimeStyles.RoundtripKind, out var t) ? t : DateTime.MinValue;
edits.Add((item, price, at, (int?)r["PurchasableItemId"] ?? 0));
}
}
var table = new Dictionary<string, int>(_watchBase);
foreach (var e in edits.OrderBy(e => e.at).ThenBy(e => e.pid))
table[e.item] = e.price; // later wins
_prices = table;
_key = key;
return table;
}
}
/// <summary>The one token price this item sells for, or null when no store has it for tokens.</summary>
public static int? PriceFor(JToken row) =>
ItemKey(row) is { } k && Table().TryGetValue(k, out var p) ? p : null;
/// <summary>Rewrite every token-priced row of a served store to the item's one price. Returns how many changed.</summary>
public static int Apply(JObject root)
{
int changed = 0;
foreach (var r in (root["StoreItems"] as JArray ?? new JArray()).OfType<JObject>())
{
r.Remove(PriceSetAtKey); // bookkeeping only - never sent to the client
if (TokenPrice(r) is not { } now || PriceFor(r) is not { } want || want == now) continue;
foreach (var p in r["Prices"] as JArray ?? new JArray())
if (((int?)p["CurrencyType"] ?? 0) == Tokens) p["Price"] = want;
foreach (var p in r["SubscriberPrices"] as JArray ?? new JArray())
if (((int?)p["CurrencyType"] ?? 0) == Tokens) p["Price"] = StoreGenerator.SubscriberPriceFor(want);
changed++;
}
return changed;
}
/// <summary>Charge the served (unified) price for a purchase priced in tokens.</summary>
public static void ApplyTo(StoreCatalog.GiftDropInfo item, JObject? servedRow)
{
if (servedRow == null || item.Currency != Tokens || TokenPrice(servedRow) is not { } price) return;
item.Cost = price;
if (item.SubscriberCurrency < 0 || item.SubscriberCurrency == Tokens)
{
item.SubscriberCost = StoreGenerator.SubscriberPriceFor(price);
item.SubscriberCurrency = Tokens;
}
}
}
}
@@ -0,0 +1,291 @@
using System.Text.RegularExpressions;
using Newtonsoft.Json.Linq;
using Campus_live_rewrite.api.server.Classes;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Every gift-drop storefront exactly as players get it from
/// GET /api/storefronts/v3/giftdropstore/{id}, and the purchase rules that follow from it:
///
/// - An item is only buyable if the store the client names SELLS it as served. buyItem and
/// bulkpurchase used to fall back to scanning every store file, so any id in any file -
/// hidden rows, rows the generated Watch store drops, another store's stock - could be
/// bought by naming the wrong store (seen 2026-09-22: Watch items bought "from RecCenter"
/// and "from LaserTag").
/// - A buy that names no real store (a player's profile / wishlist, or none) can only buy
/// something a store sells for TOKENS.
///
/// Base stock minus <see cref="Excluded"/>, then the admin's Stores-tab edits on top, so an
/// item the admin deliberately adds to a store is sold even if it matches an exclusion.
/// </summary>
public static class StoreServe
{
// ---- items taken off every store (2026-09-26 request) -----------------------------------
private static readonly Regex ClassOfShirt = new(@"^class of \d{4}\b", RegexOptions.IgnoreCase);
private static readonly Regex Contest = new(@"\bcontest\b", RegexOptions.IgnoreCase);
private static readonly Regex Golden = new(@"\bgold(en)?\b", RegexOptions.IgnoreCase);
private static readonly HashSet<string> ExcludedNames = new(StringComparer.OrdinalIgnoreCase)
{
"Laser Tag Alpha Shirt",
"Rec Room Hoodie (Gold)",
"A Gold Hoodie",
"Gold Hoodie",
"Video Partner Hoodie",
};
/// <summary>
/// Items no store sells: Class of 20XX shirts, anything from a contest (clothing, the
/// Video Contest shirts, contest Maker Pens), golden equipment skins, the gold hoodies,
/// the Laser Tag Alpha Shirt and the Video Partner Hoodie.
/// </summary>
public static bool Excluded(JToken row)
{
var gd = row["GiftDrop"];
string name = ((string?)gd?["FriendlyName"] ?? "").Trim();
if (name.Length == 0) return false;
if (ExcludedNames.Contains(name) || ClassOfShirt.IsMatch(name) || Contest.IsMatch(name)) return true;
bool isSkin = !string.IsNullOrWhiteSpace((string?)gd?["EquipmentPrefabName"]);
return isSkin && Golden.IsMatch(name);
}
// ---- served stores ----------------------------------------------------------------------
private sealed class Served
{
public string Key = "";
public string Json = "";
public Dictionary<int, JObject> ByPid = new();
}
private static readonly Dictionary<int, Served> _cache = new();
private static readonly object _lock = new();
/// <summary>Storefronts that are built per player/room, or never sell stock.</summary>
public static bool IsPerPlayer(int id) =>
id <= 0 || id == (int)StorefrontType.Wishlist || id == (int)StorefrontType.Player_Profile;
/// <summary>The store JSON as served, or null when the storefront has no stock at all.</summary>
public static string? ServedJson(int id) => Get(id)?.Json;
/// <summary>The row this store serves under this id (do not mutate), or null.</summary>
public static JObject? ServedRow(int id, int pid) =>
Get(id) is { } s && s.ByPid.TryGetValue(pid, out var r) ? r : null;
/// <summary>Every storefront that can have stock: the Watch store, each store file, each admin-stocked store.</summary>
public static List<int> StockedStores()
{
var ids = new SortedSet<int> { StoreGenerator.WatchStorefront };
try
{
if (Directory.Exists(StoreCatalog.StoreDirectory))
foreach (var f in Directory.GetFiles(StoreCatalog.StoreDirectory, "*.json"))
if (int.TryParse(Path.GetFileNameWithoutExtension(f), out var n)) ids.Add(n);
}
catch { }
foreach (int id in Enum.GetValues(typeof(StorefrontType)).Cast<int>().Distinct())
if (StoreEdits.HasEdits(id)) ids.Add(id);
ids.RemoveWhere(IsPerPlayer);
// The Watch store first: its ids are what the store pages and discovery hand out.
return ids.OrderBy(i => i == StoreGenerator.WatchStorefront ? 0 : 1).ThenBy(i => i).ToList();
}
/// <summary>
/// The first store (Watch first) that serves this id for TOKENS - what a profile,
/// wishlist or store-less buy may purchase. Null when no store sells it for tokens.
/// </summary>
public static (int store, JObject row)? FindTokenRow(int pid)
{
foreach (int id in StockedStores())
if (ServedRow(id, pid) is { } r && StoreCatalog.PricedInTokens(r)) return (id, r);
return null;
}
/// <summary>
/// The purchase check for buyItem / bulkpurchase. Returns the priced item, or null with
/// the reason. <paramref name="storefrontType"/> is what the client sent.
/// </summary>
public static StoreCatalog.GiftDropInfo? ResolvePurchase(int pid, int storefrontType, out string why)
{
if (!IsPerPlayer(storefrontType))
{
if (ServedRow(storefrontType, pid) == null)
{
// The basket checkout names the WATCH for everything in it, so every pop-up shop
// buy and gift was refused "not sold in Watch" (2026-09-27: items 900014-900029,
// PopUpShop_1). Take the store that does sell it - provided every store serving
// this id means the same item, so a buy can never deliver something other than
// what was on screen.
var elsewhere = StockedStores().Where(s => s != storefrontType)
.Select(s => (store: s, row: ServedRow(s, pid))).Where(x => x.row != null).ToList();
int drops = elsewhere.Select(x => (int?)x.row!["GiftDrop"]?["GiftDropId"] ?? 0).Distinct().Count();
if (elsewhere.Count == 0 || drops != 1)
{
why = $"not sold in {StoreCatalog.StorefrontName(storefrontType)}" +
(elsewhere.Count > 0 ? $" (and {elsewhere.Count} other stores disagree on what id {pid} is)" : "");
return null;
}
storefrontType = elsewhere[0].store;
}
var item = StoreCatalog.FindStoreItem(pid, storefrontType);
if (item != null) StorePrices.ApplyTo(item, ServedRow(storefrontType, pid));
why = item == null ? "sold, but its row could not be priced" : "";
return item;
}
// A profile / wishlist / unspecified buy: only what a store sells for tokens.
var hit = FindTokenRow(pid);
if (hit == null)
{
why = "no store sells it for tokens";
return null;
}
var found = StoreCatalog.FindStoreItem(pid, hit.Value.store);
if (found != null) StorePrices.ApplyTo(found, hit.Value.row);
if (found != null && found.Currency != (int)CurrencyType.RecCenterTokens)
{
why = "no store sells it for tokens";
return null;
}
why = found == null ? "sold, but its row could not be priced" : $"sold in {StoreCatalog.StorefrontName(hit.Value.store)}";
return found;
}
// ---- building ---------------------------------------------------------------------------
private static Served? Get(int id)
{
if (IsPerPlayer(id)) return null;
// Every store's token prices follow the Watch's (StorePrices), so build it first.
if (id != StoreGenerator.WatchStorefront) Get(StoreGenerator.WatchStorefront);
string key;
string file = "";
JObject? watch = null;
if (id == StoreGenerator.WatchStorefront)
{
watch = StoreGenerator.MergedWatchStore();
key = $"w{System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(watch)}";
}
else if ((file = StoreCatalog.StoreFileForId(id)).Length > 0)
key = $"f{File.GetLastWriteTimeUtc(file).Ticks}|{ConfigCampus.FreeHairDyes}|{ConfigCampus.HiddenItemsStorefronts}";
else if (StoreEdits.HasEdits(id))
key = "e";
else
return null;
key += $"|{StoreEdits.Version}";
if (id != StoreGenerator.WatchStorefront) key += $"|{StorePrices.WatchKey}";
lock (_lock)
if (_cache.TryGetValue(id, out var hit) && hit.Key == key) return hit;
JObject root;
try
{
if (watch != null)
root = (JObject)watch.DeepClone();
else if (file.Length > 0)
{
// Same forms giftdropstore always served: free hair dyes / hidden items, then
// every row giftable.
string? baseJson = null;
if (ConfigCampus.FreeHairDyes || ConfigCampus.HiddenItemsStorefronts)
baseJson = HairGrants.StoreJsonWithFreeDyes(file);
string json = StoreCatalog.StoreJsonGiftable(file, baseJson) ?? baseJson ?? File.ReadAllText(file);
root = JObject.Parse(json);
}
else
{
root = new JObject
{
["NewUntil"] = null,
["NextUpdate"] = "2222-02-22T22:22:00Z",
["StoreItems"] = new JArray(),
["StorefrontName"] = StoreCatalog.StorefrontName(id),
["StorefrontType"] = id,
["SubscriberDiscountPercent"] = 10
};
}
}
catch (Exception ex)
{
Console.WriteLine($"[store] could not build store {id}: {ex.Message}");
return null;
}
int dropped = 0;
if (root["StoreItems"] is JArray items)
{
var kept = new JArray();
foreach (var it in items)
{
if (Excluded(it)) { dropped++; continue; }
kept.Add(it);
}
root["StoreItems"] = kept;
}
StoreEdits.ApplyTo(id, root);
if (id == StoreGenerator.WatchStorefront) StorePrices.SetWatchBase(root, key);
// Index what this store SELLS before borrowing anything below.
var byPid = new Dictionary<int, JObject>();
foreach (var it in (root["StoreItems"] as JArray ?? new JArray()).OfType<JObject>())
{
int pid = (int?)it["PurchasableItemId"] ?? 0;
if (pid != 0) byPid.TryAdd(pid, it);
}
// The client resolves an item's details page by PurchasableItemId against the WATCH
// storefront it downloaded (its lookup awaits a "watchStorefrontTask"), whatever store
// the item was clicked in. A Stores-tab item that only exists in a pop-up shop was
// therefore unknown to it: "StoreItemModel: Could not find PurchasableGiftDrop with
// ID 900005" and a blank item page (2026-09-26, Fae Horns in PopUpShop_1). So every
// admin-added row of the other stores rides along in the Watch payload too. Admin ids
// (900000+) are unique across stores, and the Watch store's browse rows come from the
// store lists, which read the generated stock - so these are resolvable, not listed.
// They stay out of byPid: the Watch store does not SELL them (a buy names the pop-up).
if (id == StoreGenerator.WatchStorefront && root["StoreItems"] is JArray watchRows)
{
var carried = new HashSet<int>(byPid.Keys);
int borrowed = 0;
foreach (int other in Enum.GetValues(typeof(StorefrontType)).Cast<int>().Distinct())
{
if (other == id || IsPerPlayer(other)) continue;
foreach (var r in StoreEdits.Rows(other))
{
int pid = (int?)r["PurchasableItemId"] ?? 0;
if (pid < StoreEdits.AdminIdBase || !carried.Add(pid)) continue;
StoreGenerator.ShapeRow(r);
r["CanBeGifted"] = StoreCatalog.PricedInTokens(r);
watchRows.Add(r);
borrowed++;
}
}
if (borrowed > 0) Console.WriteLine($"[store] Watch payload carries {borrowed} other-store item(s) so their item pages resolve");
}
// A skin row names its skin the way the locker list does, so the item the store sells and
// the one in the locker are the same item to the client (not a short-guid twin).
foreach (var r in (root["StoreItems"] as JArray ?? new JArray()).OfType<JObject>())
if (r["GiftDrop"] is JObject sgd && !string.IsNullOrWhiteSpace((string?)sgd["EquipmentPrefabName"]))
sgd["EquipmentModificationGuid"] = SkinOwnership.CanonicalGuid((string?)sgd["EquipmentPrefabName"], (string?)sgd["EquipmentModificationGuid"]);
// One token price per item in every store (the admin's latest, else the Watch's).
int repriced = StorePrices.Apply(root);
if (repriced > 0) Console.WriteLine($"[store] {StoreCatalog.StorefrontName(id)}: {repriced} token price(s) matched to the item's one price");
// Skins / consumables / item-less boxes: the item's own picture, never a thumbnail
// name whose file is not on this server (see ItemImages).
int thumbsFixed = 0;
foreach (var r in (root["StoreItems"] as JArray ?? new JArray()).OfType<JObject>())
if (ItemImages.FixStoreRow(r)) thumbsFixed++;
if (thumbsFixed > 0) Console.WriteLine($"[store] {StoreCatalog.StorefrontName(id)}: {thumbsFixed} skin/consumable/box thumbnail(s) pointed at the item's own picture");
var served = new Served { Key = key, Json = root.ToString(Newtonsoft.Json.Formatting.None), ByPid = byPid };
if (dropped > 0)
Console.WriteLine($"[store] {StoreCatalog.StorefrontName(id)}: {dropped} excluded item(s) taken off sale, {served.ByPid.Count} served");
lock (_lock) _cache[id] = served;
return served;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
using System.Security.Cryptography;
using System.Text;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// RFC 6238 TOTP (the algorithm behind Google Authenticator / Authy / 1Password's codes) - standard HMAC-SHA1,
/// 30-second step, 6 digits. No NuGet package: it's ~20 lines on top of the BCL's own HMACSHA1.
/// </summary>
public static class Totp
{
private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private const int Digits = 6;
private const int StepSeconds = 30;
/// <summary>A fresh 160-bit secret, base32-encoded (what an authenticator app wants typed in or scanned).</summary>
public static string GenerateSecret()
{
var bytes = RandomNumberGenerator.GetBytes(20);
return Base32Encode(bytes);
}
public static string Base32Encode(byte[] data)
{
var sb = new StringBuilder((data.Length * 8 + 4) / 5);
int bits = 0, value = 0;
foreach (byte b in data)
{
value = (value << 8) | b; bits += 8;
while (bits >= 5) { sb.Append(Base32Alphabet[(value >> (bits - 5)) & 0x1F]); bits -= 5; }
}
if (bits > 0) sb.Append(Base32Alphabet[(value << (5 - bits)) & 0x1F]);
return sb.ToString();
}
public static byte[] Base32Decode(string s)
{
s = (s ?? "").Trim().TrimEnd('=').Replace(" ", "").ToUpperInvariant();
var outBytes = new List<byte>(s.Length * 5 / 8);
int bits = 0, value = 0;
foreach (char c in s)
{
int idx = Base32Alphabet.IndexOf(c);
if (idx < 0) continue; // skip anything an app/user might paste in (dashes, etc.)
value = (value << 5) | idx; bits += 5;
if (bits >= 8) { outBytes.Add((byte)((value >> (bits - 8)) & 0xFF)); bits -= 8; }
}
return outBytes.ToArray();
}
private static string Hotp(byte[] key, long counter)
{
var counterBytes = BitConverter.GetBytes(counter);
if (BitConverter.IsLittleEndian) Array.Reverse(counterBytes);
using var h = new HMACSHA1(key);
byte[] hash = h.ComputeHash(counterBytes);
int offset = hash[^1] & 0x0F;
int code = ((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16) | ((hash[offset + 2] & 0xFF) << 8) | (hash[offset + 3] & 0xFF);
return (code % (int)Math.Pow(10, Digits)).ToString(new string('0', Digits));
}
/// <summary>True if `code` matches the secret at the current 30s step, or one step either side (clock drift).</summary>
public static bool Verify(string base32Secret, string? code)
{
code = (code ?? "").Trim();
if (code.Length != Digits || !code.All(char.IsDigit)) return false;
byte[] key = Base32Decode(base32Secret);
if (key.Length == 0) return false;
long step = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / StepSeconds;
for (long d = -1; d <= 1; d++)
if (CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(Hotp(key, step + d)), Encoding.ASCII.GetBytes(code)))
return true;
return false;
}
/// <summary>otpauth:// URI for a QR code, if the admin panel ever adds one - also readable as plain text to copy.</summary>
public static string OtpAuthUri(string secret, string account, string issuer = "Campus Admin") =>
$"otpauth://totp/{Uri.EscapeDataString(issuer)}:{Uri.EscapeDataString(account)}?secret={secret}&issuer={Uri.EscapeDataString(issuer)}&digits={Digits}&period={StepSeconds}";
}
}
@@ -0,0 +1,480 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using LiteDB;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Only files the GAME produced are accepted as uploads; anything else is refused, counted
/// as a strike against the account, and after ConfigCampus.UploadStrikesToBan strikes the
/// account, its devices and its IP are banned for ConfigCampus.UploadBanDays and every
/// request from its IPs is throttled (TrafficGuardMiddleware, "upload-penalty") - from the
/// ban, not the first strike.
///
/// "Produced by the game" is decided from the BYTES, per file type:
///
/// Image (photos, polaroids, custom-item art)
/// Unity's ImageConversion encoders leave a fixed shape that no camera, phone, browser
/// or editor reproduces:
/// JPEG SOI, APP0 = JFIF 1.01 / no density / no thumbnail, then only DQT / SOF0
/// (baseline, 3 components) / DHT / DRI / SOS, EOI as the LAST two bytes. An
/// APP1 (Exif), APP2 (ICC), COM, or progressive SOF2 is something else.
/// PNG IHDR / IDAT.. / IEND and nothing else (no pHYs / sRGB / gAMA / tEXt / iCCP),
/// 8-bit RGB or RGBA, non-interlaced, valid CRCs, and the IDAT stream must
/// actually inflate to exactly one image's worth of scanlines.
/// RoomSave / RoomMetadata / Invention / Holotar
/// Protobuf messages. The whole buffer must parse as ONE top-level message - every tag
/// a sane field number with a known wire type, every length inside the buffer, exact
/// consumption - and open with the field the game's serializer writes first. A video,
/// an image, a zip or random bytes fails within the first few tags (an MP4 starts with
/// field 0, a PNG with a 25-million field number); the chance of arbitrary data walking
/// cleanly to the end is nil.
/// Video / Unknown
/// The game never uploads these on this build; refused outright.
///
/// The same validators drive SweepStorage, which quarantines (moves out of the served
/// folders) anything already on disk that would be refused today - the MP4s that were
/// posted as inventions/holotars and the room blobs posted as polaroids are the reason
/// this exists.
/// </summary>
public static class UploadGuard
{
public enum Kind { Image, WebImage, RoomSave, RoomMetadata, Invention, Holotar, Video, Unknown }
public static Kind KindOf(Images.FileType_data t) => t switch
{
Images.FileType_data.Image => Kind.Image,
Images.FileType_data.RoomSave => Kind.RoomSave,
Images.FileType_data.RoomMetadata => Kind.RoomMetadata,
Images.FileType_data.Invention => Kind.Invention,
Images.FileType_data.Holotar => Kind.Holotar,
Images.FileType_data.Video => Kind.Video,
_ => Kind.Unknown,
};
// ------------------------------------------------------------------ gate ----
/// <summary>
/// Run before an upload is stored. Null = accepted. Otherwise the upload has been
/// refused, a strike recorded (with the ban/penalty side effects when the count is
/// reached), and the returned result is what the endpoint should answer.
/// </summary>
public static ContentResult? Gate(HttpContext ctx, player_info account, Kind kind, byte[]? data, string endpoint)
{
if (!ConfigCampus.UploadGuardEnabled) return null;
string ip = LoginGuard.ClientIp(ctx);
string ua = ctx.Request.Headers["User-Agent"].ToString();
string why;
// An upload that did not come through the game's HTTP stack is not from the game,
// whatever its bytes look like.
if (ConfigCampus.RequireGameUserAgent && ua != "BestHTTP")
why = $"user-agent '{Trunc(ua, 40)}' is not the game";
else if (data == null || data.Length == 0)
why = "empty file";
else if (data.Length > ConfigCampus.UploadMaxBytes)
why = $"{data.Length} bytes exceeds the {ConfigCampus.UploadMaxBytes} byte cap";
else if (Validate(kind, data, out why))
return null;
int strikes = Strike(account.Id, ip, kind, why, endpoint, data?.Length ?? 0);
bool banned = strikes >= ConfigCampus.UploadStrikesToBan;
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = banned
? $"Uploads that don't come from the game are not allowed. This account is banned for {ConfigCampus.UploadBanDays} days."
: $"That file was not made by the game and was rejected ({strikes}/{ConfigCampus.UploadStrikesToBan} - at {ConfigCampus.UploadStrikesToBan} the account is banned for {ConfigCampus.UploadBanDays} days).",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = banned ? 403 : 400
};
}
private static string Trunc(string s, int n) => s.Length <= n ? s : s.Substring(0, n) + "…";
// ------------------------------------------------------------- validators ----
public static bool Validate(Kind kind, byte[] d, out string why)
{
switch (kind)
{
case Kind.Image: return IsGameImage(d, strict: true, out why);
case Kind.WebImage: return IsGameImage(d, strict: false, out why);
case Kind.RoomSave: return ProtobufWalk(d, firstField: 1, firstWire: 0, out why);
case Kind.Invention: return ProtobufWalk(d, firstField: 1, firstWire: 0, out why);
case Kind.RoomMetadata: return ProtobufWalk(d, firstField: 2, firstWire: 0, out why);
case Kind.Holotar: return ProtobufWalk(d, firstField: 1, firstWire: -1, out why);
case Kind.Video: why = "the game does not upload video"; return false;
default: why = "unknown file type"; return false;
}
}
public static bool IsGameImage(byte[] d, bool strict, out string why)
{
if (d.Length >= 4 && d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF) return IsGameJpeg(d, strict, out why);
if (d.Length >= 8 && d[0] == 0x89 && d[1] == 'P' && d[2] == 'N' && d[3] == 'G') return IsGamePng(d, strict, out why);
why = $"not a JPEG or PNG (starts {BitConverter.ToString(d, 0, Math.Min(8, d.Length))})";
return false;
}
// Unity's exact APP0: "JFIF\0", version 1.01, units 0, density 1x1, no thumbnail.
private static readonly byte[] UnityJfif = { 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00 };
private static bool IsGameJpeg(byte[] d, bool strict, out string why)
{
why = "";
if (d.Length < 4 || d[d.Length - 2] != 0xFF || d[d.Length - 1] != 0xD9) { why = "JPEG does not end at EOI (trailing data)"; return false; }
int pos = 2; bool sawApp0 = false, sawSof = false, sawSos = false; int n = 0;
while (pos + 4 <= d.Length)
{
if (d[pos] != 0xFF) { why = $"JPEG marker expected at {pos}"; return false; }
byte m = d[pos + 1];
if (m == 0xFF) { pos++; continue; }
int len = (d[pos + 2] << 8) | d[pos + 3];
if (len < 2 || pos + 2 + len > d.Length) { why = $"JPEG segment {m:X2} overruns"; return false; }
n++;
if (n == 1)
{
if (m != 0xE0) { why = $"JPEG first segment is {m:X2}, not APP0/JFIF"; return false; }
if (strict && (len != 16 || !d.AsSpan(pos + 4, 14).SequenceEqual(UnityJfif))) { why = "JPEG APP0 is not Unity's JFIF header"; return false; }
sawApp0 = true;
}
else switch (m)
{
case 0xDB: case 0xC4: case 0xDD: break; // DQT, DHT, DRI
case 0xC0: // SOF0 baseline
if (len < 8) { why = "JPEG SOF0 truncated"; return false; }
{
int h = (d[pos + 5] << 8) | d[pos + 6], w = (d[pos + 7] << 8) | d[pos + 8], nc = d[pos + 9];
if (w < 8 || h < 8 || w > ConfigCampus.UploadMaxImageDim || h > ConfigCampus.UploadMaxImageDim) { why = $"JPEG {w}x{h} outside allowed size"; return false; }
if (strict && nc != 3) { why = $"JPEG has {nc} components, Unity writes 3"; return false; }
}
sawSof = true; break;
case 0xDA: sawSos = true; break; // SOS - entropy data follows
case 0xC2: why = "progressive JPEG (not a Unity encode)"; return false;
case 0xE1: why = "JPEG carries Exif (APP1) - not a Unity encode"; return false;
case 0xE2: why = "JPEG carries an ICC profile (APP2) - not a Unity encode"; return false;
case 0xFE: why = "JPEG carries a comment - not a Unity encode"; return false;
default:
if (strict) { why = $"JPEG segment {m:X2} is not something Unity writes"; return false; }
break;
}
if (sawSos) break;
pos += 2 + len;
}
if (!sawApp0 || !sawSof || !sawSos) { why = "JPEG is missing APP0/SOF0/SOS"; return false; }
return true;
}
private static readonly uint[] CrcTable = MakeCrcTable();
private static uint[] MakeCrcTable()
{
var t = new uint[256];
for (uint n = 0; n < 256; n++)
{
uint c = n;
for (int k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
t[n] = c;
}
return t;
}
private static uint Crc32(byte[] d, int off, int len)
{
uint c = 0xFFFFFFFFu;
for (int i = off; i < off + len; i++) c = CrcTable[(c ^ d[i]) & 0xFF] ^ (c >> 8);
return c ^ 0xFFFFFFFFu;
}
private static bool IsGamePng(byte[] d, bool strict, out string why)
{
why = "";
if (d.Length < 8 + 25 || d[4] != 0x0D || d[5] != 0x0A || d[6] != 0x1A || d[7] != 0x0A) { why = "PNG signature broken"; return false; }
int pos = 8; bool ihdr = false, iend = false; int w = 0, h = 0, bpp = 0; var idat = new List<(int off, int len)>();
while (pos + 12 <= d.Length)
{
int len = (d[pos] << 24) | (d[pos + 1] << 16) | (d[pos + 2] << 8) | d[pos + 3];
if (len < 0 || pos + 12 + len > d.Length) { why = "PNG chunk overruns"; return false; }
string type = System.Text.Encoding.ASCII.GetString(d, pos + 4, 4);
if (Crc32(d, pos + 4, 4 + len) != (uint)((d[pos + 8 + len] << 24) | (d[pos + 9 + len] << 16) | (d[pos + 10 + len] << 8) | d[pos + 11 + len]))
{ why = $"PNG {type} chunk CRC is wrong"; return false; }
switch (type)
{
case "IHDR":
if (ihdr || len != 13) { why = "PNG IHDR malformed"; return false; }
w = (d[pos + 8] << 24) | (d[pos + 9] << 16) | (d[pos + 10] << 8) | d[pos + 11];
h = (d[pos + 12] << 24) | (d[pos + 13] << 16) | (d[pos + 14] << 8) | d[pos + 15];
int bd = d[pos + 16], ct = d[pos + 17], comp = d[pos + 18], filt = d[pos + 19], il = d[pos + 20];
if (w < 1 || h < 1 || w > ConfigCampus.UploadMaxImageDim || h > ConfigCampus.UploadMaxImageDim) { why = $"PNG {w}x{h} outside allowed size"; return false; }
if (comp != 0 || filt != 0) { why = "PNG uses an unknown compression/filter method"; return false; }
if (strict)
{
if (bd != 8 || (ct != 2 && ct != 6)) { why = $"PNG bit depth {bd} / colour type {ct} is not what Unity writes (8-bit RGB/RGBA)"; return false; }
if (il != 0) { why = "interlaced PNG - not a Unity encode"; return false; }
}
bpp = ct switch { 0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4, _ => 0 } * (bd == 16 ? 2 : 1);
if (bpp == 0) { why = $"PNG colour type {ct} unknown"; return false; }
if (bd < 8) bpp = 1; // sub-byte depths pack; the scanline check below is by whole bytes
ihdr = true; break;
case "IDAT":
if (!ihdr) { why = "PNG IDAT before IHDR"; return false; }
idat.Add((pos + 8, len)); break;
case "IEND":
iend = true; break;
default:
if (strict) { why = $"PNG carries a {type} chunk - Unity writes IHDR/IDAT/IEND only"; return false; }
if (type == "PLTE" || type == "tRNS" || type == "pHYs" || type == "sRGB" || type == "gAMA" || type == "iCCP" || type == "cHRM" || type == "bKGD" || type == "tIME" || type == "tEXt" || type == "zTXt" || type == "iTXt" || type == "sBIT" || type == "eXIf") break;
why = $"PNG carries an unexpected {type} chunk"; return false;
}
pos += 12 + len;
if (iend) break;
}
if (!ihdr || !iend || idat.Count == 0) { why = "PNG is missing IHDR/IDAT/IEND"; return false; }
if (pos != d.Length) { why = $"{d.Length - pos} bytes after PNG IEND"; return false; }
if (strict && idat.Any(c => c.len > 32768)) { why = "PNG IDAT chunk larger than Unity writes"; return false; }
if (d[idat[0].off] != 0x78) { why = "PNG IDAT is not a zlib stream"; return false; }
// The image data must inflate to exactly h scanlines of (1 filter byte + w*bpp). For
// sub-byte depths (non-strict only) the row is shorter; accept >= to stay lenient there.
long expect = (long)h * (1 + (long)w * bpp);
try
{
using var joined = new MemoryStream();
foreach (var (off, len) in idat) joined.Write(d, off, len);
joined.Position = 0;
using var z = new ZLibStream(joined, CompressionMode.Decompress);
var buf = new byte[65536]; long total = 0; int r;
while ((r = z.Read(buf, 0, buf.Length)) > 0) { total += r; if (total > expect) break; }
if (strict ? total != expect : total > expect) { why = $"PNG pixel data inflates to {total} bytes, expected {expect} for {w}x{h}"; return false; }
}
catch (Exception ex) { why = $"PNG pixel data does not inflate ({ex.GetType().Name})"; return false; }
return true;
}
/// <summary>
/// Walk the buffer as one protobuf message (see the class remarks). firstField /
/// firstWire pin the opening tag (-1 = any wire type).
/// </summary>
public static bool ProtobufWalk(byte[] d, int firstField, int firstWire, out string why)
{
why = "";
if (d.Length == 0) { why = "empty"; return false; }
int pos = 0, n = 0;
while (pos < d.Length)
{
if (!Varint(d, ref pos, out ulong tag)) { why = $"protobuf: bad tag at {pos}"; return false; }
int field = (int)(tag >> 3), wt = (int)(tag & 7);
if (field < 1 || field > 512) { why = $"protobuf: field number {field} at {pos} is not plausible"; return false; }
if (n == 0 && (field != firstField || (firstWire >= 0 && wt != firstWire)))
{ why = $"protobuf: opens with field {field}/wire {wt}, the game writes field {firstField}"; return false; }
switch (wt)
{
case 0: if (!Varint(d, ref pos, out _)) { why = $"protobuf: bad varint at {pos}"; return false; } break;
case 1: pos += 8; break;
case 5: pos += 4; break;
case 2:
if (!Varint(d, ref pos, out ulong len) || len > (ulong)(d.Length - pos)) { why = $"protobuf: length-delimited field {field} overruns at {pos}"; return false; }
pos += (int)len; break;
default: why = $"protobuf: wire type {wt} at {pos}"; return false;
}
if (pos > d.Length) { why = "protobuf: overrun"; return false; }
n++;
}
return true;
}
private static bool Varint(byte[] d, ref int pos, out ulong v)
{
v = 0; int shift = 0;
for (int i = 0; i < 10; i++)
{
if (pos >= d.Length) return false;
byte b = d[pos++];
v |= (ulong)(b & 0x7F) << shift; shift += 7;
if ((b & 0x80) == 0) return true;
}
return false;
}
// ---------------------------------------------------------------- strikes ----
public class StrikeEntry
{
[BsonId] public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public ulong AccountId { get; set; }
public string Ip { get; set; } = ""; // AES-encrypted like every other stored IP
public string Kind { get; set; } = "";
public string Reason { get; set; } = "";
public string Endpoint { get; set; } = "";
public int Bytes { get; set; }
public long At { get; set; }
}
private static ILiteCollection<StrikeEntry> Col
{
get
{
var col = PlayerDB.PlayerDBFile.GetCollection<StrikeEntry>("upload_strikes");
col.EnsureIndex(x => x.AccountId);
col.EnsureIndex(x => x.At);
return col;
}
}
private static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
private static long WindowStart => Now - (long)ConfigCampus.UploadStrikeWindowDays * 86400;
// IPs under penalty (throttled everywhere) -> unix expiry. Only a BANNED account's
// addresses (UploadStrikesToBan strikes) go here. It used to be every striking IP from the
// first strike, and the throttle (UploadPenaltyMaxPerWindow per FloodWindowSeconds) is
// below what a room load fires, so one refused photo left a player on an endless loading
// screen for UploadStrikeWindowDays (accounts 64 and 71, 2026-09-26). A strike below the
// ban is still recorded and still counts toward it. Rebuilt from the strike table at
// startup so a restart does not forgive a ban.
private static readonly ConcurrentDictionary<string, long> _penalised = new();
public static void LoadPenalties()
{
int n = 0, banned = 0;
foreach (var g in Col.Find(x => x.At >= WindowStart).GroupBy(s => s.AccountId))
{
n += g.Count();
if (g.Count() < ConfigCampus.UploadStrikesToBan) continue;
banned++;
long until = g.Max(s => s.At) + (long)ConfigCampus.UploadStrikeWindowDays * 86400;
var ips = g.Select(s => s.Ip).Where(ip => !string.IsNullOrEmpty(ip)).ToList();
try { ips.AddRange(PlayerDB.GetFullAccount(g.Key)?.player_Extra?.IpAddresses ?? new List<string>()); } catch { }
foreach (var ip in ips.Distinct())
_penalised.AddOrUpdate(ip, until, (_, cur) => Math.Max(cur, until));
}
Console.WriteLine($"[upload-guard] {(ConfigCampus.UploadGuardEnabled ? "ENABLED" : "disabled")}: {n} strike(s) in the last {ConfigCampus.UploadStrikeWindowDays} days, {banned} account(s) at the ban, {_penalised.Count} IP(s) under penalty");
}
/// <summary>Is every request from this IP to be throttled hard? (see TrafficGuardMiddleware)</summary>
public static bool IsPenalisedIp(string ip)
{
if (_penalised.IsEmpty || string.IsNullOrEmpty(ip)) return false;
if (!_penalised.TryGetValue(Utils.EncryptAes(ip.Trim()), out long until)) return false;
if (until > Now) return true;
_penalised.TryRemove(Utils.EncryptAes(ip.Trim()), out _);
return false;
}
public static int StrikeCount(ulong accountId) => Col.Count(x => x.AccountId == accountId && x.At >= WindowStart);
/// <summary>Record a refused upload; returns the account's strike count in the window.</summary>
public static int Strike(ulong accountId, string ip, Kind kind, string reason, string endpoint, int bytes)
{
string encIp = string.IsNullOrWhiteSpace(ip) ? "" : Utils.EncryptAes(ip.Trim());
Col.Insert(new StrikeEntry { AccountId = accountId, Ip = encIp, Kind = kind.ToString(), Reason = reason, Endpoint = endpoint, Bytes = bytes, At = Now });
int count = StrikeCount(accountId);
Console.WriteLine($"[upload-guard] REFUSED {kind} from account {accountId} ip {ip} on {endpoint} ({bytes} bytes): {reason} - strike {count}/{ConfigCampus.UploadStrikesToBan}");
Webhook.Send(accountId, "mod", "Upload refused",
$"({accountId}) posted a {kind} that did not come from the game on `{endpoint}` ({bytes} bytes): {reason}. Strike {count}/{ConfigCampus.UploadStrikesToBan}.", 15158332);
if (count >= ConfigCampus.UploadStrikesToBan && count % ConfigCampus.UploadStrikesToBan == 0)
Ban(accountId, ip, count);
return count;
}
private static void Ban(ulong accountId, string ip, int strikes)
{
int seconds = ConfigCampus.UploadBanDays * 86400;
string reason = $"Uploading files that did not come from the game ({strikes} strikes)";
try
{
PlayerDB.Set_moderation_block(accountId, Moderation_Detail.ReportCategory.Cheating, seconds, 0, false,
$"Uploading files that did not come from the game. Banned for {ConfigCampus.UploadBanDays} days.", null, true, 0,
logKind: "upload-ban", logNote: $"{strikes} strikes; IP and devices banned too");
if (!string.IsNullOrWhiteSpace(ip)) BanDB.BanIp(ip, accountId, 0, reason, seconds);
int devices = 0;
foreach (var b in PlayerDB.GetDeviceBindings(accountId).Select(b => b.DeviceId).Distinct())
if (BanDB.BanDevice(b, accountId, 0, reason, seconds)) devices++;
// The throttle starts here: this upload's address and every address the account
// has ever used.
var full = PlayerDB.GetFullAccount(accountId);
if (!string.IsNullOrWhiteSpace(ip))
_penalised.AddOrUpdate(Utils.EncryptAes(ip.Trim()), Now + (long)ConfigCampus.UploadStrikeWindowDays * 86400, (_, cur) => Math.Max(cur, Now + (long)ConfigCampus.UploadStrikeWindowDays * 86400));
foreach (var enc in full?.player_Extra?.IpAddresses ?? new List<string>())
_penalised.AddOrUpdate(enc, Now + (long)ConfigCampus.UploadStrikeWindowDays * 86400, (_, cur) => Math.Max(cur, Now + (long)ConfigCampus.UploadStrikeWindowDays * 86400));
Console.WriteLine($"[upload-guard] BANNED account {accountId} for {ConfigCampus.UploadBanDays} days (ip {ip}, {devices} device(s)) after {strikes} refused uploads");
Webhook.Send(accountId, "mod", "Upload ban",
$"({accountId}) banned for {ConfigCampus.UploadBanDays} days: {strikes} uploads that did not come from the game. IP + {devices} device(s) banned too.", 10038562);
}
catch (Exception ex)
{
Console.WriteLine($"[upload-guard] ban of {accountId} failed: {ex.Message}");
}
}
// ------------------------------------------------------------------ sweep ----
/// <summary>
/// Re-check everything already stored with today's validators and quarantine what
/// fails (moved to Data/Quarantine/&lt;folder&gt;/, never deleted outright so a false
/// positive can be put back by hand). Returns the number quarantined.
/// </summary>
public static int SweepStorage(bool dryRun)
{
string data = Path.Combine(Environment.CurrentDirectory, "Data");
string quarantine = Path.Combine(data, "Quarantine");
int moved = 0, checkedN = 0;
var jobs = new (string dir, Func<string, Kind?> kindOf)[]
{
(Images.ImgSavePathPolaroid, _ => Kind.Image),
// player_images mixes in-game photos with website profile pictures (pfp_*) and
// room images the admin tooling wrote; only the game's own names are held to
// the strict shape, the rest must still be a real image.
// Admin panel imports (adm_*, AdminImagesController) are exempt: an admin chose them.
(Images.ImgSavePath, n => n.StartsWith("adm_", StringComparison.OrdinalIgnoreCase) ? null
: n.StartsWith("pfp_", StringComparison.OrdinalIgnoreCase) ? Kind.WebImage : Kind.Image),
(Images.RoomSavePath, n => n.StartsWith("default_room", StringComparison.OrdinalIgnoreCase) ? null
: n.EndsWith(".room", StringComparison.OrdinalIgnoreCase) ? Kind.RoomSave
: n.EndsWith(".META", StringComparison.OrdinalIgnoreCase) ? Kind.RoomMetadata : null),
(Images.InvSavePath, n => n.EndsWith(".inv", StringComparison.OrdinalIgnoreCase) ? Kind.Invention : null),
(Images.CdnSavePath, n => n.EndsWith(".htr", StringComparison.OrdinalIgnoreCase) ? Kind.Holotar
: n.EndsWith(".dat", StringComparison.OrdinalIgnoreCase) ? Kind.Video : null),
};
foreach (var (dir, kindOf) in jobs)
{
if (!Directory.Exists(dir)) continue;
foreach (var path in Directory.EnumerateFiles(dir))
{
string name = Path.GetFileName(path);
var kind = kindOf(name);
if (kind == null) continue;
checkedN++;
byte[] bytes;
try { bytes = File.ReadAllBytes(path); } catch { continue; }
if (Validate(kind.Value, bytes, out string why)) continue;
moved++;
Console.WriteLine($"[upload-guard] sweep: {(dryRun ? "would quarantine" : "quarantined")} {Path.GetFileName(dir)}/{name} ({bytes.Length} bytes, {kind}): {why}");
if (dryRun) continue;
try
{
string qdir = Path.Combine(quarantine, Path.GetFileName(dir));
Directory.CreateDirectory(qdir);
File.Move(path, Path.Combine(qdir, name), overwrite: true);
}
catch (Exception ex) { Console.WriteLine($"[upload-guard] sweep: could not move {name}: {ex.Message}"); }
}
}
Console.WriteLine($"[upload-guard] sweep done: {checkedN} file(s) checked, {moved} {(dryRun ? "would be " : "")}quarantined under Data/Quarantine");
return moved;
}
}
}
+270
View File
@@ -0,0 +1,270 @@
using System;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using static Campus_live_rewrite.api.server.Classes.db_class.EventDBClasses;
namespace Campus_live_rewrite.api.server
{
public class Utils
{
private static readonly Random rng = new Random();
private static readonly char[] _chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".ToCharArray();
private static readonly byte[] Key = Convert.FromBase64String("f8Z7Xgkz7PzBvZ93YjNfYpNfwysNMLA5xkKYY+6IE1s=");
private static readonly byte[] IV = Convert.FromBase64String("GxCptCkqNh8W2MnA7bAYzA==");
public static string EncryptAes(string plainText)
{
using var aes = Aes.Create();
aes.Key = Key;
aes.IV = IV;
using var encryptor = aes.CreateEncryptor();
using var ms = new MemoryStream();
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
using (var sw = new StreamWriter(cs))
sw.Write(plainText);
return Convert.ToBase64String(ms.ToArray());
}
public static string DecryptAes(string encryptedBase64)
{
using var aes = Aes.Create();
aes.Key = Key;
aes.IV = IV;
using var decryptor = aes.CreateDecryptor();
using var ms = new MemoryStream(Convert.FromBase64String(encryptedBase64));
using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
using var sr = new StreamReader(cs);
return sr.ReadToEnd();
}
public static int Next(int min, int max)
{
byte[] randomBytes = new byte[4];
RandomNumberGenerator.Fill(randomBytes);
int secureRandomNumber = BitConverter.ToInt32(randomBytes, 0);
while (secureRandomNumber < min && secureRandomNumber >= max)
{
secureRandomNumber = BitConverter.ToInt32(randomBytes, 0);
}
int randomNumber = Guid.NewGuid().ToByteArray().Sum(b => b) % max;
while (randomNumber < min && randomNumber >= max)
{
randomNumber = Guid.NewGuid().ToByteArray().Sum(b => b) % max;
}
int randomNumber1 = Math.Abs(Environment.TickCount % max);
int output = randomNumber1 + randomNumber + secureRandomNumber;
output = Math.Min(output, min);
output = Math.Max(output, max);
return output;
}
public static string GenerateShortCode(int length = 6)
{
return new string(Enumerable.Range(0, length)
.Select(_ => _chars[rng.Next(_chars.Length)]).ToArray());
}
public static string GetRandomString(int len = 16)
{
const string chars = "AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz0123456789";
return new string(Enumerable.Repeat(chars, len)
.Select(s => s[rng.Next(s.Length)]).ToArray());
}
public 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 byte[]? ParseData(byte[] data, string fieldName, out bool parsed)
{
parsed = false;
try
{
var boundaryStart = Encoding.ASCII.GetString(data, 0, Math.Min(200, data.Length))
.Split('\n')[0]
.Trim();
if (string.IsNullOrEmpty(boundaryStart))
{
Console.WriteLine("Failed to detect boundary");
return null;
}
string boundary = boundaryStart;
string header = $"name=\"{fieldName}\"";
int headerIndex = Utils.IndexOf(data, Encoding.ASCII.GetBytes(header));
if (headerIndex == -1)
{
Console.WriteLine($"Field {fieldName} not found in multipart data");
return null;
}
int startOfFile = IndexOf(data, new byte[] { 13, 10, 13, 10 }, headerIndex);
if (startOfFile == -1)
{
Console.WriteLine("Malformed multipart content, missing CRLF after headers");
return null;
}
startOfFile += 4;
byte[] boundaryBytes = Encoding.ASCII.GetBytes("\r\n" + boundary);
int endOfFile = Utils.IndexOf(data, boundaryBytes, startOfFile);
if (endOfFile == -1)
{
Console.WriteLine("Failed to locate boundary end");
return null;
}
int fileLength = endOfFile - startOfFile;
if (fileLength <= 0)
{
Console.WriteLine("File length invalid");
return null;
}
byte[] fileBytes = new byte[fileLength];
Buffer.BlockCopy(data, startOfFile, fileBytes, 0, fileLength);
parsed = true;
return fileBytes;
}
catch (Exception ex)
{
Console.WriteLine($"[Images.ParseData] Exception: {ex}");
parsed = false;
return null;
}
}
public static ContentResult Error(string error)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = false,
error = error,
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
public static ContentResult ErrorRoom(string errorId, string error)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Success = false,
Value = (string?)null,
ErrorId = errorId,
Error = error,
}),
ContentType = "application/json",
StatusCode = 200
};
}
public static ContentResult ErrorEvent(EventResults errorresult)
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Result = errorresult,
TagModifyResult = (string?)null,
PlayerEvent = (string?)null,
}),
ContentType = "application/json",
StatusCode = 200
};
}
public static List<string> Nouns = new List<string>()
{
"Erm", "Admin", "AuraPoints", "Fortniter", "RecCon", "Bat", "Bear", "Bee", "Buffalo", "Butterfly",
"Capybara", "Cat", "Caterpillar", "Chameleon", "Cheetah", "Chicken", "Chimpanzee", "Cobra", "Coyote",
"Crane", "Cricket", "Crow", "Deer", "Boi", "Dolphin", "Donkey", "Windows98", "pingul", "Eagle", "Echidna",
"Elephant", "Elk", "dic", "Ferret", "Flamingo", "Fish", "memey", "Frog", "Gazelle", "Giraffe", "e12354",
"Meme", "GorillaTag", "Hamster", "Jit", "Hippo", "Horse", "Hyena", "Iguana", "Jumbotron", "Jellyfish",
"Gyat", "Kitten", "Top10BestFeet", "Youtube", "mac_os", "AAAAAAAAAA", "Blud", "Llama", "RecNet", "Monkey",
"Moose", "Mouse", "Mule", "Newt", "VBucks", "Opposum", "Ostrich", "Otter", "Owl", "Oyster", "Panda",
"Panther", "Parrot", "Penguin", "P34", "Pigeon", "Piranha", "Platypus", "Pony", "Minecraft", "Puppy",
"Quail", "Emo", "bena.png", "Salmon", "Scorpion", "Seal", "Shark", "Sheep", "Sloth", "Snail", "Bozo",
"Squirrel", "urmum", "NintendoSwitch", "Wii", "wat_time", "Roach", "Turtle", "Turkey", "Wii_U", "helpme",
"Walrus", "Whale", "Github", "Yes_fade", "Zone"
};
public static List<string> Adjectives = new List<string>()
{
"Adorable", "BitCoin", ":3", "Underground", "Aimless", "Alert", "FanumTaxed", "Alone", "Broken",
"Beautiful", "Bored", "Brave", "Scratched", "Busy", "Calm", "Carefree", "Careless", "Caring", "Charming",
"Cheerful", "Clever", "Clumsy", "Bugcrowd", "Cowardly", "Cozy", "Crabby", "Cranky", "Crawling",
"Creaky", "Creative", "Crispy", "DexCryptic", "Doxxed", "Delightful", "Determined", "Burning", "Dull",
"Eager", "Elated", "Emotional", "Enchanting", "Encouraging", "Endless", "Energetic", "Enthusiastic",
"Excited", "FNAF", "Fantastic", "Fastidious", "Fine", "Fluttering", "Fragrant",
"Friendly", "AAAAAAAAA", "Funny", "Fussy", "Fuzzy", "Generous", "Gentle", "Drunk", "Splooty", "Glorious",
"Glowing", "Good", "Grand", "Great", "Greedy", "Grimy", "Happy", "Hardworking", "Hasty", "Healthy",
"Heavy", "Helpful", "Hilarious", "Hopeful", "Icy", "Important", "Inquisitive", "Jolly", "Joyful",
"Joyous", "Kind", "Lazy", "Lively", "Loud", "Lovely", "Loyal", "Lucky", "Luminous", "Lumpy", "Majestic",
"Meek", "Melodic", "Mighty", "Moody", "Nice", "Nimble", "Odd", "Optimistic", "Perfect", "Pervasive",
"Pleasant", "Plucky", "Plush", "Polite", "Practical", "Proud", "Quick", "Quiet", "Rapid", "Redolent",
"Reliable", "Relieved", "Royal", "Skid", "Scared", "Hangar", "Sensible", "Sensitive", "Shining",
"Shrill", "Silly", "Sincere", "Sizzling", "Sleepy", "Smiling", "Smooth", "Snug", "Soaring", "Sparkling",
"Speedy", "Spiky", "Splendid", "Spoiled", "Steaming", "Still", "Strict", "Stuffed", "Sturdy",
"Successful", "Surprised", "Swift", "Taciturn", "Tense", "Gay", "Trans", "Thrifty", "Tough",
"Tricky", "Godly", "Twerking", "Racist", "Rec", "Victorious", "Wild", "Wise", "W",
"Wonderful", "Worried", "Skibidi", "Zesty", "Zealous", "Skibiti", "Hot"
};
public static List<string> imgs = new List<string>()
{
"DefaultProfileImage"
/*"DefaultImg.png",
"DefaultImgPink.png",
"DefaultImgPurple.png",
"DefaultImgRed.png",
"DefaultImgRainbow.png",
"DefaultImgGreen.png",
"DefaultImgOrange.png",
"DefaultImgYellow.png",
"DefaultImgLight.png"*/
};
public static string GetRandomName()
{
string adjective = Adjectives[rng.Next(Adjectives.Count)];
string noun = Nouns[rng.Next(Nouns.Count)];
int number = rng.Next(1000, 9999);
return adjective + noun + number;
}
public static string GetRandomImageName()
{
string img = imgs[rng.Next(imgs.Count)];
return img;
}
}
}

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