the files
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,85 @@
|
||||
namespace Campus_live_rewrite.api.server
|
||||
{
|
||||
/// <summary>
|
||||
/// Anti-farm throttle for the one currency-minting endpoint, POST /api/storefronts/v2/balance.
|
||||
///
|
||||
/// It answers Allow or Deny per award attempt, keyed on the player's LIVE room instance
|
||||
/// (decided server-side from the heartbeat, never the request body):
|
||||
///
|
||||
/// * RATE LIMIT -> Deny. At most one award per player per BalanceRateLimitSeconds. A second
|
||||
/// award that arrives sooner - e.g. the client glitching and firing twice within seconds -
|
||||
/// simply doesn't go through. No currency, no ban: the player is not punished for a glitch.
|
||||
///
|
||||
/// * LONG SESSION -> Deny. A player parked in ONE instance past BalanceMaxInstanceMinutes, or
|
||||
/// already awarded BalanceMaxAwardsPerInstance times in it, is refused further awards. The
|
||||
/// counters reset the moment they move to a different room instance (legit players move;
|
||||
/// farmers park).
|
||||
///
|
||||
/// All in-memory and self-pruning. No bans are issued here.
|
||||
/// </summary>
|
||||
public static class BalanceGuard
|
||||
{
|
||||
public enum Decision { Allow, Deny }
|
||||
|
||||
private sealed class State
|
||||
{
|
||||
public long InstanceId;
|
||||
public DateTime FirstSeenUtc;
|
||||
public DateTime LastAwardUtc;
|
||||
public int Awards;
|
||||
}
|
||||
|
||||
private static readonly object _lock = new();
|
||||
private static readonly Dictionary<ulong, State> _state = new();
|
||||
|
||||
/// <summary>
|
||||
/// Decide what to do with an award attempt BEFORE any currency is granted. On Allow the
|
||||
/// attempt is recorded (advances the rate-limit clock and the per-instance total).
|
||||
/// </summary>
|
||||
public static (Decision decision, string reason) Evaluate(ulong playerId, long roomInstanceId)
|
||||
{
|
||||
if (!ConfigCampus.BalanceGuardEnabled)
|
||||
return (Decision.Allow, "");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
lock (_lock)
|
||||
{
|
||||
// Fresh session whenever the player is in a different instance than last time
|
||||
// (or we have never seen them) - this is what resets the long-session counters.
|
||||
if (!_state.TryGetValue(playerId, out var st) || st.InstanceId != roomInstanceId)
|
||||
{
|
||||
st = new State { InstanceId = roomInstanceId, FirstSeenUtc = now, LastAwardUtc = DateTime.MinValue };
|
||||
_state[playerId] = st;
|
||||
}
|
||||
|
||||
// RATE LIMIT -> deny (the glitch-guard). Not an award, so nothing is recorded.
|
||||
double sinceLast = (now - st.LastAwardUtc).TotalSeconds;
|
||||
if (sinceLast < Math.Max(1, ConfigCampus.BalanceRateLimitSeconds))
|
||||
{
|
||||
return (Decision.Deny,
|
||||
$"rate-limited: {sinceLast:F0}s since last award (< {ConfigCampus.BalanceRateLimitSeconds}s) in instance {roomInstanceId}");
|
||||
}
|
||||
|
||||
// LONG SESSION -> deny.
|
||||
double mins = (now - st.FirstSeenUtc).TotalMinutes;
|
||||
if (mins >= ConfigCampus.BalanceMaxInstanceMinutes ||
|
||||
st.Awards >= ConfigCampus.BalanceMaxAwardsPerInstance)
|
||||
{
|
||||
return (Decision.Deny,
|
||||
$"parked in instance {roomInstanceId} for {mins:F0} min / {st.Awards} awards - denying");
|
||||
}
|
||||
|
||||
// Allow: record the award.
|
||||
st.LastAwardUtc = now;
|
||||
st.Awards++;
|
||||
return (Decision.Allow, "");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forget a player's live session (e.g. on disconnect).</summary>
|
||||
public static void ClearSession(ulong playerId)
|
||||
{
|
||||
lock (_lock) { _state.Remove(playerId); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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/<name>,
|
||||
/// or null when we don't hold it. The client hash-checks what it downloads, so a Quest client
|
||||
/// sent quest/<name> 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 <code>` with the Discord bot (BotCommands), which calls <see cref="TryResolve"/>
|
||||
/// then <see cref="Consume"/> to bind their Discord id.
|
||||
///
|
||||
/// This is DELIBERATELY its own single-purpose store, isolated from the admin session gate
|
||||
/// (AdminGate) and everything else: redeeming a code ONLY records a Discord<->account link
|
||||
/// (PlayerDB.SetDiscordLink) and can never mint an admin session or grant any privilege. That is
|
||||
/// the whole point of keeping it separate - linking Discord must never be a path into the admin
|
||||
/// panel.
|
||||
/// </summary>
|
||||
public static class DiscordLinkCodes
|
||||
{
|
||||
private const int TtlSeconds = 600; // 10 minutes
|
||||
// Unambiguous alphabet (no 0/O/1/I) so a 6-char code is easy to read off a screen and type.
|
||||
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
private static readonly ConcurrentDictionary<string, (ulong accountId, long expires)> _codes = new();
|
||||
private static readonly Random _rng = new();
|
||||
|
||||
/// <summary>Mint a fresh code for this account (replacing any it already had), valid for 10 min.</summary>
|
||||
public static (string code, int ttlSeconds) Generate(ulong accountId)
|
||||
{
|
||||
Sweep();
|
||||
// Drop any outstanding code for this account so only the newest one works.
|
||||
foreach (var kv in _codes)
|
||||
if (kv.Value.accountId == accountId) _codes.TryRemove(kv.Key, out _);
|
||||
|
||||
string code;
|
||||
do
|
||||
{
|
||||
var chars = new char[6];
|
||||
lock (_rng)
|
||||
for (int i = 0; i < chars.Length; i++) chars[i] = Alphabet[_rng.Next(Alphabet.Length)];
|
||||
code = new string(chars);
|
||||
} while (_codes.ContainsKey(code));
|
||||
|
||||
long expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + TtlSeconds;
|
||||
_codes[code] = (accountId, expires);
|
||||
return (code, TtlSeconds);
|
||||
}
|
||||
|
||||
/// <summary>Look up (without consuming) the account a live code belongs to. False if unknown/expired.</summary>
|
||||
public static bool TryResolve(string? code, out ulong accountId)
|
||||
{
|
||||
accountId = 0;
|
||||
if (string.IsNullOrWhiteSpace(code)) return false;
|
||||
code = code.Trim().ToUpperInvariant();
|
||||
if (!_codes.TryGetValue(code, out var entry)) return false;
|
||||
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > entry.expires)
|
||||
{
|
||||
_codes.TryRemove(code, out _);
|
||||
return false;
|
||||
}
|
||||
accountId = entry.accountId;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Burn a code after a successful link so it can't be reused.</summary>
|
||||
public static void Consume(string? code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code)) return;
|
||||
_codes.TryRemove(code.Trim().ToUpperInvariant(), out _);
|
||||
}
|
||||
|
||||
private static long _lastSweep;
|
||||
private static void Sweep()
|
||||
{
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
if (now - Interlocked.Read(ref _lastSweep) < 60) return;
|
||||
Interlocked.Exchange(ref _lastSweep, now);
|
||||
foreach (var kv in _codes)
|
||||
if (now > kv.Value.expires) _codes.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Campus_live_rewrite.api.dbs;
|
||||
using Campus_live_rewrite.api.server.Classes.db_class;
|
||||
|
||||
namespace Campus_live_rewrite.api.server
|
||||
{
|
||||
/// <summary>
|
||||
/// The room lists behind the Play tab, built from THIS server's rooms.
|
||||
///
|
||||
/// The Play page (Data/APIS/WatchPages/PlayHighlight.json) was captured from live Rec Room and
|
||||
/// names its carousels by algorithmic-list endpoint: foryou, HotList, staffpicks, new,
|
||||
/// inkshowcase, recentlyupdated and the genre rows (battle_algoendpoint, horror_algoendpoint,
|
||||
/// ...). GET /lists/algorithmiclists/{name}?type=1 answered each from a file under
|
||||
/// Data/APIS/AlgorithmicLists, and only two files existed - so "Recommended For You",
|
||||
/// "Featured This Week", "Trending", "Rooms We Love" and every genre row were blank. Now each
|
||||
/// name is a query over RoomDB, cached for a minute:
|
||||
///
|
||||
/// HotList / foryou live player count first, then cheers + visits (foryou reshuffles
|
||||
/// hourly so the row isn't the same as Trending)
|
||||
/// staffpicks the admin panel's featured rooms, padded from the top cheered
|
||||
/// new newest published rooms
|
||||
/// recentlyupdated most recently changed
|
||||
/// inkshowcase player-made rooms with a picture, seeded shuffle
|
||||
/// <genre>_algoendpoint rooms tagged with that genre (see GenreTags), padded from hot
|
||||
///
|
||||
/// Only rooms IsRoomListed says are public are ever listed; dorms never are.
|
||||
/// </summary>
|
||||
public static class DiscoveryLists
|
||||
{
|
||||
private static readonly object _lock = new();
|
||||
private static readonly Dictionary<string, (DateTime at, List<string> ids)> _cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private const int CacheSeconds = 60;
|
||||
private const int PageSize = 30;
|
||||
|
||||
private static readonly Dictionary<string, string[]> GenreTags = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["battle"] = new[] { "battle", "pvp", "shooter", "fps", "combat", "war", "gunfight", "paintball" },
|
||||
["roleplay"] = new[] { "roleplay", "rp", "role-play", "story" },
|
||||
["quests"] = new[] { "quest", "quests", "adventure", "rpg", "coop", "co-op" },
|
||||
["horror"] = new[] { "horror", "scary", "spooky", "creepy", "backrooms" },
|
||||
["hangout"] = new[] { "hangout", "hang out", "social", "chill", "chat", "lounge", "hangouts" },
|
||||
["casual"] = new[] { "casual", "party", "minigame", "minigames", "fun", "game" },
|
||||
["explore"] = new[] { "explore", "exploration", "showcase", "art", "museum", "parkour", "obby" },
|
||||
};
|
||||
|
||||
/// <summary>Whether this list name is one the server builds (vs a file / store row).</summary>
|
||||
public static bool Handles(string name)
|
||||
{
|
||||
string n = name.ToLowerInvariant();
|
||||
return n is "hotlist" or "foryou" or "staffpicks" or "new" or "recentlyupdated" or "inkshowcase"
|
||||
|| n.EndsWith("_algoendpoint");
|
||||
}
|
||||
|
||||
public static List<string> RoomIds(string name)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_cache.TryGetValue(name, out var hit) && (DateTime.UtcNow - hit.at).TotalSeconds < CacheSeconds)
|
||||
return hit.ids;
|
||||
}
|
||||
var ids = Build(name.ToLowerInvariant());
|
||||
lock (_lock) _cache[name] = (DateTime.UtcNow, ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static List<RoomDBClasses.RoomRoot> Listed() =>
|
||||
RoomDB.GetHotRooms().Where(r => !r.IsDorm && RoomDB.IsRoomListed(r) && !r.ExcludeFromLists).ToList();
|
||||
|
||||
private static long Score(RoomDBClasses.RoomRoot r, Dictionary<long, int> live)
|
||||
{
|
||||
long ccu = live.TryGetValue((long)r.RoomId, out var n) ? n : 0;
|
||||
long cheers = r.Stats?.CheerCount ?? 0;
|
||||
long visits = r.Stats?.VisitCount ?? 0;
|
||||
return ccu * 100_000 + cheers * 50 + visits;
|
||||
}
|
||||
|
||||
private static List<string> Ids(IEnumerable<RoomDBClasses.RoomRoot> rooms) =>
|
||||
rooms.Select(r => r.RoomId.ToString()).Distinct().Take(PageSize).ToList();
|
||||
|
||||
private static List<string> Build(string name)
|
||||
{
|
||||
var rooms = Listed();
|
||||
var live = HeartbeatDB.LivePlayerCountsByRoom();
|
||||
var hot = rooms.OrderByDescending(r => Score(r, live)).ThenByDescending(r => r.CreatedAt).ToList();
|
||||
int hourSeed = HashCode.Combine(name, DateTime.UtcNow.Year, DateTime.UtcNow.DayOfYear, DateTime.UtcNow.Hour);
|
||||
|
||||
switch (name)
|
||||
{
|
||||
case "hotlist":
|
||||
return Ids(hot);
|
||||
|
||||
case "foryou":
|
||||
{
|
||||
// The top of the hot list in a per-hour order, then the rest shuffled in, so
|
||||
// it reads as a different row from Trending without being random junk.
|
||||
var rng = new Random(hourSeed);
|
||||
var top = hot.Take(12).OrderBy(_ => rng.Next());
|
||||
var rest = hot.Skip(12).OrderBy(_ => rng.Next());
|
||||
return Ids(top.Concat(rest));
|
||||
}
|
||||
|
||||
case "staffpicks":
|
||||
{
|
||||
var picked = new List<RoomDBClasses.RoomRoot>();
|
||||
foreach (var rid in FeaturedRoomsStore.GetIds())
|
||||
{
|
||||
var r = RoomDB.GetRoom(rid);
|
||||
if (r != null && !r.IsDorm) picked.Add(r);
|
||||
}
|
||||
var pad = hot.Where(r => !RoomDB.IsRecRoomOriginal(r)).OrderByDescending(r => r.Stats?.CheerCount ?? 0);
|
||||
return Ids(picked.Concat(pad));
|
||||
}
|
||||
|
||||
case "new":
|
||||
return Ids(rooms.Where(r => !RoomDB.IsRecRoomOriginal(r)).OrderByDescending(r => r.CreatedAt));
|
||||
|
||||
case "recentlyupdated":
|
||||
return Ids(rooms.Where(r => !RoomDB.IsRecRoomOriginal(r))
|
||||
.OrderByDescending(r => r.PublishedAt > r.CreatedAt ? r.PublishedAt : r.CreatedAt));
|
||||
|
||||
case "inkshowcase":
|
||||
{
|
||||
var rng = new Random(hourSeed);
|
||||
var made = rooms.Where(r => !RoomDB.IsRecRoomOriginal(r) && !string.IsNullOrEmpty(r.ImageName)).OrderBy(_ => rng.Next());
|
||||
return Ids(made.Concat(hot));
|
||||
}
|
||||
}
|
||||
|
||||
if (name.EndsWith("_algoendpoint"))
|
||||
{
|
||||
string genre = name[..^"_algoendpoint".Length];
|
||||
var tags = GenreTags.TryGetValue(genre, out var t) ? t : new[] { genre };
|
||||
var tagged = hot.Where(r => tags.Any(tag => RoomDB.RoomHasTag(r, tag))
|
||||
|| tags.Any(tag => (r.Name ?? "").Contains(tag, StringComparison.OrdinalIgnoreCase)));
|
||||
return Ids(tagged.Concat(hot));
|
||||
}
|
||||
|
||||
return Ids(hot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Play page's "Featured Creator" tile: the captured page names a live-Rec-Room account
|
||||
/// (730029). The local creator with the most public, player-made rooms takes its place -
|
||||
/// never the system accounts (1-3) or the room importer.
|
||||
/// </summary>
|
||||
private static (DateTime at, ulong id) _creator;
|
||||
public static ulong FeaturedCreator()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_creator.id != 0 && (DateTime.UtcNow - _creator.at).TotalSeconds < 600) return _creator.id;
|
||||
}
|
||||
ulong best = 0;
|
||||
// The admin panel's Featured tab decides first; then whatever the community board
|
||||
// file names; only then the most-rooms heuristic.
|
||||
best = FeaturedStore.FeaturedPlayer();
|
||||
if (best == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
string cb = System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "communityboard.json");
|
||||
if (System.IO.File.Exists(cb))
|
||||
{
|
||||
var jo = Newtonsoft.Json.Linq.JObject.Parse(System.IO.File.ReadAllText(cb));
|
||||
ulong fp = (ulong?)jo["FeaturedPlayer"]?["Id"] ?? 0;
|
||||
if (fp != 0 && PlayerDB.GetAccountById(fp) != null) best = fp;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
if (best != 0) { lock (_lock) _creator = (DateTime.UtcNow, best); return best; }
|
||||
try
|
||||
{
|
||||
var byCreator = Listed()
|
||||
.Where(r => !RoomDB.IsRecRoomOriginal(r) && r.CreatorAccountId > 3)
|
||||
.GroupBy(r => r.CreatorAccountId)
|
||||
.Select(g => (id: g.Key, n: g.Count(), cheers: g.Sum(r => r.Stats?.CheerCount ?? 0)))
|
||||
.Where(x =>
|
||||
{
|
||||
var acc = PlayerDB.GetAccountById(x.id);
|
||||
return acc != null && !string.Equals(acc.Username, "Importer", StringComparison.OrdinalIgnoreCase);
|
||||
})
|
||||
.OrderByDescending(x => x.n).ThenByDescending(x => x.cheers)
|
||||
.FirstOrDefault();
|
||||
best = byCreator.id;
|
||||
}
|
||||
catch { }
|
||||
lock (_lock) _creator = (DateTime.UtcNow, best);
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>The room the Play page's banner shows: the first admin-featured room, else the hottest.</summary>
|
||||
public static ulong BannerRoom()
|
||||
{
|
||||
foreach (var rid in FeaturedRoomsStore.GetIds())
|
||||
{
|
||||
var r = RoomDB.GetRoom(rid);
|
||||
if (r != null && !r.IsDorm) return rid;
|
||||
}
|
||||
var top = RoomIds("hotlist").FirstOrDefault();
|
||||
return ulong.TryParse(top, out var id) ? id : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 > 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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":"<64 chars>","AppId":"1295523863648168","Source":"logged in user"}
|
||||
/// platform_id = <the Meta user id>
|
||||
/// The nonce means nothing on its own; asking Meta "was this nonce issued to this user, for
|
||||
/// this app?" is what binds the two together. So the posted platform_id is an INPUT here, and a
|
||||
/// spoofed one fails validation instead of being trusted. Only an id that comes back verified
|
||||
/// is ever bound or linked to an account.
|
||||
///
|
||||
/// Needs the app secret (ConfigCampus.MetaAppSecret): the access token is OC|appId|secret,
|
||||
/// which authenticates the APP - which is why the secret never leaves the server.
|
||||
/// </summary>
|
||||
public static class MetaNonce
|
||||
{
|
||||
private const string NonceValidateUrl = "https://graph.oculus.com/user_nonce_validate";
|
||||
private const int MaxAttempts = 3;
|
||||
private static readonly HashSet<int> TransientErrorCodes = new() { 1, 2 };
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(8) };
|
||||
|
||||
public readonly record struct Result(bool Ok, string Reason, ulong UserId, string AppId);
|
||||
|
||||
/// <summary>The {Nonce, AppId} a Meta platform_auth carries, or null when it isn't one.</summary>
|
||||
public static (string nonce, string appId)? Parse(string? platformAuth)
|
||||
{
|
||||
if (string.IsNullOrEmpty(platformAuth)) return null;
|
||||
JObject o;
|
||||
try { o = JObject.Parse(System.Net.WebUtility.UrlDecode(platformAuth)); }
|
||||
catch { try { o = JObject.Parse(platformAuth); } catch { return null; } }
|
||||
string? nonce = o["Nonce"]?.Type == JTokenType.String ? o["Nonce"]!.Value<string>() : null;
|
||||
string? appId = o["AppId"]?.Type == JTokenType.String ? o["AppId"]!.Value<string>() : null;
|
||||
if (string.IsNullOrEmpty(nonce)) return null;
|
||||
// AppId is interpolated into the access token below - keep a non-numeric string out of it.
|
||||
if (string.IsNullOrEmpty(appId) || !appId.All(char.IsDigit)) return null;
|
||||
return (nonce, appId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify platform_auth against the user id it is claimed for. Only succeeds for a nonce Meta
|
||||
/// itself confirms was issued to that user for that app. Reasons are for the server log; the
|
||||
/// client is only ever told the proof was rejected.
|
||||
/// </summary>
|
||||
public static async Task<Result> VerifyAsync(string? platformAuth, ulong userId, string appSecret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(appSecret)) return new(false, "no app secret configured", 0, "");
|
||||
if (userId == 0) return new(false, "missing platform_id", 0, "");
|
||||
var parsed = Parse(platformAuth);
|
||||
if (parsed == null) return new(false, "malformed platform_auth payload", 0, "");
|
||||
var (nonce, appId) = parsed.Value;
|
||||
|
||||
var form = new Dictionary<string, string>
|
||||
{
|
||||
["nonce"] = nonce,
|
||||
["user_id"] = userId.ToString(CultureInfo.InvariantCulture),
|
||||
["access_token"] = $"OC|{appId}|{appSecret}",
|
||||
};
|
||||
|
||||
string reason = "not attempted";
|
||||
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
|
||||
{
|
||||
var (ok, retryable, why) = await ValidateOnce(form);
|
||||
if (ok) return new(true, "", userId, appId);
|
||||
reason = why;
|
||||
if (!retryable || attempt == MaxAttempts) break;
|
||||
await Task.Delay(attempt * attempt * 250);
|
||||
}
|
||||
return new(false, reason, 0, appId);
|
||||
}
|
||||
|
||||
private static async Task<(bool ok, bool retryable, string reason)> ValidateOnce(Dictionary<string, string> form)
|
||||
{
|
||||
HttpResponseMessage res;
|
||||
try
|
||||
{
|
||||
res = await Http.PostAsync(NonceValidateUrl, new FormUrlEncodedContent(form));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, true, $"request failed: {ex.Message}");
|
||||
}
|
||||
|
||||
JObject body;
|
||||
try { body = JObject.Parse(await res.Content.ReadAsStringAsync()); }
|
||||
catch
|
||||
{
|
||||
// A non-JSON body is Meta's edge (a 5xx page, a rate-limit page), not a verdict.
|
||||
return (false, true, $"HTTP {(int)res.StatusCode} with a non-JSON body");
|
||||
}
|
||||
|
||||
if (body["error"] is JObject err)
|
||||
{
|
||||
int? code = err["code"]?.Type == JTokenType.Integer ? err["code"]!.Value<int>() : null;
|
||||
bool transient = err["is_transient"]?.Type == JTokenType.Boolean && err["is_transient"]!.Value<bool>();
|
||||
bool retry = transient || (code.HasValue && TransientErrorCodes.Contains(code.Value));
|
||||
return (false, retry, $"graph error {(code?.ToString() ?? "?")}: {err["message"]?.ToString() ?? "no message"}");
|
||||
}
|
||||
if (body["is_valid"]?.Type != JTokenType.Boolean || !body["is_valid"]!.Value<bool>())
|
||||
return (false, false, "nonce rejected");
|
||||
return (true, false, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Campus_live_rewrite.api.server
|
||||
{
|
||||
public class Moderation_Detail
|
||||
{
|
||||
public class ModerationBlockDetails
|
||||
{
|
||||
public Moderation_Detail.ReportCategory ReportCategory { get; set; } = Moderation_Detail.ReportCategory.Unknown;
|
||||
public int Duration { get; set; } = 0;
|
||||
public long GameSessionId { get; set; } = 0;
|
||||
public bool? IsHostKick { get; set; } = false;
|
||||
public string? Message { get; set; } = "";
|
||||
public ulong? PlayerIdReporter { get; set; } = null;
|
||||
public bool? IsBan { get; set; } = false;
|
||||
// The rest of the 16-key 2025 DTO (wire-shapes: playerreporting-moderationblockdetails).
|
||||
// IsWarning is what turns the kick frame into a warning dialog instead of a removal.
|
||||
public bool IsVoiceModAutoban { get; set; } = false;
|
||||
public bool IsDeviceBan { get; set; } = false;
|
||||
public bool IsWarning { get; set; } = false;
|
||||
public string? VoteKickReason { get; set; } = null;
|
||||
public DateTime? TimeoutStartedAt { get; set; } = null;
|
||||
[JsonIgnore]
|
||||
public long ModerationSetUnixTime { get; set; } = 0;
|
||||
[JsonIgnore]
|
||||
public ulong BannedByPlayerId { get; set; } = 0;
|
||||
}
|
||||
|
||||
public enum ReportCategory
|
||||
{
|
||||
Moderator = -1,
|
||||
Unknown,
|
||||
DEPRECATED_MicrophoneAbuse,
|
||||
Harassment,
|
||||
Cheating,
|
||||
DEPRECATED_ImmatureBehavior,
|
||||
AFK,
|
||||
Misc,
|
||||
Underage,
|
||||
VoteKick = 10,
|
||||
MisleadingPurchases = 11,
|
||||
CoC_Underage = 100,
|
||||
CoC_Sexual,
|
||||
CoC_Discrimination,
|
||||
CoC_Trolling,
|
||||
CoC_NameOrProfile,
|
||||
InappropriateClothing = 200,
|
||||
IssuingInaccurateReports = 1000,
|
||||
RoomInventoryItems = 1100,
|
||||
InappropriateRooms = 1101
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Campus_live_rewrite.api.dbs;
|
||||
using Campus_live_rewrite.api.server.Classes;
|
||||
|
||||
namespace Campus_live_rewrite.api.server
|
||||
{
|
||||
/// <summary>
|
||||
/// One set of rules for every place a name can be set - the website signup, the client's
|
||||
/// first-run "choose a username" (acc/account/me/username), the in-game @name change
|
||||
/// (account/me/username) and the in-game display-name change (account/me/displayname).
|
||||
/// Before this each path had its own checks and the gaps between them were the spoofing
|
||||
/// holes: the first-run path took ANY string (spaces, control characters, zero-width joiners,
|
||||
/// unlimited length, no swear filter), @name uniqueness was case-sensitive (so "kyle" sat next
|
||||
/// to "Kyle"), and a display name could be blank, all punctuation, or an admin's name.
|
||||
///
|
||||
/// @username 3-20 chars, [A-Za-z0-9_], unique case-insensitively, not a reserved word,
|
||||
/// passes the swear filter.
|
||||
/// display name 1-15 chars of printable ASCII, whitespace collapsed, at least one letter
|
||||
/// or digit, passes the swear filter, and may not impersonate an owner /
|
||||
/// staff account or a reserved word once look-alike noise is stripped
|
||||
/// ("K y l e", "Coach_", "c0ach" are all "the same name" for this check).
|
||||
/// </summary>
|
||||
public static class NameRules
|
||||
{
|
||||
public const int UsernameMin = 3, UsernameMax = 20, DisplayNameMax = 15;
|
||||
|
||||
// Names nobody may hold, in any casing or with look-alike decoration. Owner/staff accounts
|
||||
// themselves are protected by AccountLock's exemption list (their real usernames).
|
||||
private static readonly HashSet<string> Reserved = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"admin", "administrator", "moderator", "mod", "campus", "system", "root", "staff",
|
||||
"support", "null", "undefined", "everyone", "here", "recroom", "official", "owner",
|
||||
"developer", "dev", "coach", "campusteam", "campusstaff", "campusmod"
|
||||
};
|
||||
|
||||
private static readonly Regex UsernameShape = new("^[A-Za-z0-9_]+$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Fold a name to what a reader perceives: lowercase, letters and digits only,
|
||||
/// common digit-for-letter swaps undone. Two names that fold alike are "the same name".</summary>
|
||||
public static string Fold(string s)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(s.Length);
|
||||
foreach (char ch in s.ToLowerInvariant())
|
||||
{
|
||||
char c = ch switch { '0' => 'o', '1' => 'l', '3' => 'e', '4' => 'a', '5' => 's', '7' => 't', '@' => 'a', '$' => 's', '|' => 'l', _ => ch };
|
||||
if (char.IsLetterOrDigit(c)) sb.Append(c);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Does this name fold to a reserved word or to an owner/staff account's name
|
||||
/// (other than <paramref name="self"/>'s own)?</summary>
|
||||
public static bool Impersonates(string name, ulong self)
|
||||
{
|
||||
string f = Fold(name);
|
||||
if (f.Length == 0) return false;
|
||||
foreach (var staff in ConfigCampus.LockExemptUsernames ?? Array.Empty<string>())
|
||||
{
|
||||
if (Fold(staff) != f) continue;
|
||||
ulong id = PlayerDB.GetAccountIdByUsernameCI(staff);
|
||||
if (id != 0 && id == self) return false; // it IS that account - its own name is fine
|
||||
return true;
|
||||
}
|
||||
return Reserved.Any(r => Fold(r) == f);
|
||||
}
|
||||
|
||||
/// <summary>Null = acceptable (and <paramref name="clean"/> is the value to store); otherwise the player-facing reason.</summary>
|
||||
public static string? CheckUsername(string? raw, ulong self, out string clean)
|
||||
{
|
||||
clean = (raw ?? "").Trim();
|
||||
if (clean.Length < UsernameMin) return $"Your @name must have at least {UsernameMin} characters.";
|
||||
if (clean.Length > UsernameMax) return $"Your @name must not exceed {UsernameMax} characters.";
|
||||
if (!UsernameShape.IsMatch(clean)) return "Your @name can only use letters, numbers and underscores.";
|
||||
if (Sanitize.ContainsSwears(clean)) return "Your @name contains a word that isn't allowed.";
|
||||
if (Impersonates(clean, self)) return "That @name isn't available.";
|
||||
ulong taken = PlayerDB.GetAccountIdByUsernameCI(clean);
|
||||
if (taken != 0 && taken != self) return "That @name is already taken.";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? CheckDisplayName(string? raw, ulong self, out string clean)
|
||||
{
|
||||
clean = Regex.Replace((raw ?? "").Trim(), @"\s+", " ");
|
||||
if (clean.Length == 0) return "Your display name can't be empty.";
|
||||
if (clean.Length > DisplayNameMax) return $"Your display name must not exceed {DisplayNameMax} characters.";
|
||||
if (clean.Any(c => c < 0x20 || c > 0x7E)) return "Your display name can only use letters, numbers and basic punctuation.";
|
||||
if (!clean.Any(char.IsLetterOrDigit)) return "Your display name needs at least one letter or number.";
|
||||
if (Sanitize.ContainsSwears(clean)) return "Your display name contains a word that isn't allowed.";
|
||||
if (Impersonates(clean, self)) return "That display name isn't available.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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":"<hex>","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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<folder>/, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user