Files
2026-10-14 06:59:51 +02:00

7767 lines
402 KiB
C#

using System.ComponentModel.DataAnnotations;
using System.Diagnostics.Contracts;
using System.Net.Mime;
using System.Net.WebSockets;
using System.Numerics;
using System.Security.Cryptography.Xml;
using System.Security.Principal;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Discord;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Configuration.UserSecrets;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Campus_live_rewrite.api;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using CampusLiveSource.api.admin;
using CampusLiveSource.Controllers;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Metadata;
using static ImageMetadataDB;
using static Campus_live_rewrite.api.server.Classes.db_class.EventDBClasses;
using static Campus_live_rewrite.api.server.Classes.db_class.RoomDBClasses;
using static Campus_live_rewrite.api.server.Classes.RRPlus;
using static Campus_live_rewrite.api.WebsocketEvents;
using System.Text.Json.Nodes;
using System.Text.Json;
using System.IO;
namespace Campus_live_rewrite.api.Controllers
{
[ApiController]
public class APIController : ControllerBase
{
private static readonly string UPTIME = DateTime.UtcNow.ToString("o");
[HttpGet("/api/versioncheck/v4")]
public IActionResult VersionCheck([FromQuery(Name = "v"), Required] string appVersion, [FromQuery(Name = "p")] int? platform) // p optional: a client that omits it got a 400 before any answer
{
/*if (appVersion == "20250731.01")
{
return Ok(new VersionCheck
{
ValidVersion = VersionStatus.ValidForPlay,
VersionStatus = VersionStatus.ValidForPlay,
UpdateNotificationStage = UpdateNoti.None,
IsVersionIslanded = false,
IsCrossPlayDisabled = false
});
}
return Ok(new VersionCheck
{
ValidVersion = VersionStatus.UpdateRequired,
VersionStatus = VersionStatus.UpdateRequired,
UpdateNotificationStage = UpdateNoti.Prompt,
IsVersionIslanded = true,
IsCrossPlayDisabled = false
});*/
return Ok(new VersionCheck
{
ValidVersion = VersionStatus.ValidForPlay,
VersionStatus = VersionStatus.ValidForPlay,
UpdateNotificationStage = UpdateNoti.None,
IsVersionIslanded = false,
IsCrossPlayDisabled = false
});
}
[HttpGet("/api/versioncheck/islandedversions")]
public IActionResult IslandedVersions()
{
return Ok(ConfigCampus.Bracket);
}
[HttpGet("/api/gameconfigs/v1/all")]
public IActionResult Gameconfigs()
{
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "Gameconfigs.json");
if (!System.IO.File.Exists(path))
{
Console.WriteLine("Warning: Gameconfigs.json not found, returning []");
return Ok(ConfigCampus.Bracket);
}
// The Level Rewards tiles read their picture from Lifecycle.PSF.Progression.RewardMap
// (one entry per level) and UnknownRewardImage. Both are rewritten here to
// ConfigCampus.LevelRewardImage, cached on the file's mtime, so the picture is one
// config line rather than fifty JSON entries - and the file itself is left alone.
string cfg = Campus_live_rewrite.api.server.GameConfigs.WithRewardImage(path);
// PERSONAL QUICK CHAT: a player who edited their quick chat phrases on the website gets their own copy of the four
// CannedChatOptions values (everyone else gets the shared cached copy untouched). The answer then differs per player,
// so it is private and its ETag (below) is computed from the personal content.
string? personal = Campus_live_rewrite.api.server.QuickChat.ApplyToConfigs(cfg, AuthHelper.OptionalViewerId(HttpContext));
if (personal != null)
{
cfg = personal;
Response.Headers["Cache-Control"] = "private, no-store";
}
// Keep the conditional GET PhysicalFile used to give for free: the client sends
// If-None-Match on every launch and this file is ~300 KB, so a 304 is worth having.
string etag = "\"" + Convert.ToHexString(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(cfg))).ToLowerInvariant() + "\"";
Response.Headers["ETag"] = etag;
if (Request.Headers.TryGetValue("If-None-Match", out var inm) && inm.ToString() == etag)
return new StatusCodeResult(304);
return new ContentResult
{
Content = cfg,
ContentType = "application/json",
StatusCode = 200
};
}
// Voice-MODERATION config (the speech-moderation vendor creds), NOT a voice on/off switch.
// RecFlare's moderation worker returns exactly { AccountId: "", ApiKey: "" }: both blank,
// both present, both STRINGS. The client treats blank credentials as "voice moderation is
// off" and continues setting up voice - but a MISSING key or a null trips its parser and
// fails voice setup entirely, which is why the old { enabled:false, regions:[] } shape (no
// AccountId/ApiKey at all) killed voice: players could open the mic but nobody could hear
// them. AccountId is a string even though it reads as a number.
[HttpGet("/voice/config")]
public IActionResult VoiceConfig() => Ok(new
{
AccountId = "",
ApiKey = ""
});
// The caller's moderation restrictions, as a bare array. RecFlare always answers [] (an
// empty, unrestricted list) - and the client refills its restriction list from THIS: a
// chat/voice restriction is a record being present here (with an EndDate). This route was
// missing, so a 404 could leave the client on a stale or safe-default restricted state.
// Empty array = nothing restricted = voice and room chat allowed.
// svc 1 = Auth, so the client asks <auth base>/privileges/me/restrictions - the root-only
// spelling 404'd every launch (wire-shapes: privileges-me-restrictions).
[HttpGet("/Auth/privileges/me/restrictions")]
[HttpGet("/privileges/me/restrictions")]
public IActionResult MyRestrictions()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
// Active mic / room-chat bans as interventions (PlayerDB.RestrictionsFor) - the list the
// in-game Moderation Notice shows with its countdown. The bans themselves are enforced
// through the token's rn.privilege claim (PlayerDB.TokenPrivileges). [] = none.
var list = PlayerDB.RestrictionsFor(account.Id);
if (list.Count > 0) Console.WriteLine($"[restrictions] {account.Id} <- {JsonConvert.SerializeObject(list)}");
return Content(JsonConvert.SerializeObject(list), "application/json");
}
// Azure Speech text-to-speech config — same story: we don't run TTS, return empty.
[HttpGet("/api/config/v1/azurespeech")]
public IActionResult AzureSpeechConfig() => Ok(new
{
enabled = false,
regions = Array.Empty<object>(),
voiceProfiles = Array.Empty<object>()
});
[HttpGet("/api/config/v1/amplitude")] // Get amplitude, analytics stuff - e12354
public ContentResult Amplitude()
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new Amplitude2023
{
AmplitudeKey = "cb2fb2ecb9953512c29af5bca58f2b4a",
UseRudderStack = true,
RudderStackKey = "23NiJHIgu3koaGNCZIiuYvIQNCu",
UseStatSig = ConfigCampus.StatsigEnabled,
StatSigKey = "client-SBZkOrjD3r1Cat3f3W8K6sBd11WKlXZXIlCWj6l4Aje",
StatSigEnvironment = 0
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/avatar/v1/defaultunlocked")]
public IActionResult DefaultUnlockeItems()
{
// EMPTY, deliberately - this is what the 2023 server returns and it is the only
// avatar endpoint the 2025 server answered differently.
//
// It used to serve the whole 3061-item avatar_items.json, from a "everyone gets
// everything for free" idea that the ownership model has since replaced. Two
// problems with that:
//
// - The client fetches this BEFORE it logs in (it sits between
// /api/versioncheck/islandedversions and /Auth/connect/token in the request
// log), so it was handing out the entire catalog unauthenticated.
// - The client then received the same 3061 items twice, once here and again
// from /api/avatar/v4/items, and registering an item from two sources is a
// good way to end up with entries it cannot file into a wardrobe category -
// the "None does not have an associated browsable avatar item filter"
// warning, which is exactly the symptom of items not appearing while
// remaining findable by search.
//
// LOCAL EXPERIMENT (ConfigCampus.HoldNewAccountCreation): the customize screen of the client's own account
// creation showed EMPTY clothing tabs and DONE threw a NullReferenceException in AvatarCustomizationModel.
// A brand-new account may be built from THIS list (the starter set), so serve just the starter items - the
// same ones every account is granted - instead of nothing. Not the catalog: see the comment below.
if (ConfigCampus.HoldNewAccountCreation)
{
try
{
string cpath = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "avatar_items.json");
if (System.IO.File.Exists(cpath))
{
// The creation screen's tabs are built from this list, and DefaultAvatarItems.json alone has no bottoms (the dorm shows
// them because it lists what the account OWNS). A logged-in caller therefore gets the same set /api/avatar/v4/items
// gives it; an anonymous one (the pre-login fetch) gets exactly DefaultAvatarItems.json.
var cat = JArray.Parse(System.IO.File.ReadAllText(cpath));
JArray pick;
var (acc, accErr) = AuthHelper.ValidateToken(HttpContext);
if (accErr == null && acc != null)
{
var ownedSet = PlayerDB.OwnedAvatarItemDescs(acc.Id);
pick = new JArray(cat.Where(e => (bool?)e["IsBaseAvatarItem"] == true || ownedSet.Contains(PlayerDB.NormalizeAvatarItemDesc((string?)e["AvatarItemDesc"]))));
}
else
{
var starter = new HashSet<string>(PlayerDB.DefaultAvatarItemDescs().Select(d => d.Trim()), StringComparer.OrdinalIgnoreCase);
pick = new JArray(cat.Where(e => starter.Contains(((string?)e["AvatarItemDesc"] ?? "").Trim())));
}
Console.WriteLine($"[items] defaultunlocked (local experiment): {pick.Count} starter item(s)");
return new ContentResult { Content = pick.ToString(Newtonsoft.Json.Formatting.None), ContentType = "application/json", StatusCode = 200 };
}
}
catch (Exception ex) { Console.WriteLine($"[items] defaultunlocked experiment failed: {ex.Message}"); }
}
// What a player owns is answered by /api/avatar/v4/items. This endpoint means
// "unlocked for everyone regardless of account", and the answer is nothing.
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/customAvatarItems/v1/isCreationAllowedForAccount")]
public ContentResult IsCreationAllowedForAccount()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult
{
Content = JsonConvert.SerializeObject(new SuccessResponse
{
success = true,
value = null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/objectives/v1/myprogress")]
public ContentResult MyObjectivesProgress()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var (objectives, groups) = PlayerDB.GetObjectivesProgress(account.Id);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new MyObjectives
{
// The client owns the objective catalog; we only report per-(Group,Index)
// progress. Field names match the client's Objective / ObjectiveGroup DTOs.
Objectives = objectives.Select(o => (object)new
{
o.Group,
o.Index,
o.Progress,
o.VisualProgress,
o.IsCompleted,
o.HasClaimedReward
}).ToList(),
ObjectiveGroups = groups.Select(g => (object)new
{
g.Group,
g.IsCompleted,
ClearedAt = g.ClearedAt
}).ToList()
}),
ContentType = "application/json",
StatusCode = 200
};
}
// A player report, raised from the in-game report UI.
//
// THIS ROUTE DID NOT EXIST. Every report a player filed got a 404 and was thrown away,
// which is the whole of "reporting doesn't work" - the client shows its confirmation
// either way, so it looked like it had worked. Form-encoded, and the reporter is the
// BEARER TOKEN, never a body field (reference: POST /api/PlayerReporting/v3/create).
// Only PlayerIdReported is required; the client omits whatever it has no value for, so a
// report raised outside a room carries no RoomId. ReportCategory and RoomInstanceType are
// stored verbatim - neither enum is mapped here.
//
// Answers the real service's { success, error } envelope, error being an EMPTY STRING and
// not null, and the refusal uses the same shape so the client only parses one.
[HttpPost("/api/PlayerReporting/v3/create")]
[HttpPost("/api/PlayerReporting/v2/create")]
[HttpPost("/api/PlayerReporting/v1/create")]
public IActionResult CreatePlayerReport()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string F(string k) => Request.HasFormContentType ? (Request.Form[k].ToString() ?? "") : "";
ulong U(string k) => ulong.TryParse(F(k), out var v) ? v : 0;
int I(string k) => int.TryParse(F(k), out var v) ? v : 0;
double? D(string k) => double.TryParse(F(k), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : (double?)null;
ulong reported = U("PlayerIdReported");
if (reported == 0)
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = false, error = "PlayerIdReported is required" }),
ContentType = "application/json",
StatusCode = 400
};
// Reporting yourself is not a thing, and it is the obvious way to spam the queue.
if (reported == account.Id)
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = true, error = "" }),
ContentType = "application/json",
StatusCode = 200
};
// 0 / -1 are the client's "no room" values - store null rather than a bogus id.
ulong roomId = U("RoomId");
try
{
Campus_live_rewrite.api.dbs.ReportsDB.Create(new Campus_live_rewrite.api.dbs.ReportsDB.Report
{
ReporterPlayerId = account.Id,
ReportedPlayerId = reported,
ReportCategory = I("ReportCategory"),
Details = string.IsNullOrWhiteSpace(F("Details")) ? null : F("Details"),
HeightReporter = D("HeightReporter"),
HeightReported = D("HeightReported"),
RoomId = roomId > 0 ? roomId : (ulong?)null,
RoomInstanceType = string.IsNullOrWhiteSpace(F("RoomInstanceType")) ? null : F("RoomInstanceType")
});
}
catch (Exception ex)
{
// A store failure must not tell the reporter their report bounced - log it and
// take the report, the same way the real service does.
Console.WriteLine($"[report] could not store {account.Id} -> {reported}: {ex.Message}");
}
// A report filed by STAFF (developer or moderator) is acted on at once instead of
// waiting in the queue:
// * Underage (7 / CoC_Underage 100) -> the player is made junior. Being young is not
// misconduct, so no timeout - junior mode is the remedy.
// * anything else -> a 1-hour timeout (ConfigCampus.StaffReportTimeoutSeconds),
// through the same moderation block an admin ban uses, so they are removed live.
// Staff are never actioned by this (a moderator cannot time out a developer, and no
// report from anyone times out staff), nor is the Coach (1).
try
{
bool reporterIsStaff = PlayerDB.CheckDevFlag(account.Id) || PlayerDB.CheckModFlag(account.Id);
bool targetIsStaff = PlayerDB.CheckDevFlag(reported) || PlayerDB.CheckModFlag(reported);
if (reporterIsStaff && !targetIsStaff && reported != 1)
{
int cat = I("ReportCategory");
if (cat == (int)Moderation_Detail.ReportCategory.Underage || cat == (int)Moderation_Detail.ReportCategory.CoC_Underage)
{
PlayerDB.SetJunior(reported, true, account.Id, $"underage report by staff @{account.Username}");
}
else
{
var category = Enum.IsDefined(typeof(Moderation_Detail.ReportCategory), cat)
? (Moderation_Detail.ReportCategory)cat : Moderation_Detail.ReportCategory.Moderator;
int seconds = Math.Max(60, ConfigCampus.StaffReportTimeoutSeconds);
string details = F("Details").Trim();
PlayerDB.Set_moderation_block(
reported, category, seconds,
gameSessionId: -1, isHostKick: false,
message: $"You have been timed out for {Math.Max(1, seconds / 60)} minutes by a moderator." +
(details.Length > 0 ? " Reason: " + details : ""),
playerIdReporter: account.Id, isBan: true,
bannedByPlayerId: account.Id, addAppealMessage: false,
logKind: "report-timeout", logNote: details);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[report] staff action on {reported} by {account.Id} failed: {ex.Message}");
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = true, error = "" }),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/PlayerReporting/v1/moderationBlockDetails")]
public IActionResult ModerationBlockDetails()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(PlayerDB.Get_moderation_block(account.Id));
}
// The client's own tamper/health reporting. We always answer "false" (not a
// violation) - but the client ALSO acts locally, so a report here is a record of
// something the client already did to itself, not a decision we get to make.
//
// Worth logging: "Dropping to dormroom due to error [385120]" is preceded, to the
// second, by a Hile of Type=Time with an empty message. That is the client's clock
// check firing - so the random dorm drop is a client-side anti-cheat trip, not a
// server error. Recording Type and Message here is what identifies the next one
// without having to correlate journald against Player.log by hand.
[HttpPost("/api/PlayerReporting/v1/hile")]
public IActionResult HileAPI()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Read by hand: a Type value the HileTypes enum doesn't list failed model binding and
// the whole call was a 400 (210 of them today) - and a report that isn't recorded is
// the one you wanted to see. Unknown values are logged as their raw text.
string typeRaw = Request.HasFormContentType ? Request.Form["Type"].ToString() : Request.Query["Type"].ToString();
string? Message = Request.HasFormContentType ? Request.Form["Message"].ToString() : Request.Query["Message"].ToString();
string typeText = Enum.TryParse<Hile.HileTypes>(typeRaw, true, out var t) ? $"{t} ({(int)t})" : $"'{typeRaw}'";
Console.WriteLine($"[hile] {account.Id} reported {typeText}" +
$"{(string.IsNullOrWhiteSpace(Message) ? " - no message" : $": {Message}")}");
return Ok("false");
}
[HttpGet("/api/avatar/v2")]
public IActionResult myAvatarV2()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
ulong targetId = account.Id;
player_data player = PlayerDB.GetFullAccount(targetId);
if (player == null)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
if (string.IsNullOrEmpty(player.player_Extra.avatar))
{
return Ok(new
{
LegacyData = new
{
SelectionsV1 = (string?)null,
SelectionsV2 = (string?)null,
FaceFeatures = (string?)null,
SkinColor = (string?)null,
HairColor = (string?)null
},
Selections = Array.Empty<object>(),
DataVersion = 9,
CustomizationSettings = (string?)null,
ThumbnailFileName = (string?)null,
Name = (string?)null,
Accessibility = 0,
Slot = 0
});
}
return new ContentResult()
{
Content = player.player_Extra.avatar,
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/avatar/v2/{accid?}")]
public ContentResult AvatarV2(ulong? accid = null)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
ulong targetId = accid ?? account.Id;
player_data player = PlayerDB.GetFullAccount(targetId);
if (player == null)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
if (string.IsNullOrEmpty(player.player_Extra.avatar))
{
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
FaceFeatures = "",
HairColor = "",
OutfitSelections = "",
SkinColor = ""
}),
ContentType = "application/json",
StatusCode = 200
};
}
return new ContentResult()
{
Content = player.player_Extra.avatar,
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/avatar/v4/items")]
public async Task<IActionResult> GetAvatarItems()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string path = Path.Combine(
Environment.CurrentDirectory,
"Data",
"APIS",
"items",
"avatar_items.json"
);
if (!System.IO.File.Exists(path))
return Ok(ConfigCampus.Bracket);
// avatar_items.json is the CATALOG - every item that exists - not a statement
// that the caller owns them. Serving it wholesale is why every account owned
// all 3061 items. Developers keep that (the dev locker); everyone else gets
// only what they own, which starts at DefaultAvatarItems.json and grows with
// purchases. See PlayerDB.OwnedAvatarItemDescs.
//
// Per-player from here on, so it must not be cached by URL.
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
Response.Headers["Pragma"] = "no-cache";
bool isDev = PlayerDB.HasDevLocker(account.Id);
var owned = isDev ? null : PlayerDB.OwnedAvatarItemDescs(account.Id);
try
{
var catalog = JArray.Parse(await System.IO.File.ReadAllTextAsync(path));
// Optional: drop items this client build has no asset for. Off unless
// Data/APIS/items/ClientKnownGuids.json exists - see ItemGuids.
int before = catalog.Count;
var renderable = new JArray(catalog.Where(e =>
ItemGuids.ClientCanRender((string?)e["AvatarItemDesc"])));
if (renderable.Count != before)
Console.WriteLine($"[items] filtered {before - renderable.Count} item(s) the client has no asset for");
catalog = renderable;
// Serve the items RAW, exactly as avatar_items.json stores them (PascalCase, and
// whatever guid form the descriptor uses). Reshaping this - camelCase projection or
// short->full guid conversion - was tried and either did nothing or dropped items,
// so the raw record is what the client actually wants here.
// Hair dyes are not in avatar_items.json, so they have to be APPENDED rather than
// filtered in - see HairGrants.FreeHairDyeItems. Everyone owns them, so they go on
// both the dev locker and the per-player list, and the Modify Hair swatches stop
// showing a price because the client reads them as already owned.
var dyes = Campus_live_rewrite.api.server.HairGrants.FreeHairDyeItems();
if (isDev)
{
var devAll = new JArray(catalog);
// + every avatar item a store sells that the catalog doesn't list (DevLocker).
foreach (var e in Campus_live_rewrite.api.server.DevLocker.StoreOnlyAvatarItems(catalog)) devAll.Add(e);
foreach (var d in dyes) devAll.Add(d.DeepClone());
return new ContentResult
{
Content = devAll.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
// Base items come free with the account. They are the structural UGC shirt
// bases - (UGCTee_Shirt), (UGCPulloverHoodie_Shirt) - that custom shirts are
// built on, so a player without them cannot wear ANY custom shirt. The 2023
// server grants these to non-devs for the same reason.
var mine = new JArray(catalog.Where(e =>
(bool?)e["IsBaseAvatarItem"] == true ||
owned.Contains(PlayerDB.NormalizeAvatarItemDesc((string?)e["AvatarItemDesc"]))));
int fromCatalog = mine.Count;
foreach (var d in dyes) mine.Add(d.DeepClone());
Console.WriteLine($"[items] {account.Id} owns {fromCatalog} of {catalog.Count} avatar items" +
(dyes.Count > 0 ? $" + {dyes.Count} hair dye colour(s)" : ""));
return new ContentResult
{
Content = mine.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception ex)
{
// A broken catalog must not leave the player with no wardrobe at all.
Console.WriteLine($"[items] could not filter catalog for {account.Id}: {ex.Message}");
return new ContentResult
{
Content = await System.IO.File.ReadAllTextAsync(path),
ContentType = "application/json",
StatusCode = 200
};
}
}
[HttpGet("/api/checklist/v1/current")]
[HttpGet("/api/checklist/v2/current")] // v1 and v2 serve the same list (RecFlare)
public ContentResult CurrentChecklist()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
// ---- Small missing endpoints ported from RecFlare (were 404s the client treats as ----
// ---- failed loads). Shapes and defaults taken straight from the reference. ------------
// Mark a NUX checklist row done. Stubbed like RecFlare: nothing records the completion and
// nothing is granted (no reward ledger to make the 25-token grant once-only), but the
// balance-update envelope is well-formed so the flow completes instead of erroring.
[HttpPost("/api/checklist/v1/complete")]
[HttpPost("/api/checklist/v2/complete")]
public IActionResult CompleteChecklist()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return Ok(new
{
BalanceUpdates = new[] { new { UpdateResponse = 303, Data = Array.Empty<object>() } }, // 303 = CHECKLIST_REWARD_CONTEXT
Balance = 0,
CurrencyType = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens,
BalanceType = -2
});
}
// Report one objective's progress. Persists per-(Group,Index) progress; the first time an
// objective completes it grants a fixed XP reward exactly once (PlayerDB latches it), then
// hands back the parent group's state in the client's shape.
[HttpPost("/api/objectives/v1/updateobjective")]
public IActionResult UpdateObjective()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
long group = 0;
int index = 0;
double progress = 0, visualProgress = 0;
bool isCompleted = false;
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
var body = reader.ReadToEnd();
if (!string.IsNullOrWhiteSpace(body))
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(body);
long GetL(string k) => jo.TryGetValue(k, StringComparison.OrdinalIgnoreCase, out var v) && v.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? v.Value<long>() : 0;
double GetD(string k) => jo.TryGetValue(k, StringComparison.OrdinalIgnoreCase, out var v) && (v.Type == Newtonsoft.Json.Linq.JTokenType.Integer || v.Type == Newtonsoft.Json.Linq.JTokenType.Float) ? v.Value<double>() : 0;
bool GetB(string k) => jo.TryGetValue(k, StringComparison.OrdinalIgnoreCase, out var v) && v.Type == Newtonsoft.Json.Linq.JTokenType.Boolean && v.Value<bool>();
group = GetL("Group");
index = (int)GetL("Index");
progress = GetD("Progress");
visualProgress = GetD("VisualProgress");
isCompleted = GetB("IsCompleted");
}
}
catch { }
bool justCompleted = PlayerDB.UpdateObjective(account.Id, group, index, progress, visualProgress, isCompleted, out var groupState, out bool groupDone);
if (justCompleted)
{
if (ConfigCampus.ObjectiveCompletionXp > 0) PlayerDB.AddXp(account.Id, ConfigCampus.ObjectiveCompletionXp);
Console.WriteLine($"[objectives] {account.Id} completed objective (group {group}, index {index}) -> +{ConfigCampus.ObjectiveCompletionXp} XP");
}
// The last of today's challenges: a game request on the watch, once a day (the group
// latch in PlayerDB.UpdateObjective).
if (groupDone) Campus_live_rewrite.api.server.DailyChallenges.GrantReward(account.Id, group);
return Ok(new
{
group = groupState.Group,
isCompleted = groupState.IsCompleted,
clearedAt = groupState.ClearedAt ?? DateTime.UtcNow.ToString("o")
});
}
// Clear an objectives group - marks it completed and stamps the cleared time. Individual
// objectives are left intact (the client re-renders them; new challenges overwrite by key).
[HttpGet("/api/objectives/v1/cleargroup")]
[HttpPost("/api/objectives/v1/cleargroup")]
public ContentResult ClearObjectivesGroup()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
long group = 0;
try
{
if (Request.Query.TryGetValue("group", out var qg) && long.TryParse(qg, out var gq)) group = gq;
else if (Request.Query.TryGetValue("Group", out var qG) && long.TryParse(qG, out var gG)) group = gG;
else
{
using var reader = new StreamReader(HttpContext.Request.Body);
var body = reader.ReadToEnd();
if (!string.IsNullOrWhiteSpace(body))
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(body);
if (jo.TryGetValue("Group", StringComparison.OrdinalIgnoreCase, out var g) && g.Type == Newtonsoft.Json.Linq.JTokenType.Integer)
group = g.Value<long>();
}
}
}
catch { }
var g2 = PlayerDB.ClearObjectiveGroup(account.Id, group);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { group = g2.Group, isCompleted = g2.IsCompleted, clearedAt = g2.ClearedAt }),
ContentType = "application/json",
StatusCode = 200
};
}
// Minimum token price to publish a public custom avatar item (RecFlare: 100).
[HttpGet("/api/customAvatarItems/v1/minPriceForPublicItem")]
public IActionResult MinPriceForPublicItem() => Content("100", "application/json");
// "Hot" custom avatar items - none stored, so [].
[HttpGet("/api/customAvatarItems/v1/hot")]
public ContentResult HotCustomAvatarItems() => new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
// Legacy custom-item current saves - an empty map (nothing stores custom items).
[HttpGet("/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems")]
public IActionResult LegacyCustomItemSaves() => Ok(new { customAvatarItemSavesByAvatarItemDesc = new { } });
// Store ad carousel - one placeholder banner (RecFlare's ad-carousel-items.json).
[HttpGet("/api/storefronts/v1/adcarouselitems")]
public IActionResult AdCarouselItems()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return Ok(new[]
{
new { AdCarouselItemId = 1, Description = "No description.", ImageName = "AdCarouselItem.png", PurchasableItemIds = Array.Empty<int>(), Title = "Lorem ipsum" }
});
}
// Bulk UGC purchasables - none, so [].
[HttpGet("/api/ugcPurchasables/v1/items/bulk")]
public ContentResult UgcPurchasablesBulk() => new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
// The ids of a bulk lookup. The 2023 client GETs with repeated ?id=; the 2025 client POSTs a
// form body Ids=1,2,3 (reference recflare parseFormIds: Ids / ids, comma-separated or
// repeated). These routes were GET-only, so every 2025 client got 405 and profile cards /
// lists never learned anyone's level or cheers. JSON {Ids:[...]} is taken too.
private List<ulong> BulkIds(List<ulong>? queryIds)
{
var ids = new List<ulong>(queryIds ?? new List<ulong>());
try
{
if (Request.HasFormContentType)
{
// form keys are case-insensitive: "Ids" and "ids" are ONE field - read each key once
foreach (var key in Request.Form.Keys.Where(k => k.Equals("ids", StringComparison.OrdinalIgnoreCase) || k.Equals("id", StringComparison.OrdinalIgnoreCase)))
foreach (var v in Request.Form[key])
foreach (var part in (v ?? "").Split(','))
if (ulong.TryParse(part.Trim(), out var n)) ids.Add(n);
}
else if ((Request.ContentType ?? "").Contains("json", StringComparison.OrdinalIgnoreCase))
{
using var reader = new StreamReader(Request.Body);
var tok = Newtonsoft.Json.Linq.JToken.Parse(reader.ReadToEndAsync().GetAwaiter().GetResult());
var arr = tok is Newtonsoft.Json.Linq.JArray a ? a : (tok["Ids"] ?? tok["ids"]) as Newtonsoft.Json.Linq.JArray;
if (arr != null) foreach (var t in arr) if (ulong.TryParse(t.ToString(), out var n)) ids.Add(n);
}
}
catch (Exception ex) { Console.WriteLine($"[bulk] could not read ids: {ex.Message}"); }
return ids;
}
[HttpGet("/api/players/v2/progression/bulk")]
[HttpGet("/api/players/v1/progression/bulk")] // older clients call v1
[HttpGet("/api/v1/progression/bulk")] // and a legacy path
[HttpPost("/api/players/v2/progression/bulk")]
[HttpPost("/api/players/v1/progression/bulk")]
[HttpPost("/api/v1/progression/bulk")]
public ContentResult BulkPlayersProgression([FromQuery] List<ulong> id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
id = BulkIds(id);
if (id == null || id.Count == 0)
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
var result = id.Select(Progress.GetPlayerLevel);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(result),
ContentType = "application/json",
StatusCode = 200
};
}
// A single player's level + XP - what the profile panel reads when it opens one person
// (the People page uses the bulk form above). Only the bulk route existed, so this 404'd
// on every profile open, and the client left the level/progress area of the profile
// blank. Same { PlayerId, Level, XP } shape as one bulk entry. v1 is what the reference
// exposes; v2 is aliased for safety.
// The client polls this for the "XP earned today" display. It was 404 (matched the {id}
// route with id="xpEarnedToday", which can't bind to ulong), spamming the log. Literal
// segments win over the {id} parameter route, so this handles it. We don't track a per-day
// XP total, so report 0 earned today.
[HttpGet("/api/players/v1/progression/xpEarnedToday")]
[HttpGet("/api/players/v2/progression/xpEarnedToday")]
public ContentResult XpEarnedToday()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult { Content = "0", ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("/api/players/v1/progression/{id}")]
[HttpGet("/api/players/v2/progression/{id}")]
public ContentResult SinglePlayerProgression(ulong id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var prog = Progress.GetPlayerLevel(id);
// Mirror the reference's HubSendProgressionUpdate: pushing the caller's OWN progression
// back on this read keeps their crown + progress bar in sync with the server's stored
// level/XP without a relog. Only for their own (not when opening someone else's
// profile), so it doesn't spam a push per profile view.
if (id == account.Id)
Notifications.FireAndForget(
Notifications.SendToPlayer(id, JsonConvert.SerializeObject(
WebsocketEvents.createResponse_levelupdate(id, prog.Level, prog.XP))),
"progression self-sync");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(prog),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/announcement/v1/get")]
public ContentResult GetAnnouncements()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
// Equipping a skin. The client sends the full entry for the prefab it just
// changed, e.g. [{"PrefabName":"[ShareCamera]","ModificationGuid":"48c97ace-...",
// "Favorited":true,...}]. This route did not exist, so the POST 404'd and the
// choice was lost at logout. Persist it and getUnlocked below replays it.
[HttpPost("/api/equipment/v1/update")]
public IActionResult UpdateEquipment([FromBody] List<EquipmentDB.EquippedItem>? items)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// A skin the player does not own (not bought from a store, not given by an admin) is
// never stored, so it cannot come back at the next login.
var mayUse = Campus_live_rewrite.api.server.SkinOwnership.CheckerFor(account.Id);
if (mayUse != null && items != null)
{
var stolen = items.Where(i => i != null && i.Favorited && !mayUse(i.PrefabName, i.ModificationGuid)).ToList();
if (stolen.Count > 0)
{
items = items.Except(stolen).ToList();
string what = string.Join(", ", stolen.Select(i => $"{i.PrefabName} {i.FriendlyName ?? i.ModificationGuid}"));
Console.WriteLine($"[equipment] {account.Id} tried to equip {stolen.Count} skin(s) they do not own - not saved: {what}");
// The locker only ever lists skins the player owns (getUnlocked is owned-only),
// so equipping one they do not own means a modified client. Devs never get here
// (CheckerFor is null for the dev locker).
Campus_live_rewrite.api.server.SkinOwnership.BanForUnownedSkin(account.Id, what);
return StatusCode(403, JsonConvert.SerializeObject(new { success = false, error = "You do not own that skin." }));
}
}
var loadout = EquipmentDB.Update(account.Id, items);
foreach (var i in items ?? new List<EquipmentDB.EquippedItem>())
Console.WriteLine($"[equipment] {account.Id} {(i.Favorited ? "EQUIPPED" : "cleared ")} " +
$"{i.PrefabName} -> {i.FriendlyName} ({i.ModificationGuid})");
return new ContentResult
{
Content = JsonConvert.SerializeObject(loadout),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/equipment/v2/getUnlocked")]
public IActionResult GetUnlockedEquipment()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string path = Path.Combine(
Environment.CurrentDirectory,
"Data",
"APIS",
"items",
"Equipments.json"
);
if (!System.IO.File.Exists(path))
return Ok(Array.Empty<object>());
// This response is now PER PLAYER, so it must never be cached.
//
// The no-saved-loadout branch used to answer with PhysicalFile, which stamps an
// ETag and Last-Modified from Equipments.json - a file that is identical for
// everyone. The URL carries no account id either, so after switching accounts
// the client could reuse another account's cached copy and show that account's
// equipped skins. Switching away and back was exactly the case that "took the
// skin off".
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
Response.Headers["Pragma"] = "no-cache";
var saved = EquipmentDB.Get(account.Id);
// A saved choice of a skin they no longer own (equipped back when every skin was
// listed) is dropped, not replayed - the tool goes back to its default look.
var mayUse = Campus_live_rewrite.api.server.SkinOwnership.CheckerFor(account.Id);
if (mayUse != null)
saved = saved.Where(i => mayUse(i.PrefabName, i.ModificationGuid)).ToList();
// Replay the player's own choices over THEIR catalog. Skins are no longer unlocked for
// everyone: the dev locker still gets every skin, everyone else gets what they own
// (PlayerDB.OwnedEquipmentSkins - filled by purchases and admin gifts) and otherwise
// the tools' default looks. The 187 skin rows in the Watch store are how they get more.
try
{
var catalog = JArray.Parse(System.IO.File.ReadAllText(path));
if (!ConfigCampus.SkinsForEveryone && !PlayerDB.HasDevLocker(account.Id))
{
var owned = PlayerDB.OwnedEquipmentSkins(account.Id);
int before = catalog.Count;
if (ConfigCampus.ListUnownedSkinsMasked)
{
// Every skin stays in the list so a box / a friend's tool can resolve it;
// the ones not owned are masked off every platform (PlatformMask 0).
int ownedCount = 0;
foreach (var e in catalog)
{
bool has = owned.Contains(PlayerDB.EquipmentSkinKey((string?)e["PrefabName"], (string?)e["ModificationGuid"]));
e["PlatformMask"] = has ? -1 : 0;
if (has) ownedCount++;
}
Console.WriteLine($"[skins] {account.Id} owns {ownedCount} of {before} equipment skin(s) (rest masked)");
}
else
{
catalog = new JArray(catalog.Where(e =>
owned.Contains(PlayerDB.EquipmentSkinKey((string?)e["PrefabName"], (string?)e["ModificationGuid"]))));
Console.WriteLine($"[skins] {account.Id} owns {catalog.Count} of {before} equipment skin(s)");
}
}
if (saved.Count == 0)
return new ContentResult
{
Content = catalog.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
foreach (var item in saved)
{
// Matched on the normalized key (a short-guid spelling of the same skin used to
// miss here and be appended below as a duplicate).
string itemKey = PlayerDB.EquipmentSkinKey(item.PrefabName, item.ModificationGuid);
var match = catalog.FirstOrDefault(e =>
string.Equals(PlayerDB.EquipmentSkinKey((string?)e["PrefabName"], (string?)e["ModificationGuid"]), itemKey, StringComparison.OrdinalIgnoreCase));
if (match == null)
{
// Equipped something the catalog does not list - carry it anyway
// rather than silently dropping the player's choice.
catalog.Add(JObject.FromObject(item));
continue;
}
match["Favorited"] = item.Favorited;
if (!string.IsNullOrEmpty(item.ThumbnailImageName))
match["ThumbnailImageName"] = item.ThumbnailImageName;
}
return new ContentResult
{
Content = catalog.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception ex)
{
// A bad catalog must not cost the player their equipment list.
Console.WriteLine($"[equipment] could not merge saved loadout for {account.Id}: {ex.Message}");
return new ContentResult
{
Content = System.IO.File.ReadAllText(path),
ContentType = "application/json",
StatusCode = 200
};
}
}
[HttpGet("/api/consumables/v2/getUnlocked")]
public IActionResult GetUnlockedConsumables()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string path = Path.Combine(
Environment.CurrentDirectory,
"Data",
"APIS",
"items",
"Consumables.json"
);
if (!System.IO.File.Exists(path))
return Ok(Array.Empty<object>());
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
Response.Headers["Pragma"] = "no-cache";
// Consumables.json is the CATALOG (540 rows, infinite counts). Everyone used to get
// all of it. Now: the dev locker keeps the whole file; everyone else sees only what
// they own, with their real counts (see PlayerDB.OwnedConsumables / gift boxes).
if (ConfigCampus.ConsumablesForEveryone || PlayerDB.HasDevLocker(account.Id))
return PhysicalFile(path, "application/json");
try
{
var catalog = JArray.Parse(System.IO.File.ReadAllText(path));
var owned = PlayerDB.OwnedConsumables(account.Id);
var mine = new JArray();
foreach (var row in catalog.OfType<JObject>())
{
string key = PlayerDB.NormalizeConsumableDesc((string?)row["ConsumableItemDesc"]);
if (key.Length == 0 || !owned.TryGetValue(key, out var count) || count <= 0) continue;
var copy = (JObject)row.DeepClone();
copy["Count"] = count;
copy["InitialCount"] = count;
mine.Add(copy);
}
Console.WriteLine($"[consumables] {account.Id} owns {mine.Count} of {catalog.Count} consumable(s)");
return new ContentResult { Content = mine.ToString(Newtonsoft.Json.Formatting.None), ContentType = "application/json", StatusCode = 200 };
}
catch (Exception ex)
{
Console.WriteLine($"[consumables] could not filter for {account.Id}: {ex.Message}");
return PhysicalFile(path, "application/json");
}
}
[HttpGet("/api/playerReputation/v2/bulk")]
[HttpGet("/api/playerReputation/v1/bulk")] // older clients call v1 (same query shape)
[HttpPost("/api/playerReputation/v2/bulk")]
[HttpPost("/api/playerReputation/v1/bulk")]
public ContentResult BulkPlayersReputation([FromQuery] List<ulong> id)
{
id = BulkIds(id);
if (id == null || id.Count == 0)
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
var reps = new List<Rep>();
// One entry per REQUESTED id, in order - even for a player with no account row.
//
// This used to `continue` past a missing player, so the reputation list came back
// SHORTER than the id list the client sent. The People page (Recent / Here / a
// profile) lines reputation up against the parallel account and progression lists
// by position; a short list slides every entry after the gap onto the wrong player,
// which reads as profiles that half-load or show someone else's cheers. Presence
// and progression already return one-per-id; reputation now matches, handing a
// default rep for an id with no account instead of dropping it.
foreach (var playerId in id)
{
var acc = PlayerDB.GetFullAccount(playerId);
var rep = acc?.player?.PlayerReputation ?? new mPlayerReputation();
reps.Add(new Rep
{
AccountId = playerId,
IsCheerful = true, // reference ALWAYS serves true; a stored/default false reads to the client as "not in good standing" and restricts club/room creation ("too many moderation incidents")
Noteriety = 0,
SelectedCheer = PlayerDB.EffectiveSelectedCheer(rep.SelectedCheer, playerId),
CheerCredit = rep.CheerCredit,
CheerGeneral = rep.CheerGeneral,
CheerHelpful = rep.CheerHelpful,
CheerCreative = rep.CheerCreative,
CheerGreatHost = rep.CheerGreatHost,
CheerSportsman = rep.CheerSportsman,
SubscriberCount = acc?.player_Extra?.sub_count ?? 0,
SubscribedCount = acc?.player_Extra?.SubbedTo?.Count ?? 0,
});
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(reps),
ContentType = "application/json",
StatusCode = 200
};
}
// A single player's reputation (the cheer counters shown on their profile). Only the bulk
// route existed, so this 404'd when a profile opened one person, leaving the cheers area
// blank. Same per-player shape as one bulk entry; a player with no account reads back as a
// default (all-zero) rep rather than 404ing. v1 is the reference route; v2 aliased.
[HttpGet("/api/playerReputation/v1/{id}")]
[HttpGet("/api/playerReputation/v2/{id}")]
public ContentResult SinglePlayerReputation(ulong id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var acc = PlayerDB.GetFullAccount(id);
var rep = acc?.player?.PlayerReputation ?? new mPlayerReputation();
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new Rep
{
AccountId = id,
IsCheerful = true, // reference ALWAYS serves true; a stored/default false reads to the client as "not in good standing" and restricts club/room creation ("too many moderation incidents")
Noteriety = 0,
SelectedCheer = PlayerDB.EffectiveSelectedCheer(rep.SelectedCheer, id),
CheerCredit = rep.CheerCredit,
CheerGeneral = rep.CheerGeneral,
CheerHelpful = rep.CheerHelpful,
CheerCreative = rep.CheerCreative,
CheerGreatHost = rep.CheerGreatHost,
CheerSportsman = rep.CheerSportsman,
SubscriberCount = acc?.player_Extra?.sub_count ?? 0,
SubscribedCount = acc?.player_Extra?.SubbedTo?.Count ?? 0,
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/avatar/v1/defaultbaseavataritems")]
public ContentResult DefaultBaseAvatarItems()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Keep this narrow. `defaultbaseavataritems` = the BASE body templates (mannequin
// forms the client uses when building an avatar), NOT the full inventory. Returning
// the whole avatar_items.json here would tell the client every hat/gun/shirt is a
// base body template and break the customization pipeline. ConfigCampus ships the
// two correct UGC base templates (UGCTee_Shirt + UGCPulloverHoodie_Shirt).
return new ContentResult()
{
Content = ConfigCampus.DefaultBaseAvatarItems,
ContentType = "application/json",
StatusCode = 200
};
}
// The client POSTs this with a JSON body {AvatarItemDescriptions:[...]} (wire-shapes:
// avatar-lockeditems-bulk); GET-only answered 405 and the wardrobe never learned which
// items are locked. /lockeditems (no /bulk) is the same answer for the whole account.
[HttpGet("/api/avatar/v1/lockeditems")]
[HttpPost("/api/avatar/v1/lockeditems")]
public ContentResult GetLockedItems()
{
return new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("/api/avatar/v1/lockeditems/bulk")]
[HttpPost("/api/avatar/v1/lockeditems/bulk")]
public ContentResult GetLockedItemsBulk()
{
// Nothing is locked - everyone has every avatar item and equipment skin. Returning
// an empty BARE ARRAY here means the client's wardrobe UI never draws a padlock overlay
// and every item is freely equippable. (The request body is deliberately not read.)
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/config/v1/backtrace")]
public ContentResult Backtrace()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
ReportBudget = 0,
FilterType = 0,
SampleRate = 0.025,
LogLineCount = 50,
CaptureNativeCrashes = 1,
ANRThresholdMs = 0,
MessageCount = 1000,
MessageRegex = "^Cannot set the parent of the GameObject .* while its new parent|^\\>\\x2010x\\:\\x20|'LabelTheme' contains missing PaletteTheme reference on",
VersionRegex = ".*"
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/config/v2")]
public ContentResult ConfigV2()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// The client renders its XP bar against this table, so it MUST be the same curve the
// server levels players on (Progress.LEVEL_REQUIRED_XP) and it must cover every level a
// player can reach. The old table was a made-up "RequiredXp = level + 1" that only went
// to level 20 - so a player above 20 (e.g. Level 50) had no curve entry, the client
// couldn't reconcile their Level with their XP, and the anti-cheat check dropped them
// to their dorm. Now it sends the real curve, all the way to MAX_LEVEL.
var levels = new List<LevelProgressionMap>();
for (int i = 0; i <= Progress.MAX_LEVEL; i++)
{
levels.Add(new LevelProgressionMap
{
Level = i,
RequiredXp = Progress.LEVEL_REQUIRED_XP[i],
// The Progress tab draws each level's reward via Lifecycle.PSF.Progression.RewardMap
// (GiftDropId -> image). Every level pays tokens here, so each gets a synthetic
// id that the map points at the token pile (see Gameconfigs.json).
GiftDropId = i == 0 ? (int?)null : Progress.LEVEL_REWARD_GIFTDROP_BASE + i
});
}
// The daily challenges (see DailyChallenges: the old 20/21/22 were tutorial-only objectives
// that never fire, so no challenge ever progressed).
var dailyObjectives = Campus_live_rewrite.api.server.DailyChallenges.Sets;
string url = $"{Request.Scheme}://{Request.Host}";
var config = new Config
{
MessageOfTheDay = "Welcome to Campus live!",
CdnBaseUri = $"{url}",
ShareBaseUrl = $"{url}/share/{0}",
LevelProgressionMaps = levels,
MatchmakingParams = new MatchmakingParams
{
PreferFullRoomsFrequency = 1.0,
PreferEmptyRoomsFrequency = 0.0
},
DailyObjectives = dailyObjectives,
ServerMaintainence = new ServerMaintenance
{
StartsInMinutes = 0
},
AutoMicMutingConfig = new AutoMicMutingConfig
{
MicSpamVolumeThreshold = 10.0,
MicVolumeSampleInterval = 1.0,
MicVolumeSampleRollingWindowLength = 15.0,
MicSpamSamplePercentageForWarning = 100.0,
MicSpamSamplePercentageForWarningToEnd = 0.0,
MicSpamSamplePercentageForForceMute = 500.0,
MicSpamSamplePercentageForForceMuteToEnd = 0.0,
MicSpamWarningStateVolumeMultiplier = 10.0
},
StorefrontConfig = new StorefrontConfig
{
MinPlayerLevelForGifting = 0
},
RoomKeyConfig = new RoomKeyConfig
{
MaxKeysPerRoom = 100
},
RoomCurrencyConfig = new RoomCurrencyConfig
{
AwardCurrencyCooldownSeconds = 10.0
},
ConfigTable = new List<ConfigTableEntry>
{
new ConfigTableEntry { Key = "Gift.DropChance", Value = "1" },
new ConfigTableEntry { Key = "Gift.XP", Value = "0.5" }
},
PhotonConfig = new PhotonConfig
{
CloudRegion = "us",
CrcCheckEnabled = false,
EnableServerTracingAfterDisconnect = false
}
};
return new ContentResult
{
Content = JsonConvert.SerializeObject(config, Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
// Gift drop stores. Each store is a captured rec.net response saved under
// Data/APIS/GiftDropStores/{StorefrontType}.json:
//
// 1.json Laser Tag 40 items pays in LaserTagTickets (CurrencyType 1)
// 3.json Watch 968 items pays in RecCenterTokens (CurrencyType 2)
// 100.json Isle 22 items pays in LostSkullsGold (CurrencyType 100)
// 101.json Crescendo 15 items pays in DraculaSilver (CurrencyType 101)
// 102.json Crimson 24 items pays in RecCenterTokens (CurrencyType 2)
//
// The {StoreId} in the URL IS the StorefrontType (see Classes/StorefrontType.cs).
// Drop another captured store into that folder named after its type and it is served
// with no code change.
[HttpGet("/api/storefronts/v3/giftdropstore/{StoreId}")]
public IActionResult GetGiftdropStore(int StoreId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "GiftDropStores");
string specific = Path.Combine(dir, $"{StoreId}.json");
string name = System.Enum.IsDefined(typeof(Campus_live_rewrite.api.server.Classes.StorefrontType), StoreId)
? ((Campus_live_rewrite.api.server.Classes.StorefrontType)StoreId).ToString()
: $"Unknown({StoreId})";
// The Watch store is GENERATED for this client (reference sf3-2025.json): the captured
// rows with no avatar item behind them, plus one row per sellable catalog item under a
// catalog id from 10000 up. See StoreGenerator for why the captured file on its own
// showed the wrong item on click and sold a third of the catalog.
// Wishlist (1300) is per player: the rows of the items they hearted, looked up in the
// Watch store first (ids there are what the store page hands out), then the other
// catalogs. Never a file.
if (StoreId == (int)Campus_live_rewrite.api.server.Classes.StorefrontType.Wishlist)
{
ulong who = account.Id;
foreach (var k in new[] { "accountId", "playerId", "id" })
if (ulong.TryParse(Request.Query[k], out var q) && q > 0) { who = q; break; }
var wanted = WishlistDB.For(who).Select(r => r.PurchasableItemId).Distinct().ToList();
// Only what a store actually sells for tokens (StoreServe): a wishlist sits on the
// player's profile, and profile buys are token-only. Watch rows first.
var rows = new JArray();
foreach (int pid in wanted)
{
var hit = Campus_live_rewrite.api.server.StoreServe.FindTokenRow(pid);
if (hit == null) continue;
var c = (JObject)hit.Value.row.DeepClone();
c["CanBeGifted"] = true;
rows.Add(c);
}
Console.WriteLine($"[gift] giftdropstore/1300 (Wishlist) for {who} -> {rows.Count}/{wanted.Count} item(s)");
return new ContentResult
{
Content = new JObject
{
["NewUntil"] = null,
["NextUpdate"] = "2222-02-22T22:22:00Z",
["StoreItems"] = rows,
["StorefrontName"] = "Wishlist",
["StorefrontType"] = StoreId,
["SubscriberDiscountPercent"] = 10
}.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
// Watch (generated), a store file, or a store stocked only from the admin panel - as
// StoreServe builds it: base stock minus the excluded items, admin edits on top. The
// purchase check reads the same object, so what is shown is exactly what is buyable.
// A storefront with no stock serves an EMPTY store - never someone else's stock (the
// 2023 server fell back to the Watch catalog for unknown ids and served all 968 Watch
// items as Wishlist, Player_Profile, Room_Save...).
var served = Campus_live_rewrite.api.server.StoreServe.ServedJson(StoreId);
if (served != null)
{
Console.WriteLine($"[gift] giftdropstore/{StoreId} ({name}) -> " +
(StoreId == Campus_live_rewrite.api.server.StoreGenerator.WatchStorefront ? "generated Watch store"
: System.IO.File.Exists(specific) ? $"{StoreId}.json" : "admin-stocked"));
return new ContentResult { Content = served, ContentType = "application/json", StatusCode = 200 };
}
Console.WriteLine($"[gift] giftdropstore/{StoreId} ({name}) -> empty (no {StoreId}.json)");
return Ok(new
{
NewUntil = (string?)null,
NextUpdate = "2222-02-22T22:22:00Z",
StoreItems = Array.Empty<object>(),
StorefrontName = (string?)null,
StorefrontType = StoreId,
SubscriberDiscountPercent = 10
});
}
// Buy (or gift) a store item. Ported from the 2023 Campus server
// (D:\Puck\Campus-Server, StoreController.BuyItem), which had this working.
//
// Real spend + delivery: parses the client's body
// { PurchasableItemId, CurrencyType, StorefrontType, RequestedPrice,
// Gift: { ToPlayerId, Message, Anonymous, GiftContext } }
// charges the buyer, and delivers to the gift target if present, else the buyer.
//
// ONE DELIBERATE DIFFERENCE from 2023: no AlreadyOwned check. The 2023 server
// tracked per-player ownership and refused to sell something you already had.
// This server hands every player the entire 3061-item avatar catalog up front
// (see /api/avatar/v1/defaultunlocked), so that check would reject EVERY purchase.
// What a purchase buys here is the spend and the gift-box reveal, not exclusivity.
[HttpPost("/api/storefronts/v2/buyItem")]
public async Task<IActionResult> BuyItem()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string body = "";
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
body = await reader.ReadToEndAsync();
}
catch { }
int purchasableItemId = 0;
int currencyType = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens;
int giftContext = 0, storefrontType = -1, requestedPrice = -1;
ulong toPlayerId = 0;
string? giftMessage = null;
bool giftAnonymous = false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
var root = doc.RootElement;
int N(System.Text.Json.JsonElement e, string k, int fallback)
=> e.TryGetProperty(k, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.Number ? v.GetInt32() : fallback;
purchasableItemId = N(root, "PurchasableItemId", 0);
currencyType = N(root, "CurrencyType", currencyType);
storefrontType = N(root, "StorefrontType", -1);
// What the client thinks it is paying (the RR+ price when subscribed).
// Logged as a cross-check only - the real price always comes from the store file.
requestedPrice = N(root, "RequestedPrice", -1);
if (root.TryGetProperty("Gift", out var g) && g.ValueKind == System.Text.Json.JsonValueKind.Object)
{
if (g.TryGetProperty("ToPlayerId", out var tp) && tp.ValueKind == System.Text.Json.JsonValueKind.Number)
toPlayerId = (ulong)Math.Max(0, tp.GetInt64());
if (g.TryGetProperty("Message", out var m) && m.ValueKind == System.Text.Json.JsonValueKind.String)
giftMessage = m.GetString();
if (g.TryGetProperty("Anonymous", out var an)
&& (an.ValueKind == System.Text.Json.JsonValueKind.True || an.ValueKind == System.Text.Json.JsonValueKind.False))
giftAnonymous = an.GetBoolean();
giftContext = N(g, "GiftContext", 0);
}
}
catch { }
// Only what the named store serves (profile / wishlist / no store: a token row of any
// store) - StoreServe.ResolvePurchase.
var item = Campus_live_rewrite.api.server.StoreServe.ResolvePurchase(purchasableItemId, storefrontType, out var notBuyable);
// The price is decided HERE, from the store file. RequestedPrice is the client
// telling us what it expects to pay; trusting it would let anything set its own price.
int cost = item?.Cost ?? 0;
bool subDiscount = false;
if (item != null)
{
int sub = Campus_live_rewrite.api.server.StoreCatalog.SubscriberPriceFor(item);
if (sub < cost) { cost = sub; subDiscount = true; }
}
string storeName = storefrontType >= 0
? Campus_live_rewrite.api.server.StoreCatalog.StorefrontName(storefrontType)
: "store not specified";
Console.WriteLine($"[buy] item {purchasableItemId} from {storeName} -> " +
(item == null
? $"not buyable: {notBuyable}"
: $"'{item.Name}' ({item.StoreFile}, {cost} of currency {item.Currency}" +
(subDiscount ? $"; RR+ price, normally {item.Cost}" : "") + ")"));
// If these drift apart the store UI is showing players the wrong price.
if (item != null && requestedPrice > 0 && requestedPrice != cost)
Console.WriteLine($"[buy] note: client expected to pay {requestedPrice}, we charge {cost}");
int currency = item?.Currency ?? currencyType;
ulong recipient = toPlayerId > 0 ? toPlayerId : account.Id;
long balance = PlayerDB.GetBalance(account.Id, currency);
if (item == null)
return Ok(new { Result = 4, Success = false, Error = "ItemNotFound", CurrencyType = currency, Balance = balance });
// Gifting something the recipient already owns is refused (Result 3 = AlreadyOwned).
if (recipient != account.Id && string.IsNullOrEmpty(item.ConsumableItemDesc) && RecipientAlreadyHas(recipient, item))
{
Console.WriteLine($"[buy] {account.Id} -> gift {purchasableItemId} to {recipient} refused: already owned");
return Ok(new { Result = 3, Success = false, Error = "AlreadyOwned", CurrencyType = currency, Balance = balance });
}
// The item's currency is the store's, not the buyer's: a Laser Tag drop is priced in
// Laser Tag Tickets and cannot be bought with tokens, whatever CurrencyType the
// request says. The charge already reads item.Currency; this refuses the mismatch
// outright instead of silently charging a currency the client did not offer.
if (currencyType != 0 && currencyType != currency)
{
Console.WriteLine($"[buy] @{account.Username} offered currency {currencyType} for '{item.Name}', which is sold in {currency} - refused");
return Ok(new { Result = 5, Success = false, Error = "WrongCurrency", CurrencyType = currency, Balance = balance });
}
// A gift to another player arrives in the player-gift box. The client sends the
// context it wants in the Gift block; when it sends none (0) the box came up in the
// plain default look rather than the wrapped gift. Logged so the value the client
// actually sends is on record.
if (toPlayerId > 0 && toPlayerId != account.Id)
{
// Only token-priced items can be gifted - never a room-currency / ticket item.
if (currency != (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens)
return Ok(new { Result = 4, Success = false, Error = "Only items sold for tokens can be gifted.", CurrencyType = currency, Balance = balance });
// Gifting is a Level 15+ thing (ConfigCampus.GiftMinLevel), like live Rec Room.
int lvl = Campus_live_rewrite.api.server.Classes.Progress.GetPlayerLevel(account.Id)?.Level ?? 1;
if (lvl < ConfigCampus.GiftMinLevel && !PlayerDB.CheckDevFlag(account.Id))
{
Console.WriteLine($"[buy] @{account.Username} (level {lvl}) tried to gift '{item.Name}' to {toPlayerId} - gifting needs level {ConfigCampus.GiftMinLevel}");
return Ok(new { Result = 4, Success = false, Error = $"You need to be Level {ConfigCampus.GiftMinLevel} to send gifts.", CurrencyType = currency, Balance = balance });
}
if (PlayerDB.GetAccountById(toPlayerId) == null)
return Ok(new { Result = 4, Success = false, Error = "That player does not exist.", CurrencyType = currency, Balance = balance });
Console.WriteLine($"[buy] gift to {toPlayerId}: client GiftContext={giftContext}" + (giftContext == 0 ? $" -> using {ConfigCampus.PlayerGiftContext}" : ""));
if (giftContext == 0) giftContext = ConfigCampus.PlayerGiftContext;
}
if (cost > 0 && balance < cost)
return Ok(new { Result = 4, Success = false, Error = "InsufficientFunds", CurrencyType = currency, Balance = balance });
// Same gift to the same player within a minute (a double tap) - see GiftGuard.
if (toPlayerId > 0 && toPlayerId != account.Id
&& !Campus_live_rewrite.api.server.GiftGuard.TryReserve(Campus_live_rewrite.api.server.GiftGuard.Key(account.Id, toPlayerId, "pid:" + purchasableItemId)))
{
Console.WriteLine($"[buy] @{account.Username} gift of '{item.Name}' to {toPlayerId} refused: sent the same gift under a minute ago");
return Ok(new { Result = 4, Success = false, Error = Campus_live_rewrite.api.server.GiftGuard.Message, CurrencyType = currency, Balance = balance });
}
if (cost > 0)
PlayerDB.AddBalance(account.Id, currency, -cost);
WishlistDB.RemoveAcquired(recipient, purchasableItemId, item.CustomAvatarItemId, item.GiftDropId);
bool isGiftToOther = toPlayerId > 0 && toPlayerId != account.Id;
int giftId = Campus_live_rewrite.api.server.StoreCatalog.NextPurchaseGiftId();
ulong fromPlayerIdResponse = 0;
// The store's descriptor is handed to the client EXACTLY as written, trailing
// commas and all - see PendingGiftBox.AvatarItemDesc for why stripping breaks it.
string rawDesc = item.Reward ?? "";
var box = new PendingGiftBox
{
NumericId = giftId,
GiftContext = giftContext,
Message = isGiftToOther ? (giftMessage ?? "") : "A Gift for you <3",
FromAccountId = giftAnonymous ? 0 : account.Id,
Anonymous = giftAnonymous,
AvatarItemDesc = rawDesc,
ConsumableItemDesc = item.ConsumableItemDesc,
AvatarItemType = item.AvatarItemType,
GiftDropId = item.GiftDropId,
FriendlyName = item.Name,
Rarity = item.Rarity > 0 ? item.Rarity : ConfigCampus.DefaultGiftRarity,
EquipmentPrefabName = item.EquipmentPrefabName,
EquipmentModificationGuid = Campus_live_rewrite.api.server.SkinOwnership.CanonicalGuid(item.EquipmentPrefabName, item.EquipmentModificationGuid),
CustomAvatarItemId = string.IsNullOrWhiteSpace(item.CustomAvatarItemId) ? null : item.CustomAvatarItemId,
ThumbnailImageName = string.IsNullOrWhiteSpace(item.ThumbnailImageName) ? null : item.ThumbnailImageName
};
if (isGiftToOther)
{
fromPlayerIdResponse = giftAnonymous ? 0 : account.Id;
PlayerDB.AddPendingGiftBoxFull(recipient, box, grantSkin: true);
// The box drops in front of the recipient now (GiftPackageReceivedImmediate).
Campus_live_rewrite.api.server.GiftBoxes.Push(recipient, box, Campus_live_rewrite.api.server.GiftBoxes.FrameReceivedImmediate, "store gift");
Notifications.FireAndForget(
Notifications.RefreshAccount(recipient), "gift refresh");
}
else
{
// Buying for yourself. The item is already unlocked for everyone here, so
// there is nothing to grant - the gift payload below is what makes the box
// reveal in the purchase UI. Still recorded so the box survives a relog.
PlayerDB.AddPendingGiftBoxFull(account.Id, box, grantSkin: true);
}
long newBalance = PlayerDB.GetBalance(account.Id, currency);
Console.WriteLine($"[buy] @{account.Username} item {purchasableItemId} ({item.Name}) cost {cost} currency {currency} -> " +
$"{(isGiftToOther ? $"gift box {giftId} for {recipient}" : "self")}; bal {balance}->{newBalance}");
// The purchase response the client waits on: the reward rides in
// BalanceUpdates[].Data[] (the gift-drop payload the client reveals + adds),
// plus the updated Balance so the token counter settles and the spinner finishes.
var giftData = new List<object>
{
new
{
Id = giftId,
// The store's GiftDropId - the box needs it to resolve what to display,
// otherwise it falls back to the "Update your game" placeholder.
GiftDropId = item.GiftDropId,
FromPlayerId = fromPlayerIdResponse,
ConsumableItemDesc = item.ConsumableItemDesc,
// Null unless the box holds an avatar item - a skin with "" / 0 here opens
// as an avatar item with nothing to load (StoreCatalog.AvatarTypeFor).
AvatarItemDesc = Campus_live_rewrite.api.server.StoreCatalog.AvatarDescFor(item),
AvatarItemType = Campus_live_rewrite.api.server.StoreCatalog.AvatarTypeFor(item),
CustomAvatarItemId = string.IsNullOrWhiteSpace(item.CustomAvatarItemId) ? null : item.CustomAvatarItemId,
CurrencyType = item.GdCurrencyType,
Currency = item.GdCurrency,
Xp = 0,
Level = 0,
PackageType = 0,
Message = isGiftToOther ? (giftMessage ?? "") : "A Gift for you <3",
EquipmentPrefabName = item.EquipmentPrefabName,
EquipmentModificationGuid = Campus_live_rewrite.api.server.SkinOwnership.CanonicalGuid(item.EquipmentPrefabName, item.EquipmentModificationGuid),
GiftContext = giftContext,
GiftRarity = item.Rarity > 0 ? item.Rarity : ConfigCampus.DefaultGiftRarity,
Platform = -1,
PlatformsToSpawnOn = -1,
// -2 = the account-wide bucket (reference toBalanceUpdateData). Was null.
BalanceType = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket
}
};
return Ok(new
{
Result = 0,
Success = true,
Error = (string?)null,
CurrencyType = currency,
Balance = newBalance,
BalanceUpdates = new List<object>
{
new
{
UpdateResponse = 0,
CurrencyType = currency,
Balance = newBalance,
Data = giftData
}
}
});
}
// Buy a bag of storefront items at once - the shopping bag. Ported from RecFlare's
// econ.app.ts (POST /api/items/bulkpurchase). The client posts every line (an item id,
// DuplicateItemCount copies, the price it rendered, an optional Gift), plus the one
// StorefrontType and one CurrencyType they all share.
//
// The response is NOT buyItem's. It is { Success, Error, error_id, Value } - error_id
// lowercase because the client renames that one member - wrapping:
// Value = { Balance = RESULTING total, CurrencyType, Platform = -2 (the account-wide
// balance bucket), BalanceUpdates = one entry per REQUESTED line in order }
// Each entry: { UpdateResponse, Data = { GiftPackage, PurchasableItemId, CustomAvatarItem } }.
//
// UpdateResponse codes: 0 OK, 2 NotEnoughCredit, 4 NoItemAvailable, 5 CouponNotApplicable,
// 7 RequestedAmountNotAllowed. (6 RequestedPriceDoesNotMatch is deliberately NOT used -
// buyItem here charges the catalog price and never rejects on RequestedPrice, and one
// purchase path refusing what the other allows would be worse than either. We charge
// the same min(cost, RR+ price) buyItem does and log a mismatch.)
//
// Whole-bag rules: one catalog resolve, one atomic debit for the total, lines taken in
// request order while the balance covers them. Without AllowPartialSuccess, one
// unaffordable or unbuyable line refuses the whole bag and nothing is charged. Nothing
// sold at all is a refusal ({ Success=false, Value=null }), not an empty success. 400
// is reserved for a request that can't be evaluated (no lines, non-spendable currency,
// over the 200-copy cap). Count above 1 is only for consumables (they stack); anything
// owned once is refused with RequestedAmountNotAllowed rather than charged twice.
[HttpPost("/api/items/bulkpurchase")]
public async Task<IActionResult> BulkPurchase()
{
const int OK = 0, NotEnoughCredit = 2, NoItemAvailable = 4, CouponNotApplicable = 5, RequestedAmountNotAllowed = 7;
const int BulkCap = 200;
const int AllPlatformsBucket = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket;
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
string body = "";
try { using var reader = new StreamReader(HttpContext.Request.Body); body = await reader.ReadToEndAsync(); }
catch { }
IActionResult Refuse(string msg, int status = 200) => new ContentResult
{
Content = JsonConvert.SerializeObject(new { Success = false, Error = msg, error_id = (string?)null, Value = (object?)null }),
ContentType = "application/json",
StatusCode = status
};
System.Text.Json.JsonElement root;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
root = doc.RootElement.Clone();
}
catch { return Refuse("Malformed request body", 400); }
int RootInt(string k, int fb) => root.ValueKind == System.Text.Json.JsonValueKind.Object &&
root.TryGetProperty(k, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.Number ? v.GetInt32() : fb;
bool RootBool(string k) => root.ValueKind == System.Text.Json.JsonValueKind.Object &&
root.TryGetProperty(k, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.True;
if (!root.TryGetProperty("PurchaseItemRequests", out var linesEl) || linesEl.ValueKind != System.Text.Json.JsonValueKind.Array || linesEl.GetArrayLength() == 0)
return Refuse("PurchaseItemRequests must be a non-empty array", 400);
int storefrontType = RootInt("StorefrontType", -1);
int currencyType = RootInt("CurrencyType", -1);
if (storefrontType < 0 || currencyType < 0)
return Refuse("StorefrontType and CurrencyType are required", 400);
if (!Campus_live_rewrite.api.server.Classes.GameCurrency.IsSpendable(currencyType))
return Refuse("Currency type is not spendable", 400);
bool allowPartial = RootBool("AllowPartialSuccess");
bool skipGiftBox = RootBool("BypassGiftPackages");
// ---- resolve every line against the catalog ------------------------------------
var resolved = new List<BulkLine>();
foreach (var line in linesEl.EnumerateArray())
{
var bl = new BulkLine();
if (line.TryGetProperty("ItemPurchaseMethodId", out var mid) && mid.ValueKind == System.Text.Json.JsonValueKind.Object)
{
if (mid.TryGetProperty("NumberId", out var nid) && nid.ValueKind == System.Text.Json.JsonValueKind.Number)
bl.PurchasableItemId = nid.GetInt32();
if (mid.TryGetProperty("Type", out var ty) && ty.ValueKind == System.Text.Json.JsonValueKind.Number)
bl.MethodType = ty.GetInt32();
if (mid.TryGetProperty("Guid", out var gd) && gd.ValueKind == System.Text.Json.JsonValueKind.String)
bl.Caid = gd.GetString();
}
bl.Count = line.TryGetProperty("DuplicateItemCount", out var dc) && dc.ValueKind == System.Text.Json.JsonValueKind.Number ? dc.GetInt32() : 1;
bl.RequestedPrice = line.TryGetProperty("RequestedPrice", out var rp) && rp.ValueKind == System.Text.Json.JsonValueKind.Number ? rp.GetInt32() : -1;
bl.HasCoupon = line.TryGetProperty("CouponConsumablePlayerMappingId", out var cc) &&
cc.ValueKind != System.Text.Json.JsonValueKind.Null && cc.ValueKind != System.Text.Json.JsonValueKind.Undefined;
if (line.TryGetProperty("Gift", out var g) && g.ValueKind == System.Text.Json.JsonValueKind.Object)
{
if (g.TryGetProperty("ToPlayerId", out var tp) && tp.ValueKind == System.Text.Json.JsonValueKind.Number)
bl.GiftTo = (ulong)Math.Max(0, tp.GetInt64());
if (g.TryGetProperty("Message", out var m) && m.ValueKind == System.Text.Json.JsonValueKind.String)
bl.GiftMessage = m.GetString();
if (g.TryGetProperty("Anonymous", out var an) && an.ValueKind == System.Text.Json.JsonValueKind.True)
bl.GiftAnonymous = true;
if (g.TryGetProperty("GiftContext", out var gc) && gc.ValueKind == System.Text.Json.JsonValueKind.Number)
bl.GiftContext = gc.GetInt32();
}
// A guid id is a UGC item (Type 1): sold at its creator's price, in tokens, once.
// These were refused as "not carried", so nobody could buy custom clothing.
if (bl.MethodType != 0 && !string.IsNullOrEmpty(bl.Caid))
{
var rec = CustomItemsCache.GetRec(bl.Caid);
if (rec == null || rec.Accessibility == 0) { bl.Code = NoItemAvailable; resolved.Add(bl); continue; }
// Exclusive (admin-gift only) items are never sold, to anyone, even if a client asks.
if (rec.AdminOnly)
{
Console.WriteLine($"[bulkbuy] @{account.Username} tried to buy admin-only '{rec.Name}' ({rec.Id}) - refused");
bl.Code = NoItemAvailable; resolved.Add(bl); continue;
}
if (bl.HasCoupon) { bl.Code = CouponNotApplicable; resolved.Add(bl); continue; }
if (bl.Count != 1) { bl.Code = RequestedAmountNotAllowed; resolved.Add(bl); continue; }
if (currencyType != (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens) { bl.Code = NoItemAvailable; resolved.Add(bl); continue; }
ulong owner = bl.GiftTo > 0 ? bl.GiftTo : account.Id;
if (rec.Creator == owner || PlayerDB.OwnsCustomItem(owner, rec.Id)) { bl.Code = 3; resolved.Add(bl); continue; } // AlreadyOwned
bl.Ugc = rec;
bl.UnitPrice = rec.Price > 0 ? Math.Max(Campus_live_rewrite.api.server.StoreGenerator.DefaultPrice, rec.Price) : 0; // clothing floor
if (bl.RequestedPrice > 0 && bl.RequestedPrice != bl.UnitPrice)
Console.WriteLine($"[bulkbuy] ugc {rec.Id}: client expected {bl.RequestedPrice}, charging {bl.UnitPrice}");
bl.Buyable = true;
resolved.Add(bl);
continue;
}
if (bl.MethodType != 0 || bl.PurchasableItemId == 0) { bl.Code = NoItemAvailable; resolved.Add(bl); continue; }
if (bl.HasCoupon) { bl.Code = CouponNotApplicable; resolved.Add(bl); continue; }
if (bl.Count < 1) { bl.Code = RequestedAmountNotAllowed; resolved.Add(bl); continue; }
var item = Campus_live_rewrite.api.server.StoreServe.ResolvePurchase(bl.PurchasableItemId, storefrontType, out var notBuyable);
if (item == null)
{
Console.WriteLine($"[bulkbuy] {account.Id} item {bl.PurchasableItemId} from {Campus_live_rewrite.api.server.StoreCatalog.StorefrontName(storefrontType)}: not buyable - {notBuyable}");
bl.Code = NoItemAvailable; resolved.Add(bl); continue;
}
// The whole basket is paid in the request's CurrencyType, so an item priced in any
// other currency is refused rather than sold for the same number of these.
if (item.Currency != currencyType)
{
Console.WriteLine($"[bulkbuy] {account.Id} item {bl.PurchasableItemId}: priced in currency {item.Currency}, basket pays {currencyType} - refused");
bl.Code = NoItemAvailable; resolved.Add(bl); continue;
}
bool isConsumable = !string.IsNullOrEmpty(item.ConsumableItemDesc);
if (bl.Count > 1 && !isConsumable) { bl.Code = RequestedAmountNotAllowed; resolved.Add(bl); continue; }
// A GIFT of something the recipient already owns (or already has waiting in a
// box) is refused as AlreadyOwned - the buyer's tokens would only buy a duplicate
// they can't use. Buying for yourself is unchanged (the reveal is the purchase).
if (bl.GiftTo > 0 && bl.GiftTo != account.Id && !isConsumable && RecipientAlreadyHas(bl.GiftTo, item))
{
Console.WriteLine($"[bulkbuy] {account.Id} -> gift {bl.PurchasableItemId} to {bl.GiftTo} refused: already owned");
bl.Code = 3; resolved.Add(bl); continue; // AlreadyOwned
}
// Price: the catalog decides, same as buyItem (min of cost and the RR+ price).
int cost = item.Cost;
int sub = Campus_live_rewrite.api.server.StoreCatalog.SubscriberPriceFor(item);
if (sub < cost) cost = sub;
if (bl.RequestedPrice > 0 && bl.RequestedPrice != cost)
Console.WriteLine($"[bulkbuy] item {bl.PurchasableItemId}: client expected {bl.RequestedPrice}, charging {cost}");
bl.Item = item;
bl.UnitPrice = cost;
bl.Buyable = true;
resolved.Add(bl);
}
var buyable = resolved.Where(l => l.Buyable).ToList();
int totalCopies = buyable.Sum(l => l.Count);
if (totalCopies > BulkCap)
return Refuse($"A bulk purchase is capped at {BulkCap} items", 400);
var firstFailure = resolved.FirstOrDefault(l => !l.Buyable);
if (!allowPartial && firstFailure != null)
return Refuse(BulkFailureMessage(firstFailure.Code));
// Gift recipients must exist, or the buyer pays and the box is stranded.
foreach (var to in buyable.Where(l => l.GiftTo > 0 && l.GiftTo != account.Id).Select(l => l.GiftTo).Distinct())
if (PlayerDB.GetFullAccount(to)?.player == null)
return Refuse("No such player to gift to", 404);
if (buyable.Any(l => l.GiftTo > 0 && l.GiftTo != account.Id))
{
if (currencyType != (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens)
return Refuse("Only items sold for tokens can be gifted");
int lvl = Campus_live_rewrite.api.server.Classes.Progress.GetPlayerLevel(account.Id)?.Level ?? 1;
if (lvl < ConfigCampus.GiftMinLevel && !PlayerDB.CheckDevFlag(account.Id))
return Refuse($"You need to be Level {ConfigCampus.GiftMinLevel} to send gifts");
}
// Affordability, in request order, against the CURRENT balance for this currency.
long balance = PlayerDB.GetBalance(account.Id, currencyType);
var bought = new HashSet<BulkLine>();
long total = 0;
int cooled = 0;
foreach (var l in buyable)
{
long lineCost = (long)l.UnitPrice * l.Count;
if (total + lineCost > balance) continue;
// The same gift to the same player again within a minute: an accidental double
// tap. Reserved here, before anything is charged, so two requests can't both pass.
if (l.GiftTo > 0 && l.GiftTo != account.Id)
{
string key = Campus_live_rewrite.api.server.GiftGuard.Key(account.Id, l.GiftTo, l.Ugc != null ? "ugc:" + l.Ugc.Id : "pid:" + l.PurchasableItemId);
if (!Campus_live_rewrite.api.server.GiftGuard.TryReserve(key))
{
Console.WriteLine($"[bulkbuy] @{account.Username} gift of {(l.Ugc != null ? l.Ugc.Name : l.PurchasableItemId.ToString())} to {l.GiftTo} refused: sent the same gift under a minute ago");
cooled++;
continue;
}
l.GiftKey = key;
}
total += lineCost;
bought.Add(l);
}
if ((!allowPartial && bought.Count != buyable.Count) || bought.Count == 0)
{
foreach (var l in bought) Campus_live_rewrite.api.server.GiftGuard.Release(l.GiftKey);
if (cooled > 0) return Refuse(Campus_live_rewrite.api.server.GiftGuard.Message);
if (bought.Count == 0)
return Refuse(firstFailure != null ? BulkFailureMessage(firstFailure.Code) : "Insufficient balance");
return Refuse("Insufficient balance");
}
// One atomic debit for the whole bag.
if (total > 0) PlayerDB.AddBalance(account.Id, currencyType, -total);
// Grant what sold and keep each line's box for the response.
foreach (var l in bought)
{
ulong recipient = l.GiftTo > 0 ? l.GiftTo : account.Id;
bool giftToOther = recipient != account.Id;
l.GiftId = Campus_live_rewrite.api.server.StoreCatalog.NextPurchaseGiftId();
WishlistDB.RemoveAcquired(recipient, l.Ugc != null ? 0 : l.PurchasableItemId, l.Ugc?.Id ?? l.Item?.CustomAvatarItemId, l.Item?.GiftDropId ?? 0);
// UGC: ownership, the creator's cut, and (for a gift) a box carrying the item.
if (l.Ugc != null)
{
PlayerDB.GrantCustomItem(recipient, l.Ugc.Id);
EarningsDB.Record(l.Ugc.Creator, account.Id, "ugc", l.Ugc.Id, l.Ugc.Name, currencyType, l.UnitPrice);
Console.WriteLine($"[bulkbuy] @{account.Username} bought UGC '{l.Ugc.Name}' ({l.Ugc.Id}) for {l.UnitPrice} -> {(giftToOther ? $"gift to {recipient}" : "self")}");
if (giftToOther && !skipGiftBox)
{
var ubox = new PendingGiftBox
{
NumericId = l.GiftId,
// Drawn like a store gift: the client sends GiftContext -1 for a UGC gift
// and custom items carry no rarity, and that pair drew a blank WHITE box
// where a store gift is the blue one (context 500, rarity 50).
GiftContext = l.GiftContext > 0 ? l.GiftContext : ConfigCampus.StoreGiftContext,
Message = l.GiftMessage ?? "",
FromAccountId = l.GiftAnonymous ? 0 : account.Id,
Anonymous = l.GiftAnonymous,
AvatarItemDesc = "",
CustomAvatarItemId = l.Ugc.Id,
FriendlyName = l.Ugc.Name,
Rarity = ConfigCampus.DefaultGiftRarity
};
PlayerDB.AddPendingGiftBoxFull(recipient, ubox);
Campus_live_rewrite.api.server.GiftBoxes.Push(recipient, ubox, Campus_live_rewrite.api.server.GiftBoxes.FrameReceivedImmediate, "ugc gift");
}
continue;
}
if (!skipGiftBox)
{
var box = new PendingGiftBox
{
NumericId = l.GiftId,
GiftContext = l.GiftContext,
Message = giftToOther ? (l.GiftMessage ?? "") : "A Gift for you <3",
FromAccountId = l.GiftAnonymous ? 0 : account.Id,
Anonymous = l.GiftAnonymous,
AvatarItemDesc = l.Item!.Reward ?? "",
ConsumableItemDesc = l.Item.ConsumableItemDesc,
AvatarItemType = l.Item.AvatarItemType,
GiftDropId = l.Item.GiftDropId,
FriendlyName = l.Item.Name,
Rarity = l.Item.Rarity > 0 ? l.Item.Rarity : ConfigCampus.DefaultGiftRarity, // rarity 0 = blank white box
EquipmentPrefabName = l.Item.EquipmentPrefabName,
EquipmentModificationGuid = Campus_live_rewrite.api.server.SkinOwnership.CanonicalGuid(l.Item.EquipmentPrefabName, l.Item.EquipmentModificationGuid),
ConsumableCount = l.Item.ItemCount * Math.Max(1, l.Count),
CustomAvatarItemId = string.IsNullOrWhiteSpace(l.Item.CustomAvatarItemId) ? null : l.Item.CustomAvatarItemId,
ThumbnailImageName = string.IsNullOrWhiteSpace(l.Item.ThumbnailImageName) ? null : l.Item.ThumbnailImageName
};
PlayerDB.AddPendingGiftBoxFull(recipient, box, grantSkin: true);
if (giftToOther)
{
Campus_live_rewrite.api.server.GiftBoxes.Push(recipient, box, Campus_live_rewrite.api.server.GiftBoxes.FrameReceivedImmediate, "store gift");
Notifications.FireAndForget(Notifications.RefreshAccount(recipient), "gift refresh");
}
}
}
long newBalance = PlayerDB.GetBalance(account.Id, currencyType);
Console.WriteLine($"[bulkbuy] @{account.Username}: {bought.Count}/{buyable.Count} line(s), {totalCopies} copies, " +
$"spent {total} of currency {currencyType}; bal {balance}->{newBalance}");
// One entry per REQUESTED line, in order - failures included.
var updates = resolved.Select(l =>
{
if (!l.Buyable)
return (object)new { UpdateResponse = l.Code, Data = new { GiftPackage = (object?)null, PurchasableItemId = (int?)l.PurchasableItemId, CustomAvatarItem = (object?)null } };
bool sold = bought.Contains(l);
// A UGC line has no storefront item behind it - answer it before the package is built.
if (sold && l.Ugc != null)
return (object)new { UpdateResponse = OK, Data = new { GiftPackage = (object?)null, PurchasableItemId = (int?)null, CustomAvatarItem = (object?)l.Ugc.ToJObject() } };
object? pkg = (sold && !skipGiftBox && l.Item != null) ? new
{
Id = l.GiftId,
PlayerId = l.GiftTo > 0 ? l.GiftTo : account.Id,
FromPlayerId = l.GiftAnonymous ? 0 : account.Id,
ConsumableItemDesc = l.Item!.ConsumableItemDesc,
AvatarItemType = Campus_live_rewrite.api.server.StoreCatalog.AvatarTypeFor(l.Item),
AvatarItemDesc = Campus_live_rewrite.api.server.StoreCatalog.AvatarDescFor(l.Item),
CustomAvatarItemId = string.IsNullOrWhiteSpace(l.Item.CustomAvatarItemId) ? null : l.Item.CustomAvatarItemId,
EquipmentPrefabName = l.Item.EquipmentPrefabName,
EquipmentModificationGuid = Campus_live_rewrite.api.server.SkinOwnership.CanonicalGuid(l.Item.EquipmentPrefabName, l.Item.EquipmentModificationGuid),
CurrencyType = l.Item.GdCurrencyType,
Currency = l.Item.GdCurrency,
Xp = 0,
GiftContext = l.GiftContext,
GiftRarity = l.Item.Rarity > 0 ? l.Item.Rarity : ConfigCampus.DefaultGiftRarity,
Message = l.GiftTo > 0 && l.GiftTo != account.Id ? (l.GiftMessage ?? "") : "A Gift for you <3",
Signature = Campus_live_rewrite.api.server.GiftBoxes.SignatureFor(l.GiftId,
l.GiftTo > 0 ? l.GiftTo : account.Id, l.GiftAnonymous ? 0 : account.Id, l.Item.Name,
l.GiftTo > 0 && l.GiftTo != account.Id ? (l.GiftMessage ?? "") : "A Gift for you <3", l.Item.GdCurrency, l.Item.GdCurrencyType),
IsSignatureValid = true,
Platform = -1,
PlatformsToSpawnOn = -1,
BalanceType = AllPlatformsBucket
} : null;
return (object)new { UpdateResponse = sold ? OK : NotEnoughCredit, Data = new { GiftPackage = pkg, PurchasableItemId = (int?)l.PurchasableItemId, CustomAvatarItem = (object?)null } };
}).ToList();
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
Success = true,
Error = (string?)null,
error_id = (string?)null,
Value = new
{
Balance = newBalance, // RESULTING total, not the change
CurrencyType = currencyType,
Platform = AllPlatformsBucket, // the account-wide bucket the client sums
BalanceUpdates = updates
}
}),
ContentType = "application/json",
StatusCode = 200
};
}
private sealed class BulkLine
{
public int PurchasableItemId;
public int MethodType;
public int Count = 1;
public int RequestedPrice = -1;
public bool HasCoupon;
public ulong GiftTo;
public string? GiftMessage;
public bool GiftAnonymous;
public int GiftContext;
public bool Buyable;
public int Code; // UpdateResponse when not buyable
public int UnitPrice;
public int GiftId;
public Campus_live_rewrite.api.server.StoreCatalog.GiftDropInfo? Item;
// A UGC (custom avatar item) line: guid-keyed, priced by the item's creator.
public string? Caid;
public CustomItemsCache.Rec? Ugc;
public string? GiftKey; // GiftGuard reservation for a gift line
}
/// <summary>Does this player already own (or have boxed) what a store row delivers?</summary>
private static bool RecipientAlreadyHas(ulong playerId, Campus_live_rewrite.api.server.StoreCatalog.GiftDropInfo item)
{
try
{
string? desc = Campus_live_rewrite.api.server.StoreCatalog.AvatarDescFor(item);
if (!string.IsNullOrWhiteSpace(desc))
{
string key = PlayerDB.NormalizeAvatarItemDesc(desc);
if (key.Length > 0 && PlayerDB.GetOwnedAvatarItemKeys(playerId).Contains(key)) return true;
}
if (!string.IsNullOrWhiteSpace(item.CustomAvatarItemId) && PlayerDB.OwnsCustomItem(playerId, item.CustomAvatarItemId)) return true;
if (!string.IsNullOrWhiteSpace(item.EquipmentPrefabName) && !string.IsNullOrWhiteSpace(item.EquipmentModificationGuid)
&& PlayerDB.OwnedEquipmentSkins(playerId).Contains(PlayerDB.EquipmentSkinKey(item.EquipmentPrefabName, item.EquipmentModificationGuid))) return true;
// Already waiting in an unopened box.
foreach (var b in PlayerDB.GetPendingGiftBoxes(playerId))
{
if (!string.IsNullOrWhiteSpace(desc) && string.Equals(PlayerDB.NormalizeAvatarItemDesc(b.AvatarItemDesc), PlayerDB.NormalizeAvatarItemDesc(desc), StringComparison.OrdinalIgnoreCase)) return true;
if (!string.IsNullOrWhiteSpace(item.CustomAvatarItemId) && string.Equals(b.CustomAvatarItemId, item.CustomAvatarItemId, StringComparison.OrdinalIgnoreCase)) return true;
if (!string.IsNullOrWhiteSpace(item.EquipmentModificationGuid) && string.Equals(b.EquipmentModificationGuid, item.EquipmentModificationGuid, StringComparison.OrdinalIgnoreCase)) return true;
}
}
catch (Exception ex) { Console.WriteLine($"[bulkbuy] ownership check for {playerId} failed: {ex.Message}"); }
return false;
}
private static string BulkFailureMessage(int code) => code switch
{
3 => "They already own that item",
4 => "One or more items are not available",
5 => "Coupons are not supported",
7 => "An item was requested in a quantity that is not allowed",
_ => "One or more items could not be purchased"
};
[HttpGet("/api/challenge/v2/getCurrent")]
public ContentResult GetCurrentChallenges()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// This week's website challenges (WeeklyChallenge) in the game's shape - the 2025 client
// never asks, but an older one would see the same week.
return new ContentResult()
{
Content = Campus_live_rewrite.api.server.WeeklyChallenge.Build(account.Id).ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
// Report weekly-challenge progress.
//
// The 2025 client never calls this (no weekly challenge screen); the weekly challenges
// are tracked server-side from finished games and shown on the website (WeeklyChallenge).
// Kept as a latch-and-echo for any client that does.
[HttpPost("/api/challenge/v2/updateProgress")]
public IActionResult UpdateChallengeProgress([FromBody] System.Text.Json.JsonElement body)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string GetStr(string k) => body.ValueKind == System.Text.Json.JsonValueKind.Object &&
body.TryGetProperty(k, out var v) ? v.ToString() : "";
int GetInt(string k) => int.TryParse(GetStr(k), out var n) ? n : 0;
int mapId = GetInt("ChallengeMapId");
int challengeId = GetInt("ChallengeId");
bool complete = GetStr("Complete").Equals("true", StringComparison.OrdinalIgnoreCase);
// Latch this challenge's completion (false -> true only, within the rotation).
bool stored = PlayerDB.RecordChallengeProgress(account.Id, mapId, challengeId, complete);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
ChallengeMapId = GetStr("ChallengeMapId"),
ChallengeId = GetStr("ChallengeId"),
Config = GetStr("Config"),
Complete = stored
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/PlayerReporting/v1/voteToKickReasons")]
public ContentResult VoteToKickReasons()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var reasons = new[]
{
new { Reason = "Discriminatory language", ReportCategory = 102 },
new { Reason = "Discriminatory behavior", ReportCategory = 102 },
new { Reason = "Threats or encouraging suicide", ReportCategory = 102 },
new { Reason = "Toxic behavior", ReportCategory = 102 },
new { Reason = "Sexual behavior in public", ReportCategory = 101 },
new { Reason = "Sexual language in public", ReportCategory = 101 },
new { Reason = "Non-consensual sexual behavior", ReportCategory = 101 },
new { Reason = "Player in walls or floor", ReportCategory = 103 },
new { Reason = "Friendly fire", ReportCategory = 103 },
new { Reason = "Microphone spam", ReportCategory = 103 },
new { Reason = "Abusing bugs or exploits", ReportCategory = 103 },
new { Reason = "Spawn camping", ReportCategory = 103 },
new { Reason = "Inactive in games (AFK)", ReportCategory = 6 },
new { Reason = "Prefab swapping", ReportCategory = 6 },
new { Reason = "Not following game rules", ReportCategory = 6 }
};
return new ContentResult()
{
Content = JsonConvert.SerializeObject(reasons),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/images/v2/named")]
public ContentResult NamedImages()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
// Gift boxes waiting for this player. Populated by the admin panel
// (/admin/api/gift_giftbox) and by end-of-game rewards.
//
// CAVEAT: the exact element shape rec.net returns here was never captured, so the
// fields below are our own. The client may not render them as real gift boxes
// until that shape is known - the grant itself IS stored either way, so nothing
// is lost when the correct shape is filled in.
// Open (consume) a gift box.
//
// The item was already granted when the box was created (buyItem / rewards write it
// into the wardrobe immediately), so opening is just the cosmetic reveal: mark the box
// opened so it stops coming back in the pending list. This route did not exist, so the
// client's POST 404'd, the box was never marked opened, and it reappeared every time.
//
// ALWAYS answers the { error, success, value } envelope with 200 - even with no token,
// a zero id, or a box already gone - because the client parses it to finish the open
// animation. Opening someone else's box is refused (their box is not in the caller's
// list, so nothing is consumed). Trailing slash tolerated: the client posts
// "/gifts/consume/".
// ONE route only. ASP.NET Core's matcher already treats "/consume" and "/consume/" as the
// same path, so declaring BOTH templates made the trailing-slash URL the client actually
// posts ("/gifts/consume/") match two identical endpoints -> AmbiguousMatchException -> 500.
// The consume then never ran, the box was never marked opened, and it reappeared forever.
[HttpPost("/api/avatar/v2/gifts/consume")]
public IActionResult ConsumeGift([FromForm] string? Id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
object envelope = new { error = "", success = true, value = (object?)null };
if (error == null && account != null && !string.IsNullOrWhiteSpace(Id))
{
int numericId = int.TryParse(Id, out var n) ? n : 0;
var opened = PlayerDB.ConsumeGiftBox(account.Id, Id, numericId);
if (opened == null)
{
Console.WriteLine($"[gifts] {account.Id} tried to open {Id} - no matching unopened box");
// Reference: a box that is another player's is 403 (empty body); the caller's
// own already-opened box is a harmless success. The client's Open All walks
// every box it can see in the room, so a foreign one must fail fast rather
// than be answered "success" and then throw "Gift is missing from the list".
if (numericId != 0 && PlayerDB.GiftBoxBelongsToSomeoneElse(account.Id, numericId))
return StatusCode(403, "");
}
else
{
Campus_live_rewrite.api.server.GiftBoxes.PayOnOpen(account.Id, opened); // tokens land as the box opens
// A consumable box: ConsumableMappingAdded (70) so the item shows up in the
// inventory without a refetch (reference pushConsumableAdded). Avatar-item and
// skin boxes carry no ConsumableItemDesc and get nothing here.
if (!string.IsNullOrWhiteSpace(opened.ConsumableItemDesc))
{
try
{
var addedFrame = JsonConvert.SerializeObject(new WebsocketEvents.Reponse
{
Id = ((int)WebsocketEvents.ResponseResults.ConsumableMappingAdded).ToString(),
Msg = new
{
Id = opened.NumericId,
ConsumableItemDesc = opened.ConsumableItemDesc,
CreatedAt = DateTime.UtcNow,
Count = 1,
InitialCount = 0,
IsActive = false,
ActiveDurationMinutes = 0,
IsTransferable = false
}
});
Campus_live_rewrite.api.server.Notifications.FireAndForget(
Campus_live_rewrite.api.server.Notifications.SendToPlayer(account.Id, addedFrame), "consumable added");
}
catch { }
}
// gift.manualconsumed (reference notification-types): tells the client the
// package is gone so the opened box despawns instead of lingering.
try
{
var goneFrame = JsonConvert.SerializeObject(new WebsocketEvents.Reponse
{
Id = "gift.manualconsumed",
Msg = new { GiftPackageId = opened.NumericId != 0 ? (object)opened.NumericId : opened.Id }
});
Campus_live_rewrite.api.server.Notifications.FireAndForget(
Campus_live_rewrite.api.server.Notifications.SendToPlayer(account.Id, goneFrame), "gift consumed");
}
catch { }
}
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(envelope),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/avatar/v2/gifts")]
public ContentResult Gifts()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Drop boxes that carry NOTHING - no avatar item, no consumable, no equipment
// and no gift drop to resolve one from.
//
// The client rejects the ENTIRE array when it meets one of these, logging
// "Malformed Response: '[{...\"AvatarItemDesc\":\"\",\"FriendlyName\":\"\"...}]'",
// so a handful of contentless test gifts takes out every real gift with them.
// They can never be opened either, so nothing is lost by not sending them.
// Retire contentless boxes so they don't linger forever. They are filtered out of the
// response below (the client rejects the whole array if one is empty), and if left
// unopened they keep counting toward the gift badge as "random gifts" that can never
// be opened. Marking them opened here clears them for good.
int cleaned = PlayerDB.MarkEmptyGiftBoxesOpened(account.Id);
if (cleaned > 0)
Console.WriteLine($"[gifts] {account.Id}: retired {cleaned} empty gift box(es)");
// Give every box a numeric Id (older reward/select boxes had NumericId 0 -> guid Id ->
// client rejects the whole array as "Malformed Response"). Backfills + persists.
PlayerDB.EnsureGiftBoxNumericIds(account.Id);
var boxes = PlayerDB.GetPendingGiftBoxes(account.Id);
var usable = boxes.Where(Campus_live_rewrite.api.server.GiftBoxes.HasContent).ToList();
if (usable.Count != boxes.Count)
Console.WriteLine($"[gifts] {account.Id}: skipped {boxes.Count - usable.Count} empty gift box(es) " +
$"the client cannot parse, sent {usable.Count}");
// One shape for the list and the hub frames - see GiftBoxes. The box's own currency,
// XP and thumbnail ride along; they were hardcoded 0 / absent here, which is why a
// token box read "0 Tokens" and no box showed its XP when opened from the list.
var pending = usable.Select(b => Campus_live_rewrite.api.server.GiftBoxes.ToGiftContent(b, account.Id)).ToList();
return new ContentResult()
{
Content = JsonConvert.SerializeObject(pending),
ContentType = "application/json",
StatusCode = 200
};
}
// The caller's reward selections that were offered and never picked, in the same shape
// the RewardSelectionReceived push carries - so a client that re-reads its pending
// selections (on login, or when it opens the picker from the watch) finds the one it was
// just pushed instead of an empty list. Was a hardcoded "[]".
[HttpGet("/api/gamerewards/v1/pending")]
public ContentResult PendingGameRewards()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var pending = Campus_live_rewrite.api.server.RewardSelections.PendingFor(account.Id);
Console.WriteLine($"[gamereward] {account.Id} read pending selections: {pending.Count}");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(pending.Select(Campus_live_rewrite.api.server.RewardSelections.ToPayload).ToList()),
ContentType = "application/json",
StatusCode = 200
};
}
// End-of-game reward request. Real client sends form-urlencoded:
// GiftContext={int}&Message={string}&RewardType={PostGameActivity|...}
// GiftContext identifies which gift box (4003 Golden, etc - see GiftContextIds).
//
// KNOWN GAP: the exact success RESPONSE econ.rec.net returns here was not captured
// (the reference tools only send the request and print r.text). This returns a
// minimal success envelope so the call does not error; if the client expects the
// granted gift-box contents echoed back to render them, that shape still needs a
// capture or a live probe. The request IS recorded, and a currency reward is
// credited so the reward is tangible.
[HttpPost("/api/gamerewards/v1/request")]
public ContentResult RequestGameReward()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Read the fields MANUALLY, not via [FromForm] int GiftContext. The client sends
// giftContext as the ACTIVITY NAME (e.g. "giftContext=Dodgeball"), which cannot bind to
// an int - that model-bind failure was the 400 this endpoint kept returning. Keep both a
// numeric GiftContext (when it happens to be a number) and the raw name for the reward
// pool lookup.
string? RewardType = null, Message = null, giftContextRaw = null;
if (Request.HasFormContentType)
{
RewardType = Request.Form["rewardType"].ToString();
Message = Request.Form["Message"].ToString();
giftContextRaw = Request.Form["giftContext"].ToString();
}
int GiftContext = int.TryParse(giftContextRaw, out var gcNum) ? gcNum : 0;
string? giftContextName = string.IsNullOrWhiteSpace(giftContextRaw) ? null : giftContextRaw;
_ = Task.Run(() => Webhook.Send(
account.Id, "prod", "Game reward",
$"@{account.Username} ({account.Id}) requested reward - GiftContext={GiftContext}, Type={RewardType}",
5898927, null));
Console.WriteLine($"[gamereward] account {account.Id} GiftContext={GiftContext} RewardType={RewardType} Message='{Message}'");
// Amount and currency depend on the reward type (see GameRewardRules).
var rewardType = Campus_live_rewrite.api.server.Classes.GameRewardRules.Parse(RewardType);
// Pay in the CURRENT ROOM's currency, same rule as /storefronts/v2/balance:
// finishing a game in Crescendo pays Silver, Isle pays Gold, and everything
// else pays Rec Center Tokens. Falls back to the reward type's default when
// the room is unknown.
var rewardInst = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance;
string? rewardRoom = rewardInst?.name;
// A DEV ROOM (admin ticked it) is where chips are tested: the request chip there must answer every
// press, so the once-a-day and one-a-minute pacing below is skipped - for developer accounts only,
// because the picker's cards can carry tokens and an unpaced request would farm them. Currency rules
// are unchanged.
bool devRoomReward = rewardInst != null && RoomDB.IsDevRoom((ulong)rewardInst.roomId) && PlayerDB.CheckDevFlag(account.Id);
if (devRoomReward) Console.WriteLine($"[gamereward] {account.Id} is in dev room {rewardInst!.roomId} ('{rewardRoom}') - pacing skipped");
var roomCur = Campus_live_rewrite.api.server.Classes.GameCurrency.ForRoomName(rewardRoom);
int RewardCurrency = (int)(roomCur ?? Campus_live_rewrite.api.server.Classes.GameRewardRules.CurrencyFor(rewardType));
long RewardAmount = Campus_live_rewrite.api.server.Classes.GameRewardRules.AmountFor(rewardType);
// Rec Center Tokens (and any non-room currency) are server/admin only - a player
// reward can never mint them. When a reward would pay one, pay 0 currency but still
// grant XP below, so levelling from play keeps working while tokens stay locked down.
// EXCEPTION: a once-per-day reward (FirstActivityOfDay - the daily first-game bonus) is
// rate-limited to one claim per day, so it CANNOT be farmed even in Rec Center Tokens.
// Without this it paid 0 in any token-currency room and the daily bonus "did nothing".
if (!Campus_live_rewrite.api.server.Classes.GameCurrency.IsPlayerGrantable(RewardCurrency)
&& !Campus_live_rewrite.api.server.Classes.GameRewardRules.IsOncePerDay(rewardType))
{
Console.WriteLine($"[gamereward] {account.Id} currency {RewardCurrency} not player-grantable " +
$"(server/admin only) - awarding 0 currency, XP only");
RewardAmount = 0;
}
// Once-per-day rewards must actually be once per day. FirstActivityOfDay is
// requested by the client on activity, not only the first of the day, so
// without this gate it paid out on every single request and could be farmed
// by simply replaying the call.
if (!devRoomReward && Campus_live_rewrite.api.server.Classes.GameRewardRules.IsOncePerDay(rewardType))
{
if (!PlayerDB.TryClaimDailyReward(account.Id, rewardType.ToString()))
{
Console.WriteLine($"[gamereward] {account.Id} already claimed {rewardType} today - awarding nothing");
// No selection to show: null, not a status object (see the end of this method).
return new ContentResult() { Content = "null", ContentType = "application/json", StatusCode = 200 };
}
// The day's first: the game complete that comes with it rides through the
// per-minute limit below (GameRewardGate).
Campus_live_rewrite.api.server.GameRewardGate.NoteDailyAccepted(account.Id);
}
// Game completes: one per minute per player, the whole payout - not just the box.
// A replayed PostGameActivity used to pay currency + XP every time.
string limited = "";
if (!devRoomReward && !Campus_live_rewrite.api.server.GameRewardGate.Allow(account.Id, rewardType, out limited))
{
Console.WriteLine($"[gamereward] {account.Id} rate-limited: {limited} - awarding nothing");
return new ContentResult() { Content = "null", ContentType = "application/json", StatusCode = 200 };
}
// A finished game counts toward the website's weekly challenges (the game has no weekly
// screen of its own). Only past the rate limit, so replays can't farm them.
if (!devRoomReward && rewardType == Campus_live_rewrite.api.server.Classes.GameRewardType.PostGameActivity)
Campus_live_rewrite.api.server.WeeklyChallenge.RecordGame(account.Id);
long newTotal = PlayerDB.AddBalance(account.Id, RewardCurrency, RewardAmount);
// The corner counter moves now, not on the next relog (reference: pushBalanceUpdate).
Campus_live_rewrite.api.server.GiftBoxes.PushBalance(account.Id, RewardCurrency, newTotal, "gamereward");
// Grant XP too - a game reward pays XP as well as currency, and nothing was doing it,
// so players never levelled up. GAME_REWARD_XP (5) per claim, level-ups applied on the
// real curve (see Progress). The currency reward already gated once-per-day rewards
// above, so we only reach here when a reward is actually being paid.
var xpResult = PlayerDB.AddXp(account.Id, Progress.GAME_REWARD_XP);
if (xpResult.HasValue && xpResult.Value.LevelsGained > 0)
Console.WriteLine($"[progression] {account.Id} reached Level {xpResult.Value.Level} " +
$"(+{xpResult.Value.LevelsGained}) from a {rewardType} reward");
// Offer the three-choice reward SELECTION (the watch picker). Mint three distinct drops
// for this GiftContext / current room, record them, and push "RewardSelectionReceived"
// over the notifications hub - the client shows the watch and the player picks one with
// POST /api/gamerewards/v1/select, which is what actually grants the item. Cooldown-gated
// (QuestRewards.TryClaimGiftCooldown) so a spammy client can't spawn a selection per call.
// Every activity pays TOKENS for now (ConfigCampus.RewardItemsEnabled): three random
// amounts from the 10..1000 ladder, in the ACTIVITY's own box - a Crescendo win comes
// out of the Crescendo box (its GiftContext), an unknown activity out of the plain
// tokens box. RewardSelections.Offer is the same path the admin panel's "Send Game
// Reward" uses.
Campus_live_rewrite.api.server.RewardSelections.Selection? offered = null;
// The request gate above (GameRewardGate) already paced this request; the older
// box-only cooldown (QuestRewards.TryClaimGiftCooldown) is not applied on top of it,
// or the game complete the daily grace let through would pay but offer no box.
if (Campus_live_rewrite.api.server.QuestRewards.Loaded)
{
offered = Campus_live_rewrite.api.server.RewardSelections.Offer(account.Id, GiftContext, giftContextName ?? rewardRoom, Message);
}
// The RESPONSE is the selection itself, the same object the RewardSelectionReceived
// frame carries. The client builds its watch picker from this response body directly
// (the hub frame is the cross-device copy): the old {success, Awarded, Balance} answer
// deserialised into a selection with no drops - three blank "Reward" cards showing
// "0 Tokens", RewardSelectionId 0 and a default CreatedAt that the expiry timer read
// as long past, which is the "picker opens blank / auto-picks card 1 and closes"
// report. Nothing offered (cooldown, daily already claimed) answers null.
return new ContentResult()
{
Content = offered != null
? JsonConvert.SerializeObject(Campus_live_rewrite.api.server.RewardSelections.ToPayload(offered))
: "null",
ContentType = "application/json",
StatusCode = 200
};
}
// Claim one of the three rewards an earlier selection offered (the watch picker's pick).
// The selection (minted + pushed by /gamerewards/v1/request) must be the caller's, unconsumed,
// and must actually contain the claimed drop - otherwise 403, so a player can't mint a reward
// they were never offered or redeem one twice. On success the chosen item is granted as a gift
// box (also entering the wardrobe) plus any XP it carries, and announced over the hub as
// GiftPackageRewardSelectionReceived ("32"). Items + XP only (tokens are server/admin).
[HttpPost("/api/gamerewards/v1/select")]
public ContentResult SelectGameReward()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
int selId = 0, giftDropId = 0;
try
{
if (Request.HasFormContentType)
{
int.TryParse(Request.Form["rewardSelectionId"], out selId);
int.TryParse(Request.Form["giftDropId"], out giftDropId);
}
else
{
using var reader = new StreamReader(HttpContext.Request.Body);
var body = reader.ReadToEnd();
if (!string.IsNullOrWhiteSpace(body))
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(body);
selId = (int?)jo["rewardSelectionId"] ?? (int?)jo["RewardSelectionId"] ?? 0;
giftDropId = (int?)jo["giftDropId"] ?? (int?)jo["GiftDropId"] ?? 0;
}
}
}
catch { }
if (giftDropId == 0)
return new ContentResult { Content = "{\"error\":\"giftDropId is required\"}", ContentType = "application/json", StatusCode = 400 };
var sel = selId <= 0 ? null : Campus_live_rewrite.api.server.RewardSelections.Get(selId);
var chosen = sel?.Drops.FirstOrDefault(d => d.Id == giftDropId);
// The client re-sends the pick (a double tap, or a retry after the box popped). A
// selection this account already claimed answers the SAME drop again with 200 - it
// used to 403, which the client surfaced as "Failed to consume reward".
if (sel != null && sel.AccountId == account.Id && sel.Consumed && chosen != null)
{
var already = sel.ChosenDropId != 0 ? sel.Drops.FirstOrDefault(d => d.Id == sel.ChosenDropId) ?? chosen : chosen;
Console.WriteLine($"[gamereward-select] {account.Id} re-sent selection {selId}: already claimed drop {already.Id}, answering it again");
return new ContentResult { Content = JsonConvert.SerializeObject(Campus_live_rewrite.api.server.QuestRewards.ToDrop(already)), ContentType = "application/json", StatusCode = 200 };
}
if (sel == null || sel.AccountId != account.Id || sel.Consumed || chosen == null)
{
Console.WriteLine($"[gamereward] {account.Id} select refused: selection {selId} " +
(sel == null ? "unknown (expired, or minted before a restart)" : sel.AccountId != account.Id ? "belongs to someone else" : sel.Consumed ? "already claimed" : $"does not offer drop {giftDropId}"));
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 403 };
}
// Consume conditionally: two racing claims mean the second one loses.
if (!Campus_live_rewrite.api.server.RewardSelections.Consume(sel.RewardSelectionId, giftDropId))
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 403 };
bool tokens = Campus_live_rewrite.api.server.QuestRewards.IsTokens(chosen);
int boxXp = chosen.Xp > 0 ? chosen.Xp : Campus_live_rewrite.api.server.Classes.Progress.GAME_REWARD_XP;
var box = new Campus_live_rewrite.api.server.Classes.db_class.PendingGiftBox
{
// The box is filed under the DROP's context (reference: giftContext ?? drop.Context):
// an activity's item under the activity's box, tokens under GameRewardTokens (51)
// so the client draws the token box and plays its sound, not the XP box's.
GiftContext = chosen.Context != 0 ? chosen.Context : sel.GiftContext,
Message = sel.Message,
FromAccountId = 0,
GiftDropId = chosen.Id,
FriendlyName = tokens ? $"{chosen.Currency} Tokens" : chosen.Message,
ThumbnailImageName = tokens ? Campus_live_rewrite.api.server.QuestRewards.TokenThumbnail
: (Campus_live_rewrite.api.server.QuestRewards.ItemThumbnail(chosen) is { Length: > 0 } th ? th : null),
AvatarItemDesc = chosen.AvatarItemDesc,
ConsumableItemDesc = chosen.ConsumableItemDesc,
EquipmentPrefabName = chosen.EquipmentPrefabName,
EquipmentModificationGuid = chosen.EquipmentModificationGuid,
Rarity = chosen.GiftRarity,
CurrencyType = chosen.Currency > 0 ? chosen.CurrencyType : 0,
Currency = chosen.Currency > 0 ? chosen.Currency : 0,
Xp = boxXp
};
try { PlayerDB.AddPendingGiftBoxFull(account.Id, box); }
catch (Exception ex) { Console.WriteLine($"[gamereward-select] {account.Id} grant failed: {ex.Message}"); }
// A currency drop (a duplicate converted to tokens, or Laser Tag's tickets) is paid when
// the player OPENS the box (GiftBoxes.PayOnOpen from /gifts/consume), so the counter
// moves with the reveal rather than silently at pick time.
box.PayOnOpen = box.Currency > 0 && box.CurrencyType != 0;
if (box.PayOnOpen) PlayerDB.SetGiftBoxPayOnOpen(account.Id, box);
// Announce the gift box over the hub (GiftPackageRewardSelectionReceived = 32) so it pops
// without waiting for the next /api/avatar/v2/gifts poll. BEFORE the XP lands: a level-up
// box announced by AddXp should arrive after the reward that earned it.
Campus_live_rewrite.api.server.GiftBoxes.Push(account.Id, box,
Campus_live_rewrite.api.server.GiftBoxes.FrameRewardSelection, "GiftPackageRewardSelectionReceived");
if (boxXp > 0) PlayerDB.AddXp(account.Id, boxXp);
Console.WriteLine($"[gamereward-select] {account.Id} claimed drop {giftDropId} from selection {sel.RewardSelectionId}");
return new ContentResult
{
Content = JsonConvert.SerializeObject(Campus_live_rewrite.api.server.QuestRewards.ToDrop(chosen)),
ContentType = "application/json",
StatusCode = 200
};
}
// Referral / gift generation. Same request shape as the reward request; same
// captured-response gap. Records + acknowledges.
[HttpPost("/api/avatar/v3/gifts/generate")]
[HttpPost("/api/avatar/v2/gifts/generate")] // older clients call v2
public ContentResult GenerateAvatarGift([FromForm] int GiftContext, [FromForm] string? Message, [FromForm] string? RewardType)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
Console.WriteLine($"[giftgen] account {account.Id} GiftContext={GiftContext} RewardType={RewardType}");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
error_id = "",
GiftContext = GiftContext,
RewardType = RewardType ?? ""
}),
ContentType = "application/json",
StatusCode = 200
};
}
// Friendotron / free-gift button. The client posts MultiRecipientFreeGiftRequestDTO as
// JSON: { ToPlayerIds: [int], Message, GiftContext (1300 Friendotron_Gift) }. It 404'd, so
// the machine said "Failed to Send Gift" every time. Each recipient gets a token box in
// the Friendotron look, from the sender, paid when opened, pushed live. The client
// enforces its own once-a-day limit; the server caps recipients per call.
[HttpPost("/api/freegifts/v1/sendmultiple")]
public IActionResult SendFreeGifts()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var ids = new List<ulong>();
string message = "";
int giftContext = 1300;
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
var body = reader.ReadToEnd();
if (!string.IsNullOrWhiteSpace(body))
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(body);
var arr = jo["ToPlayerIds"] as Newtonsoft.Json.Linq.JArray ?? jo["toPlayerIds"] as Newtonsoft.Json.Linq.JArray
?? jo["PlayerIds"] as Newtonsoft.Json.Linq.JArray ?? new Newtonsoft.Json.Linq.JArray();
foreach (var t in arr) { if (long.TryParse(t.ToString(), out var v) && v > 0) ids.Add((ulong)v); }
message = (string?)jo["Message"] ?? (string?)jo["message"] ?? "";
giftContext = (int?)jo["GiftContext"] ?? (int?)jo["giftContext"] ?? 1300;
}
}
catch (Exception ex) { Console.WriteLine($"[freegift] {account.Id}: bad body - {ex.Message}"); }
ids = ids.Distinct().Take(10).ToList();
if (ids.Count == 0)
return new ContentResult { Content = "{\"Result\":1,\"Success\":false,\"Error\":\"No recipients\"}", ContentType = "application/json", StatusCode = 400 };
string boxMsg = string.IsNullOrWhiteSpace(message) ? $"A free gift from @{account.Username}!" : message.Trim();
if (boxMsg.Length > 120) boxMsg = boxMsg.Substring(0, 120);
int sent = 0;
foreach (var pid in ids)
{
if (PlayerDB.GetFullAccount(pid)?.player == null) continue;
long tokens = Campus_live_rewrite.api.server.QuestRewards.RollTokenAmount();
var box = new Campus_live_rewrite.api.server.Classes.db_class.PendingGiftBox
{
GiftContext = giftContext != 0 ? giftContext : 1300,
Message = boxMsg,
FromAccountId = account.Id,
FriendlyName = $"{tokens} Tokens",
ThumbnailImageName = Campus_live_rewrite.api.server.QuestRewards.TokenThumbnail,
CurrencyType = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens,
Currency = tokens,
Rarity = Campus_live_rewrite.api.server.QuestRewards.TokenRarity(tokens),
PayOnOpen = true
};
if (!PlayerDB.AddPendingGiftBoxFull(pid, box)) continue;
Campus_live_rewrite.api.server.GiftBoxes.Push(pid, box, Campus_live_rewrite.api.server.GiftBoxes.FrameReceivedImmediate, "friendotron gift");
sent++;
}
Console.WriteLine($"[freegift] @{account.Username} ({account.Id}) sent Friendotron gifts to {sent}/{ids.Count} player(s): [{string.Join(",", ids)}]");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { Result = 0, Success = true, Sent = sent }),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/communityboard/v2/current")]
public ContentResult CurrentCommunityBoard()
{
string authtoken = HttpContext.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authtoken))
{
authtoken = HttpContext.Request.Cookies["Authorization"];
}
if (string.IsNullOrEmpty(authtoken))
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
}
string playerdata = ClientSecurity.DecodeToken(authtoken);
if (playerdata == null)
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
if (playerdata.Contains("error_token"))
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
player_info account = JsonConvert.DeserializeObject<player_info>(playerdata);
string filePath = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "communityboard.json");
string content = System.IO.File.ReadAllText(filePath);
// The board's featured player, featured rooms and announcement come from the admin
// panel's Featured tab (FeaturedStore / FeaturedRoomsStore) when set there; the file
// is the fallback for anything the panel leaves blank.
try
{
var jo = JObject.Parse(content);
var fs = Campus_live_rewrite.api.server.FeaturedStore.Get();
ulong fp = Campus_live_rewrite.api.server.DiscoveryLists.FeaturedCreator();
if (fp != 0)
{
jo["FeaturedPlayer"] ??= new JObject();
jo["FeaturedPlayer"]!["Id"] = fp;
}
var rooms = new JArray();
foreach (var rid in Campus_live_rewrite.api.server.FeaturedRoomsStore.GetIds())
{
var r = RoomDB.GetRoom(rid);
if (r == null || r.IsDorm) continue;
rooms.Add(new JObject
{
["RoomId"] = r.RoomId, ["RoomName"] = r.Name ?? "", ["ImageName"] = r.ImageName ?? "",
["IsRecRoomApproved"] = r.IsRecRoomApproved, ["ExcludeFromLists"] = false, ["ExcludeFromSearch"] = false
});
}
if (rooms.Count > 0)
{
jo["FeaturedRoomGroup"] ??= new JObject { ["FeaturedRoomGroupId"] = 1, ["Name"] = "Featured Rooms", ["StartAt"] = "2020-01-01T00:00:00Z", ["EndAt"] = "9999-12-31T00:00:00Z" };
jo["FeaturedRoomGroup"]!["Rooms"] = rooms;
}
if (!string.IsNullOrWhiteSpace(fs.Announcement))
{
jo["CurrentAnnouncement"] ??= new JObject();
jo["CurrentAnnouncement"]!["Message"] = fs.Announcement;
jo["CurrentAnnouncement"]!["MoreInfoUrl"] = string.IsNullOrWhiteSpace(fs.AnnouncementUrl) ? null : fs.AnnouncementUrl;
}
content = jo.ToString(Newtonsoft.Json.Formatting.None);
}
catch (Exception ex) { Console.WriteLine($"[communityboard] overlay failed: {ex.Message}"); }
return new ContentResult()
{
Content = content,
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/storefronts/v4/balance/{balance_type}")]
public ContentResult StorefrontBalance(int balance_type = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
long stored = PlayerDB.GetBalance(account.Id, balance_type);
var balance = new[]
{
new
{
Balance = stored,
BalanceType = balance_type,
CurrencyType = balance_type,
// The account-wide bucket. Was 0 (SteamPurchased) while the purchase response
// and the 61 push said -2, so a client that bought something summed two
// buckets and showed the wrong total until a relog.
Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket
}
};
return new ContentResult()
{
Content = JsonConvert.SerializeObject(balance),
ContentType = "application/json",
StatusCode = 200
};
}
// Add currency. Request (from the real client / farm tools):
// {"CurrencyType":N,"BalanceAdds":[{"Multiplier":1,"BalanceAddType":M}]}
// Response shape mirrors econ.rec.net: a BalanceUpdates array, one per add, each
// with an UpdateResponse (0 = OK) and a Data block the client reads for the award.
//
// NOTE: this is the same endpoint the currency farm tools abuse. On this private
// server that is acceptable (it is also how the game legitimately awards currency,
// e.g. laser tag tickets), but it applies the add for whoever is authenticated -
// there is no server-side rate limit. Add one here if that becomes a problem.
// The award DEFINITION the client fetches when a balance room loads, before any checkpoint:
// GET /api/storefronts/v1/balanceAddType/{currency}/{type} (was a 404, four times a run)
// It answers the same RewardBalanceModificationDTO shape a balance add returns, with the
// base per unit and no total. The currency is decided by the room the caller is standing
// in, never by the URL - a client asking for Silver from the Rec Center gets no award type.
[HttpGet("/api/storefronts/v1/balanceAddType/{currencyType}/{addType}")]
public ContentResult BalanceAddTypeDefinition(int currencyType, int addType)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var live = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance;
var roomCur = Campus_live_rewrite.api.server.Classes.GameCurrency.ForBalanceRoom(live?.name);
if (roomCur == null || (int)roomCur.Value != currencyType)
{
Console.WriteLine($"[balance] {account.Id} asked for add type {addType} of currency {currencyType} in '{live?.name ?? "no room"}' - not that room's currency");
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
BalanceAddType = addType,
BalanceInGiftBox = false, // a bool on the wire - "expected:'true | false', actual:'0'" (Player.log)
BaseAward = addType <= 0 ? 1 : addType,
BonusAward = 0,
CurrencyType = currencyType,
CurrentCount = 0,
Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket,
RateLimit = 0,
Total = PlayerDB.GetBalance(account.Id, currencyType)
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/storefronts/v2/balance")]
public ContentResult AddStorefrontBalance()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string body;
using (var reader = new StreamReader(HttpContext.Request.Body))
body = reader.ReadToEndAsync().GetAwaiter().GetResult();
int currencyType = 0;
long bodyInstanceId = 0, bodyRoomId = 0;
var adds = new List<(int addType, double multiplier)>();
try
{
var j = Newtonsoft.Json.Linq.JObject.Parse(body);
currencyType = (int?)j["CurrencyType"] ?? 0;
// Optional client-supplied room context. Only used to CROSS-CHECK the server's
// authoritative heartbeat instance below - never trusted on its own.
bodyInstanceId = (long?)j["RoomInstanceId"] ?? (long?)j["roomInstanceId"] ?? 0;
bodyRoomId = (long?)j["RoomId"] ?? (long?)j["roomId"] ?? 0;
foreach (var a in j["BalanceAdds"] as Newtonsoft.Json.Linq.JArray ?? new Newtonsoft.Json.Linq.JArray())
{
// Multiplier is a DECIMAL and it is where the score lives - a real
// Crescendo checkpoint sends {"BalanceAddType":1,"Multiplier":999.90}.
// Reading it as an int truncated the score and threw away the fraction.
double mult = (double?)a["Multiplier"] ?? 1d;
int at = (int?)a["BalanceAddType"] ?? 0;
adds.Add((at, mult <= 0d ? 1d : mult));
}
}
catch
{
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
}
if (adds.Count == 0)
adds.Add((0, 1));
// Empty award set = a 200 with no updates. Nothing to mint, nothing to police.
ContentResult NoAward() => new ContentResult
{
// Same DTO as a real answer (see the end of this method) - a bare BalanceUpdates is
// what the client refuses as malformed.
Content = JsonConvert.SerializeObject(new
{
BalanceUpdates = Array.Empty<object>(),
Balance = PlayerDB.GetBalance(account.Id, currencyType),
CurrencyType = currencyType,
Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket
}),
ContentType = "application/json",
StatusCode = 200
};
// ---- SERVER-SIDE room + instance gate ---------------------------------------
//
// The award is decided entirely from the player's LIVE room instance (the heartbeat),
// never from the request body. This is what stops "generating balance they ain't meant
// to have": you can only mint the currency of the room you are actually standing in.
var liveInstance = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance;
// (1) Must be in a real room instance - "need your RoomInstanceId and RoomId".
if (liveInstance == null || liveInstance.roomInstanceId == 0 || liveInstance.roomId == 0)
{
Console.WriteLine($"[balance] {account.Id} DENY - not in a live room instance (no RoomInstanceId/RoomId)");
return NoAward();
}
// If the client bothered to send its own ids, they must match the server's truth.
if ((bodyInstanceId != 0 && bodyInstanceId != liveInstance.roomInstanceId) ||
(bodyRoomId != 0 && bodyRoomId != liveInstance.roomId))
{
Console.WriteLine($"[balance] {account.Id} DENY - body instance/room ({bodyInstanceId}/{bodyRoomId}) " +
$"!= live ({liveInstance.roomInstanceId}/{liveInstance.roomId})");
return NoAward();
}
// (2) Only the whitelisted balance rooms mint anything, and the room decides the
// currency. Everything else (incl. Rec Center Token rooms) mints nothing here.
var roomCurrency = Campus_live_rewrite.api.server.Classes.GameCurrency.ForBalanceRoom(liveInstance.name);
if (roomCurrency == null)
{
Console.WriteLine($"[balance] {account.Id} in '{liveInstance.name}' DENY - not a balance room");
return NoAward();
}
currencyType = (int)roomCurrency.Value;
// (3) Rec Center Tokens (and any non-room currency) are server/admin only.
if (!Campus_live_rewrite.api.server.Classes.GameCurrency.IsPlayerGrantable(currencyType))
{
Console.WriteLine($"[balance] {account.Id} DENY - currency {currencyType} is not player-grantable");
return NoAward();
}
// ---- Anti-farm guard: 1-minute rate limit + long-session cap (deny, never ban) ----
var guard = Campus_live_rewrite.api.server.BalanceGuard.Evaluate(account.Id, liveInstance.roomInstanceId);
if (guard.decision == Campus_live_rewrite.api.server.BalanceGuard.Decision.Deny)
{
Console.WriteLine($"[balance][DENY] {account.Id} - {guard.reason}");
return NoAward();
}
Console.WriteLine($"[balance] {account.Id} room='{liveInstance.name}' inst={liveInstance.roomInstanceId} " +
$"currency={currencyType} adds=[{string.Join(", ", adds.Select(a => $"base{a.addType}x{a.multiplier}"))}]");
var updates = new List<object>();
long lastTotal = PlayerDB.GetBalance(account.Id, currencyType);
foreach (var (addType, multiplier) in adds)
{
// How much this award is worth.
//
// Real client request (Crescendo checkpoint):
// {"BalanceAdds":[{"BalanceAddType":1,"Multiplier":999.90}],"CurrencyType":101}
//
// BalanceAddType is the BASE per unit (1), not the amount. Multiplier is
// the score. So the award is base x multiplier, rounded - 1 x 999.90 = 1000
// Silver for that checkpoint.
//
// Clamped per currency: a Crescendo checkpoint is worth at most 30,000
// Silver (150,000 over five), so a wrong or forged Multiplier cannot mint
// more than a single checkpoint is worth.
long baseAward = addType;
long bonus = 0;
long raw = (long)Math.Round(baseAward * multiplier, MidpointRounding.AwayFromZero);
var cur = (Campus_live_rewrite.api.server.Classes.CurrencyType)currencyType;
long awarded = Campus_live_rewrite.api.server.Classes.GameCurrency.ClampAward(cur, raw + bonus);
if (awarded != raw + bonus)
Console.WriteLine($"[balance] {account.Id} award {raw + bonus} {cur} clamped to {awarded}");
long total = PlayerDB.AddBalance(account.Id, currencyType, awarded);
lastTotal = total;
updates.Add(new
{
UpdateResponse = 0, // 0 = success
// Data = the client's RewardBalanceModificationDTO, which it copies straight
// into a CurrencyAward { baseAward, bonusAward, currentCount, rateLimit,
// balanceAddType, currencyType, platformType } and then PLAYS - the coins that
// fly into the counter are baseAward + bonusAward. So BaseAward has to be the
// amount actually granted (base x score), not the base unit: with the unit here
// the player watched "+1" arrive while 1000 had been credited.
Data = new
{
BalanceAddType = addType,
BalanceInGiftBox = false, // a bool on the wire - "expected:'true | false', actual:'0'" (Player.log)
BaseAward = awarded,
BonusAward = 0,
CurrencyType = currencyType,
CurrentCount = 0,
Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket,
RateLimit = 0,
Total = total
}
});
}
// The client deserialises this as BalanceUpdateResponseDTO<T>: BalanceUpdates PLUS the
// BalanceResponseDTO base - Balance (the resulting total), CurrencyType and Platform
// (BalanceType renamed on the wire). Sending BalanceUpdates alone was rejected by the
// client as "Malformed Response" - the checkpoint silver was credited server-side and
// then thrown away by the client, which is why a Crescendo checkpoint paid nothing
// visible. (wire-shapes: econ-bulkpurchase, the same DTO.)
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
BalanceUpdates = updates,
Balance = lastTotal,
CurrencyType = currencyType,
Platform = Campus_live_rewrite.api.server.Classes.GameCurrency.AllPlatformsBucket
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/customAvatarItems/v1/isCreationEnabled")]
public ContentResult IsCreationEnabled()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
player_data FullAccount = PlayerDB.GetFullAccount(account.Id);
return new ContentResult()
{
Content = "true",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/customAvatarItems/v1/isRenderingEnabled")]
public ContentResult IsRenderingEnabled()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
player_data FullAccount = PlayerDB.GetFullAccount(account.Id);
return new ContentResult()
{
Content = "true",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/CampusCard/v1/UpdateAndGetSubscription")]
public IActionResult RRPlus()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var subscription = new SubscriptionResponse
{
subscription = new Subscription
{
subscriptionId = 507911,
recNetPlayerId = account.Id,
platformType = 0,
platformId = "76561198216660599",
platformPurchaseId = "4069635207998545083",
@event = null,
type = 0,
// SubscriptionLevel: Gold = 0, Platinum = 1 (see api/server/Classes/RRPlus.cs).
// Platinum is the top tier, so nothing gated on level stays locked.
level = (int)Campus_live_rewrite.api.server.Classes.RRPlus.SubscriptionLevel.Platinum,
// SubscriptionPeriod: Month = 0, Year = 1.
period = (int)Campus_live_rewrite.api.server.Classes.RRPlus.SubscriptionPeriod.Year,
// Was: 2024-03-20 - an expiry two years in the past, so the client
// correctly read the subscription as lapsed and showed the
// "Unlock Maker AI with RR+ / Subscribe Now" upsell. Far-future so
// it never lapses again.
expirationDate = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc),
isAutoRenewing = true,
createdAt = new DateTime(2024, 2, 18, 7, 15, 0, 284, DateTimeKind.Utc).AddTicks(5308),
modifiedAt = new DateTime(2024, 3, 20, 7, 1, 55, DateTimeKind.Utc).AddTicks(3454572)
},
platformAccountSubscribedPlayerId = null
};
return Content(System.Text.Json.JsonSerializer.Serialize(subscription), "application/json");
}
// The RR+ season running now. The 20250718 client reads ONE season object here (the
// reference's `[]` was for a 2023 client): `[]` failed its parse and logged
// "GetCurrentSeason failed" on every boot and every RR+ page. No season runs here, so the
// answer is JSON null - "no current season", which the object reader accepts.
[HttpGet("/api/subscriptionseasons/v1/seasons/current")]
public ContentResult RRPlus2()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = "null",
ContentType = "application/json",
StatusCode = 200
};
}
// The global slideshow feed: the most recent PUBLIC ShareCamera photos across all rooms,
// newest first, each joined to its creator's username and room name. Ported from RecFlare
// (getSlideshowImages); was a hardcoded "[]" so every slideshow surface sat empty.
//
// Deliberately PUBLIC (no token): it surfaces only already-public images and backs the
// anonymous homepage slideshow. `take` defaults to 10, capped at 100 so an unauthenticated
// caller can't ask for the whole image table. Answers { Images, ValidTill } where ValidTill
// is a ~2-minute cache hint the client refreshes against.
[HttpGet("/api/images/v1/slideshow")]
public ContentResult Slideshow([FromQuery] int? take = null)
{
var rows = ImageMetadataDB.GetSlideshowImages(take ?? ImageMetadataDB.SlideshowLimit);
// Bulk-resolve usernames and room names once, so the join is not a query per row.
var names = new Dictionary<ulong, string>();
foreach (var pid in rows.Select(r => r.PlayerId).Where(p => p != 0).Distinct())
names[pid] = PlayerDB.GetFullAccount(pid)?.player?.Username ?? $"Player{pid}";
var roomNames = new Dictionary<int, string?>();
foreach (var rid in rows.Select(r => r.RoomId).Where(r => r != 0).Distinct())
roomNames[rid] = RoomDB.GetRoom((ulong)rid)?.Name;
var images = rows.Select(img => new
{
SavedImageId = img.Id,
ImageName = img.ImageName,
Username = img.PlayerId != 0 && names.TryGetValue(img.PlayerId, out var n) ? n : $"Player{img.PlayerId}",
RoomName = img.RoomId != 0 && roomNames.TryGetValue(img.RoomId, out var rn) ? rn : null,
RoomId = img.RoomId == 0 ? (int?)null : img.RoomId,
SavedImageType = (int)img.Type,
PlayerEventId = img.PlayerEventId == 0 ? (ulong?)null : img.PlayerEventId,
Accessibility = (int)img.Accessibility,
PlayerIds = img.TaggedPlayerIds ?? new List<ulong>()
});
var body = new
{
Images = images,
ValidTill = DateTime.UtcNow.AddMinutes(2).ToString("o")
};
return new ContentResult()
{
Content = Newtonsoft.Json.JsonConvert.SerializeObject(body),
ContentType = "application/json",
StatusCode = 200
};
}
// The item wishlist (the heart on a store item / the Wishlist page). The 2023 server's
// working shape: the reads are a bare array of PurchasableItemIds, the Wishlist
// storefront (giftdropstore/1300) is built per player from those ids. The 2025 client
// posts add/remove to /wishlist/add and /wishlist/remove; the item id is read from the
// body, the query or the route, whichever it used, and the raw fields are logged so the
// spelling can be pinned.
private static long WishlistItemId(Dictionary<string, string> f, long fromRoute = 0)
{
if (fromRoute > 0) return fromRoute;
return RoomModeration.Long(f, "PurchasableItemId", "purchasableItemId", "itemId", "ItemId", "id", "Id", "item");
}
// The 2025 client posts { purchasableItemId, customAvatarItemId } - a UGC wish carries
// purchasableItemId -1 and the item GUID. The client reads the wishlist as a LIST OF ENTRY
// OBJECTS (real RecNet: WishlistItemId / AccountId / PurchasableItemId / CreatedAt); the
// bare id array we answered before failed its parser the moment it was non-empty
// ("Failed to add item to wishlist" / "Failed to get wishlist for player: An error occurred").
private static string? WishlistCustomId(Dictionary<string, string> f) =>
RoomModeration.Str(f, "customAvatarItemId", "CustomAvatarItemId", "customItemId") is { Length: > 0 } s && s != "null" ? s : null;
[HttpPost("/api/itemWishlists/v1/wishlist/add")]
[HttpPut("/api/itemWishlists/v1/wishlist/add")]
[HttpPost("/api/itemWishlists/v1/wishlist/add/{itemId:long}")]
[HttpPost("/api/itemWishlists/v1/wishlist/me/{itemId:long}")]
[HttpPut("/api/itemWishlists/v1/wishlist/me/{itemId:long}")]
[HttpPost("/api/itemWishlists/v1/wishlist/me")]
[HttpPut("/api/itemWishlists/v1/wishlist/me")]
public IActionResult WishlistAdd(long itemId = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
long item = WishlistItemId(f, itemId);
string? custom = WishlistCustomId(f);
int sf = (int)RoomModeration.Long(f, "StorefrontType", "storefrontType", "Storefront");
Console.WriteLine($"[wishlist] {account.Id} add {Request.Path}: {RoomModeration.Dump(f)} -> item {item} ugc {custom ?? "-"}");
if (item <= 0 && custom == null) return BadRequest(new { success = false, error = "no item id" });
var row = WishlistDB.Add(account.Id, sf, item > 0 ? (int)item : -1, custom, RoomModeration.Dump(f));
return Ok(WishlistDB.ToWire(row));
}
[HttpPost("/api/itemWishlists/v1/wishlist/remove")]
[HttpDelete("/api/itemWishlists/v1/wishlist/remove")]
[HttpPost("/api/itemWishlists/v1/wishlist/delete")]
[HttpDelete("/api/itemWishlists/v1/wishlist/delete")]
[HttpPost("/api/itemWishlists/v1/wishlist/remove/{itemId:long}")]
[HttpDelete("/api/itemWishlists/v1/wishlist/remove/{itemId:long}")]
[HttpDelete("/api/itemWishlists/v1/wishlist/me/{itemId:long}")]
[HttpDelete("/api/itemWishlists/v1/wishlist/me")]
[HttpDelete("/api/itemWishlists/v1/wishlist/add")]
[HttpDelete("/api/itemWishlists/v1/wishlist/{itemId:long}")]
[HttpDelete("/api/itemWishlists/v1/wishlist")]
public IActionResult WishlistRemove(long itemId = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
long item = WishlistItemId(f, itemId);
string? custom = WishlistCustomId(f);
// Removal by the entry's WishlistItemId (the guid the list handed out) - route tail,
// body or query - is resolved back to the row it names.
string? wid = RoomModeration.Str(f, "wishlistItemId", "WishlistItemId", "id", "Id");
string tail = Request.Path.Value?.TrimEnd('/').Split('/').LastOrDefault() ?? "";
if (Guid.TryParse(wid, out var g1) || Guid.TryParse(tail, out g1))
{
var row = WishlistDB.For(account.Id).FirstOrDefault(r => WishlistDB.WireId(r) == g1);
if (row != null) { item = row.PurchasableItemId; custom = row.CustomAvatarItemId; }
}
Console.WriteLine($"[wishlist] {account.Id} remove {Request.Path}: {RoomModeration.Dump(f)} -> item {item} ugc {custom ?? "-"}");
if (item <= 0 && custom == null) return BadRequest(new { success = false, error = "no item id" });
// Answered with the removed ENTRY - the client reads remove with the same response type
// as add (same request helper instantiation in its wishlist service). The whole list we
// used to send failed that parse, so the heart stayed filled and players pressed remove
// again and again (57 of 192 removes repeated within 30 s, 2026-09-27).
var gone = WishlistDB.For(account.Id).FirstOrDefault(r => custom != null
? string.Equals(r.CustomAvatarItemId, custom, StringComparison.OrdinalIgnoreCase)
: r.CustomAvatarItemId == null && r.PurchasableItemId == (int)item)
?? new WishlistDB.Row { Key = custom != null ? $"{account.Id}|ugc|{custom.ToLowerInvariant()}" : $"{account.Id}|{item}", AccountId = account.Id, PurchasableItemId = custom != null ? -1 : (int)item, CustomAvatarItemId = custom };
WishlistDB.Remove(account.Id, (int)item, custom);
return Ok(WishlistDB.ToWire(gone));
}
// The caller's creator earnings: what is pending, what was paid, and when the next
// Sunday payout lands.
[HttpGet("/api/earnings/v1/mine")]
[HttpGet("/api/site/earnings")]
public IActionResult MyEarnings()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var (pending, paid, next) = EarningsDB.Summary(account.Id);
var rows = EarningsDB.ForPlayer(account.Id).Select(e => new
{
e.Id, e.Kind, e.ItemName, e.Spent, e.Share, e.CurrencyType, e.BoughtAt, e.PayoutAt, e.PaidAt,
Buyer = PlayerDB.GetAccountById(e.BuyerId)?.Username ?? e.BuyerId.ToString()
}).ToList();
return Ok(new { Pending = pending, Paid = paid, NextPayout = next, Percent = ConfigCampus.CreatorEarningsPercent, PendDays = ConfigCampus.CreatorEarningsPendDays, Sales = rows });
}
[HttpGet("/api/itemWishlists/v1/wishlist/me")]
public IActionResult MyItemWishlists()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var mine = WishlistDB.WireFor(account.Id);
// The star on a store item is drawn from what this read returns, so say what went out (which ids) - GETs are not
// logged anywhere else. 2026-10-02: the Luau Hat star stayed empty although the item was on the wishlist.
Console.WriteLine($"[wishlist] {account.Id} GET {Request.Path}{Request.QueryString} -> {mine.Count} entr{(mine.Count == 1 ? "y" : "ies")}: " +
string.Join(",", WishlistDB.For(account.Id).Take(40).Select(r => r.PurchasableItemId > 0 ? r.PurchasableItemId.ToString() : "ugc")));
return Ok(mine);
}
[HttpGet("/api/itemWishlists/v1/wishlist/{playerId:long}")]
public IActionResult GetWishlistForPlayer(ulong playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var theirs = WishlistDB.WireFor(playerId);
Console.WriteLine($"[wishlist] {account.Id} GET {Request.Path}{Request.QueryString} (player {playerId}) -> {theirs.Count} entr{(theirs.Count == 1 ? "y" : "ies")}");
return Ok(theirs);
}
// The STAR on a store item. The 2025 client (isonwishlist/bulk, GameAssembly 0x7c91820) sends
// { accountIds: [..], purchasableItemId, customAvatarItemId } - one ITEM, a list of ACCOUNTS -
// and reads List<bool>: one answer per account id, in order, "is this item on THAT account's
// wishlist". It used to be answered as one bool per item id, so the list came back empty, the
// star never filled, and pressing it again sent another ADD instead of a REMOVE.
[HttpPost("/api/itemWishlists/v1/isonwishlist/bulk")]
[HttpGet("/api/itemWishlists/v1/isonwishlist/bulk")]
public IActionResult IsOnWishlistBulk()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
long pid = RoomModeration.Long(f, "purchasableItemId", "PurchasableItemId");
string? custom = WishlistCustomId(f);
// Old spelling (a list of item ids, one answer per item) - kept only when the item form is absent.
if (pid <= 0 && custom == null && RoomModeration.Str(f, "purchasableItemIds", "PurchasableItemIds", "itemIds") != null)
{
var items = RoomModeration.ULongs(f, "purchasableItemIds", "PurchasableItemIds", "itemIds");
var mine = WishlistDB.For(account.Id).Where(r => r.PurchasableItemId > 0).Select(r => (ulong)r.PurchasableItemId).ToHashSet();
return Ok(items.Select(i => mine.Contains(i)).ToList());
}
// Every account id, in the order sent (repeats kept: the answer is positional).
var accounts = new List<ulong>();
foreach (var n in new[] { "accountIds", "AccountIds", "accountId", "AccountId", "ids" })
{
if (!f.TryGetValue(n, out var v) || string.IsNullOrWhiteSpace(v)) continue;
foreach (var part in v.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries))
accounts.Add(ulong.TryParse(part.Trim().Trim('[', ']', '"'), out var a) ? a : 0UL);
if (accounts.Count > 0) break;
}
if (accounts.Count == 0) accounts.Add(account.Id);
var answer = accounts.Select(a => WishlistDB.Has(a, pid, custom)).ToList();
Console.WriteLine($"[wishlist] {account.Id} isonwishlist: {RoomModeration.Dump(f)} -> [{string.Join(",", answer.Select(b => b ? "1" : "0"))}]");
return Ok(answer);
}
// Anything else under itemWishlists: logged by name so a new spelling is one line in the
// journal instead of a silent 404. A guid in the path on a DELETE/POST is treated as a
// removal by WishlistItemId (e.g. DELETE .../wishlist/{guid}).
[Route("/api/itemWishlists/{**rest}")]
public IActionResult WishlistFallback(string? rest)
{
var f = RoomModeration.Fields(Request);
Console.WriteLine($"[wishlist] UNHANDLED {Request.Method} {Request.Path}{Request.QueryString} {RoomModeration.Dump(f)}");
string tail = (rest ?? "").TrimEnd('/').Split('/').LastOrDefault() ?? "";
if (Guid.TryParse(tail, out _) && !HttpMethods.IsGet(Request.Method)) return WishlistRemove(0);
return Ok(Array.Empty<long>());
}
[HttpGet("/api/server/status")]
public ContentResult status()
{
string key = HttpContext.Request.Headers["key"].ToString();
if (key == ConfigCampus.VALID_STATUS_KEY)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
uptime = UPTIME,
}),
ContentType = "application/json",
StatusCode = 200
};
}
else
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
message = "Forbidden!"
}),
ContentType = "application/json",
StatusCode = 403
};
}
}
[HttpPost("/api/sanitize/v1")]
public IActionResult APISanitizeV1([FromBody] Sanitize request)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string value = request?.Value ?? "";
if (ConfigCampus.AllowSwears == true)
{
return Content(JsonConvert.SerializeObject(value), "application/json");
}
// The client tells us what to mask with (ReplacementChar, "*" in practice).
char mask = string.IsNullOrEmpty(request?.ReplacementChar) ? '*' : request.ReplacementChar[0];
string sanitized = Sanitize.SanitizeMessage(value, mask);
if (ConfigCampus.LogMessagesFromSanitize == true)
{
_ = Task.Run(() =>
{
try
{
Webhook.Send(account.Id, "mod", "Reporting Info - Sanitize", $"({account.Id}) @{account.Username} \n**Sanitize requested!**\n" +
$"**Current Room:** {HeartbeatDB.GetPlayerCurrentRoomName(account.Id)}\n" +
$"**Request Message:** \"{value}\"\n" +
$"**Sanitized Message:** \"{sanitized}\"\n" +
$"**Context:** \"{request.Context}\""
, 5898927);
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
}
});
}
return Content(JsonConvert.SerializeObject(sanitized), "application/json");
}
[HttpPost("/api/sanitize/v1/isPure")]
public IActionResult ApiSanitizeV1IsPure([FromBody] Sanitize request)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var isPure = !Sanitize.ContainsSwears(request.Value);
return Ok(new
{
IsPure = isPure
});
}
[HttpGet("/api/customAvatarItems/v2/fromCreator/{PlayerId}")]
public ContentResult CustomAvatarItemsFromCreator(ulong PlayerId, [FromQuery] int skip = 0, [FromQuery] int take = 1000)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (take > 1000) take = 1000;
if (take < 0) take = 0;
if (skip < 0) skip = 0;
// The UGC this player created, newest first, from the in-memory index. Another
// player's profile only shows what the creator PUBLISHED (Accessibility != 0) - the
// creator sees their private / unpublished items too, the way the reference does.
bool self = PlayerId == account.Id;
var mine = CustomItemsCache.ByCreator(PlayerId)
.Where(x => self || ((int?)x["Accessibility"] ?? 0) != 0)
.OrderByDescending(x => ((string?)x["CreatedAt"]) ?? "")
.ToList();
// The Coach (account 1) also owns the first-party stock, as on the reference.
if (PlayerId == CustomItemsCache.CoachAccountId && ConfigCampus.ServeFirstPartyClothing)
mine.AddRange(CustomItemsCache.FirstPartyRecs().Select(r => r.ToJObject()));
var page = mine.Skip(skip).Take(take).ToList();
return new ContentResult()
{
Content = new JObject
{
["Results"] = new JArray(page),
["TotalResults"] = mine.Count
}.ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/influencerpartnerprogram/influencer")]
public ContentResult influencerpartnerprogram(ulong accountId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Bare number, always 200 (a 404 here is the "404 blanks the profile" trap). The value
// is the influencer THIS ACCOUNT SUPPORTS (0 = nobody) - the same thing /myinfluencer
// answers for the caller. "Is X an influencer" is a different question, answered by
// the /influencers id list the client badges creators from. Answering X's own id here
// whenever X was an influencer made every influencer's profile read as supporting
// themselves, whoever they had actually picked.
string body = (PlayerDB.GetMyInfluencer(accountId) ?? 0).ToString();
return new ContentResult()
{
Content = body,
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/avatar/v2/set")]
public async Task<ContentResult> SetAvatar()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string jsonData = "";
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
jsonData = await reader.ReadToEndAsync();
}
catch
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
if (string.IsNullOrWhiteSpace(jsonData))
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
try
{
Newtonsoft.Json.Linq.JToken.Parse(jsonData);
}
catch
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
var player = PlayerDB.GetFullAccount(account.Id);
if (player == null)
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
try
{
PlayerDB.set_player_avatar(jsonData, player.playerid);
}
catch (Exception ex)
{
Console.WriteLine($"[Avatar Set Error] {ex}");
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/consumables/v1/consume")]
public IActionResult ConsumeConsumable()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok("");
}
[HttpGet("/api/online_players")]
public IActionResult OnlinePlayers()
{
var onlineList = new List<object>();
foreach (var (playerId, socket) in NotificationsController.WebSockets)
{
onlineList.Add(new
{
PlayerId = playerId,
CurrentRoom = HeartbeatDB.GetPlayerCurrentRoomName(playerId)
});
}
return Ok(onlineList);
}
[HttpGet("/api/ping")]
public ContentResult ServerPing()
{
return new ContentResult
{
Content = "true",
ContentType = "application/json",
StatusCode = 200
};
}
// todo check if it a real image file and return a 400 error if it not a image file - Waeboi69
[HttpPost("/api/images/v4/uploadsaved")]
public async Task<ContentResult> UploadImage()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
HttpContext.Request.EnableBuffering();
byte[] array;
using (var memoryStream = new MemoryStream(capacity: 4 * 1024 * 1024))
{
await HttpContext.Request.Body.CopyToAsync(memoryStream, bufferSize: 131072);
array = memoryStream.ToArray();
}
HttpContext.Request.Body.Position = 0;
// Only a photo the game's camera encoded gets stored (see UploadGuard). Checked
// before anything touches disk.
byte[]? photo = Utils.ParseData(array, "file.bin", out bool photoParsed);
var refused = UploadGuard.Gate(HttpContext, account, UploadGuard.Kind.Image, photoParsed ? photo : null, "images/v4/uploadsaved");
if (refused != null) return refused;
var saveImageTask = Task.Run(() => Images.SaveImageFile(array));
var formTask = HttpContext.Request.ReadFormAsync();
var file = await saveImageTask.ConfigureAwait(false);
var form = await formTask.ConfigureAwait(false);
if (!form.TryGetValue("imgMeta", out var imgMetaValue) || string.IsNullOrEmpty(imgMetaValue))
{
return new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
ImageMetadataDB.ImageMetadata? metadata = null;
if (!string.IsNullOrEmpty(imgMetaValue))
{
metadata = JsonConvert.DeserializeObject<ImageMetadataDB.ImageMetadata>(imgMetaValue!);
}
var newImage = new FullSavedImage
{
Id = 0,
RoomId = metadata.roomId,
PlayerId = account.Id,
ImageName = file.rawfilename,
Description = metadata.description,
Accessibility = metadata.accessibility,
TaggedPlayerIds = metadata.playerIds,
CreatedAt = DateTime.UtcNow
};
if (metadata.savedImageType != SavedImageTypeEnum.OutfitThumbnail)
{
await Task.Run(() => ImageMetadataDB.InsertImage(newImage)).ConfigureAwait(false);
if (metadata.accessibility == ImageAccessibility.Public)
{
_ = Task.Run(() => Webhook.Send(
account.Id, "prod", "Server info",
$"({account.Id}) @{account.Username} has uploaded an image",
5898927, $"https://camprec.net/img/{file.rawfilename}"
));
}
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
ImageName = file.rawfilename
}),
ContentType = "application/json",
StatusCode = 200
};
}
// The client expects ONE BARE 13-key image record here, never an array (wire-shapes:
// api-images-v6). Both spellings are live: ?name= and the path form /api/images/v6/{id},
// which used to 404. An unknown image answers a stub record rather than [] - the client's
// post-deserialize hook only normalises null ids to 0, so a stub is safe and an array is a
// Malformed Response.
[HttpGet("/api/images/v6/{savedImageId:long}")]
public ContentResult GetImageById(long savedImageId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var rec = ImageMetadataDB.GetImageV6ById(savedImageId);
return new ContentResult
{
Content = JsonConvert.SerializeObject(rec ?? ImageMetadataDB.EmptyImageV6("")),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/images/v6")]
public ContentResult GetImageByImageName(string name)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
try
{
var image = ImageMetadataDB.GetImageV6ByName(name) ?? ImageMetadataDB.EmptyImageV6(name);
return new ContentResult
{
Content = JsonConvert.SerializeObject(image),
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception ex)
{
Console.WriteLine($"[Image V6 Error] {ex}");
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
}
[HttpGet("/api/images/v5/cheered/bulk")]
[HttpPost("/api/images/v5/cheered/bulk")]
public ContentResult ImagesCheered([FromQuery] int[] id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// The 2025 client POSTs the ids (form or JSON "id"/"ids"/"imageIds") - 405'd as GET-only.
if (id == null || id.Length == 0)
{
var f = RoomModeration.Fields(Request);
id = RoomModeration.ULongs(f, "id", "ids", "imageIds", "savedImageIds", "SavedImageIds").Select(u => (int)u).ToArray();
}
var results = id.Select(imageId => new
{
SavedImageId = imageId,
IsCheered = ImageMetadataDB.HasUserCheered(imageId, account.Id)
}).ToList();
return new ContentResult
{
Content = JsonConvert.SerializeObject(results),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/images/v4/room/{id}")]
public ContentResult GetRoomImages(int id, int sort = 0, int filter = 0, int take = 50, int skip = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var images = ImageMetadataDB.GetRoomImages(account.Id, id, sort, filter, take, skip);
return new ContentResult
{
Content = JsonConvert.SerializeObject(images, Formatting.Indented),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/images/v1/cheer")]
public ContentResult CheerImage([FromBody] CheerRequest request)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
bool isCheered = ImageMetadataDB.ToggleCheer(request.SavedImageId, account.Id, request.Cheer);
int cheerCount = ImageMetadataDB.GetCheerCount(request.SavedImageId);
ImageMetadataDB.UpdateImageCheerCount(request.SavedImageId, cheerCount);
return new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/images/v5/player/{playerid}")]
public IActionResult GetPlayerImages(ulong playerid, int sort = 0, int skip = 0, int take = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var playerImages = ImageMetadataDB.GetPlayerImages(account.Id, playerid, sort, skip, take);
return Ok(playerImages);
}
// The photo tab on a profile: the images this player took PLUS the ones they're tagged in,
// newest first (RecFlare /api/images/v3/feed/player). Only v3/feed/GLOBAL and v5/player
// existed, so the profile's photo feed 404'd - a big reason a profile opened blank. Serves
// the same ImagesPlayer projection (bare array) as the other player photo lists.
[HttpGet("/api/images/v3/feed/player/{playerid}")]
public IActionResult GetPlayerFeed(ulong playerid, [FromQuery] int skip = 0, [FromQuery] int take = 100, [FromQuery] int sort = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ImageMetadataDB.GetPlayerFeed(account.Id, playerid, skip, take, sort: sort));
}
// A player's own photos (RecFlare /api/images/v4/player) - the older sibling of v5/player,
// same ImagesPlayer projection. Was missing, so a client that asks for v4 got a 404 and an
// empty photo grid.
[HttpGet("/api/images/v4/player/{playerid}")]
public IActionResult GetPlayerImagesV4(ulong playerid, [FromQuery] int skip = 0, [FromQuery] int take = 100, [FromQuery] int sort = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ImageMetadataDB.GetPlayerImages(account.Id, playerid, sort, skip, take));
}
[HttpGet("/api/images/v5/bulk")]
public ContentResult GetImagesByBulkIds()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var idsRaw = Request.Query["ids"];
List<int> idList = idsRaw
.SelectMany(x => x.Split(',', StringSplitOptions.RemoveEmptyEntries))
.Select(x => x.Trim())
.Where(x => int.TryParse(x, out _))
.Select(int.Parse)
.ToList();
if (idList.Count == 0)
{
return new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
// SavedImageId / SavedImageType shape - see ImageMetadataDB.GetImagesV6ByIds.
var images = ImageMetadataDB.GetImagesV6ByIds(idList);
return new ContentResult
{
Content = JsonConvert.SerializeObject(images),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/rooms/v1/filters")]
public ContentResult GetRoomTags()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var roomTags = new
{
PinnedFilters = new[]
{
"recroomoriginal", "community", "featured", "quest", "pvp", "hangout", "game",
"art", "store", "tutorial", "fandom", "performance", "action", "horror"
},
PopularFilters = new[]
{
"pvp", "quest", "game", "hangout", "art"
},
TrendingFilters = new[]
{
"roleplay", "nomp", "rp", "casual", "fun", "action", "military", "sports"
}
};
return new ContentResult
{
Content = JsonConvert.SerializeObject(roomTags),
ContentType = "application/json",
StatusCode = 200
};
}
// Whether the caller holds at least `role` in a room - the gate the client checks before
// letting someone use the Maker Pen / room settings in-room (reference: verifyRole). A bare
// true/false; every failure is false rather than an error status. The creator always
// passes; anyone else needs a Roles entry at that level or higher. This answered "[]" for
// everyone, so a co-owner (and anyone the client asked about) was told "no" - the "can't
// do anything in a room I'm co-owner of" complaint. Params from the form body, falling
// back to the query string; `context` (e.g. MakerPen) is accepted and ignored.
[HttpPost("/api/rooms/v1/verifyRole")]
public ContentResult VerifyRole()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return new ContentResult { Content = "false", ContentType = "application/json", StatusCode = 200 };
string Param(string name)
{
try
{
if (Request.HasFormContentType)
{
var v = Request.Form[name].ToString();
if (!string.IsNullOrEmpty(v)) return v;
}
}
catch { }
return Request.Query[name].ToString();
}
ulong.TryParse(Param("roomId"), out var roomId);
int.TryParse(Param("role"), out var role);
if (roomId == 0) roomId = (ulong)Math.Max(0, long.TryParse(Param("RoomId"), out var r2) ? r2 : 0);
var room = roomId != 0 ? RoomDB.GetRoom(roomId) : null;
bool ok = room != null && RoomDB.PlayerHasRoleAtLeast(roomId, account.Id, role);
// role 0 is the "an in-game role lets me use the pen" check, which the server can't
// see and so answers yes to any visitor. Rec Room Originals (the Rec Center...) have
// no such in-game roles: there it takes the creator, a real server role, or staff,
// so a client claiming the pen in the Rec Center is told no.
if (ok && role <= 0 && RoomDB.IsRecRoomOriginal(room) && room!.CreatorAccountId != account.Id
&& !AutoEnforce.IsStaff(account.Id)
&& !(room.Roles?.Any(r => (ulong)r.AccountId == account.Id && r.Role != RoomDBClasses.Role_data.None && r.Role != RoomDBClasses.Role_data.Banned) ?? false))
ok = false;
Console.WriteLine($"[verifyRole] {account.Id} room {roomId} role>={role} ctx='{Param("context")}' -> {ok}");
// The game only asks when it already believes the player may use the tool, so a
// refusal means a tampered client (or a player banned from the room still in it).
// Only for a real room: roomId 0 / an unknown room is ambiguous and just answers no.
if (!ok && room != null && ConfigCampus.VerifyRoleFailDisconnect)
AutoEnforce.DisconnectAndTimeout(account.Id, Moderation_Detail.ReportCategory.Cheating, ConfigCampus.VerifyRoleFailTimeoutSeconds,
"Disconnected: you tried to use a tool you don't have permission for in this room.",
$"verifyRole refused: room {roomId} ('{room.Name}') role>={role} ctx='{Param("context")}'");
return new ContentResult { Content = ok ? "true" : "false", ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("/api/avatar/v3/saved")]
public ContentResult GetOutfit()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = PlayerDB.GetAvatarSaved(account.Id),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/avatar/v4/saved/set")]
public async Task<ContentResult> SaveOutfit()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
using var reader = new StreamReader(HttpContext.Request.Body);
string body = await reader.ReadToEndAsync();
avatar_data_saved avatar;
try
{
avatar = JsonConvert.DeserializeObject<avatar_data_saved>(body);
if (avatar == null)
throw new Exception("Deserialization returned null");
Console.WriteLine($"Slot: {avatar.Slot}, Name: {avatar.Name}, SkinColor: {avatar.SkinColor}");
// Keep the raw document the client posted so a preset round-trips without losing
// any field our structured type doesn't model.
if (string.IsNullOrEmpty(avatar.Avatar)) avatar.Avatar = body;
PlayerDB.SetAvatarSaved(account.Id, new List<avatar_data_saved> { avatar });
}
catch (Exception ex)
{
Console.WriteLine("Failed to parse or save avatar JSON: " + ex.Message);
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Success = true,
Slot = avatar.Slot
}),
ContentType = "application/json",
StatusCode = 200
};
}
// v3 of the save-outfit route. SAME payload, table and slot-overwrite semantics as
// /api/avatar/v4/saved/set above - the reference shares one handler for both and only the
// RESPONSE differs (v3 echoes the whole saved outfit back; v4 answers { Success, Slot }).
// This route did not exist, so a client that saves through v3 got a 404 and the outfit
// never reached the database. Persists into the same store (PlayerDB.SetAvatarSaved ->
// player_Extra.SavedOutfits) that /api/avatar/v3/saved and /outfits/me/saved read back.
[HttpPost("/api/avatar/v3/saved/set")]
public async Task<ContentResult> SaveOutfitV3()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
using var reader = new StreamReader(HttpContext.Request.Body);
string body = await reader.ReadToEndAsync();
avatar_data_saved avatar;
try
{
avatar = JsonConvert.DeserializeObject<avatar_data_saved>(body);
if (avatar == null)
throw new Exception("Deserialization returned null");
Console.WriteLine($"[v3/saved/set] Slot: {avatar.Slot}, Name: {avatar.Name}");
if (string.IsNullOrEmpty(avatar.Avatar)) avatar.Avatar = body;
PlayerDB.SetAvatarSaved(account.Id, new List<avatar_data_saved> { avatar });
}
catch (Exception ex)
{
Console.WriteLine("[v3/saved/set] failed to parse or save avatar JSON: " + ex.Message);
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
}
// v3 echoes the saved outfit back (v4 returns the lean ack instead).
return new ContentResult
{
Content = JsonConvert.SerializeObject(avatar),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/PlayerCheer/v1/SetSelectedCheer")]
public async Task<ContentResult> SetSelectedCheer()
{
try
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Form, query or JSON - Request.Form threw on a non-form body and the catch below
// turned every such call into a 400.
var cf = Campus_live_rewrite.api.server.RoomModeration.Fields(Request);
string? cheerCategoryStr = Campus_live_rewrite.api.server.RoomModeration.Str(cf, "CheerCategory", "cheerCategory", "category", "value");
if (string.IsNullOrEmpty(cheerCategoryStr))
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
if (!Enum.TryParse<CheerCategoryEnum>(cheerCategoryStr, out var cheerCategory))
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
if (account == null)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
}
// Enum.TryParse above accepts ANY whole number, so "4000" or "9000" (the Rec Room Developer badge) used to be stored for
// anyone. A normal player may only pick none or the five real cheers (0/10/20/30/40); a developer may pick any badge.
if (!PlayerDB.MaySelectCheer(cheerCategory, account.Id))
{
Console.WriteLine($"[cheer] {account.Id} tried to select cheer {cheerCategoryStr} - refused");
return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 400 };
}
await Task.Run(() => PlayerDB.SetPlayerCheer(cheerCategory, account.Id));
try
{
var selfUpdate = JsonConvert.SerializeObject(WebsocketEvents.CreateResponseReputationUpdate(account.Id));
var globalUpdate = JsonConvert.SerializeObject(WebsocketEvents.CreateResponseReputationUpdate(account.Id));
var notifySelfTask = Notifications.SendToPlayer(account.Id, selfUpdate);
var notifyAllTask = Notifications.SendToAll(globalUpdate);
await Task.WhenAll(notifySelfTask, notifyAllTask);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to send notification: {ex.Message}");
}
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
catch (Exception ex)
{
Console.WriteLine($"SetSelectedCheer Error: {ex}");
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
}
[HttpGet("/api/incentivizedreferrals/progress")]
public ContentResult get_incentivizedreferrals_incentivizedreferrals()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
success = true,
value = new
{
ReferralsVerifiedCount = 0,
PlayerReferralRewards = new List<object>()
},
/*
[{
"UnlockedAt": null,
"ClaimedAt": null,
"Status": 0,
"ReferralRewardTier": {
"ReferralRewardTierId": 1,
"RequiredReferralCount": 1,
"RewardSelectionCount": 1
}
}]
*/
}),
ContentType = "application/json",
StatusCode = 200
};
}
//https://econ.rec.net/api/influencerpartnerprogram/influencer?accountId=1503269750 return a 404 or a influencer account id
// /api/influencerpartnerprogram/support
// /api/inventions/v1/fromcreators // todo: add this
[HttpGet("/api/influencerpartnerprogram/influencers")]
public ContentResult GetAllInfluencers()
{
// https://econ.rec.net/api/influencerpartnerprogram/influencers?take=1000
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
InfluencerIds = PlayerDB.GetInfluencerIds(),
ContinuationToken = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/influencerpartnerprogram/support")]
public ContentResult SupportInfluencer()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string? SupportIdStr = HttpContext.Request.Form["influencerAccountId"].ToString();
if (!ulong.TryParse(SupportIdStr, out ulong SupportId)) SupportId = 0;
// you can only support someone ELSE, and only an actual partner-program influencer
if (SupportId == account.Id || (SupportId != 0 && !PlayerDB.IsInfluencer(SupportId)))
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = false, error = SupportId == account.Id ? "You can't support yourself." : "That player isn't in the creator program.", value = (string?)null }),
ContentType = "application/json",
StatusCode = 200
};
PlayerDB.SupportInfluencer(account.Id, SupportId);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/influencerpartnerprogram/remove")]
public ContentResult RemoveInfluencer()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
PlayerDB.SupportInfluencer(account.Id, 0);
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
success = true,
error = "",
value = (string?)null
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/influencerpartnerprogram/myinfluencer")]
public IActionResult MyInfluencer()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Bare number, always 200 - see the note on /influencer above. The reference returns 0
// for everyone (no partner program); returning the stored tier (0 when none) matches
// that and drops the 404 that a profile-open can choke on.
var myInfluencer = PlayerDB.GetMyInfluencer(account.Id);
return Content(myInfluencer.ToString(), "application/json");
}
// /api/relationships/v2/sendfriendrequest?id={playerid}
// /api/relationships/v2/addfriend?id={playerid}
// /api/relationships/v2/acceptfriendrequest?id={playerid}
// Both of these 404'd on every call (bulkignoreplatformusers 13x in two days).
// They are platform-friend plumbing: the client asks which Steam/Xbox users are
// blocked, and which platform friends referred it. This server has no platform
// friend graph, so the honest answer is an empty list - but it has to BE a list.
// A 404 leaves the client's social panel waiting on a request that never resolves.
[HttpPost("/api/relationships/v1/bulkignoreplatformusers")]
[HttpGet("/api/relationships/v1/bulkignoreplatformusers")]
public ContentResult BulkIgnorePlatformUsers()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/externalfriendinvite/v1/getplatformreferrers")]
[HttpGet("/api/externalfriendinvite/v1/getplatformreferrers")]
public ContentResult GetPlatformReferrers()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/relationships/v2/get")]
public ContentResult GetRelationships()
{
string authtoken = HttpContext.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authtoken))
{
authtoken = HttpContext.Request.Cookies["Authorization"];
}
if (string.IsNullOrEmpty(authtoken))
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
}
string playerdata = ClientSecurity.DecodeToken(authtoken);
if (playerdata == null)
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
if (playerdata.Contains("error_token"))
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
player_info? account = JsonConvert.DeserializeObject<player_info>(playerdata);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(
FriendsDB.GetRelationships(account.Id).Select(RelationshipDto).ToList()
),
ContentType = "application/json",
StatusCode = 200
};
}
// The client's RelationshipResponse is EXACTLY these five fields (RecFlare's
// RelationshipDto, asserted with `toEqual`). Our internal RelationshipDetail also carries
// Id, OtherPlayerID and VoiceVolume, and Id is a ~1.79-trillion value that overflows an
// int32 - emitting those made the client's strict parser drop the WHOLE array, so the
// friends list came back empty (it never even fetched the account cards - no
// /Accounts/account/bulk followed the relationships GET). Project to the exact shape,
// flags as 0/1.
private static object RelationshipDto(RelationshipDetail r) => new
{
PlayerID = r.PlayerID,
RelationshipType = (int)r.RelationshipType,
Favorited = r.Favorited == ReciprocalStatus.None ? 0 : 1,
Ignored = FriendsDB.IgnoredWire(r.Ignored), // 1 mine / 2 theirs / 3 both - see FriendsDB.IgnoredWire
Muted = r.Muted == ReciprocalStatus.None ? 0 : 1,
VoiceVolume = FriendsDB.VoiceVolumeWire(r.VoiceVolume) // the per-player volume slider (0-200)
};
[HttpGet("/api/relationships/v2/sendfriendrequest")]
[HttpPost("/api/relationships/v2/sendfriendrequest")]
public ContentResult SendFriendRequest(ulong id) // player id
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Was: a dev-flagged sender who added Coach (id 1) had the request auto-accepted after
// 1 s. The intent was a staff convenience, but it meant adding Coach "automatically
// accepts your friend request" with no notification, no record, and no way for Coach
// to decline. Removed - Coach now gets pending requests like every other account.
RelationshipDetail relationship = FriendsDB.SendFriendRequest(account.Id, id);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(RelationshipDto(relationship)),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/relationships/v2/acceptfriendrequest")]
[HttpPost("/api/relationships/v2/acceptfriendrequest")]
public ContentResult AcceptFriendRequest(ulong id) // player id
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Todo: add rate limiting
RelationshipDetail relationship = FriendsDB.AcceptFriendRequest(id, account.Id);
if (relationship == null)
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(RelationshipDto(relationship)),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/relationships/v2/addfriend")]
[HttpPost("/api/relationships/v2/addfriend")]
public ContentResult AddFriendViaHandshakeInGame(ulong id) // player id
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Befriend DIRECTLY, no pending-request step (reference: addfriend - "Become friends
// with no pending-request step. Already being friends is a no-op."). This is the
// in-game handshake: both players have already agreed physically. It used to only
// ACCEPT an existing request, so a handshake between two players with no request
// pending returned null, threw (500) and made no friends.
if (id == 0 || id == account.Id)
return new ContentResult { Content = "{\"error\":\"invalid player id\"}", ContentType = "application/json", StatusCode = 400 };
RelationshipDetail? relationship = FriendsDB.AddFriendDirect(account.Id, id);
if (relationship == null)
return new ContentResult { Content = "{\"error\":\"could not add friend\"}", ContentType = "application/json", StatusCode = 400 };
Console.WriteLine($"[handshake] {account.Id} and {id} are now friends");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(RelationshipDto(relationship)),
ContentType = "application/json",
StatusCode = 200
};
}
// Per-player voice volume (the slider on a player's card, 0-200; body {PlayerId, VoiceVolume}).
// Stored on the caller's own side of the relationship row. The client parses the RESPONSE as
// that player's relationship and caches it - the volume it then applies to their voice is
// read from that cache. It used to answer {success,error}, so the cached record came back
// with no PlayerID / VoiceVolume and the slider never changed anyone's volume (2026-09-26).
[HttpPost("/api/relationships/v2/updateVoiceVolume")]
[HttpPut("/api/relationships/v2/updateVoiceVolume")]
public IActionResult UpdateVoiceVolume()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
ulong target = RoomModeration.ULong(f, "PlayerId", "playerId", "id", "OtherPlayerId");
long vol = RoomModeration.Long(f, "VoiceVolume", "voiceVolume", "Volume", "volume");
if (target == 0 || target == account.Id) return BadRequest(new { error = "invalid player id" });
var rel = FriendsDB.SetVoiceVolume(account.Id, target, (int)Math.Clamp(vol, 0, 200));
Console.WriteLine($"[voice-volume] {account.Id} set player {target} to {Math.Clamp(vol, 0, 200)}%");
return new ContentResult { Content = JsonConvert.SerializeObject(rel), ContentType = "application/json", StatusCode = 200 };
}
// Report one chat message (reference api POST /api/chatreport/createChatReport): the report
// is filed against the message's SENDER, and only a member of the thread may file it.
[HttpPost("/api/chatreport/createChatReport")]
public IActionResult CreateChatReport()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
long messageId = RoomModeration.Long(f, "ChatMessageId", "chatMessageId", "MessageId");
long threadId = RoomModeration.Long(f, "ChatThreadId", "chatThreadId", "ThreadId");
string details = RoomModeration.Str(f, "ReportDescription", "Details", "Description") ?? "";
var catRaw = RoomModeration.Str(f, "ReportCategory", "Category") ?? "";
int category = int.TryParse(catRaw, out var cn) ? cn
: Enum.TryParse<Moderation_Detail.ReportCategory>(catRaw, true, out var ce) ? (int)ce : 0;
var msg = ChatDB.GetMessage(messageId);
if (msg == null || !ChatDB.IsMember(msg.ChatThreadId, account.Id) || msg.SenderPlayerId <= 0)
return Ok(new { success = false, error = "That message could not be found." });
Campus_live_rewrite.api.dbs.ReportsDB.Create(new Campus_live_rewrite.api.dbs.ReportsDB.Report
{
ReporterPlayerId = account.Id,
ReportedPlayerId = (ulong)msg.SenderPlayerId,
ReportCategory = category,
Details = $"[chat message {messageId} in thread {msg.ChatThreadId}] {(int.TryParse(catRaw, out _) ? "" : catRaw + ": ")}{details}".Trim(),
});
Console.WriteLine($"[report] {account.Id} reported chat message {messageId} by {msg.SenderPlayerId} ({catRaw})");
return Ok(new { success = true, error = "" });
}
[HttpGet("/api/relationships/v2/removefriend")]
[HttpPost("/api/relationships/v2/removefriend")]
public ContentResult RemoveFriend(ulong id) // player id
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
//Todo: add rate limiting
RelationshipDetail relationship = FriendsDB.RemoveFriend(id, account.Id);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(RelationshipDto(relationship)),
ContentType = "application/json",
StatusCode = 200
};
}
// ---------------------------------------------------------------------------------------
// Per-side relationship flags: favorite / ignore / mute and their inverses. Ported from
// RecFlare's social routes; none of these existed before, so favoriting ("liking") a
// friend, ignoring, and muting all 404'd. The flag lands on the CALLER's own side of the
// pair (FriendsDB.SetRelationshipFlag) - and can be set on a player you were never friends
// with, which just creates a bare (None) row to hold it. The client calls these as a GET
// with the target in ?id=; POST + a PlayerId form field is accepted too. Body is the
// { Success, Message } ack RecFlare returns; the caller re-reads /relationships/v2/get to
// see the new flag (favorited friends pin to the top of the list).
// ---------------------------------------------------------------------------------------
private ContentResult RelationshipFlagResult(FriendInfo flag, bool value)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
ulong target = 0;
string q = Request.Query["id"].ToString();
if (string.IsNullOrEmpty(q)) q = Request.Query["playerId"].ToString();
if (string.IsNullOrEmpty(q) && Request.HasFormContentType)
{
q = Request.Form["PlayerId"].ToString();
if (string.IsNullOrEmpty(q)) q = Request.Form["playerId"].ToString();
if (string.IsNullOrEmpty(q)) q = Request.Form["Id"].ToString();
}
if (!ulong.TryParse(q, out target) || target == 0 || target == account.Id)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { error = "invalid player id" }),
ContentType = "application/json",
StatusCode = 400
};
}
// Ignore is BLOCK: two-sided, and only the blocker can lift it (FriendsDB.SetBlock).
var rel = flag == FriendInfo.Ignored
? FriendsDB.SetBlock(account.Id, target, value)
: FriendsDB.SetRelationshipFlag(account.Id, target, flag, value);
// The real RecNet answers the updated relationship (research/RecNet API.postman:
// /api/relationships/v1/favorite -> {Id, PlayerID, RelationshipType, Favorited, Muted,
// Ignored}), which is what the client's star / mute toggles update themselves from.
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
Id = rel.Id,
PlayerID = rel.PlayerID,
RelationshipType = (int)rel.RelationshipType,
Favorited = rel.Favorited == ReciprocalStatus.None ? 0 : 1,
Muted = rel.Muted == ReciprocalStatus.None ? 0 : 1,
Ignored = FriendsDB.IgnoredWire(rel.Ignored)
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/relationships/v1/favorite")]
[HttpPost("/api/relationships/v1/favorite")]
public ContentResult FavoritePlayer() => RelationshipFlagResult(FriendInfo.Favorited, true);
[HttpGet("/api/relationships/v1/unfavorite")]
[HttpPost("/api/relationships/v1/unfavorite")]
public ContentResult UnfavoritePlayer() => RelationshipFlagResult(FriendInfo.Favorited, false);
[HttpGet("/api/relationships/v1/ignore")]
[HttpPost("/api/relationships/v1/ignore")]
public ContentResult IgnorePlayer() => RelationshipFlagResult(FriendInfo.Ignored, true);
[HttpGet("/api/relationships/v1/unignore")]
[HttpPost("/api/relationships/v1/unignore")]
public ContentResult UnignorePlayer() => RelationshipFlagResult(FriendInfo.Ignored, false);
[HttpGet("/api/relationships/v1/mute")]
[HttpPost("/api/relationships/v1/mute")]
public ContentResult MutePlayer() => RelationshipFlagResult(FriendInfo.Muted, true);
[HttpGet("/api/relationships/v1/unmute")]
[HttpPost("/api/relationships/v1/unmute")]
public ContentResult UnmutePlayer() => RelationshipFlagResult(FriendInfo.Muted, false);
// Presence for the caller's favorited friends. Like RecFlare, presence isn't joined here
// yet, so this is an empty list - but it must answer 200 [] instead of 404ing, which the
// client treats as an error on the friends panel.
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
public ContentResult FavoriteFriendOnlineStatus()
{
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 200
};
}
/*
[HttpPost("/api/customAvatarItems/v1")]
public async Task<ContentResult> CreateCustomAvatar()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
HttpContext.Request.EnableBuffering();
byte[] array;
using (var memoryStream = new MemoryStream(capacity: 4 * 1024 * 1024))
{
await HttpContext.Request.Body.CopyToAsync(memoryStream, bufferSize: 131072);
array = memoryStream.ToArray();
}
HttpContext.Request.Body.Position = 0;
// todo: get the desinflesti elif g
var saveImageTask = Task.Run(() => Images.SaveImageFile(array));
var formTask = HttpContext.Request.ReadFormAsync();
var file = await saveImageTask.ConfigureAwait(false);
var form = await formTask.ConfigureAwait(false);
if (!form.TryGetValue("metadata", out var metadatavalue) || string.IsNullOrEmpty(metadatavalue))
{
return new ContentResult
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
CreateCustomAvatarItemResponse metadata = JsonConvert.DeserializeObject<CreateCustomAvatarItemResponse>(metadatavalue);
if (metadata.Accessibility == 1)
{
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
Success = false,
Value = (string?)null,
ErrorId = "Rooms.DoesntExist",
Error = "Sorry, that option is not available yet. Switch to 'Just Me'"
}),
ContentType = "application/json",
StatusCode = 200
};
}
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Success = true,
Value = metadata,
}),
ContentType = "application/json",
StatusCode = 200
};
}
*/
// custom items the client's starter avatar wears that this server has no record of (seen 2026-10-13)
internal static readonly string[] StarterCustomItemIds = { "32ffe4db-609c-4094-8ea0-0fc40a48f355", "77f44048-1c78-4f59-890f-cd0437153638" };
private static JObject? _standInTemplate;
internal static JObject? StandInCustomItem(string id)
{
try
{
if (_standInTemplate == null)
{
string fp = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "FirstPartyUGC.json");
var results = JObject.Parse(System.IO.File.ReadAllText(fp))["Results"] as JArray;
_standInTemplate = results?.OfType<JObject>().FirstOrDefault(r => (int?)r["OutfitType"] == 0 && r["CurrentSaves"] is JArray sv && sv.Count > 0)
?? results?.OfType<JObject>().FirstOrDefault();
}
if (_standInTemplate == null) return null;
var o = (JObject)_standInTemplate.DeepClone();
o["CustomAvatarItemId"] = id;
o["RankedEntityId"] = id;
o["Name"] = "(Starter item)";
o["Price"] = 0;
o["Accessibility"] = 1;
if (o["CurrentSaves"] is JArray saves) foreach (var sv in saves.OfType<JObject>()) sv["CustomAvatarItemId"] = id;
return o;
}
catch (Exception ex) { Console.WriteLine($"[cai-bulk] stand-in failed: {ex.Message}"); return null; }
}
[HttpPost("/api/customAvatarItems/v1/bulk")]
[HttpGet("/api/customAvatarItems/v1/bulk")]
public IActionResult BulkCustomAvatarItems([FromForm] List<string>? customAvatarItemIds)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// The GET spelling (?customAvatarItemIds=a&customAvatarItemIds=b, or comma-joined) was
// a 405 - it is how a client resolves an item it sees on SOMEONE ELSE's avatar.
if (customAvatarItemIds == null || customAvatarItemIds.Count == 0)
{
customAvatarItemIds = new List<string>();
foreach (var k in new[] { "customAvatarItemIds", "CustomAvatarItemIds", "ids", "id" })
foreach (var v in Request.Query[k])
foreach (var part in (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries))
customAvatarItemIds.Add(part.Trim());
}
// Resolve the requested custom-item ids from the in-memory UGC index (was re-reading +
// parsing customitems.json per call). This is the outfit-build lookup - an equipped UGC
// item that resolves here renders instead of failing "customAvatarItemId not found".
var items = CustomItemsCache.GetByIds(customAvatarItemIds ?? new List<string>());
// LOCAL EXPERIMENT (HoldNewAccountCreation): the random avatar of a brand-new account names custom items this server has
// no record of (not in UGC.json / FirstPartyUGC.json), the lookup misses, and the customize screen throws a
// NullReferenceException on every retry. Answer each unknown id with a stand-in record so the load can finish.
if (ConfigCampus.HoldNewAccountCreation && customAvatarItemIds != null && customAvatarItemIds.Count > 0 && customAvatarItemIds.Count < 12)
{
var have = new HashSet<string>(items.Select(i => ((string?)i["CustomAvatarItemId"] ?? "").ToLowerInvariant()));
foreach (var missing in customAvatarItemIds.Where(i => !string.IsNullOrWhiteSpace(i) && !have.Contains(i.Trim().ToLowerInvariant())).Distinct())
{
var stand = StandInCustomItem(missing.Trim());
if (stand != null) { items.Add(stand); Console.WriteLine($"[cai-bulk] {account.Id}: no record for {missing} - answered with a stand-in"); }
}
}
if (ConfigCampus.LocalDev && (customAvatarItemIds?.Count ?? 0) < 12)
Console.WriteLine($"[cai-bulk] {account.Id}: asked for {customAvatarItemIds?.Count ?? 0} id(s) [{string.Join(", ", customAvatarItemIds ?? new List<string>())}] -> {items.Count()} found");
return new ContentResult
{
Content = new JArray(items).ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
// Search published custom avatar items (reference: GET /api/customAvatarItems/v1/search).
// Filters the custom-item catalog (customitems.json Results) and returns a BARE ARRAY,
// newest first: searchQuery matches Name or Description (case-insensitive substring),
// outfitTypes is a whitelist (none = no filter), minPrice/maxPrice bound Price inclusively,
// skip/take page (take capped at 200), unpublished items (Accessibility 0) are left out.
// Empty while no UGC items exist - the same answer the reference gives for an empty table.
[HttpGet("/api/customAvatarItems/v1/search")]
public IActionResult SearchCustomAvatarItems(
[FromQuery] string? searchQuery,
[FromQuery] List<int> outfitTypes,
[FromQuery] int? minPrice,
[FromQuery] int? maxPrice,
[FromQuery] ulong? creatorAccountId,
[FromQuery] int skip = 0,
[FromQuery] int take = 50,
[FromQuery] string? includeCoachItems = null,
[FromQuery] int ordering = 0,
[FromQuery] bool? isOwned = null,
[FromQuery] bool? isFeatured = null)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (take > 1000) take = 1000; // the creator-filtered path below; the broad search re-clamps to 200
if (take < 0) take = 0;
if (skip < 0) skip = 0;
// creatorAccountId IS A REAL FILTER. It was not declared here at all, so ASP.NET
// dropped it silently and every request that carried one got the WHOLE published
// catalog back - 24k items. That is what put strangers' clothing on everybody's
// profile: the Portfolio's Custom Clothing card is a StoreItemList bound to
// PortfolioAccountId, and it asks for it with exactly this query:
//
// /api/customAvatarItems/v1/search?creatorAccountId=295&includePurchaseInfos=True
// &includeCoachItems=False&skip=0&take=1000&...
//
// Answered from the SAME index as /v2/fromCreator/{id}, deliberately - not by
// scanning All for a matching CreatorAccountId. The base catalog's creator ids are
// the original RecNet ones (2830 of them literally say "1"), so a scan would hand
// account 1 - and anyone whose id collided - thousands of items they never made.
// CustomItemsCache only attributes items actually created on this server, and these
// two routes must agree or the card and its "View More" page disagree.
if (creatorAccountId.HasValue && creatorAccountId.Value != 0)
{
bool own = creatorAccountId.Value == account.Id;
var byCreatorAll = CustomItemsCache.ByCreator(creatorAccountId.Value)
.Where(x => own || ((int?)x["Accessibility"] ?? 0) != 0) // only the creator sees their unpublished drafts
.OrderByDescending(x => ((string?)x["CreatedAt"]) ?? "")
.ToList();
if (creatorAccountId.Value == CustomItemsCache.CoachAccountId && ConfigCampus.ServeFirstPartyClothing)
byCreatorAll.AddRange(CustomItemsCache.FirstPartyRecs().Select(r => r.ToJObject()));
var byCreator = byCreatorAll.Skip(skip).Take(take).ToList();
return new ContentResult
{
Content = new JArray(byCreator).ToString(),
ContentType = "application/json",
StatusCode = 200
};
}
// Unfiltered store search. Left at the page size it has always served - the client
// asks for 1000, but this one walks the whole 24k catalog and nothing needed the
// bigger page before now.
if (take > 200) take = 200;
// From the in-memory UGC index (was re-parsing customitems.json every search).
// Published only, and - while HiddenItemsStorefronts is on - no bundle-backed item:
// those are the Clothing tab, and none of them load (CustomItemsCache.StoreListable).
// Compact records all the way to the page: only the items actually returned are
// materialised as JSON objects (CustomItemsCache.Rec).
IEnumerable<CustomItemsCache.Rec> q = CustomItemsCache.AllRecs.Where(x => x.Accessibility != 0 && CustomItemsCache.StoreListable(x));
// includeCoachItems picks a SIDE of the catalog, as on the reference: True (the store's
// Clothing tab) = only the Coach's first-party stock, False (the Custom Clothing /
// player-made tab) = only player-made items, absent = both. The client capitalises it.
string coach = (includeCoachItems ?? "").Trim().ToLowerInvariant();
if (coach == "true") q = q.Where(x => CustomItemsCache.IsFirstParty(x));
else if (coach == "false") q = q.Where(x => !CustomItemsCache.IsFirstParty(x));
if (outfitTypes != null && outfitTypes.Count > 0)
q = q.Where(x => outfitTypes.Contains(x.OutfitType));
if (minPrice.HasValue)
q = q.Where(x => x.Price >= minPrice.Value);
if (maxPrice.HasValue)
q = q.Where(x => x.Price <= maxPrice.Value);
if (isFeatured.HasValue)
q = q.Where(x => x.Featured == isFeatured.Value);
if (isOwned.HasValue)
{
// "Owned" is what /econ/customAvatarItems/v1/owned would list: the caller's own
// creations, plus the imported set for a dev. The Filters dialog sends
// isOwned=False to hide what the player already has.
var owned = new HashSet<string>(CustomItemsCache.ByCreatorRecs(account.Id).Select(x => x.Id), StringComparer.OrdinalIgnoreCase);
if (PlayerDB.CheckDevFlag(account.Id))
owned.UnionWith(CustomItemsCache.ImportedIds);
q = q.Where(x => owned.Contains(x.Id) == isOwned.Value);
}
// Ranked: what was typed is matched word by word against the name (and, weakly, the
// description) and the best matches come first - see CustomItemSearch. Was a raw
// substring test in date order, which is why "eyepatches" found nothing and "eye"
// led with a belt.
var page = Campus_live_rewrite.api.server.CustomItemSearch.Search(q, searchQuery, ordering)
.Skip(skip).Take(take)
.Select(r => r.ToJObject())
.ToList();
return new ContentResult
{
Content = new JArray(page).ToString(),
ContentType = "application/json",
StatusCode = 200
};
}
// Report a custom avatar item (reference: POST /api/customAvatarItems/v1/{id}/report).
// Accepts the report and answers the {success,error} envelope. We don't persist UGC reports
// yet (no UGC items exist); a moderator flow would file it like a player report.
[HttpPost("/api/customAvatarItems/v1/{id}/report")]
public IActionResult ReportCustomAvatarItem(string id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { success = true, error = "" }),
ContentType = "application/json",
StatusCode = 200
};
}
// One Maker-Pen circuit-chip palette (reference: GET /api/CircuitChipLists/{list}).
// Always empty - nothing records which chips a player has used/favourited - and an unknown
// name is empty too rather than a 404 (a 404 renders as a palette that failed to load).
[HttpGet("/api/CircuitChipLists/{list}")]
public ContentResult CircuitChipList(string list)
=> new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
// Discord-presence room lookup. Returns a room's display NAME and whether it's PRIVATE -
// the SAME source the admin panel uses (the live heartbeat instance: name + isPrivate),
// falling back to the static room. The patch's presence calls this so it works for DORMS
// (which the static rooms API doesn't describe the same way) and can label a private room
// as [PRIVATE]. No JWT (the patch's presence fetch is unauthenticated) - it carries the
// X-Campus-Patch header, so the patch gate lets it through, and it exposes nothing but a
// room name + a private flag.
// Discord-presence SELF lookup - everything the patch needs to decide what to show, from
// the caller's OWN heartbeat: their current room (name + isPrivate instance flag), that
// room's accessibility (to tell a dev/unpublished map apart from a normal room), and
// whether they appear online to EVERYONE. If "Appear Online To" isn't All (statusVisibility
// 1 Friends / 2 Favorites / 3 No One), appearOnlineToAll is false and the patch hides the
// room. accessibility is -1 when the room isn't a known published room (dev/unpublished).
// No JWT (the patch's presence fetch is unauthenticated) - it carries X-Campus-Patch.
[HttpGet("/api/presence/self/{accountId}")]
public IActionResult PresenceSelf(ulong accountId)
{
var (room, statusVisibility) = HeartbeatDB.GetSelfPresence(accountId);
// Hide only for the explicit non-everyone choices; anything else (0 Everyone, or a
// legacy/default value) counts as "appear to all".
bool appearOnlineToAll = !(statusVisibility == 1 || statusVisibility == 2 || statusVisibility == 3);
if (room == null)
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { found = false, appearOnlineToAll }),
ContentType = "application/json",
StatusCode = 200
};
int accessibility = -1;
var r = RoomDB.GetRoom((ulong)room.roomId);
if (r != null) accessibility = (int)r.Accessibility;
return new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
found = true,
name = room.name ?? "",
isPrivate = room.isPrivate,
accessibility,
appearOnlineToAll
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/presence/room/{roomId}")]
public IActionResult PresenceRoom(ulong roomId)
{
var inst = HeartbeatDB.FindInstanceByRoomId((long)roomId);
if (inst != null)
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { found = true, name = inst.name ?? "", isPrivate = inst.isPrivate }),
ContentType = "application/json",
StatusCode = 200
};
var room = RoomDB.GetRoom(roomId);
if (room != null)
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { found = true, name = room.Name ?? "", isPrivate = room.Accessibility != RoomAccessibility.Public }),
ContentType = "application/json",
StatusCode = 200
};
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { found = false, name = "", isPrivate = false }),
ContentType = "application/json",
StatusCode = 200
};
}
// Purchase info for a bag of items (reference: POST /api/items/purchaseInfos).
// Resolves Ids[] ({itemType,itemId}) against the custom-item catalog and answers how each
// may be bought. Only itemType 3 (custom avatar item) is served; other types and unknown
// ids are dropped, so the response is one entry per RESOLVED id in request order.
[HttpPost("/api/items/purchaseInfos")]
public async Task<IActionResult> ItemPurchaseInfos()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
// Read the body ourselves: MVC's System.Text.Json formatter cannot bind a JObject
// parameter (it threw on "$.Ids" -> a 500 on every purchase-info lookup).
JObject? body = null;
try
{
using var reader = new StreamReader(Request.Body);
string raw = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(raw)) body = JObject.Parse(raw);
}
catch (Exception ex) { Console.WriteLine($"[purchaseInfos] {account.Id}: bad body - {ex.Message}"); }
var outList = new JArray();
var ids = body?["Ids"] as JArray;
if (ids != null && ids.Count > 0)
{
foreach (var idRef in ids)
{
int itemType = (int?)idRef["itemType"] ?? -1;
string itemId = ((string?)idRef["itemId"]) ?? "";
if (itemType != 3 || string.IsNullOrEmpty(itemId)) continue; // only custom avatar items
var item = CustomItemsCache.Get(itemId);
if (item == null) continue; // unknown id dropped
int price = (int?)item["Price"] ?? 0;
outList.Add(new JObject
{
["ItemId"] = new JObject { ["itemType"] = itemType, ["itemId"] = itemId },
["PurchaseMethodId"] = new JObject { ["Type"] = 1, ["NumberId"] = null, ["Guid"] = itemId },
["Prices"] = new JArray { new JObject {
// 2 = RecCenterTokens. This said 1 with a "RecCenterTokens" comment,
// and 1 is LaserTagTickets (Classes/CurrencyType.cs) - so every custom
// item was priced in the wrong currency and a buy tried to debit a
// balance nobody has. Reference: toItemPurchaseInfo.
["CurrencyType"] = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens,
["Price"] = price,
["StorefrontSaleData"] = new JObject { ["SalePercent"] = 0, ["SaleStartDate"] = null, ["SaleEndDate"] = null }
}},
["NewUntil"] = null,
["AvailableAt"] = null,
["AvailableUntil"] = null,
["CanBeGifted"] = true,
["CanApplySubscriberDiscount"] = false,
["SubscribersOnly"] = false,
["IsFeatured"] = false
});
}
}
return new ContentResult { Content = outList.ToString(), ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("/api/customAvatarItems/v1/featured")]
public IActionResult FeaturedCustomAvatarItems()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Featured UGC only, from the in-memory index (was streaming the ENTIRE catalog file -
// fine when it was empty, an 87MB response now). Capped, published only, newest first.
var featured = CustomItemsCache.AllRecs
.Where(x => x.Featured && x.Accessibility != 0 && CustomItemsCache.StoreListable(x))
.OrderByDescending(x => x.Created)
.Take(100)
.Select(r => r.ToJObject())
.ToList();
// BARE ARRAY - /v1/search, /v1/featured and /v1/bulk all deserialize into a
// List<CustomAvatarItem>; a {Results,TotalResults} object raises the client's
// Malformed Response throw and the featured row renders empty (wire-shapes:
// customavataritems-routes).
return new ContentResult
{
Content = new JArray(featured).ToString(Newtonsoft.Json.Formatting.None),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/testcasemanagement/v1/{route}")]
public ContentResult TestCaseManagement(string route)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (route == "testplans")
{
return new ContentResult()
{
Content = "[]", // todo db
ContentType = "application/json",
StatusCode = 200
};
}
if (route == "testpasssummary")
{
return new ContentResult()
{
Content = "[]", // todo db
ContentType = "application/json",
StatusCode = 200
};
}
return new ContentResult()
{
Content = "[]", // todo db
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/playerevents/v1/searchlive")]
public ContentResult SearchLivePlayerEvents()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Was a hardcoded "[]" marked "todo db", so the live-events list was always empty.
// REVERT: return Content = "[]" here to restore that.
var live = EventDB.GetLiveEvents();
return new ContentResult()
{
Content = JsonConvert.SerializeObject(live.Select(EventWire)),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/messages/v2/get")]
public ContentResult GetMessages()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Room-role invites that were already answered or withdrawn: drop their notices (they
// used to stay in the list for good, Accept / Decline and all).
try
{
var staleRooms = PlayerDB.GetMessages(account.Id)
.Where(m => m.Type == WebsocketEvents.MessageType.RoomCoOwnerInvited && m.RoomId.HasValue
&& RoomDB.PendingRoleInvite(m.RoomId.Value, (long)account.Id) == RoomDBClasses.Role_data.None)
.Select(m => m.RoomId!.Value).Distinct().ToList();
foreach (var r in staleRooms) PlayerDB.DeleteRoomRoleMessages(account.Id, r);
}
catch (Exception ex) { Console.WriteLine($"[messages] stale role-invite sweep failed: {ex.Message}"); }
return new ContentResult()
{
Content = JsonConvert.SerializeObject(PlayerDB.GetMessages(account.Id)),
ContentType = "application/json",
StatusCode = 200
};
}
// Send one message to another player - the "invite me!" / party-invite prompts the
// client fires. Mirrors RecFlare's POST /api/messages/v2/send: FromPlayerId is the
// token's subject (never a body field), the recipient gets a MessageReceived frame
// (inner Id "2"), and the body is the { success, error } envelope. Type defaults to 0
// and Data to "". An offline recipient simply isn't delivered (SendToPlayer no-ops);
// that is still a success, since there's nothing to fail.
[HttpPost("/api/messages/v2/send")]
public async Task<IActionResult> SendMessageToPlayer([FromForm] ulong ToPlayerId, [FromForm] int Type = 0, [FromForm] string? Data = null)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (ToPlayerId == 0)
return BadRequest(new { success = false, error = "ToPlayerId is required" });
await PushPlayerMessage(account.Id, ToPlayerId, Type, Data ?? "");
return Ok(new { success = true, error = "" });
}
// The bulk form: one message to several recipients (JSON body
// {"ToPlayerIds":[..],"Type":..,"Data":".."}). Ids are de-duplicated and non-positive
// ones dropped; each recipient gets its own frame. Mirrors RecFlare's
// POST /api/messages/v1/sendMultiple.
[HttpPost("/api/messages/v1/sendMultiple")]
public async Task<IActionResult> SendMultipleMessages([FromBody] SendMultipleMessagesRequest? body)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var ids = (body?.ToPlayerIds ?? new List<ulong>())
.Where(i => i > 0)
.Distinct()
.ToList();
if (ids.Count == 0)
return BadRequest(new { success = false, error = "ToPlayerIds is required" });
foreach (var toId in ids)
await PushPlayerMessage(account.Id, toId, body!.Type, body.Data ?? "");
return Ok(new { success = true, error = "" });
}
// Push a single MessageReceived frame from one player to another. Shared by the single
// and bulk send endpoints. Not stored (unlike cheers) - the notification IS the delivery,
// matching the reference; a hub hiccup is logged, not surfaced, since there's no store to
// reconcile against.
private static async Task PushPlayerMessage(ulong fromPlayerId, ulong toPlayerId, int type, string data)
{
var msg = new MessageData
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
FromPlayerId = fromPlayerId,
SentTime = DateTime.UtcNow,
Type = (WebsocketEvents.MessageType)type,
Data = data,
RoomId = null,
PlayerEventId = null
};
try
{
var frame = JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg));
await Notifications.SendToPlayer(toPlayerId, frame);
}
catch (Exception ex)
{
Console.WriteLine($"[messages/send] push to {toPlayerId} failed: {ex.Message}");
}
}
// A cheer (wire-shapes playercheer-create; reference recflare api progression.ts).
// * The cheered player's counter goes up in the cheer's category - it never did before.
// * What PLAYS the cheer on their client is a Message of type 50 PlayerCheer (51
// PlayerCheerAnonymous, FromPlayerId 0 - an anonymous cheer used to carry the sender)
// with Data = the category; ReputationUpdate only refreshes the numbers. A cheer happens
// in front of people, so the cheered player's ReputationUpdate goes to everyone in the
// giver's room instance (from presence, not the body's RoomId), and the giver gets their
// own (their CheerCredit moved).
// * The reply is a BARE {Success, Message}, Message null on success - not the
// {success, error, value} envelope. Refusals are Success=false with a readable Message.
[HttpPost("/api/PlayerCheer/v1/create")]
public async Task<IActionResult> SendCheer([FromForm] ulong PlayerIdTo, [FromForm] int CheerCategory, [FromForm] ulong RoomId, [FromForm] bool Anonymous)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
IActionResult Result(string? why) =>
Content(JsonConvert.SerializeObject(new { Success = why == null, Message = why }), "application/json");
if (PlayerIdTo == account.Id) return Result("You cannot cheer yourself!");
if (PlayerIdTo == 0 || PlayerDB.GetAccountById(PlayerIdTo) == null) return Result("That player couldn't be found.");
// checked BEFORE the credit is spent: a category we can't count would take a cheer for nothing
if (!PlayerDB.IsCheerCategory(CheerCategory)) return Result("That isn't a cheer.");
if (account.PlayerReputation.CheerCredit <= 0) return Result("You have no more cheers today.");
await Task.Run(() => PlayerDB.SetCheerCredit(account.PlayerReputation.CheerCredit - 1, account.Id));
PlayerDB.AddCheer(PlayerIdTo, CheerCategory);
var cheerType = Anonymous ? Campus_live_rewrite.api.WebsocketEvents.MessageType.PlayerCheerAnonymous
: Campus_live_rewrite.api.WebsocketEvents.MessageType.PlayerCheer;
var newMsg = PlayerDB.AddMessage(
toPlayerId: PlayerIdTo,
fromPlayerId: Anonymous ? 0 : account.Id,
type: cheerType,
data: CheerCategory.ToString(),
roomId: RoomId,
playerEventId: null
);
try
{
if (newMsg != null)
await Notifications.SendToPlayer(PlayerIdTo, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(newMsg)));
string cheered = JsonConvert.SerializeObject(WebsocketEvents.CreateResponseReputationUpdate(PlayerIdTo));
var audience = new HashSet<ulong> { PlayerIdTo };
long instanceId = HeartbeatDB.PeekPresence(account.Id).room?.roomInstanceId ?? 0;
foreach (var id in HeartbeatDB.PlayerIdsInInstance(instanceId)) audience.Add(id);
await Notifications.SendToPlayers(audience.ToList(), cheered);
await Notifications.SendToPlayer(account.Id, JsonConvert.SerializeObject(WebsocketEvents.CreateResponseReputationUpdate(account.Id)));
}
catch (Exception ex)
{
Console.WriteLine($"[cheer] notify failed: {ex.Message}");
}
Console.WriteLine($"[cheer] {account.Id} -> {PlayerIdTo} category {CheerCategory}{(Anonymous ? " (anonymous)" : "")}");
return Result(null);
}
// Body is {"MessageIds":[long,...]} (wire-shapes: messages-v3-delete); the answer is never
// read. [FromBody] bound through MVC's formatter and answered 400 on every call (21 today),
// so a message the player deleted came straight back on the next refetch. Read by hand.
[HttpPost("/api/messages/v3/delete")]
public async Task<IActionResult> DeleteMessages()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var ids = new List<ulong>();
try
{
if (Request.HasFormContentType)
{
foreach (var k in new[] { "MessageIds", "messageIds", "ids", "id" })
foreach (var v in Request.Form[k])
foreach (var part in (v ?? "").Split(','))
if (ulong.TryParse(part.Trim(), out var n)) ids.Add(n);
}
else
{
using var reader = new StreamReader(Request.Body);
string raw = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(raw))
{
var tok = JToken.Parse(raw);
var arr = tok is JArray ja ? ja : (tok["MessageIds"] ?? tok["messageIds"] ?? tok["ids"]) as JArray;
if (arr != null) foreach (var t in arr) if (ulong.TryParse(t.ToString(), out var n)) ids.Add(n);
}
}
}
catch (Exception ex) { Console.WriteLine($"[messages] delete: unreadable body - {ex.Message}"); }
foreach (var id in ids) PlayerDB.DeleteMessage(account.Id, id);
SiteNotifications.Notify(account.Id); // game deleted -> website inbox refreshes
if (ids.Count > 0) Console.WriteLine($"[messages] {account.Id} deleted {ids.Count} message(s)");
return NoContent();
}
[HttpGet("/api/playerevents/v1/room/{RoomId}")]
public ContentResult PlayerEventsForRoom(ulong RoomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Was a hardcoded "[]" marked "todo db", so a room never listed the events booked
// in it. REVERT: return Content = "[]" here to restore that.
var events = EventDB.GetEventsForRoom(RoomId);
return new ContentResult()
{
Content = JsonConvert.SerializeObject(events.Select(EventWire)),
ContentType = "application/json",
StatusCode = 200
};
}
/// <summary>
/// The wire shape for a player event.
///
/// Every event listing built its own anonymous object and they disagreed: none of them
/// sent Tags - so the tag filters from /api/playerevents/v1/tagfilters had nothing to
/// match on and filtering by tag always came back empty - and /all always sent an empty
/// Responses list, so nobody's RSVP was ever visible. One shape, used everywhere.
/// </summary>
private static object EventWire(FullPlayerEvent e) => new
{
e.PlayerEventId,
e.CreatorPlayerId,
e.ImageName,
e.RoomId,
e.SubRoomId,
e.ClubId,
e.Name,
e.Description,
e.StartTime,
e.EndTime,
e.AttendeeCount,
e.State,
e.Accessibility,
e.IsMultiInstance,
e.SupportMultiInstanceRoomChat,
e.DefaultBroadcastPermissions,
e.CanRequestBroadcastPermissions,
e.BroadcastingRoomInstanceId,
e.RecurrenceSchedule,
Tags = e.Tags ?? new List<FullTag>(),
Responses = e.Responses ?? new List<FullEventResponses>()
};
// GET /api/playerevents/v1/bulk?id=1&id=2 - the events behind ids the client already
// holds. This is what the EVENT DETAIL PAGE fetches when you open an event; it was
// missing (only bulkInvite existed), so the detail page rendered blank. Bare array, in
// request order, skipping ids with no event.
[HttpGet("/api/playerevents/v1/bulk")]
public ContentResult PlayerEventsBulk()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var ids = Request.Query["id"]
.Select(s => ulong.TryParse(s, out var v) ? v : 0UL)
.Where(v => v != 0)
.ToList();
var list = new List<object>();
foreach (var id in ids)
{
var e = EventDB.GetFullEventById(id);
if (e != null) list.Add(EventWire(e));
}
Console.WriteLine($"[events] bulk fetch {ids.Count} ids -> {list.Count} events");
return new ContentResult()
{
Content = JsonConvert.SerializeObject(list),
ContentType = "application/json",
StatusCode = 200
};
}
// GET /api/playerevents/v1/{EventId} - a SINGLE event (the event detail page fetches
// this; it was 404ing, so the page rendered blank with only the RSVP bar). Bare event
// object (EventWire), 404 if unknown. {EventId:long} so literal siblings (all/search/
// clubs/bulk/respond/report) still win.
[HttpGet("/api/playerevents/v1/{EventId:long}")]
public ContentResult GetSinglePlayerEvent(ulong EventId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var ev = EventDB.GetFullEventById(EventId);
if (ev == null)
return new ContentResult { Content = "{}", ContentType = "application/json", StatusCode = 404 };
return new ContentResult { Content = JsonConvert.SerializeObject(EventWire(ev)), ContentType = "application/json", StatusCode = 200 };
}
// GET /api/playerevents/v1/clubs - events across the caller's clubs (bare array, empty).
[HttpGet("/api/playerevents/v1/clubs")]
public ContentResult PlayerEventsForMyClubs() =>
new ContentResult { Content = "[]", ContentType = "application/json", StatusCode = 200 };
// GET /api/playerevents/v1/club/{clubId} - one club's events (paged envelope, empty page).
[HttpGet("/api/playerevents/v1/club/{clubId:long}")]
public ContentResult PlayerEventsForClub(long clubId) =>
new ContentResult { Content = "{\"ContinuationToken\":\"\",\"Events\":[]}", ContentType = "application/json", StatusCode = 200 };
[HttpGet("/api/playerevents/v1/tagfilters")]
public ContentResult PlayerEventsTagFilters()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var tagfilters = new
{
PinnedFilters = new[]
{
"workshops",
"celebration",
"game",
"meetup",
"performance",
"coop",
"grandopening",
"class",
"competition"
},
PopularFilters = new[]
{
"workshops",
"celebration",
"class",
"coop",
"competition",
"game",
"grandopening",
"meetup",
"performance"
},
TrendingFilters = (string?)null
};
return new ContentResult()
{
Content = JsonConvert.SerializeObject(tagfilters),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPost("/api/playerevents/v2")]
public ContentResult CreatePlayerEvent([FromBody] EventDBClasses.CreateEventRequest request)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Event names and descriptions are player-authored text shown to everyone, so they
// go through the same filter as room names, usernames and bios. They were the only
// player-authored strings on the server with no check at all - which is how
// "come rape childeren with me" ended up listed as a public event.
//
// REVERT: ConfigCampus.AllowSwears = true disables this along with every other
// text filter (see Sanitize.ContainsSwears).
if (!string.IsNullOrWhiteSpace(request?.Name) && Sanitize.ContainsSwears(request.Name))
{
Console.WriteLine($"[events] REFUSED create by {account.Id}: name failed the filter");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { Result = EventResults.InappropriateName, TagModifyResult = (string?)null, PlayerEvent = (object?)null }),
ContentType = "application/json",
StatusCode = 200
};
}
if (!string.IsNullOrWhiteSpace(request?.Description) && Sanitize.ContainsSwears(request.Description))
{
Console.WriteLine($"[events] REFUSED create by {account.Id}: description failed the filter");
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { Result = EventResults.InappropriateDescription, TagModifyResult = (string?)null, PlayerEvent = (object?)null }),
ContentType = "application/json",
StatusCode = 200
};
}
FullPlayerEvent NewlyCreatedEvent = EventDB.CreateEvent(account.Id, request);
Console.WriteLine($"[events] {account.Id} created event {NewlyCreatedEvent.PlayerEventId} " +
$"'{NewlyCreatedEvent.Name}' in room {NewlyCreatedEvent.RoomId}");
// PlayerEventCreated (80) to the creator: the event appears on their screen without a refetch.
PushEventFrame(new[] { account.Id }, WebsocketEvents.ResponseResults.PlayerEventCreated, EventNotification(NewlyCreatedEvent));
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Result = EventResults.Success,
TagModifyResult = (string?)null,
PlayerEvent = new
{
Tags = NewlyCreatedEvent.Tags,
PlayerEventId = NewlyCreatedEvent.PlayerEventId,
CreatorPlayerId = NewlyCreatedEvent.CreatorPlayerId,
ImageName = NewlyCreatedEvent.ImageName,
RoomId = NewlyCreatedEvent.RoomId,
SubRoomId = NewlyCreatedEvent.SubRoomId,
ClubId = NewlyCreatedEvent.ClubId,
Name = NewlyCreatedEvent.Name,
Description = NewlyCreatedEvent.Description,
StartTime = NewlyCreatedEvent.StartTime,
EndTime = NewlyCreatedEvent.EndTime,
AttendeeCount = NewlyCreatedEvent.AttendeeCount,
State = NewlyCreatedEvent.State,
ImageAccessibility = NewlyCreatedEvent.Accessibility,
IsMultiInstance = NewlyCreatedEvent.IsMultiInstance,
SupportMultiInstanceRoomChat = NewlyCreatedEvent.SupportMultiInstanceRoomChat,
DefaultBroadcastPermissions = NewlyCreatedEvent.DefaultBroadcastPermissions,
CanRequestBroadcastPermissions = NewlyCreatedEvent.CanRequestBroadcastPermissions,
RecurrenceSchedule = NewlyCreatedEvent.RecurrenceSchedule
}
}), // todo db
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/playerevents/v1/{EventId}/responses")]
public ContentResult PlayerEventResponsesForEventId(ulong EventId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var playerEvent = EventDB.GetFullEventById(EventId);
if (playerEvent == null)
{
return new ContentResult
{
Content = "[]",
ContentType = "application/json",
StatusCode = 404
};
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(playerEvent.Responses ?? new List<FullEventResponses>()),
ContentType = "application/json",
StatusCode = 200
};
}
public class EventRespondBody { public ulong PlayerEventId { get; set; } public int Type { get; set; } }
// POST /api/playerevents/v1/respond {PlayerEventId, Type} (Type: 0 Going, 1 Interested,
// 2 Can't go). RSVP to an event - the endpoint was missing, so "I'm going" did nothing
// (EventDB.SetResponse already existed, nothing called it). One row per player per event;
// only Going counts toward AttendeeCount. Answers the same envelope create does so the
// client re-renders the event from it.
[HttpPost("/api/playerevents/v1/respond")]
public ContentResult RespondToPlayerEvent()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
// Form, query or JSON - the client's param helper posts a form body, and [FromBody]
// answered that with a 415 before anything ran.
var f = RoomModeration.Fields(Request);
var body = new EventRespondBody
{
PlayerEventId = RoomModeration.ULong(f, "PlayerEventId", "EventId", "Id"),
Type = (int)RoomModeration.Long(f, "Type", "Response", "ResponseType")
};
string typeText = RoomModeration.Str(f, "Type", "Response", "ResponseType") ?? "";
if (body.Type == 0 && typeText.Length > 0 && !char.IsDigit(typeText[0]))
body.Type = typeText.ToLowerInvariant() switch { "yes" or "going" or "attending" => 0, "interested" or "maybe" => 1, "no" or "notgoing" or "declined" => 2, _ => 0 };
if (body.PlayerEventId == 0 || body.Type < 0 || body.Type > 2)
return new ContentResult { Content = "{}", ContentType = "application/json", StatusCode = 400 };
// client Type 0/1/2 == server enum Yes/Interested/No (0/1/2)
var updated = EventDB.SetResponse(body.PlayerEventId, account.Id, (EventDBClasses.PlayerEventResponseType)body.Type, out var responseRow);
if (updated == null)
return new ContentResult { Content = "{}", ContentType = "application/json", StatusCode = 404 };
// PlayerEventResponseChanged (83): what populates the event on the responder's client
// (an invitee answering Pending -> Going included). BOTH nested objects are required -
// the client's handlers dereference one level down with no null guard.
if (responseRow != null)
PushEventFrame(new[] { account.Id }, WebsocketEvents.ResponseResults.PlayerEventResponseChanged,
new { PlayerEvent = EventNotification(updated), PlayerEventResponse = EventResponseDto(responseRow) });
Console.WriteLine($"[events] {account.Id} responded {body.Type} to event {body.PlayerEventId}");
return EventEnvelope(updated);
}
// POST /api/playerevents/v1/bulkInvite {PlayerEventId, InvitedPlayerIds[]} - the "invite
// friends" button on an event. Was a stub that acked and invited nobody. Now (reference):
// * each invitee gets a PENDING (3) response row - an open invitation, not an acceptance,
// so they don't count toward AttendeeCount until they answer through /respond;
// * an invite never overwrites an answer, and re-inviting is a no-op;
// * each NEWLY invited player is sent the invitation as a Message of type 81
// PlayerEventInvitation from the caller (stored in their inbox, pushed as
// MessageReceived), the event id on PlayerEventId and, as a string, in Data. No
// PlayerEventResponseChanged on invite - that frame is theirs once they answer;
// * the caller must be on the event (its creator, or anyone with a response row): 403
// otherwise. The caller and duplicate ids are skipped.
// Answers the v2 envelope (attendee count unchanged by an invite).
[HttpPost("/api/playerevents/v1/bulkInvite")]
public async Task<ContentResult> BulkInviteToEvent()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
ulong eventId = 0; var invited = new List<ulong>();
try
{
Request.EnableBuffering();
string body;
using (var sr = new StreamReader(Request.Body, leaveOpen: true)) body = await sr.ReadToEndAsync();
Request.Body.Position = 0;
if (body.TrimStart().StartsWith("{"))
{
var j = Newtonsoft.Json.Linq.JObject.Parse(body);
eventId = (ulong?)(j["PlayerEventId"] ?? j["playerEventId"]) ?? 0;
if ((j["InvitedPlayerIds"] ?? j["invitedPlayerIds"]) is Newtonsoft.Json.Linq.JArray arr)
foreach (var t in arr) if (ulong.TryParse(t.ToString(), out var id)) invited.Add(id);
}
else if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
ulong.TryParse(form["PlayerEventId"].ToString(), out eventId);
foreach (var v in form["InvitedPlayerIds"].Concat(form["InvitedPlayerIds[]"]))
foreach (var part in (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (ulong.TryParse(part, out var id)) invited.Add(id);
}
}
catch (Exception ex) { Console.WriteLine($"[events] bulkInvite body unreadable: {ex.Message}"); }
if (eventId == 0 || invited.Count == 0)
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
var ev = EventDB.GetFullEventById(eventId);
if (ev == null) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
bool onEvent = ev.CreatorPlayerId == account.Id || (ev.Responses?.Any(r => r.PlayerId == account.Id) ?? false);
if (!onEvent) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 403 };
var updated = EventDB.InviteToEvent(eventId, invited.Where(id => id != account.Id), out var added)!;
foreach (var pid in added)
{
try
{
var msg = PlayerDB.AddMessage(pid, account.Id, WebsocketEvents.MessageType.PlayerEventInvitation, eventId.ToString(), null, eventId);
if (msg != null) Notifications.FireAndForget(Notifications.SendToPlayer(pid, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg))), $"event invite -> {pid}");
}
catch (Exception ex) { Console.WriteLine($"[events] invitation message to {pid} failed: {ex.Message}"); }
}
Console.WriteLine($"[events] {account.Id} invited {added.Count} player(s) to event {eventId} ({invited.Count} asked)");
return EventEnvelope(updated);
}
// POST /api/playerevents/v1/report {PlayerEventId, ReportCategory, Details}
// Files a report against an event (reported player = the event's creator). Answers the
// {success,error} envelope. Was missing -> reporting an event 404'd.
public class EventReportBody { public ulong PlayerEventId { get; set; } public int ReportCategory { get; set; } public string? Details { get; set; } }
[HttpPost("/api/playerevents/v1/report")]
public ContentResult ReportPlayerEvent([FromBody] EventReportBody? body)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (body == null || body.PlayerEventId == 0)
return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "PlayerEventId is required" }), ContentType = "application/json", StatusCode = 400 };
var ev = EventDB.GetFullEventById(body.PlayerEventId);
if (ev == null)
return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "No such event" }), ContentType = "application/json", StatusCode = 404 };
Console.WriteLine($"[events] {account.Id} reported event {body.PlayerEventId} (creator {ev.CreatorPlayerId}, category {body.ReportCategory})");
return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "" }), ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("/api/playerevents/v1")]
public ContentResult GetAllEvents()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var events = EventDB.SearchEventByQuery("");
return new ContentResult
{
Content = JsonConvert.SerializeObject(events.Select(e => new
{
e.PlayerEventId,
e.CreatorPlayerId,
e.ImageName,
e.RoomId,
e.SubRoomId,
e.ClubId,
e.Name,
e.Description,
e.StartTime,
e.EndTime,
e.AttendeeCount,
e.State,
e.Accessibility,
e.IsMultiInstance,
e.SupportMultiInstanceRoomChat,
e.DefaultBroadcastPermissions,
e.CanRequestBroadcastPermissions,
e.BroadcastingRoomInstanceId,
e.RecurrenceSchedule
})),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/playerevents/v1/all")]
public ContentResult GetAllEvents2()
{
// Auth stays optional here (it was commented out and the client relies on that),
// but the caller is resolved when a token IS present so their own RSVPs can be
// returned. No token simply means no personal responses.
ulong viewerId = 0;
try
{
var (acc, err) = AuthHelper.ValidateToken(HttpContext);
if (err == null && acc != null) viewerId = acc.Id;
}
catch { }
var events = EventDB.SearchEventByQuery("");
return new ContentResult
{
// Created now carries Tags (so the tag filters work) and each event's real
// Responses. Responses at the top level is every response the caller has made,
// which is what the client uses to show "you are attending".
Content = JsonConvert.SerializeObject(new
{
Created = events.Select(EventWire),
Responses = events
.SelectMany(e => e.Responses ?? new List<FullEventResponses>())
.Where(r => r.PlayerId == viewerId)
.ToList(),
}),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/api/playerevents/v1/search")]
public ContentResult SearchEvents(string query = "", string sort = "Attendance", string scheduleFilter = "Upcoming")
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var events = EventDB.SearchEventByQuery(query);
return new ContentResult
{
Content = JsonConvert.SerializeObject(events.Select(e => new
{
e.PlayerEventId,
e.CreatorPlayerId,
e.ImageName,
e.RoomId,
e.SubRoomId,
e.ClubId,
e.Name,
e.Description,
e.StartTime,
e.EndTime,
e.AttendeeCount,
e.State,
e.Accessibility,
e.IsMultiInstance,
e.SupportMultiInstanceRoomChat,
e.DefaultBroadcastPermissions,
e.CanRequestBroadcastPermissions,
e.BroadcastingRoomInstanceId,
e.RecurrenceSchedule
})),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("/api/playerevents/v2/{EventId}/name")] // was missing the /api prefix -> client 404'd every rename
public ContentResult SetEventName(ulong EventId, [FromForm] string name)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
FullPlayerEvent playerEvent = EventDB.GetFullEventById(EventId);
if (playerEvent == null)
{
Utils.ErrorEvent(EventResults.DoesNotExist);
}
if (playerEvent.CreatorPlayerId != account.Id)
{
Utils.ErrorEvent(EventResults.DoesNotOwnEvent);
}
if (string.IsNullOrEmpty(name) || name.Length > 64)
{
return Utils.ErrorEvent(EventResults.InappropriateName);
}
if (Sanitize.ContainsSwears(name))
{
return Utils.ErrorEvent(EventResults.InappropriateName);
}
FullPlayerEvent UpdatedEvent = EventDB.SetEventName(EventId, name);
if (UpdatedEvent != null)
PushEventFrame(EventDB.GoingPlayerIds(UpdatedEvent), WebsocketEvents.ResponseResults.PlayerEventUpdated, EventNotification(UpdatedEvent));
return new ContentResult()
{
Content = JsonConvert.SerializeObject(new
{
Result = EventResults.Success,
TagModifyResult = (string?)null,
PlayerEvent = new
{
Tags = UpdatedEvent.Tags,
PlayerEventId = UpdatedEvent.PlayerEventId,
CreatorPlayerId = UpdatedEvent.CreatorPlayerId,
ImageName = UpdatedEvent.ImageName,
RoomId = UpdatedEvent.RoomId,
SubRoomId = UpdatedEvent.SubRoomId,
ClubId = UpdatedEvent.ClubId,
Name = UpdatedEvent.Name,
Description = UpdatedEvent.Description,
StartTime = UpdatedEvent.StartTime,
EndTime = UpdatedEvent.EndTime,
AttendeeCount = UpdatedEvent.AttendeeCount,
State = UpdatedEvent.State,
ImageAccessibility = UpdatedEvent.Accessibility,
IsMultiInstance = UpdatedEvent.IsMultiInstance,
SupportMultiInstanceRoomChat = UpdatedEvent.SupportMultiInstanceRoomChat,
DefaultBroadcastPermissions = UpdatedEvent.DefaultBroadcastPermissions,
CanRequestBroadcastPermissions = UpdatedEvent.CanRequestBroadcastPermissions,
RecurrenceSchedule = UpdatedEvent.RecurrenceSchedule
}
}),
ContentType = "application/json",
StatusCode = 200
};
}
// ---- live event frames (reference routes/events.ts) -------------------------------------
// The camelCase projection the PlayerEventCreated (80) / PlayerEventUpdated (81) frames and
// PlayerEventResponseChanged's `PlayerEvent` carry (reference toEventNotification) - NOT the
// PascalCase record the HTTP responses serve; the client decoder accepts either casing.
private static object EventNotification(FullPlayerEvent ev) => new
{
tags = (ev.Tags ?? new List<EventDBClasses.FullTag>()).Select(t => new { tag = t.Tag, type = t.Type }).ToList(),
playerEventId = ev.PlayerEventId,
creatorPlayerId = ev.CreatorPlayerId,
roomId = ev.RoomId,
subRoomId = ev.SubRoomId,
clubId = ev.ClubId,
name = ev.Name,
description = ev.Description,
imageName = ev.ImageName ?? "",
startTime = ev.StartTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"),
endTime = ev.EndTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"),
attendeeCount = ev.AttendeeCount,
accessibility = ev.Accessibility,
isMultiInstance = ev.IsMultiInstance,
supportMultiInstanceRoomChat = ev.SupportMultiInstanceRoomChat,
defaultBroadcastPermissions = ev.DefaultBroadcastPermissions,
canRequestBroadcastPermissions = ev.CanRequestBroadcastPermissions,
broadcastingRoomInstanceId = (ulong?)null
};
private static object EventResponseDto(EventDBClasses.FullEventResponses r) => new
{
PlayerEventResponseId = r.PlayerEventResponseId,
PlayerEventId = r.PlayerEventId,
PlayerId = r.PlayerId,
CreatedAt = r.CreatedAt,
Type = (int)r.Type
};
// One {Id, Msg} frame to each player; best-effort (the change is already stored).
private static void PushEventFrame(IEnumerable<ulong> playerIds, WebsocketEvents.ResponseResults frame, object payload)
{
string json = JsonConvert.SerializeObject(new WebsocketEvents.Reponse { Id = ((int)frame).ToString(), Msg = payload });
foreach (var pid in playerIds.Distinct())
Notifications.FireAndForget(Notifications.SendToPlayer(pid, json), $"event frame {frame} -> {pid}");
}
// An edited event: PlayerEventUpdated (81) to everyone GOING (creator included), then the
// same envelope as before. Every edit route answers through this.
private ContentResult EditedEnvelope(FullPlayerEvent ev)
{
PushEventFrame(EventDB.GoingPlayerIds(ev), WebsocketEvents.ResponseResults.PlayerEventUpdated, EventNotification(ev));
return EventEnvelope(ev);
}
// The v2 event envelope every edit answers - the updated event under Result/PlayerEvent.
private ContentResult EventEnvelope(FullPlayerEvent ev) => new ContentResult
{
Content = JsonConvert.SerializeObject(new
{
Result = EventResults.Success,
TagModifyResult = (string?)null,
PlayerEvent = new
{
Tags = ev.Tags,
PlayerEventId = ev.PlayerEventId,
CreatorPlayerId = ev.CreatorPlayerId,
ImageName = ev.ImageName,
RoomId = ev.RoomId,
SubRoomId = ev.SubRoomId,
ClubId = ev.ClubId,
Name = ev.Name,
Description = ev.Description,
StartTime = ev.StartTime,
EndTime = ev.EndTime,
AttendeeCount = ev.AttendeeCount,
State = ev.State,
ImageAccessibility = ev.Accessibility,
IsMultiInstance = ev.IsMultiInstance,
SupportMultiInstanceRoomChat = ev.SupportMultiInstanceRoomChat,
DefaultBroadcastPermissions = ev.DefaultBroadcastPermissions,
CanRequestBroadcastPermissions = ev.CanRequestBroadcastPermissions,
RecurrenceSchedule = ev.RecurrenceSchedule
}
}),
ContentType = "application/json",
StatusCode = 200
};
// Creator-only guard shared by the edits below. Returns the event when the caller may
// edit it, else null with `err` set to the response to return.
private FullPlayerEvent? EditableEvent(ulong eventId, out ContentResult? err)
{
err = null;
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) { err = error; return null; }
var ev = EventDB.GetFullEventById(eventId);
if (ev == null) { err = new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 }; return null; }
if (ev.CreatorPlayerId != account.Id) { err = new ContentResult { Content = "", ContentType = "application/json", StatusCode = 403 }; return null; }
return ev;
}
[HttpPut("/api/playerevents/v2/{EventId}/description")]
public ContentResult SetEventDescription(ulong EventId, [FromForm] string? description)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var updated = EventDB.SetEventDescription(EventId, (description ?? "").Trim());
return EditedEnvelope(updated!);
}
[HttpPut("/api/playerevents/v2/{EventId}/time")]
public ContentResult SetEventTime(ulong EventId, [FromForm] string? startTime, [FromForm] string? endTime)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
DateTime start = DateTime.TryParse(startTime, null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var s) ? s : ev!.StartTime;
DateTime end = DateTime.TryParse(endTime, null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var e) ? e : ev!.EndTime;
var updated = EventDB.SetEventTime(EventId, start, end);
return EditedEnvelope(updated!);
}
// 400'd every time as [FromForm] int - the value is read by hand now (a number or the
// enum name, from form, query or JSON), so an event can actually be made Public.
[HttpPut("/api/playerevents/v2/{EventId}/accessibility")]
[HttpPost("/api/playerevents/v2/{EventId}/accessibility")]
public ContentResult SetEventAccessibility(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var f = RoomModeration.Fields(Request);
string raw = RoomModeration.Str(f, "accessibility", "Accessibility", "playerEventAccessibility", "value") ?? "";
if (raw.Length == 0 && f.Count == 1) raw = f.Values.First();
EventDBClasses.PlayerEventAccessibility acc;
if (int.TryParse(raw, out var n)) acc = (EventDBClasses.PlayerEventAccessibility)n;
else if (!Enum.TryParse(raw, true, out acc))
{
Console.WriteLine($"[events] accessibility for {EventId}: could not read '{raw}' from {RoomModeration.Dump(f)}");
return new ContentResult { Content = JsonConvert.SerializeObject(new { Result = EventResults.SomethingWentWrong, TagModifyResult = (object?)null, PlayerEvent = (object?)null }), ContentType = "application/json", StatusCode = 400 };
}
var updated = EventDB.SetEventAccessibility(EventId, acc);
Console.WriteLine($"[events] {EventId} accessibility -> {acc}");
return EditedEnvelope(updated!);
}
// POST api/playerevents/v2/{id} - the FULL update (the 12-parameter edit): the same JSON
// body as create, every field present replacing the stored one. Was a 404, so "Save"
// on the event editor failed while the per-field PUTs worked.
[HttpPost("/api/playerevents/v2/{EventId:long}")]
[HttpPut("/api/playerevents/v2/{EventId:long}")]
public async Task<ContentResult> UpdatePlayerEvent(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
Newtonsoft.Json.Linq.JObject b;
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
string raw = await reader.ReadToEndAsync();
b = string.IsNullOrWhiteSpace(raw) || !raw.TrimStart().StartsWith("{") ? new Newtonsoft.Json.Linq.JObject() : Newtonsoft.Json.Linq.JObject.Parse(raw);
}
catch { b = new Newtonsoft.Json.Linq.JObject(); }
if (b.Count == 0)
foreach (var kv in RoomModeration.Fields(Request)) b[kv.Key] = kv.Value;
Newtonsoft.Json.Linq.JToken? T(params string[] keys) { foreach (var k in keys) { var t = b[k]; if (t != null && t.Type != Newtonsoft.Json.Linq.JTokenType.Null) return t; } return null; }
string? name = (string?)T("Name", "name");
string? desc = (string?)T("Description", "description");
if (!string.IsNullOrWhiteSpace(name) && Sanitize.ContainsSwears(name))
return new ContentResult { Content = JsonConvert.SerializeObject(new { Result = EventResults.InappropriateName, TagModifyResult = (object?)null, PlayerEvent = (object?)null }), ContentType = "application/json", StatusCode = 200 };
if (!string.IsNullOrWhiteSpace(desc) && Sanitize.ContainsSwears(desc))
return new ContentResult { Content = JsonConvert.SerializeObject(new { Result = EventResults.InappropriateDescription, TagModifyResult = (object?)null, PlayerEvent = (object?)null }), ContentType = "application/json", StatusCode = 200 };
var updated = EventDB.Mutate(EventId, e =>
{
if (!string.IsNullOrWhiteSpace(name)) e.Name = name.Trim();
if (desc != null) e.Description = desc.Trim();
var img = (string?)T("ImageName", "imageName"); if (!string.IsNullOrWhiteSpace(img)) e.ImageName = img;
var rid = (ulong?)T("RoomId", "roomId"); if (rid is > 0) e.RoomId = rid.Value;
if (b.ContainsKey("SubRoomId") || b.ContainsKey("subRoomId")) e.SubRoomId = (ulong?)T("SubRoomId", "subRoomId");
if (b.ContainsKey("ClubId") || b.ContainsKey("clubId")) e.ClubId = (ulong?)T("ClubId", "clubId");
if (DateTime.TryParse((string?)T("StartTime", "startTime"), null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var st)) e.StartTime = st;
if (DateTime.TryParse((string?)T("EndTime", "endTime"), null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var et)) e.EndTime = et;
var acc = T("Accessibility", "accessibility");
if (acc != null) { if (int.TryParse(acc.ToString(), out var an)) e.Accessibility = (EventDBClasses.PlayerEventAccessibility)an; else if (Enum.TryParse(acc.ToString(), true, out EventDBClasses.PlayerEventAccessibility ae)) e.Accessibility = ae; }
var mi = (bool?)T("IsMultiInstance", "isMultiInstance"); if (mi != null) e.IsMultiInstance = mi.Value;
var mc = (bool?)T("SupportMultiInstanceRoomChat", "supportMultiInstanceRoomChat"); if (mc != null) e.SupportMultiInstanceRoomChat = mc.Value;
var dbp = (int?)T("DefaultBroadcastPermissions", "defaultBroadcastPermissions"); if (dbp != null) e.DefaultBroadcastPermissions = (EventDBClasses.BroadcastPermissionsEnum)dbp.Value;
var crp = (int?)T("CanRequestBroadcastPermissions", "canRequestBroadcastPermissions"); if (crp != null) e.CanRequestBroadcastPermissions = (EventDBClasses.BroadcastPermissionsEnum)crp.Value;
if (T("Tags", "tags") is Newtonsoft.Json.Linq.JArray ta)
e.Tags = ta.Select(x => x.Type == Newtonsoft.Json.Linq.JTokenType.String ? (string?)x : (string?)x["Tag"]).Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => new EventDBClasses.FullTag { Tag = t!.Trim(), Type = EventDBClasses.TagType.Unknown1 }).ToList();
});
Console.WriteLine($"[events] {EventId} updated by the editor ({b.Count} field(s))");
return EditedEnvelope(updated!);
}
[HttpPut("/api/playerevents/v2/{EventId}/image")]
[HttpPost("/api/playerevents/v2/{EventId}/image")]
public ContentResult SetEventImage(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var f = RoomModeration.Fields(Request);
string img = RoomModeration.Str(f, "imageName", "ImageName", "image", "value") ?? "";
if (img.Length == 0 && f.Count == 1) img = f.Values.First();
return EditedEnvelope(EventDB.Mutate(EventId, e => { if (img.Length > 0) e.ImageName = img; })!);
}
[HttpPut("/api/playerevents/v2/{EventId}/room")]
[HttpPost("/api/playerevents/v2/{EventId}/room")]
public ContentResult SetEventRoom(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var f = RoomModeration.Fields(Request);
long rid = RoomModeration.Long(f, "roomId", "RoomId");
long sid = RoomModeration.Long(f, "subRoomId", "SubRoomId");
return EditedEnvelope(EventDB.Mutate(EventId, e => { if (rid > 0) e.RoomId = (ulong)rid; e.SubRoomId = sid > 0 ? (ulong)sid : null; })!);
}
[HttpPut("/api/playerevents/v2/{EventId}/club")]
[HttpPost("/api/playerevents/v2/{EventId}/club")]
public ContentResult SetEventClub(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var f = RoomModeration.Fields(Request);
long cid = RoomModeration.Long(f, "clubId", "ClubId");
return EditedEnvelope(EventDB.Mutate(EventId, e => e.ClubId = cid > 0 ? (ulong)cid : null)!);
}
[HttpPut("/api/playerevents/v2/{EventId}/multiinstance")]
[HttpPost("/api/playerevents/v2/{EventId}/multiinstance")]
public ContentResult SetEventMultiInstance(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var f = RoomModeration.Fields(Request);
bool B(string fallbackKeyless, params string[] keys) { string v = RoomModeration.Str(f, keys) ?? (f.Count == 1 ? f.Values.First() : ""); return v.Equals("true", StringComparison.OrdinalIgnoreCase) || v == "1"; }
bool multi = B("", "isMultiInstance", "IsMultiInstance", "multiInstance", "value");
bool chat = B("", "supportMultiInstanceRoomChat", "SupportMultiInstanceRoomChat");
return EditedEnvelope(EventDB.Mutate(EventId, e => { e.IsMultiInstance = multi; e.SupportMultiInstanceRoomChat = chat; })!);
}
[HttpPut("/api/playerevents/v2/{EventId}/tags")]
public ContentResult SetEventTags(ulong EventId, [FromForm] List<string>? tags)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var updated = EventDB.SetEventTags(EventId, tags ?? new List<string>());
return EditedEnvelope(updated!);
}
[HttpPost("/api/playerevents/v2/delete/{EventId}")]
[HttpDelete("/api/playerevents/v2/delete/{EventId}")]
public ContentResult DeleteEvent(ulong EventId)
{
var ev = EditableEvent(EventId, out var err); if (err != null) return err;
var going = EventDB.GoingPlayerIds(ev!); // read before the delete takes the RSVPs with it
EventDB.DeleteEvent(EventId);
PushEventFrame(going, WebsocketEvents.ResponseResults.PlayerEventDeleted, new { PlayerEventId = EventId });
return new ContentResult { Content = JsonConvert.SerializeObject(new { Result = EventResults.Success }), ContentType = "application/json", StatusCode = 200 };
}
// A moderator's warning (the vmod menu's "Warn": WarnedPlayerId, ReportCategory,
// DisplayReason, ModeratorNote). Staff only - moderators as well as developers; it used to
// require the developer flag, so a vmod's warning 403'd. The warned player gets the
// ModerationKick frame with IsWarning set (the dialog, not a removal). It is NOT stored as
// the account's moderation block any more: that overwrote an active ban with a 0-second
// record and lifted it.
[HttpPost("/api/playerwarnings")]
public IActionResult CreatePlayerWarning() // WarnedPlayerId=3&ReportCategory=103&DisplayReason=Intentional%20team%20killing&ModeratorNote=
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (!PlayerDB.CheckDevFlag(account.Id) && !PlayerDB.CheckModFlag(account.Id))
return StatusCode(403, JsonConvert.SerializeObject(new { success = false, error = "Staff only" }));
var f = RoomModeration.Fields(Request);
ulong target = RoomModeration.ULong(f, "WarnedPlayerId", "PlayerId", "AccountId", "Id");
if (target == 0)
return BadRequest(new { success = false, error = "WarnedPlayerId is required" });
var category = RoomModeration.Category(f, "ReportCategory", "Category");
string reason = RoomModeration.Str(f, "DisplayReason", "Message", "Reason") ?? "";
string note = RoomModeration.Str(f, "ModeratorNote", "Note") ?? "";
var hb = HeartbeatDB.GetPlayerHeartbeat(target);
var block = new Moderation_Detail.ModerationBlockDetails
{
ReportCategory = category,
Duration = 0,
// 0, never the instance they are in: the client reads a moderation frame naming
// its current game session as a removal from that room - a warned player dropped
// out of the instance within a second (491, 2026-09-27 04:45) and, in their own
// dorm, could not get back into it. A warning is only the dialog.
GameSessionId = 0,
IsHostKick = false,
Message = reason,
PlayerIdReporter = account.Id,
IsBan = false,
IsWarning = true,
TimeoutStartedAt = DateTime.UtcNow
};
try { Notifications.SendToPlayer(target, JsonConvert.SerializeObject(WebsocketEvents.CreateBan(block))); } catch { }
// The client leaves the room on the warning dialog (it does even with GameSessionId 0),
// then goes wherever its presence points - which was still the instance, so it
// reconnected straight back in (495, 2026-09-27 06:02 -> back in 18 s). Send them home.
// Not when they are in their OWN dorm already: that is home, and a fresh dorm instance
// would only split them from their guests.
if (hb?.roomInstance != null && hb.roomInstance.roomInstanceId > 0
&& (ulong)hb.roomInstance.roomId != PlayerDB.GetDormId(target))
RoomModeration.SendHome(target, 0, 0);
ModerationLogDB.Record("warning", target, account.Id, (ulong)(hb?.roomInstance?.roomId ?? 0), hb?.roomInstance?.roomInstanceId ?? 0, 0, category, reason, note);
Console.WriteLine($"[moderation] {account.Id} warned {target}: {category} \"{reason}\"{(note.Length > 0 ? $" (note: {note})" : "")}");
return Ok(new
{
success = true,
error = "",
value = (string?)null
});
}
/// <summary>
/// The vmod menu's kick (modKick / roomModKick) - the "restrict from room" that follows a
/// warning. Fields are read by name from form, query or JSON (the exact names the 2025
/// client sends are logged the first times so they can be pinned): the player, the game
/// session, a duration in seconds, a category and a message.
///
/// What it does:
/// - the player is removed from the instance (ModerationKick frame + presence cleared)
/// - they are kept out of THAT INSTANCE for ConfigCampus.RoomKickInstanceBlockSeconds
/// (8 h): the matchmaker never pools them back into it and a join-player / invite
/// into it is refused. Any other instance of the room is still open to them.
///
/// A kick is NOT a room ban. It used to be stored as a permanent room ban ("Kicked by X"),
/// so a moderator's kick in ^RecCenter locked the player out of the whole map until
/// somebody found and lifted it. Banning is the separate room ban route
/// (POST rooms/{id}/bans), which the room's owners lift from Room Settings.
///
/// Who: staff (developer / moderator flag), or a moderator-or-better on the room the
/// player is in. Room owners and staff cannot be kicked by non-staff.
/// </summary>
[HttpPost("/api/PlayerReporting/v1/modKick")]
[HttpPost("/api/PlayerReporting/v1/roomModKick")]
public IActionResult ModKick()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var f = RoomModeration.Fields(Request);
Console.WriteLine($"[modkick] {Request.Path} by {account.Id}: {RoomModeration.Dump(f)}");
ulong target = RoomModeration.ULong(f, "PlayerId", "KickedPlayerId", "TargetPlayerId", "AccountId", "Id", "PlayerIds");
if (target == 0)
return Ok(new { success = false, error = "No player given" });
if (target == account.Id)
return Ok(new { success = false, error = "You cannot kick yourself" });
long session = RoomModeration.Long(f, "GameSessionId", "RoomInstanceId", "SessionId");
long seconds = RoomModeration.Long(f, "Duration", "DurationSeconds", "TimeoutDuration", "TimeoutSeconds");
if (seconds <= 0) seconds = RoomModeration.Long(f, "DurationMinutes", "Minutes") * 60;
var category = RoomModeration.Category(f, "ReportCategory", "Category");
string message = RoomModeration.Str(f, "Message", "DisplayReason", "Reason", "KickMessage") ?? "";
string note = RoomModeration.Str(f, "ModeratorNote", "Note") ?? "";
// Which room: where the player is now, else the instance named, else a RoomId field.
var hb = HeartbeatDB.GetPlayerHeartbeat(target);
var inst = hb?.roomInstance ?? (session != 0 ? HeartbeatDB.FindInstanceById(session) : null);
ulong roomId = inst != null ? (ulong)inst.roomId : RoomModeration.ULong(f, "RoomId");
if (session == 0 && inst != null) session = inst.roomInstanceId;
var room = roomId != 0 ? RoomDB.GetRoom(roomId) : null;
bool staff = PlayerDB.CheckDevFlag(account.Id) || PlayerDB.CheckModFlag(account.Id);
bool roomMod = room != null && RoomDB.CanModerateRoom(room, account.Id);
if (!staff && !roomMod)
{
Console.WriteLine($"[modkick] REFUSED {account.Id} -> {target}: not staff and not a moderator of room {roomId}");
return StatusCode(403, JsonConvert.SerializeObject(new { success = false, error = "You cannot moderate this room" }));
}
if (!PlayerDB.CheckDevFlag(account.Id) && PlayerDB.CheckDevFlag(target))
return Ok(new { success = false, error = "You cannot kick a staff member" });
if (!staff && room != null && (room.CreatorAccountId == target || RoomDB.IsCoOwner(room, target)))
return Ok(new { success = false, error = "You cannot kick an owner of this room" });
string shown = message.Length > 0 ? message : (room != null ? $"You have been removed from {room.Name}." : "You have been removed from the room.");
// 1. out of the instance now (the ModerationKick frame their client leaves on, and
// their presence cleared). Nothing account-wide is stored: a room kick is not a
// timeout, whatever duration field the client may send.
RoomModeration.RemoveFromRoom(target, roomId, session, shown, account.Id, isBan: false, isHostKick: !staff);
// 2. and out of THAT instance for the next 8 hours. Other instances of the room stay
// open, and nothing is written to the room's ban list.
int blockSeconds = Math.Max(60, ConfigCampus.RoomKickInstanceBlockSeconds);
if (inst != null && room != null && room.CreatorAccountId != target)
InstanceBlocks.Add(target, inst, account.Id, TimeSpan.FromSeconds(blockSeconds), message.Length > 0 ? message : $"Kicked by {account.Username}");
else if (inst == null)
Console.WriteLine($"[modkick] {target} is not in a live instance - removed only, nothing to block");
ModerationLogDB.Record("modkick", target, account.Id, roomId, session, blockSeconds, category, message, note);
return Ok(new { success = true, error = "" });
}
// Vote to kick (form: PlayerId, Response, Reason, GameSessionId). Was an ack that did
// nothing, so a vote never reached anyone. Now: the caller and the target must both be
// in that instance; everyone else there gets the MessageReceived type-5 (VoteToKick)
// frame their client raises the prompt from; each Response is tallied per
// (instance, target), and once more than half of the OTHER players have said yes the
// target is removed and kept out of that INSTANCE for ConfigCampus.VoteKickBanSeconds.
// Answers the {success,error} envelope.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, (Dictionary<ulong, bool> votes, DateTime started)> _voteKicks = new();
[HttpPost("/api/PlayerReporting/v3/voteToKick")]
[HttpPost("/api/PlayerReporting/v2/voteToKick")]
public IActionResult VoteToKick()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var f = RoomModeration.Fields(Request);
ulong target = RoomModeration.ULong(f, "PlayerId", "playerId", "TargetPlayerId", "Id");
long session = RoomModeration.Long(f, "GameSessionId", "gameSessionId", "RoomInstanceId");
string responseRaw = RoomModeration.Str(f, "Response", "response", "Vote") ?? "";
string reason = RoomModeration.Str(f, "Reason", "reason") ?? "";
if (target == 0) return BadRequest(new { success = false, error = "PlayerId is required" });
var myHb = HeartbeatDB.GetPlayerHeartbeat(account.Id);
if (session == 0) session = myHb?.roomInstance?.roomInstanceId ?? 0;
if (session == 0) return BadRequest(new { success = false, error = "GameSessionId is required" });
if (myHb?.roomInstance?.roomInstanceId != session)
return StatusCode(403, JsonConvert.SerializeObject(new { success = false, error = "You are not in that game session!" }));
var targetHb = HeartbeatDB.GetPlayerHeartbeat(target);
if (targetHb?.roomInstance?.roomInstanceId != session)
return StatusCode(403, JsonConvert.SerializeObject(new { success = false, error = "That player is not in that game session!" }));
var room = RoomDB.GetRoom((ulong)myHb.roomInstance.roomId);
if (RoomDB.CanModerateRoom(room, target) && !RoomDB.CanModerateRoom(room, account.Id))
return Ok(new { success = false, error = "You cannot vote to kick a moderator of this room" });
// Everyone in the instance except the caller.
var audience = RoomPermissions.PlayersInRoom((ulong)myHb.roomInstance.roomId)
.Where(pid => pid != account.Id && HeartbeatDB.GetPlayerHeartbeat(pid)?.roomInstance?.roomInstanceId == session)
.ToList();
string key = $"{session}|{target}";
var entry = _voteKicks.GetOrAdd(key, _ => (new Dictionary<ulong, bool>(), DateTime.UtcNow));
if ((DateTime.UtcNow - entry.started).TotalMinutes > 3) { entry = (new Dictionary<ulong, bool>(), DateTime.UtcNow); _voteKicks[key] = entry; }
bool newVote = entry.votes.Count == 0;
bool yes = responseRaw.Length == 0 || responseRaw.Equals("true", StringComparison.OrdinalIgnoreCase) || responseRaw == "1" || responseRaw.Equals("yes", StringComparison.OrdinalIgnoreCase);
lock (entry.votes) entry.votes[account.Id] = yes;
// A NEW vote is put to the room; a later Response is just tallied.
if (newVote)
{
// The frame the client raises its "Vote kick X?" prompt from (reference: the
// vote-to-kick Message). Two things it reads literally:
// * FromPlayerId is the player being VOTED ON - the prompt is about whoever this
// names; the caller is named nowhere on the frame (and needn't be).
// * Data is the REASON as plain text ("Abusing bugs or exploits") - it's a string
// on the wire like every Message's Data, and the prompt prints it verbatim. It
// used to be an escaped {PlayerId, Response, GameSessionId, Reason} object, so
// players saw that JSON where the reason belongs.
string data = reason;
foreach (var pid in audience)
{
var msg = new MessageData
{
Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
FromPlayerId = target, ToPlayerId = pid, SentTime = DateTime.UtcNow,
Type = WebsocketEvents.MessageType.VoteToKick, Data = data
};
Notifications.FireAndForget(Notifications.SendToPlayer(pid, JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg))), "vote-kick");
}
Console.WriteLine($"[votekick] {account.Id} called a vote on {target} in instance {session} ({audience.Count} asked){(reason.Length > 0 ? $" - {reason}" : "")}");
}
// Tally: yes votes among everyone other than the target.
int voters = Math.Max(1, audience.Count(p => p != target) + 1); // + the caller
int yesVotes; lock (entry.votes) yesVotes = entry.votes.Where(kv => kv.Key != target && kv.Value).Count();
Console.WriteLine($"[votekick] instance {session} target {target}: {yesVotes}/{voters} yes");
if (yesVotes > voters * ConfigCampus.VoteKickMajority || (voters == 1 && yesVotes >= 1 && audience.Count(p => p != target) == 0))
{
_voteKicks.TryRemove(key, out _);
// A plain removal from the room - the same frame a host kick sends (Duration 0). It
// used to go out as a VoteKick block with Duration = VoteKickBanSeconds, which the
// client shows as a 15-minute account TIMEOUT. Being voted out of one room is not a
// timeout: they only stay out of THIS instance (the instance block below).
string shown = reason.Length > 0 ? $"The room voted to remove you: {reason}" : "The room voted to remove you.";
RoomModeration.RemoveFromRoom(target, (ulong)myHb.roomInstance.roomId, session, shown, account.Id, isBan: false, isHostKick: true);
if (room != null && room.CreatorAccountId != target)
InstanceBlocks.Add(target, myHb.roomInstance, account.Id, TimeSpan.FromSeconds(Math.Max(60, ConfigCampus.VoteKickBanSeconds)), "Vote kicked" + (reason.Length > 0 ? $": {reason}" : ""));
ModerationLogDB.Record("votekick", target, account.Id, room?.RoomId ?? 0, session, ConfigCampus.VoteKickBanSeconds, Moderation_Detail.ReportCategory.VoteKick, reason);
Console.WriteLine($"[votekick] {target} was vote-kicked from instance {session}");
}
return Ok(new { success = true, error = "" });
}
// In-game bug report ("Report a bug" on the watch posts to /ado/bug - Azure DevOps on the
// real service). Was a 404. Whatever the client sends - form, JSON or multipart with
// screenshots / logs - is kept and shown on the admin panel's Reports tab.
[HttpPost("/ado/bug")]
[HttpPost("/ado/bug/{**rest}")]
[HttpPost("/ado/file")]
[HttpPost("/ado/file/{**rest}")]
[HttpPost("/api/bugreport/v1")]
public async Task<IActionResult> BugReport(string? rest = null)
{
var (account, _) = AuthHelper.ValidateToken(HttpContext);
var r = new BugReportDB.Report { AccountId = account?.Id ?? 0, Username = account?.Username ?? "" };
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var kv in form) fields[kv.Key] = string.Join(",", kv.Value.ToArray());
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "BugReports");
Directory.CreateDirectory(dir);
foreach (var file in form.Files)
{
if (file.Length <= 0 || file.Length > 25 * 1024 * 1024) continue;
string safe = System.Text.RegularExpressions.Regex.Replace(Path.GetFileName(file.FileName ?? "file"), @"[^A-Za-z0-9_.-]", "_");
string name = $"{DateTime.UtcNow:yyyyMMdd-HHmmss}-{account?.Id ?? 0}-{safe}";
using var fs = System.IO.File.Create(Path.Combine(dir, name));
await file.CopyToAsync(fs);
r.Files.Add(name);
}
}
else
{
foreach (var kv in RoomModeration.Fields(Request)) fields[kv.Key] = kv.Value;
}
}
catch (Exception ex) { Console.WriteLine($"[bug] could not read report body: {ex.Message}"); }
string Pick(params string[] names) { foreach (var n in names) if (fields.TryGetValue(n, out var v) && !string.IsNullOrWhiteSpace(v)) return v; return ""; }
r.Title = Pick("title", "Title", "summary", "Summary", "subject");
r.Description = Pick("description", "Description", "body", "Body", "details", "Details", "text", "message", "Message", "repro", "ReproSteps");
r.Category = Pick("category", "Category", "type", "Type", "area", "Area", "bugType");
r.Room = Pick("roomName", "RoomName", "room", "Room", "roomId", "RoomId");
r.Platform = Pick("platform", "Platform", "device", "Device", "buildVersion", "appVersion");
r.Raw = string.Join("\n", fields.Select(kv => $"{kv.Key}={(kv.Value.Length > 4000 ? kv.Value[..4000] + "…" : kv.Value)}"));
if (r.Title.Length == 0 && r.Description.Length > 0) r.Title = r.Description.Length > 80 ? r.Description[..80] : r.Description;
var hb = account != null ? HeartbeatDB.GetPlayerHeartbeat(account.Id) : null;
if (r.Room.Length == 0 && hb?.roomInstance != null) r.Room = $"{hb.roomInstance.name} ({hb.roomInstance.roomId})";
BugReportDB.Add(r);
Console.WriteLine($"[bug] #{r.Id} from {r.Username} ({r.AccountId}): '{r.Title}' fields={fields.Count} files={r.Files.Count}");
return Ok(new { success = true, error = "", value = r.Id, id = r.Id });
}
/// <summary>
/// Eject players from one live instance (JSON {GameSessionId, PlayerIds}). Room moderators
/// or staff. Only players actually in that instance are touched; nothing is stored and
/// nothing stops them rejoining - that is what modKick is for. Answers {success,error}.
/// Was an ack that did nothing.
/// </summary>
[HttpPost("/api/PlayerReporting/v1/instantKick")]
public IActionResult InstantKick()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var f = RoomModeration.Fields(Request);
long session = RoomModeration.Long(f, "GameSessionId", "RoomInstanceId");
var ids = RoomModeration.ULongs(f, "PlayerIds", "PlayerId", "Ids");
if (session == 0 || ids.Count == 0)
return BadRequest(new { success = false, error = "GameSessionId and PlayerIds are required" });
var inst = HeartbeatDB.FindInstanceById(session);
if (inst == null)
return NotFound(new { success = false, error = "No such game session" });
var room = RoomDB.GetRoom((ulong)inst.roomId);
if (!RoomDB.CanModerateRoom(room, account.Id))
return StatusCode(403, JsonConvert.SerializeObject(new { success = false, error = "You cannot moderate this room" }));
int kicked = 0;
foreach (var pid in ids)
{
if (pid == account.Id) continue;
if (RoomDB.CanModerateRoom(room, pid) && !PlayerDB.CheckDevFlag(account.Id)) continue;
var hb = HeartbeatDB.GetPlayerHeartbeat(pid);
if (hb?.roomInstance?.roomInstanceId != session) continue;
RoomModeration.RemoveFromRoom(pid, (ulong)inst.roomId, session, $"You have been removed from {room?.Name ?? "the room"}.", account.Id, isBan: false, isHostKick: true);
ModerationLogDB.Record("kick", pid, account.Id, (ulong)inst.roomId, session, 0, Moderation_Detail.ReportCategory.Moderator, "");
kicked++;
}
Console.WriteLine($"[instantkick] {account.Id} kicked {kicked}/{ids.Count} from instance {session}");
return Ok(new { success = true, error = "" });
}
[HttpGet("/api/banappeal/generateCode")]
public IActionResult GenerateBanAppealCode()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
success = false,
error = "",
value = "Make a ticket in https://discord.gg/xH8MMwK5Bk"
});
}
[HttpGet("/api/outfits/me")]
[HttpGet("/outfits/me")]
public IActionResult GetMyOutfits()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var player = PlayerDB.GetFullAccount(account.Id);
// Was: no null check, then player.player_Extra.avatar - NRE for an
// account with no row.
if (player?.player_Extra == null)
{
Console.WriteLine($"[/outfits/me] no player/player_Extra row for {account.Id}");
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
}
if (string.IsNullOrEmpty(player.player_Extra.avatar))
{
return Ok(new
{
LegacyData = new
{
SelectionsV1 = (string?)null,
SelectionsV2 = (string?)null,
FaceFeatures = (string?)null,
SkinColor = (string?)null,
HairColor = (string?)null
},
Selections = Array.Empty<object>(),
DataVersion = 9,
CustomizationSettings = (string?)null,
ThumbnailFileName = (string?)null,
Name = (string?)null,
Accessibility = 0,
Slot = 0
});
}
// REVERTED 2026-08-23 to the original Ok(...) form. Changing this - along
// with wrapping the stored avatar in a LegacyData envelope - stopped avatar
// items loading entirely and broke the watch's Customize tab. The client
// parse errors those changes removed were less harmful than the breakage
// they caused, so the original behaviour stands until there is a way to
// verify a replacement against the real client before shipping it.
// Was: Ok(<string>). ASP.NET's StringOutputFormatter writes a raw string as
// Content-Type: text/plain, so these JSON payloads were served mislabelled
// and the client would not parse them. Verified live: /outfits/me returned
// text/plain while /api/avatar/v2 returned application/json. Same bytes,
// correct content type.
return new ContentResult { Content = player.player_Extra.avatar, ContentType = "application/json", StatusCode = 200 };
}
[HttpPut("/api/outfits/me")]
[HttpPut("/outfits/me")]
public async Task<IActionResult> SetMyOutfit()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string jsonData = "";
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
jsonData = await reader.ReadToEndAsync();
}
catch
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
if (string.IsNullOrWhiteSpace(jsonData))
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
try
{
Newtonsoft.Json.Linq.JToken.Parse(jsonData);
}
catch
{
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
// The newer client saves a WARDROBE PRESET by PUTting here with Slot > 0; the outfit
// being WORN is Slot 0 (or no Slot at all). A preset save must NOT overwrite the worn
// avatar - which is exactly what happened before, because Slot was ignored and every
// save clobbered player_Extra.avatar. So Slot > 0 is upserted into the saved wardrobe
// (the same store /api/avatar/v3/saved and /outfits/me/saved read) and returns here;
// Slot 0 / absent falls through to the unchanged worn-avatar path below.
int savedSlot = 0;
try
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(jsonData);
if (jo.TryGetValue("Slot", StringComparison.OrdinalIgnoreCase, out var slotTok))
savedSlot = slotTok.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? slotTok.Value<int>() : 0;
}
catch { /* not an object / no Slot -> treat as worn (slot 0) */ }
if (savedSlot > 0)
{
try
{
var preset = JsonConvert.DeserializeObject<avatar_data_saved>(jsonData) ?? new avatar_data_saved();
preset.Slot = (ulong)savedSlot;
// Keep the raw document the client sent, so a preset round-trips without losing
// any field our structured type doesn't model.
if (string.IsNullOrEmpty(preset.Avatar)) preset.Avatar = jsonData;
PlayerDB.SetAvatarSaved(account.Id, new List<avatar_data_saved> { preset });
}
catch (Exception ex)
{
Console.WriteLine($"[/outfits/me] preset save (slot {savedSlot}) failed: {ex.Message}");
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { Success = true, Error = (string?)null, error_id = (string?)null }),
ContentType = "application/json",
StatusCode = 200
};
}
var player = PlayerDB.GetFullAccount(account.Id);
if (player == null)
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 401
};
try
{
PlayerDB.set_player_avatar(jsonData, player.playerid);
}
catch (Exception ex)
{
Console.WriteLine($"[Avatar Set Error] {ex}");
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 400
};
}
return new ContentResult()
{
Content = "",
ContentType = "application/json",
StatusCode = 200
};
}
// The caller's saved-outfit wardrobe. Was `ConfigCampus.Bracket` - an economy config
// object, not a list of outfits - so the newer wardrobe screen showed nothing no matter
// what you saved, which read as "saving outfits doesn't work". Returns the real saved
// outfits (the same store PUT /outfits/me slot>0 and /api/avatar/v4/saved/set write to,
// and /api/avatar/v3/saved reads); "[]" for a player who has saved none.
[HttpGet("/api/outfits/me/saved")]
[HttpGet("/outfits/me/saved")]
public ContentResult GetMySavedOutfits()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return new ContentResult()
{
Content = PlayerDB.GetAvatarSaved(account.Id),
ContentType = "application/json",
StatusCode = 200
};
}
// Several players' WORN outfits at once, keyed by account id - the call the newer client
// makes to dress a whole room (and to render other people's avatars, e.g. on a profile).
// Ported from RecFlare's POST /outfits/bulk; it was missing, so in the newer client every
// OTHER player rendered with no/default avatar. Body is { AccountIds: [..] }; the answer is
// { OutfitsByAccountId: { "<id>": <worn outfit>, ... } }. A player with no saved avatar is
// simply ABSENT from the map (a map says "no outfit" by not having the key) rather than
// carrying a null. Worn outfit = player_Extra.avatar, handed back exactly as it was saved.
[HttpPost("/outfits/bulk")]
public async Task<ContentResult> GetOutfitsBulk()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string body;
using (var reader = new StreamReader(HttpContext.Request.Body))
body = await reader.ReadToEndAsync();
List<ulong> ids = new();
try
{
var jo = Newtonsoft.Json.Linq.JObject.Parse(body);
if (jo.TryGetValue("AccountIds", StringComparison.OrdinalIgnoreCase, out var arr)
&& arr is Newtonsoft.Json.Linq.JArray jarr)
{
foreach (var t in jarr)
if (t.Type == Newtonsoft.Json.Linq.JTokenType.Integer)
ids.Add(t.Value<ulong>());
}
}
catch
{
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 400 };
}
// One round trip, so cap the list (a room never holds this many). Distinct ids only.
var distinct = ids.Distinct().Take(99).ToList();
var map = new Dictionary<string, object>();
foreach (var pid in distinct)
{
var p = PlayerDB.GetFullAccount(pid);
var worn = p?.player_Extra?.avatar;
if (string.IsNullOrEmpty(worn)) continue; // absent from the map, never null
try { map[pid.ToString()] = Newtonsoft.Json.Linq.JToken.Parse(worn); }
catch { /* stored avatar isn't valid JSON - skip rather than break the whole map */ }
}
return new ContentResult
{
Content = JsonConvert.SerializeObject(new { OutfitsByAccountId = map }),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpGet("/sampling")]
public IActionResult Sampling()
{
// Validate the user token
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
var samplingData = new List<object>
{
new { eventType = "SimilarEntitiesContext", shouldBeSampled = false },
new { eventType = "OnlineRankingContext", shouldBeSampled = false },
new { eventType = "ugc_makerpen_modify_object", shouldBeSampled = false },
new { eventType = "heartbeat", shouldBeSampled = false },
new { eventType = "edible_bite", shouldBeSampled = false },
new { eventType = "impression", shouldBeSampled = false },
new { eventType = "downloadable_cache_loaded", shouldBeSampled = false },
new { eventType = "ugc_makerpen_selectionChanged", shouldBeSampled = false },
new { eventType = "hand_gesture_use", shouldBeSampled = false },
new { eventType = "ugc_makerpen_open_menu", shouldBeSampled = false },
new { eventType = "load_outfit_failed", shouldBeSampled = false },
new { eventType = "subroom_stats", shouldBeSampled = false },
new { eventType = "button_press", shouldBeSampled = false },
new { eventType = "click", shouldBeSampled = false },
new { eventType = "emote_use", shouldBeSampled = false },
new { eventType = "watch_stats", shouldBeSampled = false },
new { eventType = "edible_consume", shouldBeSampled = false },
new { eventType = "ugc_makerpen_menu_item_selected", shouldBeSampled = false },
new { eventType = "drinkable_drink", shouldBeSampled = false },
new { eventType = "sessionMetadata", shouldBeSampled = false },
new { eventType = "stacked_UI_flow_change", shouldBeSampled = false },
new { eventType = "food_spawned", shouldBeSampled = false },
new { eventType = "Announcement_Shown", shouldBeSampled = false },
new { eventType = "ugc_makerpen_connect", shouldBeSampled = false },
new { eventType = "mtm_round_stats", shouldBeSampled = false },
new { eventType = "RRUI_route_change", shouldBeSampled = false },
new { eventType = "drinkable_consume", shouldBeSampled = false },
new { eventType = "makerpen_session_detailed", shouldBeSampled = false },
new { eventType = "store_search", shouldBeSampled = false },
new { eventType = "ugc_makerpen_create_object", shouldBeSampled = false },
new { eventType = "ugc_makerpen_changed_setting", shouldBeSampled = false },
new { eventType = "ugc_room_save_stats_detailed", shouldBeSampled = false },
new { eventType = "downloadable_item_loaded", shouldBeSampled = false },
new { eventType = "ugc_makerpen_configure_object", shouldBeSampled = false },
new { eventType = "ugc_makerpen_search", shouldBeSampled = false },
new { eventType = "ugc_makerpen_acm_movement_check", shouldBeSampled = false },
new { eventType = "eligible_for_matchmaking_toast_event", shouldBeSampled = false },
new { eventType = "ineligible_for_matchmaking_toast_event", shouldBeSampled = false },
new { eventType = "external_room_progression_shown", shouldBeSampled = false },
new { eventType = "time_to_content_load", shouldBeSampled = false },
new { eventType = "time_to_first_card_loaded", shouldBeSampled = false },
new { eventType = "avatar_outfit_changed", shouldBeSampled = false },
new { eventType = "DataAPIQuery", shouldBeSampled = false },
new { eventType = "RoomElasticsearchQuery", shouldBeSampled = false },
new { eventType = "ugc_makerpen_favorite_object", shouldBeSampled = false },
new { eventType = "mlm_quest_step_start", shouldBeSampled = false },
new { eventType = "mlm_acquire_egg", shouldBeSampled = false },
new { eventType = "mlm_monster_added", shouldBeSampled = false },
new { eventType = "mlm_monster_need_met", shouldBeSampled = false },
new { eventType = "mlm_monster_lvl_up", shouldBeSampled = false },
new { eventType = "mlm_travel_companion_change", shouldBeSampled = false },
new { eventType = "mlm_monster_rename", shouldBeSampled = false },
new { eventType = "mlm_left_evergrown", shouldBeSampled = false },
new { eventType = "mlm_crafting_complete", shouldBeSampled = false },
new { eventType = "mlm_sanctuary_lvl_up", shouldBeSampled = false },
new { eventType = "mlm_monster_light_lvl_up", shouldBeSampled = false },
new { eventType = "mlm_daily_check_in", shouldBeSampled = false },
new { eventType = "mlm_add_guaranteed_item", shouldBeSampled = false },
new { eventType = "RoomAutocompleteElasticsearchQuery", shouldBeSampled = false },
new { eventType = "mlm_player_capacity_reached", shouldBeSampled = false },
new { eventType = "mlm_tag_failure", shouldBeSampled = false },
new { eventType = "mlm_npc_nearby", shouldBeSampled = false },
new { eventType = "mlm_conversation_start", shouldBeSampled = false },
new { eventType = "autocomplete_impression", shouldBeSampled = false },
new { eventType = "rtb_player_stats", shouldBeSampled = false },
new { eventType = "rtb_challenge_stats", shouldBeSampled = false },
new { eventType = "rtb_public_event_stats", shouldBeSampled = false },
new { eventType = "ugc_makerpen_container_rename", shouldBeSampled = false },
new { eventType = "rtb_feedback", shouldBeSampled = false },
new { eventType = "contextual_features", shouldBeSampled = false },
new { eventType = "RoomSearchRequest", shouldBeSampled = false },
new { eventType = "StatsigParameterDesync", shouldBeSampled = false },
new { eventType = "reccon_candy_stats", shouldBeSampled = false },
new { eventType = "reccon_roomstats", shouldBeSampled = false },
new { eventType = "reccon_trivia_stats", shouldBeSampled = false },
new { eventType = "reccon_fashion_stats", shouldBeSampled = false },
new { eventType = "mlm_missing_replicators", shouldBeSampled = false },
new { eventType = "engagementTitlescreenJumpRightInStatsigTimeout", shouldBeSampled = false },
new { eventType = "engagementTitlescreenJumpRightInOnlineFriendClick", shouldBeSampled = false },
new { eventType = "engagementTitlescreenJumpRightInOnlineFriendsPagination", shouldBeSampled = false },
new { eventType = "engagementTitlescreenJumpRightInOnlineFriendsJoinFriendClick", shouldBeSampled = false },
new { eventType = "engagementTitlescreenJumpRightInOnlineFriendsViewAll", shouldBeSampled = false },
new { eventType = "smart_circuit_makerpen_drag_released", shouldBeSampled = false },
new { eventType = "ugc_room_event", shouldBeSampled = false },
new { eventType = "CustomAvatarItemSearchRequest", shouldBeSampled = false },
new { eventType = "perf_metrics", shouldBeSampled = false },
new { eventType = "whalegame_boatmaterialreset", shouldBeSampled = false },
new { eventType = "engagementTitlescreenJumpRightInOnlineFriendsViewMore", shouldBeSampled = false },
new { eventType = "ValentinesDay2025", shouldBeSampled = false },
new { eventType = "orientation_purchase_flow_steps", shouldBeSampled = false },
new { eventType = "ai_error", shouldBeSampled = false },
new { eventType = "ai_mistake", shouldBeSampled = false },
new { eventType = "ai_missing_functionality", shouldBeSampled = false },
new { eventType = "ai_response_error", shouldBeSampled = false },
new { eventType = "ai_text_bug", shouldBeSampled = false },
new { eventType = "socialcreation1_robotparts", shouldBeSampled = false },
new { eventType = "socialcreation1_robotactivated", shouldBeSampled = false },
new { eventType = "ai_conversation", shouldBeSampled = false },
new { eventType = "ai_expense", shouldBeSampled = false },
new { eventType = "crossed_bridge", shouldBeSampled = false },
new { eventType = "completed_task_normally", shouldBeSampled = false },
new { eventType = "bridge_dropped_time", shouldBeSampled = false },
new { eventType = "Player_pressed_button", shouldBeSampled = false },
new { eventType = "bridge_dropped_due_to_time", shouldBeSampled = false },
new { eventType = "Players_at_finishline", shouldBeSampled = false },
new { eventType = "Players_at_timeout", shouldBeSampled = false },
new { eventType = "Social_creation_2_events", shouldBeSampled = false },
new { eventType = "socail_creation_2_completed_task_normally", shouldBeSampled = false },
new { eventType = "Socialcreation2_Players_at_finishline", shouldBeSampled = false },
new { eventType = "Socialcreation2_completed_task_normally", shouldBeSampled = false },
new { eventType = "Socialcreation2_Placed_object", shouldBeSampled = false },
new { eventType = "Socialcreation2_Players_at_timeout", shouldBeSampled = false },
new { eventType = "Socialcreation2_crossed_bridge", shouldBeSampled = false },
new { eventType = "Socialcreation2_bridge_dropped_due_to_time", shouldBeSampled = false },
new { eventType = "Socialcreation2_Player_pressed_button", shouldBeSampled = false },
new { eventType = "widget_watch_state_change", shouldBeSampled = false },
new { eventType = "ListFilteredEntities", shouldBeSampled = false },
new { eventType = "roomie_ai_session", shouldBeSampled = false },
new { eventType = "roomie_ai_response", shouldBeSampled = false },
new { eventType = "roomie_ai_conversation_history", shouldBeSampled = false },
new { eventType = "ai_session", shouldBeSampled = false },
new { eventType = "ai_conversation_history", shouldBeSampled = false },
new { eventType = "ai_response", shouldBeSampled = false },
new { eventType = "roomie_ai_expense", shouldBeSampled = false },
new { eventType = "roomie_ai_error", shouldBeSampled = false },
new { eventType = "roomie_ai_response_error", shouldBeSampled = false },
new { eventType = "avatar_item_consumable", shouldBeSampled = false },
new { eventType = "ham_reached", shouldBeSampled = false },
new { eventType = "prompt_submitted", shouldBeSampled = false },
new { eventType = "llm_response_received", shouldBeSampled = false },
new { eventType = "AccountsToGenericCandidatesContext", shouldBeSampled = false },
new { eventType = "RoomsToGenericSimilarEntitiesContext", shouldBeSampled = false },
new { eventType = "Rooms_searchContext", shouldBeSampled = false },
new { eventType = "QueryRecommendation", shouldBeSampled = false },
new { eventType = "Generic_searchContext", shouldBeSampled = false },
new { eventType = "avatar_effect_grenade", shouldBeSampled = false },
new { eventType = "game_ai_session", shouldBeSampled = false },
new { eventType = "game_ai_conversation_history", shouldBeSampled = false },
new { eventType = "game_ai_response", shouldBeSampled = false },
new { eventType = "game_ai_response_error", shouldBeSampled = false },
new { eventType = "roomie_avatar_equip", shouldBeSampled = false },
new { eventType = "game_ai_error", shouldBeSampled = false },
new { eventType = "roomie_ai_stuck_in_text", shouldBeSampled = false },
new { eventType = "roomie_ai_restart", shouldBeSampled = false },
new { eventType = "gift_button_clicked", shouldBeSampled = false },
new { eventType = "gift_button_consume_clicked", shouldBeSampled = false },
new { eventType = "rma_booth_entered", shouldBeSampled = false },
new { eventType = "rma_rate_button_pushed ", shouldBeSampled = false },
new { eventType = "rma_rating_completion", shouldBeSampled = false },
new { eventType = "sj_rate_button_pushed", shouldBeSampled = false },
new { eventType = "sj_rating_completion", shouldBeSampled = false },
new { eventType = "avatar_glitch_effect_grenade", shouldBeSampled = false },
new { eventType = "game_ai_expense", shouldBeSampled = false },
new { eventType = "roomie_interaction", shouldBeSampled = false },
new { eventType = "roomie_ai_energy_updated", shouldBeSampled = false },
new { eventType = "Ticket consumed", shouldBeSampled = false }
};
return Ok(samplingData);
}
[HttpGet("/api/consumables/v1/all")]
public async Task<IActionResult> GetUnlockableConsumables()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "AllUnlockableConsumables.json");
string content = await System.IO.File.ReadAllTextAsync(path);
// Was: Ok(<string>). ASP.NET's StringOutputFormatter writes a raw string as
// Content-Type: text/plain, so these JSON payloads were served mislabelled
// and the client would not parse them. Verified live: /outfits/me returned
// text/plain while /api/avatar/v2 returned application/json. Same bytes,
// correct content type.
return new ContentResult { Content = content, ContentType = "application/json", StatusCode = 200 };
}
[HttpPost("/statsigUserProperties")]
public IActionResult GetStatsigUserProperties()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
success = ConfigCampus.StatsigEnabled
});
}
[HttpGet("/api/referee/files")]
public IActionResult GetRefereeFiles()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpPost("/api/PlayerReporting/v1/referee")] // we hate referee so false it to hell
public IActionResult RefereeCheck()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok("false");
}
[HttpGet("/api/ugcPurchasables/v1/items/room/{roomId}")]
public IActionResult RefereeCheck(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(ConfigCampus.Bracket);
}
[HttpPost("/lists/contextualfeatures")]
public IActionResult ContextualFeatures()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
success = true,
error_id = (string?)null,
error = (string?)null
});
}
// /api/progressionEvents/active moved to ProgressionEventsController (limited-time events).
// Maker AI free trial: the 20250718 client reads an OBJECT (the reference's bare `false`
// was a 2023 shape - "expected:'{', actual:'false'" in Player.log). Not eligible either way.
[HttpGet("/api/makerai/checkfreetrialeligibility")]
public IActionResult CheckFreeTrialEligibility()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Content("{\"IsEligible\":false,\"Eligible\":false,\"Success\":true,\"Error\":null}", "application/json");
}
// The friends the caller and another player have in common - what the client shows on
// someone else's profile. Ported from RecFlare's /relationships/mutualfriends; the C#
// handler was a stub that returned ConfigCampus.Bracket (nonsense on the profile panel).
//
// Answers account cards (AccountId, Username, DisplayName, ProfileImage), ascending by
// account id, capped at 100. Only real friendships on BOTH sides count. Degenerate cases
// (no id, id <= 0, or the caller asking for mutuals with themselves) return [] rather than
// an error - this feeds a profile panel. Mutual ids with no account row are dropped, so
// the list can be shorter than the raw intersection. ProfileImage is "" (never null).
[HttpGet("/api/relationships/mutualfriends")]
public IActionResult GetMutualFriends([FromQuery(Name = "id")] long id = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
if (id <= 0 || (ulong)id == account.Id)
return Ok(Array.Empty<object>());
ulong otherId = (ulong)id;
HashSet<ulong> FriendIds(ulong who) => FriendsDB.GetRelationships(who)
.Where(r => r.RelationshipType == RelationshipType.Friend)
.Select(r => r.PlayerID)
.ToHashSet();
var mine = FriendIds(account.Id);
var theirs = FriendIds(otherId);
mine.IntersectWith(theirs);
mine.Remove(account.Id);
mine.Remove(otherId);
// The element type is the client's RELATIONSHIP DTO, not an account card:
// BaseAccountModel.MutualFriendList is List<LANKKABFAEB>, the same type its
// get_Relationship() returns - {PlayerID, RelationshipType, Favorited, Ignored, Muted},
// exactly one row of /api/relationships/v2/get. The reference server (and this handler
// until now) answered account cards, which deserialise into rows with PlayerID 0 - so
// "friends in common" on another player's profile came up empty even when the server
// had found ten. The card fields are kept alongside: unknown keys cost nothing, and
// anything that did read them keeps working.
var cards = mine
.OrderBy(x => x)
.Take(100)
.Select(fid => PlayerDB.GetFullAccount(fid)?.player)
.Where(p => p != null)
.Select(p => new
{
PlayerID = p!.Id,
RelationshipType = (int)RelationshipType.Friend,
Favorited = 0,
Ignored = 0,
Muted = 0,
AccountId = p.Id,
Username = p.Username ?? "",
DisplayName = p.DisplayName ?? "",
ProfileImage = p.profileImage ?? ""
})
.ToList();
return Ok(cards);
}
[HttpGet("/api/CampusCard/v1/SignUpBonus")]
public IActionResult GetRRPlusSignUpBonus()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
RRPlusSignUpBonusId = 3,
MinFreeItemsPrice = 6000,
MaxFreeItemsPrice = 10000
});
}
// Store category carousel -> name needles (reference lists.app.ts STORE_ROW_RULES). A slug
// here draws store items whose FriendlyName contains ANY needle; null/empty = the whole
// store (itemsforyou); skinsitems is present so it resolves to an (empty) skins row rather
// than falling through to the file lookup. The catalog records no real slot, so name match
// is the crude stand-in the reference uses too.
// The wardrobe slots (RecRoom.Avatars.OutfitType) each category row sells - the reference's
// CUSTOM_ITEM_ROW_OUTFIT_TYPES. Used for every Watch row whose slot is known
// (StoreGenerator.OutfitTypeOf: custom items, and legacy items with a downloadable twin);
// the name needles below only sort what no record slots.
private static readonly Dictionary<string, int[]> StoreRowSlots = new(StringComparer.OrdinalIgnoreCase)
{
["headwearitems"] = new[] { 0 }, // Hat
["topsitems"] = new[] { 101, 104 }, // Shirt, TeamJersey
["handsitems"] = new[] { 200, 203 }, // Wrist, TeamWrist
["hairitems"] = new[] { 2 }, // Hair
["facialhairitems"] = new[] { 20 }, // Beard
["waistitems"] = new[] { 102 }, // Waist
["accessoriesitems"]= new[] { 3, 10, 103 }, // Ear, Eye, Neck
["footwearitems"] = new[] { 301 }, // Feet
["bottomsitems"] = new[] { 300 }, // Legs
["shoulderitems"] = new[] { 100 }, // Shoulder
};
private static readonly Dictionary<string, string[]?> StoreRowRules = new(StringComparer.OrdinalIgnoreCase)
{
["headwearitems"] = new[] { "hat" },
["topsitems"] = new[] { "shirt", "jacket", "dress" },
["handsitems"] = new[] { "glove", "hand", "wrist" },
["hairitems"] = new[] { "hair" },
["facialhairitems"] = new[] { "beard", "mustache" },
["waistitems"] = new[] { "belt" },
["accessoriesitems"] = new[] { "earrings", "hearing aids" },
["footwearitems"] = new[] { "shoes", "sneakers", "sandals", "boots" },
["bottomsitems"] = new[] { "pants", "shorts" },
["shoulderitems"] = new[] { "quiver", "backpack", "sword" },
["skinsitems"] = System.Array.Empty<string>(), // no storefront carries skins yet -> empty
["itemsforyou"] = null, // whole store, no name filter
};
[HttpGet("/lists/algorithmiclists/{algorithmicList}")]
public async Task<IActionResult> GetAlgorithmicList(string algorithmicList, [FromQuery] int type)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var safeFileName = Path.GetFileName(algorithmicList);
// The store's carousels are algorithmic-list rows too, but they name PURCHASABLE ITEMS
// (not rooms), and there is no live "what's new in the store" feed - so RecFlare serves
// them from a fixed, hand-picked id list (STATIC_ROW_ENTITIES). Without this the store's
// "New" (newitems) and Featured "Medieval" (summerpartycarousel) carousels came back
// empty and the store landing looked broken. The ids resolve against the storefront
// catalogs the client then fetches. Keyed on the lowercased slug, like the reference.
var storeCarousel = safeFileName.ToLowerInvariant();
// "New" / "Hot" / the featured carousel: a draw from the generated Watch store rather
// than a hand-picked id list. The old fixed ids (257, 192, 641, 657) were
// PurchasableItemIds from ANOTHER capture - in ours they either named nothing or
// named a different item, which is one way a click showed the wrong thing. Nothing
// here ranks items yet, so it is a random draw of sellable rows - honestly arbitrary,
// like the reference, instead of pretending to be a ranking.
if (storeCarousel == "newitems" || storeCarousel == "hotitems" || storeCarousel == "summerpartycarousel")
{
var all = (Campus_live_rewrite.api.server.StoreGenerator.MergedWatchStore()["StoreItems"] as JArray ?? new JArray())
.Where(it => ((int?)it["GiftDrop"]?["Rarity"] ?? 0) != -1 && !string.IsNullOrEmpty((string?)it["GiftDrop"]?["AvatarItemDesc"]))
.Select(it => ((int?)it["PurchasableItemId"])?.ToString())
.Where(s => !string.IsNullOrEmpty(s)).Select(s => s!).ToList();
// STABLE within the hour. A fresh Random per request meant the row the client drew
// the board from and the row it re-fetched when a tile was clicked were two different
// draws - so tile N now named a different item and the click opened "a random item".
// Seeded on the hour and the row name, the same request gets the same list until the
// hour rolls over, which is also what the board's "Items change in..." timer is for.
var rng0 = new Random(HashCode.Combine(storeCarousel, DateTime.UtcNow.Year, DateTime.UtcNow.DayOfYear, DateTime.UtcNow.Hour));
var draw = all.OrderBy(_ => rng0.Next()).Take(24).ToList();
string pfx = type == 5 ? "0." : "";
return Ok(new
{
Type = type,
Entities = draw.Select(id => new { Id = pfx + id, Context = (string?)null }).ToArray()
});
}
// LOCAL EXPERIMENT (HoldNewAccountCreation): the creation screen's clothing tabs are built from customizeitems?type=5. That list is a
// capture of the real game's store ids (3,065 ids, ~1,500 of which exist in our store), so few bottoms resolve and the Bottoms tab is empty
// even though the dorm - which lists what the account OWNS - shows plenty. Serve the owned items that our store sells instead.
if (ConfigCampus.HoldNewAccountCreation && type == 5 && storeCarousel == "customizeitems")
{
var ownedSet = PlayerDB.OwnedAvatarItemDescs(account.Id);
var storeRows = Campus_live_rewrite.api.server.StoreGenerator.MergedWatchStore()["StoreItems"] as JArray ?? new JArray();
var mine = storeRows
.Where(it => !string.IsNullOrWhiteSpace((string?)it["GiftDrop"]?["AvatarItemDesc"])
&& ownedSet.Contains(PlayerDB.NormalizeAvatarItemDesc((string?)it["GiftDrop"]?["AvatarItemDesc"])))
.Select(it => ((int?)it["PurchasableItemId"])?.ToString())
.Where(x => !string.IsNullOrEmpty(x)).Select(x => x!).Distinct().ToList();
Console.WriteLine($"[lists] customizeitems for {account.Id}: {mine.Count} owned item(s) that the store sells (the capture file lists 3065)");
return Ok(new { Type = type, Entities = mine.Select(id => new { Id = id, Context = (string?)null }).ToArray() });
}
// Store CATEGORY carousels, generated from our storefront by NAME - the reference's
// lists.app.ts STORE_ROW_RULES, ported. The reference's StoreCategories page names a
// slug per wardrobe slot (headwearitems, topsitems, ..., skinsitems); each draws a
// random handful of matching store items so those carousels populate instead of showing
// empty. Ids are our store's PurchasableItemIds, which the client resolves against the
// storefront it fetches. Generic rows (type=5) need the "0." PurchasableItem prefix;
// the store's own rows are type=4 and use bare ids. skinsitems stays empty until a
// storefront carries skins - exactly as the reference notes for its own skins row.
if (StoreRowRules.TryGetValue(storeCarousel, out var needles))
{
var picks = new List<string>();
if (storeCarousel == "skinsitems")
{
// Every skin row in the Watch store - the captured ones and the ones generated
// from Equipments.json (StoreGenerator), stable per hour like the other rows.
var rngS = new Random(HashCode.Combine(storeCarousel, DateTime.UtcNow.Year, DateTime.UtcNow.DayOfYear, DateTime.UtcNow.Hour));
picks = Campus_live_rewrite.api.server.StoreGenerator.SkinPurchasableIds()
.OrderBy(_ => rngS.Next()).Take(60).Select(i => i.ToString()).ToList();
}
else
{
// From the GENERATED Watch store, so every id handed out here is one the
// client's loaded storefront can resolve to the same item.
{
var storeItems = Campus_live_rewrite.api.server.StoreGenerator.MergedWatchStore()["StoreItems"] as JArray ?? new JArray();
StoreRowSlots.TryGetValue(storeCarousel, out var slots);
var matched = storeItems.Where(it =>
{
var gd = it["GiftDrop"];
if (gd == null) return false;
if (((int?)gd["Rarity"] ?? 0) == -1) return false; // unsellable tier
if (!string.IsNullOrEmpty((string?)gd["EquipmentPrefabName"])) return false; // skins have their own row
if (needles == null || needles.Length == 0) return true; // whole store (itemsforyou)
// The real slot when a record carries one; the name only for the rest.
int slot = Campus_live_rewrite.api.server.StoreGenerator.OutfitTypeOf(it);
if (slot >= 0) return slots != null && slots.Contains(slot);
string name = (((string?)gd["FriendlyName"]) ?? "").ToLowerInvariant();
return needles.Any(n => name.Contains(n));
})
.Select(it => ((int?)it["PurchasableItemId"])?.ToString())
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => s!)
.ToList();
// Same stable-per-hour seed as the featured rows, for the same reason: a
// re-fetch during a click must return the list the board was drawn from.
var rng = new Random(HashCode.Combine(storeCarousel, DateTime.UtcNow.Year, DateTime.UtcNow.DayOfYear, DateTime.UtcNow.Hour));
picks = matched.OrderBy(_ => rng.Next()).Take(50).ToList(); // reference: ORDER BY RANDOM() LIMIT 50
}
}
string idPrefix = type == 5 ? "0." : "";
return Ok(new
{
Type = type,
Entities = picks.Select(pid => new { Id = idPrefix + pid, Context = (string?)null }).ToArray()
});
}
// Room rows (type 1 = Rooms): built from this server's rooms - foryou, HotList,
// staffpicks, new, recentlyupdated, inkshowcase and the genre rows. These answered
// from files that mostly didn't exist, which is why the Play tab's carousels were blank.
if (type == 1 && Campus_live_rewrite.api.server.DiscoveryLists.Handles(safeFileName))
{
var ids = Campus_live_rewrite.api.server.DiscoveryLists.RoomIds(safeFileName);
return Ok(new
{
Type = type,
Entities = ids.Select(id => new { Id = id, Context = (string?)null }).ToArray()
});
}
string path = Path.Combine(
Environment.CurrentDirectory,
"Data", "APIS", "AlgorithmicLists",
$"{safeFileName}_{type}.json"
);
if (!System.IO.File.Exists(path))
return Ok(new
{
Type = type,
Entities = Array.Empty<object>()
});
try
{
var jsonText = await System.IO.File.ReadAllTextAsync(path);
var node = JsonNode.Parse(jsonText);
return Ok(new
{
Type = type,
Entities = node as JsonArray ?? new JsonArray()
});
}
catch (System.Text.Json.JsonException)
{
return Ok(new
{
Type = type,
Entities = Array.Empty<object>()
});
}
}
// The room genre tags a room can be filed under (wire-shapes: curatedlists-lookup-and-
// pagesource-names: name=RoomGenreTags&type=5, ItemIds are the tag names verbatim). Was a
// placeholder with no ids, so room settings offered no genre.
private static readonly string[] RoomGenreTags =
{ "Hangout", "Battle", "Quests", "Roleplay", "Horror", "Casual", "Explore", "Art", "Sports", "Music", "Parkour", "Comedy" };
private static object CuratedListObj(long listId, ulong creator, string name, int type, IEnumerable<string> ids, DateTime? created = null, int? accessibility = null)
{
var o = new JObject
{
["ListId"] = listId,
["CreatorAccountId"] = (long)creator,
["Name"] = name,
["Description"] = null,
["ImageName"] = "",
["Type"] = type,
["ItemIds"] = new JArray(ids),
["CreatedAt"] = (created ?? new DateTime(2025, 7, 18, 0, 0, 0, DateTimeKind.Utc)).ToString("o"),
};
if (accessibility.HasValue) o["Accessibility"] = accessibility.Value;
return o;
}
[HttpGet("/lists/curatedlists")]
public async Task<IActionResult> GetCuratedlists([FromQuery] ulong creatorAccountId, [FromQuery] ulong type, [FromQuery] string name)
{
Console.WriteLine($"[lists] curatedlists{Request.QueryString}");
name ??= "";
// A player's OWN list (Saved for Later) - a real, stable ListId whether or not it has
// been created yet, so the client's save has something to save into. Was ListId 0,
// which the client reads as "No such list" and never writes to.
if (name.StartsWith("__") || CuratedListDB.Get(creatorAccountId, (int)type, name) != null)
{
var mine = CuratedListDB.Get(creatorAccountId, (int)type, name);
return new ContentResult
{
Content = JsonConvert.SerializeObject(mine != null
? CuratedListObj(mine.ListId, mine.CreatorAccountId, mine.Name, mine.Type, mine.ItemIds, mine.CreatedAt, mine.Accessibility)
: CuratedListObj(CuratedListDB.IdFor(creatorAccountId, (int)type, name), creatorAccountId, name, (int)type, Array.Empty<string>(), null, 0)),
ContentType = "application/json",
StatusCode = 200
};
}
if (string.Equals(name, "RoomGenreTags", StringComparison.OrdinalIgnoreCase))
return new ContentResult
{
Content = JsonConvert.SerializeObject(CuratedListObj(1, 1, "RoomGenreTags", 5, RoomGenreTags)),
ContentType = "application/json",
StatusCode = 200
};
string path = Path.Combine(
Environment.CurrentDirectory,
"Data", "APIS", "CuratedLists",
$"{creatorAccountId}_{type}_{name}.json"
);
if (System.IO.File.Exists(path))
return PhysicalFile(path, "application/json");
return new ContentResult
{
Content = JsonConvert.SerializeObject(CuratedListObj(0, creatorAccountId, name, (int)type, Array.Empty<string>())),
ContentType = "application/json",
StatusCode = 200
};
}
// Save a room for later (reference: PUT /curatedlists/{name}/items/{itemId}/createlistifneeded).
// The owner is the TOKEN's account; `type` and `accessibility` (query or form) apply only
// when the list is created. Answers the list as it now stands, without Accessibility.
[HttpPut("/lists/curatedlists/{name}/items/{itemId}/createlistifneeded")]
[HttpPost("/lists/curatedlists/{name}/items/{itemId}/createlistifneeded")]
public IActionResult SaveToCuratedList(string name, string itemId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
string Q(string k) { string v = Request.Query[k].ToString(); if (string.IsNullOrEmpty(v) && Request.HasFormContentType) v = Request.Form[k].ToString(); return v; }
int type = int.TryParse(Q("type"), out var t) ? t : 1;
int acc = int.TryParse(Q("accessibility"), out var a) ? a : 0;
var list = CuratedListDB.AddItem(account.Id, type, name, acc, itemId);
Console.WriteLine($"[lists] {account.Id} saved {itemId} into '{name}' ({list.ItemIds.Count} item(s))");
return new ContentResult
{
Content = JsonConvert.SerializeObject(CuratedListObj(list.ListId, list.CreatorAccountId, list.Name, list.Type, list.ItemIds, list.CreatedAt)),
ContentType = "application/json",
StatusCode = 200
};
}
// Un-save (the spelling is a guess at the reference's inverse; any other write under
// /lists/curatedlists is logged by the catch-all below so it can be named).
[HttpDelete("/lists/curatedlists/{name}/items/{itemId}")]
[HttpDelete("/lists/curatedlists/{name}/items/{itemId}/createlistifneeded")]
public IActionResult RemoveFromCuratedList(string name, string itemId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
int type = int.TryParse(Request.Query["type"], out var t) ? t : 1;
var list = CuratedListDB.RemoveItem(account.Id, type, name, itemId);
if (list == null) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
Console.WriteLine($"[lists] {account.Id} removed {itemId} from '{name}'");
return new ContentResult
{
Content = JsonConvert.SerializeObject(CuratedListObj(list.ListId, list.CreatorAccountId, list.Name, list.Type, list.ItemIds, list.CreatedAt)),
ContentType = "application/json",
StatusCode = 200
};
}
[HttpPut("/lists/curatedlists/{**rest}")]
[HttpPost("/lists/curatedlists/{**rest}")]
[HttpDelete("/lists/curatedlists/{**rest}")]
public IActionResult UnknownCuratedListWrite(string rest)
{
string body = "";
try { if (Request.HasFormContentType) body = string.Join("&", Request.Form.Select(kv => kv.Key + "=" + kv.Value)); } catch { }
Console.WriteLine($"[lists] UNHANDLED {Request.Method} /lists/curatedlists/{rest}{Request.QueryString} body='{body}'");
return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
} // Bulk curated-list lookup (the client repeats ?id=). The store landing (StoreCategories)
// has a "store-featured" section sourced from a CuratedList, and it fetches the list here;
// this route was MISSING, so the section 404'd and the store landing had nothing to draw.
// Matches RecFlare: one canned list whatever is asked for, shaped the way the client parses
// it - ItemIds are STRINGS, Description may be null, ImageName MUST be a string. Answers 200
// even for an unknown id (a 404 shows as a row that failed to load).
[HttpGet("/lists/curatedlists/bulk")]
public IActionResult GetCuratedListsBulk()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
Console.WriteLine($"[lists] curatedlists/bulk{Request.QueryString}");
// The Play/Watch pages ask for live Rec Room's playlists by id ("Fun with Friends",
// "New Ways to Play"). Each is answered as a ROOM list built here: a playlist id the
// page names maps to one of the DiscoveryLists rows, anything else to the hot list.
// Was a placeholder list of two room ids that don't exist.
var lists = new List<object>();
foreach (var raw in Request.Query["id"])
{
if (!long.TryParse(raw, out var listId)) continue;
var owned = CuratedListDB.GetById(listId);
if (owned != null)
{
lists.Add(CuratedListObj(owned.ListId, owned.CreatorAccountId, owned.Name, owned.Type, owned.ItemIds, owned.CreatedAt));
continue;
}
string row = listId switch
{
7732248245192312892 => "hangout_algoendpoint", // Fun with Friends
640498224290317839 => "new", // New Ways to Play
_ => "hotlist",
};
string name = listId switch
{
7732248245192312892 => "Fun with Friends",
640498224290317839 => "New Ways to Play",
_ => "Trending",
};
lists.Add(new
{
ListId = listId,
CreatorAccountId = 1L,
Name = name,
Description = (string?)null,
ImageName = "",
Type = 1, // ListEntityType: 0 Accounts, 1 Rooms, 2 Inventions, 3 CustomAvatarItems, 4 PurchasableItems, 5 Generic
ItemIds = Campus_live_rewrite.api.server.DiscoveryLists.RoomIds(row).ToArray(),
CreatedAt = "2025-07-18T00:00:00Z"
});
}
return Ok(lists);
}
// The charades activity's word lists - Icebreakers, Charades, ... - one file per list
// in Data/APIS/Charades/<list>.json ([{Id, Difficulty, EN_US}]). ^3DCharades and
// ^Legacy3DCharades ask for /words/Charades when a round starts; when that file was
// missing this answered [] and the cards fell back to the client's built-in words.
// Charades.json is the full 1.3k-word bank; CharadesApril.json replaces it on April 1st
// (UTC), and a list name with no file of its own gets the Charades bank rather than
// nothing (mirrors the reference server, where the name isn't matched on at all).
[HttpGet("/api/activities/charades/v1/words/{list}")]
public IActionResult GetCharadesWords(string list, ulong RoomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
string dir = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "Charades");
string safe = System.Text.RegularExpressions.Regex.Replace(list ?? "", @"[^A-Za-z0-9_-]", "");
string path = Path.Combine(dir, safe + ".json");
if (safe.Length == 0 || !System.IO.File.Exists(path))
path = Path.Combine(dir, "Charades.json");
if (safe.Equals("Charades", StringComparison.OrdinalIgnoreCase))
{
var now = DateTime.UtcNow;
string april = Path.Combine(dir, "CharadesApril.json");
if (now.Month == 4 && now.Day == 1 && System.IO.File.Exists(april)) path = april;
}
if (!System.IO.File.Exists(path))
return Ok(ConfigCampus.Bracket);
return PhysicalFile(path, "application/json");
}
[HttpGet("/aimoderation/roomie/enabledForPlayer")]
public IActionResult IsRoomieEnabledForPlayer()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Was: Ok(<string>). ASP.NET's StringOutputFormatter writes a raw string as
// Content-Type: text/plain, so these JSON payloads were served mislabelled
// and the client would not parse them. Verified live: /outfits/me returned
// text/plain while /api/avatar/v2 returned application/json. Same bytes,
// correct content type.
return new ContentResult { Content = "true", ContentType = "application/json", StatusCode = 200 };
}
[HttpPost("/aimoderation/roomie/conversation")]
public IActionResult SaveRoomieConversation([FromForm] string conversation)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (string.IsNullOrEmpty(conversation))
return BadRequest();
string pattern = @"(?<=\d{2}:\d{2}:\d{2}\s)(USER|AI)(?=\s\(|:)";
string processedConversation = Regex.Replace(conversation ?? "", pattern, m =>
{
return m.Value == "USER" ? account.Username : "Roomie";
});
_ = Task.Run(() => Webhook.Send(
account.Id,
"roomie",
"Roomie Info",
$"User: @{account.Username} ({account.Id})\n\nLog: {processedConversation}",
5898927,
null
));
return Ok();
}
[HttpGet("/api/players/v1/playerPhotoTaggingSetting")]
public IActionResult GetPlayerPhotoTaggingSetting()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(PlayerDB.GetPlayerPhotoTaggingSetting(account.Id));
}
[HttpPut("/api/players/v1/playerPhotoTaggingSetting")]
public IActionResult SetPlayerPhotoTaggingSetting([FromBody] SetPlayerPhotoTaggingSettingRequest request)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(PlayerDB.SetPlayerPhotoTaggingSetting(account.Id, request.Setting));
}
[HttpGet("/api/keepsakes/globalconfig")]
public IActionResult GetKeepsakeGlobalConfig()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
KeepsakeFeatureEnabled = true,
KeepsakeRoomLimit = 10,
SocialXpBoostEnabled = false
});
}
// The pending quick-play action (join a friend / deep link), read-and-clear. Nothing
// queues one, so all three fields are null - the reference shape; a 204 read as
// "Response was empty".
[HttpGet("/api/quickPlay/v1/getandclear")]
public IActionResult GetAndClearQuickPlay()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Content("{\"RoomName\":null,\"ActionCode\":null,\"TargetPlayerId\":null}", "application/json");
}
[HttpGet("/api/keepsakes/rooms/{roomId}")]
public IActionResult GetKeepsakesForRoom(ulong roomId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return NoContent();
}
[HttpPost("/api/messages/v1/friendOnlineStatus")]
public IActionResult GetFriendOnlineCount()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
// Real count: the caller's accepted friends that currently hold a live notification
// websocket. Was int.MaxValue as a joke, which surfaced verbatim in the client's
// "Failed to load room" dialog as "You have 2147483647 friends online."
return Ok(new
{
success = true,
value = new
{
FriendsOnlineCount = FriendsDB.CountOnlineFriends(account.Id)
}
});
}
[HttpGet("/api/purchasableXpBoosts/activations")]
public IActionResult GetPurchasableXpBoosts()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null)
return error;
return Ok(new
{
Results = Array.Empty<object>(),
TotalResults = 0
});
}
[HttpPost("/api/consumables/v1/updateActive")]
public IActionResult UpdateActiveConsumable()
{
return NoContent();
}
// A player's inventions as another player sees them (the profile's Inventions tab):
// PUBLISHED ones only; the creator's own private saves stay in /inventions/v2/mine.
[HttpGet("/api/inventions/v1/fromcreators")]
public IActionResult InventionsFromCreator([FromQuery] ulong id, [FromQuery] int skip, [FromQuery] int take)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
bool self = id == account.Id;
var list = InventionDB.GetByCreator(id)
.Where(i => self || i.IsPublished)
.Select(Campus_live_rewrite.Controllers.InventionController.InventionDto).ToList();
if (skip > 0) list = list.Skip(skip).ToList();
if (take > 0) list = list.Take(take).ToList();
return new ContentResult { Content = JsonConvert.SerializeObject(list), ContentType = "application/json", StatusCode = 200 };
}
[HttpGet("/sections/bulk")]
public IActionResult GetSectionsBulk([FromQuery] List<string> id)
{
var filePath = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "WatchPages", "bulk.json");
if (!System.IO.File.Exists(filePath))
return NotFound();
var jsonString = System.IO.File.ReadAllText(filePath);
var allSections = System.Text.Json.JsonSerializer.Deserialize<List<Dictionary<string, object>>>(jsonString);
if (id == null || !id.Any())
return Ok(new List<object>());
var filtered = allSections.Where(s => s.ContainsKey("id") && id.Contains(s["id"]?.ToString())).ToList();
return Ok(filtered);
}
}
}