feat: Add account privacy settings endpoint

- Implemented new endpoints for retrieving account privacy settings.
- Added fields for privacy preferences such as profile visibility, online status, and activity visibility.

feat: Extend play menu tags functionality

- Updated PlayMenuTagsRequest to include TrendingTags.
- Modified SetPlayMenuTags to handle trending tags.
- Enhanced logging to include trending tags.

fix: Increase maximum archive size for room zip imports

- Updated MaxArchiveBytes from 12GB to 25GB in RoomZipImportController.

feat: Improve CDN image handling

- Added local image path resolution for room images.
- Implemented lazy loading of room image aliases from JSON file.

feat: Enhance match player presence resolution

- Updated ResolveDataBlobForPresenceAsync to consider sub-room IDs and private instance status.

feat: Extend RoomEntity with new properties

- Added AllowNewUsers, MinLevel, MaxPlayerCalculationMode, LoadScreensJson, PromoImagesJson, and PromoExternalContentJson fields.

feat: Update database schema for new room properties

- Added new columns to the Rooms table for AllowNewUsers, MinLevel, MaxPlayerCalculationMode, LoadScreensJson, PromoImagesJson, and PromoExternalContentJson.

feat: Implement platform notifications controller

- Created PlatformNotificationsController for managing user notification preferences.
- Added endpoints for retrieving and setting notification preferences and categories.
This commit is contained in:
Alexa
2026-06-25 01:23:57 +03:00
parent 1d4777e3da
commit 312ecbbdc8
18 changed files with 1615 additions and 239 deletions
+334 -200
View File
@@ -49,6 +49,7 @@ public class Mod : MelonMod
private static readonly Dictionary<string, Type> ResolvedTypeCache = new(); private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
private readonly HashSet<string> _diagnosticPatchLabels = new(); private readonly HashSet<string> _diagnosticPatchLabels = new();
private bool _networkPatchesRegistered; private bool _networkPatchesRegistered;
private bool _httpDiagnosticsRegistered;
private bool _joinTracePatchesRegistered; private bool _joinTracePatchesRegistered;
private bool _quitTraceCoreRegistered; private bool _quitTraceCoreRegistered;
private bool _quitTraceGameComplete; private bool _quitTraceGameComplete;
@@ -86,6 +87,9 @@ public class Mod : MelonMod
// is a UnityEngine.KeyCode name; BackQuote is the `~` key. // is a UnityEngine.KeyCode name; BackQuote is the `~` key.
public static bool EnableDebugConsole = false; public static bool EnableDebugConsole = false;
public static string DebugConsoleToggleKey = "BackQuote"; public static string DebugConsoleToggleKey = "BackQuote";
public static string[] DevCommands = Array.Empty<string>();
public static bool DiagnoseDevMenu = false;
public static bool EnableBuiltInDevDebugTools = false;
// ── Desktop Screen Sharing gadget FPS ── // ── Desktop Screen Sharing gadget FPS ──
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts // RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
@@ -110,29 +114,6 @@ public class Mod : MelonMod
public static int DesktopScreenShareResolution = 0; // horizontal px public static int DesktopScreenShareResolution = 0; // horizontal px
public static int DesktopScreenShareQuality = 0; // JPEG quality public static int DesktopScreenShareQuality = 0; // JPEG quality
// One-shot dev-UI diagnostic (no behaviour change). When true,
// ~5s after load the mod logs whether the watch's native developer
// menu content actually exists in this build (HomeScreenFlow.
// devMenuPanel / devButtonPrefab present vs NULL), the dev-flow list
// counts, and whether RecRoom.Debugging.DebugConsole is in the scene
// + whether its static Execute() command path works. This tells us
// stripped-vs-gated so we know whether to un-gate the game's own dev
// menu or build a custom watch entry. Output goes to the MelonLoader
// console + dorknet-diagnostics.log.
public static bool DiagnoseDevMenu = false;
// Inject a "Dev Console" button into the watch's native dev-menu
// panel (HomeScreenFlow.devMenuPanel) by cloning the game's own
// devButtonPrefab. The panel/prefab exist in this build (just gated
// off), so we instantiate + activate + label, then wire its click to
// run console commands. Native Button3D → works in VR and Screen Mode.
public static bool EnableDevWatchButton = false;
// Preset dev commands shown as watch buttons (when EnableDevWatchButton
// is on). Each entry is "Label=command"; clicking runs
// DebugConsole.Execute(command). Edit to match this build's actual
// DebugConsoleCommandConfig command names/args.
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
// Raise RRO quest party size past the baked 4. The cap lives in each // Raise RRO quest party size past the baked 4. The cap lives in each
// quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize // quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize
// ScriptableObject (baked in the client bundle; no server lever). The // ScriptableObject (baked in the client bundle; no server lever). The
@@ -186,116 +167,10 @@ public class Mod : MelonMod
"NotifyServerCertificate", "NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix)); prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
} }
if (Cfg.EnableDebugConsole || Cfg.EnableDevWatchButton)
Log.Msg($"[debugconsole] armed: toggleKey={Cfg.DebugConsoleToggleKey}, commands={DebugConsolePatches.FormatConfiguredCommandsForLog()}");
// Image CDN signature verification. Server responses still include
// Content-Signature, but the official RSA public key is baked into
// GameAssembly and the matching private key is not available. This
// method is the verifier in the 2023 build (JFDPIJFDHAD.HOCPMNEKIHP);
// skipping it lets signed local CDN/image bytes load.
TryPatchByName("JFDPIJFDHAD",
"HOCPMNEKIHP",
args: new[]
{
typeof(string),
typeof(Il2CppStructArray<byte>),
typeof(Il2CppStructArray<byte>),
typeof(Il2CppStructArray<byte>),
typeof(Il2CppStructArray<byte>)
},
prefix: nameof(ImageSignaturePatches.VerifyImageSignature_Prefix));
RegisterQuitTracePatches(logMisses: true); RegisterQuitTracePatches(logMisses: true);
RegisterAntiTamperCallbackPatches(logMisses: true); RegisterAntiTamperCallbackPatches(logMisses: true);
RegisterToxModPatches(logMisses: true); RegisterToxModPatches(logMisses: true);
PatchFileHashCheckerCallback(); PatchFileHashCheckerCallback();
// Post-save blob-name refresh. The 2020 watch's
// RoomPersistenceManager.RoomDataBlobName is only ever set by
// /goto-driven flows — neither the SubscriptionUpdateRoom push
// nor SubscriptionUpdatePresence pushes the server fires after
// save propagate into this field. So the PostSaveReloading
// sub-state's MasterReloadRoomAsync deserializes the cached
// bytes from the OLD blob name (the one /goto returned at boot),
// wiping any MakerPen object the user just placed even though
// the new blob is already on S3.
//
// Verified live: after a dorm save with object placement, server
// pushes SubscriptionUpdateRoom + PresenceUpdate both carrying
// dataBlob="dorm_p<id>_v(N+1).dat", but the watch's
// set_RoomDataBlobName trace immediately after the save shows
// old="v(N)" new="v(N)" — no propagation, no fresh download,
// no respawn. Leave+return triggers /goto which DOES update
// RoomDataBlobName, so the fix is to do the same write
// ourselves: when entering the SavingRoomSuccess state on the
// RoomPersistenceManager, read the just-pushed
// Rooms.LocalRoomScene.DataBlobName and force-set
// __instance.RoomDataBlobName to it. That fires the real
// setter → OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync
// for the new blob → PostSaveReloading picks up the fresh
// bytes and respawns the MakerPen objects.
TryPatchByName("RecRoom.Persistence.RoomPersistenceManager",
"set_RoomDataBlobName",
args: new[] { typeof(string) },
prefix: nameof(SavePatches.SetRoomDataBlobName_Prefix));
// Intercept the SubscriptionUpdateRoom push BEFORE the watch's
// (cachedRoomId, cachedSubRoomId) early-out gate drops it. We
// stash the just-arrived Scenes[0].DataBlobName into a static
// so the set_RoomDataBlobName hijack can use it — otherwise
// LocalRoomDetails / LocalRoomScene never get the new blob
// name on the second-or-later push for the same (room, sub)
// tuple (= every post-save push in a dorm).
TryPatchByName("RecNet.Rooms",
"OnSubscriptionUpdateRoom",
prefix: nameof(SavePatches.OnSubscriptionUpdateRoom_Prefix));
// Force AccountExtensions.CanLocalPlayerChat to return true. The
// 2020 watch's room-chat send path (PlayerEmotes.SendChatEmote
// Coroutine d__53 offset 040) gates on this — if false, the
// coroutine bails before raising the Photon RPC, so the sender
// doesn't even see their own message. The check ANDs three
// signals: LocalAccount.TreatAsJunior (ObscuredBool at offset
// 0x40), a PlatformManager flag, and a ModerationManager
// singleton field. At least one returns the wrong value against
// our server. Force-allow — single-tenant private server, text
// moderation isn't a concern.
TryPatchByName("RecNet.AccountExtensions",
"CanLocalPlayerChat",
args: Type.EmptyTypes,
postfix: nameof(ChatPatches.CanLocalPlayerChat_Postfix));
// PlayerEmotes.RemoveInvalidCharactersFromMessage walks the chat
// font asset's character table via TMP_FontAsset.HasCharacters
// to strip glyphs the font can't render. On 2020 Rec Room the
// chat font field can be null at the moment this fires (font
// asset bundle hasn't loaded yet?) and HasCharacters NREs deep
// inside Unity's TMP — which propagates out through the chat
// send callback and aborts the RPC. The watch's own ISIL has
// "skipping invalid character check" log paths for missing
// assets but doesn't actually short-circuit on the null field
// we hit. Skip the method entirely; chat lines will just keep
// any glyphs the font can't render (most common: emoji).
TryPatchByName("RecRoom.Players.PlayerEmotes",
"RemoveInvalidCharactersFromMessage",
prefix: nameof(ChatPatches.RemoveInvalidCharactersFromMessage_Prefix));
// Trace hooks so we can see exactly where chat dies. Send path:
// KeyboardInputField.submit → RoomChatMenu.SendEmoteMessage(string)
// → PlayerEmotes.SendChatEmote(msg, true)
// → SendChatEmoteCoroutine d__53 → PurifyString HTTP call
// → b__0 callback (error, cleanVersion) — fires Photon RPC
// AND local-echoes via ProcessNewChatMessageReceived
// → on each receiver (including self): RpcChatEmote(msg) →
// ProcessNewChatMessageReceived → ReceiveRoomChat appends to
// the chat log buffer.
TryPatchByName("RecRoom.Players.PlayerEmotes",
"SendChatEmote",
args: new[] { typeof(string), typeof(bool) },
prefix: nameof(ChatPatches.SendChatEmote_Prefix));
TryPatchByName("RecRoom.Players.PlayerEmotes",
"ProcessNewChatMessageReceived",
args: new[] { typeof(string), typeof(bool) },
prefix: nameof(ChatPatches.ProcessNewChatMessageReceived_Prefix));
TryPatchByName("RecRoom.Players.PlayerEmotes",
"RpcChatEmote",
args: new[] { typeof(string) },
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
// Desktop Screen Sharing FPS override. Only register when a target // Desktop Screen Sharing FPS override. Only register when a target
// is set; the postfix on the gadget's Awake rewrites the baked // is set; the postfix on the gadget's Awake rewrites the baked
// refresh-frequency field (and, if enabled, raises the Photon // refresh-frequency field (and, if enabled, raises the Photon
@@ -309,11 +184,6 @@ public class Mod : MelonMod
$"(raisePhotonRate={Cfg.DesktopScreenShareRaisePhotonRate})"); $"(raisePhotonRate={Cfg.DesktopScreenShareRaisePhotonRate})");
} }
TryPatchByName("RecRoom.Tools.MakerPenVisuals",
"set_LaserPointerEnabled",
args: new[] { typeof(bool) },
prefix: nameof(MakerPenGiftPreviewPatches.LaserPointerEnabled_Prefix));
// RRO quest party-size bump. Prefix the runtime config-apply path so // RRO quest party-size bump. Prefix the runtime config-apply path so
// we can read the config's Name (scope to the allowlisted quests) and // we can read the config's Name (scope to the allowlisted quests) and
// bump GameConfigurationData.TeamConfigurations[].TeamSize before the // bump GameConfigurationData.TeamConfigurations[].TeamSize before the
@@ -338,9 +208,30 @@ public class Mod : MelonMod
RegisterStudioTracePatches(); RegisterStudioTracePatches();
if (Cfg.EnableBuiltInDevDebugTools)
RegisterBuiltInDevDebugTools();
Log.Msg("=== Client patches registered ==="); Log.Msg("=== Client patches registered ===");
} }
private void RegisterBuiltInDevDebugTools()
{
Log.Msg("=== Registering built-in Dev Debug Tools hooks ===");
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDeveloperImpl",
"DGFIJKPCBLK", args: Type.EmptyTypes,
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDeveloperAndNotDebugBuildImpl",
"DGFIJKPCBLK", args: Type.EmptyTypes,
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDebugBuildImpl",
"DGFIJKPCBLK", args: Type.EmptyTypes,
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
TryPatchByName("RRUI.Data.DevDebugBuildInformationModel",
"get_BuildInfoFormatted", args: Type.EmptyTypes,
postfix: nameof(BuiltInDevDebugToolsPatches.BuildInfoFormatted_Postfix));
Log.Msg("=== Built-in Dev Debug Tools hooks registered ===");
}
// ── Room-join + watch-button trace ──────────────────────────────── // ── Room-join + watch-button trace ────────────────────────────────
// Traces the full "press the Dorm Room button → join the room" chain // Traces the full "press the Dorm Room button → join the room" chain
// so a stuck join (e.g. the watch's Dorm Room button doing nothing) // so a stuck join (e.g. the watch's Dorm Room button doing nothing)
@@ -631,12 +522,6 @@ public class Mod : MelonMod
new[] { typeof(string), typeof(UriKind) }, new[] { typeof(string), typeof(UriKind) },
prefix: nameof(UriPatches.UriStringCtor_Prefix)); prefix: nameof(UriPatches.UriStringCtor_Prefix));
complete &= TryPatchHttpRequestUriConstructors(); complete &= TryPatchHttpRequestUriConstructors();
TryPatchByName("BestHTTP.HTTPRequest", "set_Uri",
args: new[] { typeof(Uri) },
prefix: nameof(UriPatches.HttpRequestSetUri_Prefix));
TryPatchByName("BestHTTP.HTTPRequest", "PrepareUri",
args: new[] { typeof(Uri) },
prefix: nameof(UriPatches.HttpRequestPrepareUri_Prefix));
complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send", complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send",
prefix: nameof(UriPatches.HttpRequestSend_Prefix)); prefix: nameof(UriPatches.HttpRequestSend_Prefix));
complete &= TryPatchHttpManagerStringSendRequestOverloads(); complete &= TryPatchHttpManagerStringSendRequestOverloads();
@@ -644,9 +529,90 @@ public class Mod : MelonMod
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) }, args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix)); prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix));
RegisterHttpDiagnostics();
_networkPatchesRegistered = complete; _networkPatchesRegistered = complete;
} }
private void RegisterHttpDiagnostics()
{
if (_httpDiagnosticsRegistered) return;
var complete = true;
complete &= TryPatchBndRequestBuilderDiagnostics();
complete &= TryPatchDiagnosticAllOverloads("BestHTTP.HTTPRequest", "CallCallback",
postfix: nameof(HttpTracePatches.CallCallback_Postfix),
finalizer: nameof(HttpTracePatches.CallCallback_Finalizer));
complete &= TryPatchByName("BestHTTP.HTTPRequest", "AddHeader",
args: new[] { typeof(string), typeof(string) },
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
complete &= TryPatchByName("BestHTTP.HTTPRequest", "SetHeader",
args: new[] { typeof(string), typeof(string) },
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
_httpDiagnosticsRegistered = complete;
}
private bool TryPatchBndRequestBuilderDiagnostics()
{
const string label = "BNDIAONDFFF.request-builder";
if (_diagnosticPatchLabels.Contains(label))
return true;
var type = ResolveType("BNDIAONDFFF");
if (type is null)
{
Log.Warning("[patch-miss] BNDIAONDFFF request-builder: type not found");
return false;
}
var prefix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix)));
var postfix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Postfix)));
var patched = 0;
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
var parameters = ctor.GetParameters();
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
try
{
HarmonyInstance.Patch(ctor, prefix: prefix, postfix: postfix);
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] BNDIAONDFFF..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
}
}
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.Name != "FILJELOLBKK") continue;
var parameters = method.GetParameters();
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
try
{
HarmonyInstance.Patch(method, prefix: prefix, postfix: postfix);
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] BNDIAONDFFF.FILJELOLBKK({FormatParameterTypes(parameters)}): {ex.Message}");
}
}
if (patched == 0)
{
Log.Warning("[patch-miss] BNDIAONDFFF request-builder overloads");
return false;
}
_diagnosticPatchLabels.Add(label);
Log.Msg($"[patch-ok] BNDIAONDFFF request-builder x{patched}");
return true;
}
private bool TryPatchHttpRequestUriConstructors() private bool TryPatchHttpRequestUriConstructors()
{ {
var type = ResolveType("BestHTTP.HTTPRequest"); var type = ResolveType("BestHTTP.HTTPRequest");
@@ -753,10 +719,10 @@ public class Mod : MelonMod
RegisterToxModPatches(logMisses: false); RegisterToxModPatches(logMisses: false);
} }
if (Cfg.EnableDebugConsole || Cfg.EnableDevWatchButton) if (Cfg.EnableDebugConsole)
DebugConsolePatches.PollToggleKey(); DebugConsolePatches.PollToggleKey();
if (Cfg.DiagnoseDevMenu || Cfg.EnableDevWatchButton) if (Cfg.DiagnoseDevMenu)
DevMenuProbe.Tick(); DevMenuProbe.Tick();
} }
@@ -783,13 +749,22 @@ public class Mod : MelonMod
if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost; if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean(); if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean(); if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean(); if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey; if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
if (TryGetConfigValue(r, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
if (TryGetConfigValue(r, "EnableBuiltInDevDebugTools", out v)) Cfg.EnableBuiltInDevDebugTools = v.GetBoolean();
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
{
var commands = new List<string>();
foreach (var e in v.EnumerateArray())
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
commands.Add(e.GetString()!);
Cfg.DevCommands = commands.ToArray();
}
if (TryGetConfigValue(r, "DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble(); if (TryGetConfigValue(r, "DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean(); if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32(); if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32(); if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
if (TryGetConfigValue(r, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32(); if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array) if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
{ {
@@ -799,17 +774,6 @@ public class Mod : MelonMod
rooms.Add(e.GetString()!); rooms.Add(e.GetString()!);
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none) Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
} }
if (TryGetConfigValue(r, "EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
{
var list = new List<string>();
foreach (var e in v.EnumerateArray())
{
var s = e.GetString();
if (!string.IsNullOrWhiteSpace(s)) list.Add(s!);
}
if (list.Count > 0) Cfg.DevCommands = list.ToArray();
}
Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, " + Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, " +
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}"); $"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
} }
@@ -1063,7 +1027,7 @@ public class Mod : MelonMod
{ {
// Look in all three patch holder classes — small enough that a // Look in all three patch holder classes — small enough that a
// linear scan is cheaper than per-class lookups. // linear scan is cheaper than per-class lookups.
foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ImageSignaturePatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) }) foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(BuiltInDevDebugToolsPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
{ {
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m; if (m is not null) return m;
@@ -1085,17 +1049,6 @@ public class Mod : MelonMod
} }
var complete = true; var complete = true;
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
prefix: nameof(QuitTracePatches.SessionManagerTryApplicationQuit_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
args: new[] { typeof(int) },
prefix: nameof(QuitTracePatches.SessionManagerTryApplicationQuitInt_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("SessionManager", "FatalApplicationQuit",
args: new[] { typeof(int), typeof(string) },
prefix: nameof(QuitTracePatches.SessionManagerFatalApplicationQuit_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "GDNAOLMIDLM", complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "GDNAOLMIDLM",
prefix: nameof(QuitTracePatches.ApplicationWantsToQuit_Prefix), prefix: nameof(QuitTracePatches.ApplicationWantsToQuit_Prefix),
logMiss: logMisses); logMiss: logMisses);
@@ -1145,17 +1098,6 @@ public class Mod : MelonMod
logMiss: logMisses); logMiss: logMisses);
} }
TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
logMiss: logMisses);
TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
args: new[] { typeof(int) },
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuitInt_Prefix),
logMiss: logMisses);
TryPatchDiagnosticByName("SessionManager", "FatalApplicationQuit",
args: new[] { typeof(int), typeof(string) },
prefix: nameof(DiagnosticPatches.SessionManagerFatalApplicationQuit_Prefix),
logMiss: logMisses);
RegisterDeepRoomDiagnostics(logMisses); RegisterDeepRoomDiagnostics(logMisses);
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were // SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
// pure-logging diagnostics that fired during Unity's very first // pure-logging diagnostics that fired during Unity's very first
@@ -1732,6 +1674,31 @@ internal static class RoomLoadDiagnostics
} }
// ── Debug console access ─────────────────────────────────────────────── // ── Debug console access ───────────────────────────────────────────────
// Built-in watch Dev Debug Tools hooks. The 2023 RRUI page already exists,
// but retail rows are hidden by GlobalModelController hide gates. We keep
// those rows visible and append the debug-console command table to the
// built-in Build Information text so the watch page reflects the real client
// command config.
internal static class BuiltInDevDebugToolsPatches
{
public static bool KeepVisible_Prefix() => false;
public static void BuildInfoFormatted_Postfix(ref string __result)
{
try
{
var commands = DebugConsolePatches.BuildCommandReferenceText("Debug Console Commands", maxCommands: 60);
__result = string.IsNullOrWhiteSpace(__result)
? commands
: __result.TrimEnd() + "\n\n" + commands;
}
catch (Exception ex)
{
Mod.Log.Warning($"[devtools] failed to append command list: {ex.Message}");
}
}
}
// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game // The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game
// dev console (text input + a DebugConsoleCommandConfig command table: // dev console (text input + a DebugConsoleCommandConfig command table:
// SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily, // SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily,
@@ -1766,6 +1733,7 @@ internal static class DebugConsolePatches
private static Type? _consoleType; private static Type? _consoleType;
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool) private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
private static MethodInfo? _setConsoleText; // DebugConsole.SetConsoleText(string) private static MethodInfo? _setConsoleText; // DebugConsole.SetConsoleText(string)
private static List<ConfiguredCommand>? _commandCache;
// Harmony prefix returning false → skips the original CheatManager // Harmony prefix returning false → skips the original CheatManager
// detector, so a tripped heuristic never runs its punish/drop path. // detector, so a tripped heuristic never runs its punish/drop path.
@@ -1773,7 +1741,7 @@ internal static class DebugConsolePatches
public static string FormatConfiguredCommandsForLog() public static string FormatConfiguredCommandsForLog()
{ {
var commands = GetConfiguredCommands(); var commands = GetAvailableCommands();
if (commands.Count == 0) return "<none>"; if (commands.Count == 0) return "<none>";
var parts = new List<string>(); var parts = new List<string>();
@@ -1971,27 +1939,49 @@ internal static class DebugConsolePatches
private static string BuildConfiguredCommandText() private static string BuildConfiguredCommandText()
{ {
var commands = GetConfiguredCommands(); return BuildCommandReferenceText("DorkNet debug commands", maxCommands: 80) + "\n\nType a command and press Enter.";
}
public static string BuildCommandReferenceText(string header, int maxCommands = int.MaxValue)
{
var commands = GetAvailableCommands();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("DorkNet debug commands"); sb.AppendLine(header);
sb.AppendLine(); sb.AppendLine();
if (commands.Count == 0) if (commands.Count == 0)
{ {
sb.AppendLine("No commands configured in DevCommands."); sb.AppendLine("No DebugConsoleCommandConfig commands found.");
} }
else else
{ {
for (var i = 0; i < commands.Count; i++) var visible = Math.Min(commands.Count, maxCommands);
for (var i = 0; i < visible; i++)
sb.AppendLine($"{i + 1}. {commands[i].Label}: {commands[i].Command}"); sb.AppendLine($"{i + 1}. {commands[i].Label}: {commands[i].Command}");
if (visible < commands.Count)
{
sb.AppendLine();
sb.AppendLine($"+ {commands.Count - visible} more commands available through the debug console.");
}
} }
sb.AppendLine();
sb.AppendLine("Type a command and press Enter.");
return sb.ToString().TrimEnd(); return sb.ToString().TrimEnd();
} }
private static List<ConfiguredCommand> GetConfiguredCommands() internal static List<ConfiguredCommand> GetAvailableCommands()
{ {
if (_commandCache is not null) return _commandCache;
var commands = new List<ConfiguredCommand>(); var commands = new List<ConfiguredCommand>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AddConfiguredCommands(commands, seen);
AddRuntimeCommands(commands, seen);
commands.Sort((a, b) => string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase));
_commandCache = commands;
Mod.Log.Msg($"[debugconsole] command table loaded: {commands.Count} command(s)");
return commands;
}
private static void AddConfiguredCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
{
foreach (var entry in Mod.Cfg.DevCommands) foreach (var entry in Mod.Cfg.DevCommands)
{ {
if (string.IsNullOrWhiteSpace(entry)) continue; if (string.IsNullOrWhiteSpace(entry)) continue;
@@ -2000,12 +1990,117 @@ internal static class DebugConsolePatches
var command = split >= 0 ? entry[(split + 1)..].Trim() : entry.Trim(); var command = split >= 0 ? entry[(split + 1)..].Trim() : entry.Trim();
if (string.IsNullOrWhiteSpace(command)) continue; if (string.IsNullOrWhiteSpace(command)) continue;
if (string.IsNullOrWhiteSpace(label)) label = command; if (string.IsNullOrWhiteSpace(label)) label = command;
commands.Add(new ConfiguredCommand(label, command)); AddCommand(commands, seen, label, command);
} }
return commands;
} }
private readonly struct ConfiguredCommand private static void AddRuntimeCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
{
try
{
var cfgType = Mod.ResolveType("RecRoom.Debugging.DebugConsoleCommandConfig");
if (cfgType is null)
{
Mod.Log.Warning("[debugconsole] DebugConsoleCommandConfig type not found");
return;
}
foreach (var method in cfgType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (method.GetParameters().Length != 0) continue;
if (!cfgType.IsAssignableFrom(method.ReturnType)) continue;
try { AddCommandConfig(method.Invoke(null, Array.Empty<object>()), commands, seen); }
catch { }
}
var il2cppType = ToIl2CppType(cfgType);
var resources = Mod.ResolveType("UnityEngine.Resources");
if (il2cppType is not null && resources is not null)
{
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
var count = ArrLen(all);
for (var i = 0; i < count; i++)
AddCommandConfig(ArrItem(all, i), commands, seen);
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[debugconsole] runtime command scan failed: {ex.Message}");
}
}
private static void AddCommandConfig(object? config, List<ConfiguredCommand> commands, HashSet<string> seen)
{
if (config is null) return;
var metas = GetMemberValue(config, "Metas");
var count = ArrLen(metas);
for (var i = 0; i < count; i++)
{
var meta = ArrItem(metas, i);
if (meta is null) continue;
var methodName = ReadStringMember(meta, "MethodName");
if (string.IsNullOrWhiteSpace(methodName)) continue;
var declaringType = ReadStringMember(meta, "DeclaringType");
var label = string.IsNullOrWhiteSpace(declaringType)
? methodName
: $"{ShortTypeName(declaringType)}.{methodName}";
AddCommand(commands, seen, label, methodName);
}
}
private static void AddCommand(List<ConfiguredCommand> commands, HashSet<string> seen, string label, string command)
{
label = CleanLabel(label);
command = (command ?? string.Empty).Trim();
if (command.Length == 0 || !seen.Add(command)) return;
commands.Add(new ConfiguredCommand(label, command));
}
private static object? GetMemberValue(object instance, string name)
{
var type = instance.GetType();
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
{
try { return prop.GetValue(instance); } catch { }
}
var field = type.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (field is not null)
{
try { return field.GetValue(instance); } catch { }
}
return null;
}
private static string ReadStringMember(object instance, string name)
{
return GetMemberValue(instance, name)?.ToString() ?? string.Empty;
}
private static string CleanLabel(string label)
{
label = (label ?? string.Empty).Trim();
if (label.Length == 0) return "Command";
var cleaned = new char[label.Length];
for (var i = 0; i < label.Length; i++)
{
var ch = label[i];
cleaned[i] = char.IsLetterOrDigit(ch) || ch is '.' or '_' or '-' or ' ' ? ch : '_';
}
return new string(cleaned);
}
private static string ShortTypeName(string typeName)
{
var idx = typeName.LastIndexOf('.');
return idx >= 0 && idx < typeName.Length - 1 ? typeName[(idx + 1)..] : typeName;
}
internal readonly struct ConfiguredCommand
{ {
public ConfiguredCommand(string label, string command) public ConfiguredCommand(string label, string command)
{ {
@@ -2677,6 +2772,8 @@ internal static class HttpTracePatches
private static int _failureLogCount; private static int _failureLogCount;
private static int _responseTupleLogCount; private static int _responseTupleLogCount;
private static int _builderSendLogCount; private static int _builderSendLogCount;
private static int _callbackLogCount;
private static int _uriUnreadDumpCount;
public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod) public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod)
{ {
@@ -2827,6 +2924,7 @@ internal static class HttpTracePatches
{ {
try try
{ {
if (!ShouldLogCallback(__instance)) return;
DiagnosticPatches.Write( DiagnosticPatches.Write(
$"[http-callback] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " + $"[http-callback] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{DescribeRequestAndResponse(__instance)}"); $"{DescribeRequestAndResponse(__instance)}");
@@ -2837,6 +2935,27 @@ internal static class HttpTracePatches
} }
} }
public static Exception? CallCallback_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
{
if (__exception is not null)
{
try
{
DiagnosticPatches.Write(
$"[http-callback] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{DescribeRequestAndResponse(__instance)} " +
$"{RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
$"state={DumpObjectFields(__instance, 32, 900)}");
}
catch (Exception ex)
{
LogFailure("callback-finalizer", ex);
}
}
return __exception;
}
public static void TraceUrl(string? url, string source) public static void TraceUrl(string? url, string source)
{ {
if (string.IsNullOrWhiteSpace(url)) return; if (string.IsNullOrWhiteSpace(url)) return;
@@ -2855,14 +2974,18 @@ internal static class HttpTracePatches
try try
{ {
var id = NextRequestId(); var id = NextRequestId();
var uri = TryReadRequestUri(request)?.ToString() ?? "<uri unread>"; var uriValue = TryReadRequestUri(request);
var uri = uriValue?.ToString() ?? "<uri unread>";
var method = TryReadHttpMethod(request); var method = TryReadHttpMethod(request);
var headers = TryReadHeaders(request); var headers = TryReadHeaders(request);
var body = TryReadBodyPreview(request); var body = TryReadBodyPreview(request);
var fallback = uriValue is null && System.Threading.Interlocked.Increment(ref _uriUnreadDumpCount) <= 40
? $" requestFields={DumpObjectFields(request, 24, 260)}"
: "";
DiagnosticPatches.Write( DiagnosticPatches.Write(
$"[http-trace] #{id} {source} method={method} uri={SanitizeInline(uri)} " + $"[http-trace] #{id} {source} method={method} uri={SanitizeInline(uri)} " +
$"headers={headers} body={body}"); $"headers={headers} body={body}{fallback}");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -3019,6 +3142,14 @@ internal static class HttpTracePatches
return "<body unread>"; return "<body unread>";
} }
private static bool ShouldLogCallback(object request)
{
var response = TryReadResponseObject(request);
var status = response is null ? null : TryReadStatusCode(response);
if (status >= 400) return true;
return System.Threading.Interlocked.Increment(ref _callbackLogCount) <= 80;
}
private static string DescribeRequestAndResponse(object request) private static string DescribeRequestAndResponse(object request)
{ {
var uri = TryReadRequestUri(request)?.ToString() ?? "<uri unread>"; var uri = TryReadRequestUri(request)?.ToString() ?? "<uri unread>";
@@ -3064,6 +3195,17 @@ internal static class HttpTracePatches
return null; return null;
} }
private static int? TryReadStatusCode(object response)
{
if (TryReadMemberValue(response, "StatusCode", out var value))
{
try { return Convert.ToInt32(value); }
catch { }
}
return null;
}
private static string DescribeResponseObject(object response) private static string DescribeResponseObject(object response)
{ {
var known = DescribeKnownMembers(response, var known = DescribeKnownMembers(response,
@@ -3806,14 +3948,6 @@ internal static class TlsPatches
} }
} }
internal static class ImageSignaturePatches
{
public static bool VerifyImageSignature_Prefix()
{
return false;
}
}
internal static class QuitTracePatches internal static class QuitTracePatches
{ {
public static bool ApplicationQuit_Prefix() public static bool ApplicationQuit_Prefix()
@@ -13,6 +13,7 @@ namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
[ApiController] [ApiController]
[Route("api/customAvatarItems")] [Route("api/customAvatarItems")]
[Route("api/customAvatarItems/v1")] [Route("api/customAvatarItems/v1")]
[Route("api/customAvatarItems/v2")]
public class CustomAvatarItemsController( public class CustomAvatarItemsController(
DorkNetDbContext db, DorkNetDbContext db,
LevelService level, LevelService level,
@@ -110,6 +111,26 @@ public class CustomAvatarItemsController(
return Ok(rows.Select(ToWire)); return Ok(rows.Select(ToWire));
} }
[HttpGet("fromCreator/{creatorId:long}")]
[AllowAnonymous]
public async Task<IActionResult> FromCreator(
long creatorId,
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var currentPlayerId = this.CurrentPlayerId();
var rows = await db.CustomAvatarItems
.Where(i => i.CreatorPlayerId == creatorId
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId))
.OrderByDescending(i => i.UpdatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("me")] [HttpGet("me")]
[Authorize] [Authorize]
public async Task<IActionResult> Me([FromQuery] int skip = 0, [FromQuery] int take = 100) public async Task<IActionResult> Me([FromQuery] int skip = 0, [FromQuery] int take = 100)
@@ -396,19 +396,63 @@ public class ImagesController(
[HttpGet("api/images/v3/byaccount/{accountId:long}")] [HttpGet("api/images/v3/byaccount/{accountId:long}")]
[HttpGet("api/images/v4/byaccount/{accountId:long}")] [HttpGet("api/images/v4/byaccount/{accountId:long}")]
[HttpGet("api/images/v5/byaccount/{accountId:long}")] [HttpGet("api/images/v5/byaccount/{accountId:long}")]
[HttpGet("api/images/v2/player/{accountId:long}")]
[HttpGet("api/images/v3/player/{accountId:long}")]
[HttpGet("api/images/v4/player/{accountId:long}")]
[HttpGet("api/images/v5/player/{accountId:long}")]
public async Task<IActionResult> GetByAccount(long accountId, public async Task<IActionResult> GetByAccount(long accountId,
[FromQuery] int take = 50, [FromQuery] int skip = 0) [FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] int sort = 0)
{ {
take = Math.Clamp(take, 1, 200); take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip); skip = Math.Max(0, skip);
var rows = await db.Photos var query = db.Photos
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null) .Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt) .AsQueryable();
.Skip(skip).Take(take) query = sort switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt),
};
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync(); .ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, accountId))); return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
} }
[HttpGet("api/images/v2/room/{roomId:long}")]
[HttpGet("api/images/v3/room/{roomId:long}")]
[HttpGet("api/images/v4/room/{roomId:long}")]
[HttpGet("api/images/v5/room/{roomId:long}")]
public async Task<IActionResult> GetByRoom(
long roomId,
[FromQuery] int take = 50,
[FromQuery] int skip = 0,
[FromQuery] int sort = 0,
[FromQuery] int filter = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var query = db.Photos
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
.AsQueryable();
query = sort switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt),
};
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
/// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo /// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo
/// to public so it shows up on feed.rec.net. The watch's "Share" /// to public so it shows up on feed.rec.net. The watch's "Share"
/// button hits this; if the watch posts to a different URL we'll /// button hits this; if the watch posts to a different URL we'll
@@ -56,6 +56,30 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
return Ok(rows.Select(ToWire)); return Ok(rows.Select(ToWire));
} }
[HttpGet("events/{eventId:long}")]
public async Task<IActionResult> EventInstances(long eventId)
{
var eventKey = eventId.ToString(System.Globalization.CultureInfo.InvariantCulture);
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me
&& k.Category == "event"
&& (k.EventKey == eventKey
|| k.EventKey == $"event:{eventKey}"
|| k.EventKey == $"event/{eventKey}"
|| EF.Functions.Like(k.EventKey, $"event:{eventKey}:%")
|| EF.Functions.Like(k.EventKey, $"event/{eventKey}/%")))
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(new
{
KeepsakeProgressionEventId = eventId,
Instances = rows.Select(ToWire),
CollectionRecords = Array.Empty<object>(),
KeepsakeProgressionEventIds = new[] { eventId },
});
}
[HttpGet("rooms/{roomId:long}")] [HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> RoomKeepsakes(long roomId) public async Task<IActionResult> RoomKeepsakes(long roomId)
{ {
@@ -74,6 +74,95 @@ public class RoomsController(
}); });
} }
[HttpGet("rooms/topcreators")]
[HttpGet("roomserver/rooms/topcreators")]
public async Task<IActionResult> TopCreators([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var rows = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.VisitCount)
.Take(250)
.ToListAsync();
var page = rows
.GroupBy(r => r.CreatorPlayerId)
.Select(g => g.First())
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToList();
return Ok(await BuildRoomServerListAsync(page));
}
[HttpGet("rooms/contestwinners")]
[HttpGet("roomserver/rooms/contestwinners")]
public async Task<IActionResult> ContestWinners([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var rows = await PublicRoomQuery()
.Where(r =>
EF.Functions.Like(r.TagsCsv, "%contest%") ||
EF.Functions.Like(r.TagsCsv, "%winner%") ||
EF.Functions.Like(r.TagsCsv, "%featured%"))
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
if (rows.Count == 0)
{
rows = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
}
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("rooms/fromcreators")]
[HttpPost("rooms/fromcreators")]
[HttpGet("roomserver/rooms/fromcreators")]
[HttpPost("roomserver/rooms/fromcreators")]
public async Task<IActionResult> FromCreators([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var creatorIds = Request.Query["creatorId"]
.Concat(Request.Query["creatorIds"])
.Concat(Request.Query["accountId"])
.Concat(Request.Query["accountIds"])
.Concat(Request.Query["id"])
.SelectMany(v => (v ?? string.Empty).Split(',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.ToList();
var query = PublicRoomQuery();
if (creatorIds.Count > 0)
query = query.Where(r => creatorIds.Contains(r.CreatorPlayerId));
var rows = await query
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.UpdatedAt)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("rooms/magic_door")]
[HttpGet("roomserver/rooms/magic_door")]
public async Task<IActionResult> MagicDoor()
{
var room = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Id)
.FirstOrDefaultAsync();
if (room is null) return NotFound();
return Ok((await BuildRoomServerListAsync(new[] { room })).First());
}
/// <summary> /// <summary>
/// GET <c>/roomserver/rooms/recommendations</c> — the home-tab /// GET <c>/roomserver/rooms/recommendations</c> — the home-tab
/// "Recommended" carousel. <b>Wire shape is NOT a flat /// "Recommended" carousel. <b>Wire shape is NOT a flat
@@ -127,6 +216,38 @@ public class RoomsController(
return Ok(result); return Ok(result);
} }
[HttpGet("rooms/{roomId:long}/similar")]
[HttpGet("roomserver/rooms/{roomId:long}/similar")]
public async Task<IActionResult> SimilarRooms(long roomId, [FromQuery] int take = 12)
{
var seed = await rooms.GetByIdAsync(roomId);
if (seed is null) return NotFound();
var tags = (seed.TagsCsv ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
var query = PublicRoomQuery().Where(r => r.Id != roomId);
if (tags.Length > 0)
{
var primaryTag = tags[0];
query = query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{primaryTag}%"));
}
var similar = await query
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
var seedWire = (await BuildRoomServerListAsync(new[] { seed })).First();
var similarWire = await BuildRoomServerListAsync(similar);
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
{
["SeedRoom"] = seedWire,
["Rooms"] = similarWire,
});
}
/// <summary> /// <summary>
/// `Rooms.SearchForRooms(query)` — free-text room search. /// `Rooms.SearchForRooms(query)` — free-text room search.
/// </summary> /// </summary>
@@ -172,6 +293,7 @@ public class RoomsController(
{ {
PinnedFilters = tags.PinnedTags, PinnedFilters = tags.PinnedTags,
PopularFilters = tags.PopularTags, PopularFilters = tags.PopularTags,
TrendingFilters = tags.TrendingTags,
}); });
} }
@@ -179,7 +301,11 @@ public class RoomsController(
public async Task<IActionResult> Tags() public async Task<IActionResult> Tags()
{ {
var tags = await serverSettings.GetPlayMenuTagsAsync(); var tags = await serverSettings.GetPlayMenuTagsAsync();
return Ok(tags.PinnedTags.Concat(tags.PopularTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()); return Ok(tags.PinnedTags
.Concat(tags.PopularTags)
.Concat(tags.TrendingTags)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray());
} }
[HttpGet("api/rooms/v1/pinnedtags")] [HttpGet("api/rooms/v1/pinnedtags")]
@@ -190,6 +316,10 @@ public class RoomsController(
public async Task<IActionResult> PopularTags() public async Task<IActionResult> PopularTags()
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags); => Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags);
[HttpGet("api/rooms/v1/trendingtags")]
public async Task<IActionResult> TrendingTags()
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).TrendingTags);
// ── Single room lookups ────────────────────────────────────────────── // ── Single room lookups ──────────────────────────────────────────────
/// <summary> /// <summary>
@@ -1388,10 +1518,19 @@ public class RoomsController(
SupportsWalkVR = src.SupportsWalkVR, SupportsWalkVR = src.SupportsWalkVR,
SupportsTeleportVR = src.SupportsTeleportVR, SupportsTeleportVR = src.SupportsTeleportVR,
AllowsJuniors = src.AllowsJuniors, AllowsJuniors = src.AllowsJuniors,
AllowNewUsers = src.AllowNewUsers,
MinLevel = src.MinLevel,
MaxPlayerCalculationMode = src.MaxPlayerCalculationMode,
LoadScreensJson = src.LoadScreensJson,
PromoImagesJson = src.PromoImagesJson,
PromoExternalContentJson = src.PromoExternalContentJson,
RoomWarningMask = src.RoomWarningMask, RoomWarningMask = src.RoomWarningMask,
CustomRoomWarning = src.CustomRoomWarning, CustomRoomWarning = src.CustomRoomWarning,
DisableMicAutoMute = src.DisableMicAutoMute, DisableMicAutoMute = src.DisableMicAutoMute,
LocationReplicationId = src.LocationReplicationId, LocationReplicationId = src.LocationReplicationId,
IsStudioRoom = src.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = src.IsRoomLinkedToRecRoomStudio,
StudioSessionId = src.StudioSessionId,
TagsCsv = src.TagsCsv, TagsCsv = src.TagsCsv,
CheerCount = src.CheerCount, CheerCount = src.CheerCount,
FavoriteCount = src.FavoriteCount, FavoriteCount = src.FavoriteCount,
@@ -1425,7 +1564,12 @@ public class RoomsController(
? Array.Empty<object>() ? Array.Empty<object>()
: room.TagsCsv : room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t }) .Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
Tag = t,
IsPrimaryGenre = false,
})
.ToArray(); .ToArray();
// Scenes population: every room must produce a Scenes[0] entry // Scenes population: every room must produce a Scenes[0] entry
@@ -1710,7 +1854,12 @@ public class RoomsController(
? Array.Empty<object>() ? Array.Empty<object>()
: room.TagsCsv : room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t }) .Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
Tag = t,
IsPrimaryGenre = false,
})
.ToArray(); .ToArray();
return new return new
@@ -1733,6 +1882,9 @@ public class RoomsController(
SupportsQuest2 = true, SupportsQuest2 = true,
SupportsMobile = room.SupportsMobile, SupportsMobile = room.SupportsMobile,
SupportsJuniors = room.AllowsJuniors, SupportsJuniors = room.AllowsJuniors,
AllowNewUsers = room.AllowNewUsers,
AllowsNewUsers = room.AllowNewUsers,
MinLevel = room.MinLevel,
CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt) CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"), .ToString("yyyy-MM-ddTHH:mm:ssZ"),
Stats = new Stats = new
@@ -1743,10 +1895,14 @@ public class RoomsController(
VisitCount = room.VisitCount, VisitCount = room.VisitCount,
}, },
IsDorm = room.IsDormRoom, IsDorm = room.IsDormRoom,
IsStudioRoom = room.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = room.IsRoomLinkedToRecRoomStudio,
StudioSessionId = room.StudioSessionId,
CloningAllowed = room.CloningAllowed, CloningAllowed = room.CloningAllowed,
DisableMicAutoMute = room.DisableMicAutoMute, DisableMicAutoMute = room.DisableMicAutoMute,
DisableRoomComments = false, DisableRoomComments = false,
EncryptVoiceChat = false, EncryptVoiceChat = false,
LoadScreenLocked = false,
// Studio/UGC version family. The 2023 client reads UgcVersion to // Studio/UGC version family. The 2023 client reads UgcVersion to
// decide the asset-bundle version it requests // decide the asset-bundle version it requests
// (unity_assets/{id}/{target}/{UgcVersion}) and PersistenceVersion // (unity_assets/{id}/{target}/{UgcVersion}) and PersistenceVersion
@@ -1758,17 +1914,74 @@ public class RoomsController(
UgcVersion = room.IsStudioRoom ? StudioUgcVersion : 0, UgcVersion = room.IsStudioRoom ? StudioUgcVersion : 0,
UgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0, UgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
MinUgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0, MinUgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
IsDeveloperOwned = room.IsStudioRoom || HasRoomTag(room, "developer"),
IsJuniorCreated = false,
IsRecRoomApproved = false,
ExcludeFromLists = false,
BoostCount = 0,
RestrictedCircuitsAllowListNames = Array.Empty<string>(),
PublishStateAvailability = new
{
CanSaveAsBeta = false,
CanSaveAsUpdate = true,
AvailableUpdateTokenCount = 3,
NextAvailableUpdateDateTimeUtc = (string?)null,
},
PublishState = 0, PublishState = 0,
MaxPlayerCalculationMode = 0, MaxPlayerCalculationMode = room.MaxPlayerCalculationMode,
SubRooms = subRooms, SubRooms = subRooms,
Roles = wireRoles, Roles = wireRoles,
LoadScreens = Array.Empty<object>(), LoadScreens = JsonArrayOrEmpty(room.LoadScreensJson),
PromoImages = Array.Empty<string>(), PromoImages = JsonStringArrayOrEmpty(room.PromoImagesJson),
PromoExternalContent = Array.Empty<object>(), PromoExternalContent = JsonArrayOrEmpty(room.PromoExternalContentJson),
Tags = tags, Tags = tags,
}; };
} }
private static int RoomTagTypeForWire(string tag)
{
if (string.Equals(tag, "beta", StringComparison.OrdinalIgnoreCase)) return 1;
return tag.ToLowerInvariant() switch
{
"rrstudio" or "community" or "screen" or "walkvr" or "teleportvr" or "junior" or "pickup" => 2,
_ => 0,
};
}
private static bool HasRoomTag(RoomEntity room, string tag)
{
return !string.IsNullOrWhiteSpace(room.TagsCsv) &&
room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase));
}
private static JsonElement[] JsonArrayOrEmpty(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<JsonElement>();
try
{
return JsonSerializer.Deserialize<JsonElement[]>(json) ?? Array.Empty<JsonElement>();
}
catch (JsonException)
{
return Array.Empty<JsonElement>();
}
}
private static string[] JsonStringArrayOrEmpty(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<string>();
try
{
return JsonSerializer.Deserialize<string[]>(json) ?? Array.Empty<string>();
}
catch (JsonException)
{
return Array.Empty<string>();
}
}
/// <summary>PersistenceVersion / UgcSubVersion stamped on a baked Studio /// <summary>PersistenceVersion / UgcSubVersion stamped on a baked Studio
/// scene's CurrentSave. The 2023 client's room-load gate treats anything /// scene's CurrentSave. The 2023 client's room-load gate treats anything
/// below the Studio-asset-bundle era as a legacy non-baked save and skips /// below the Studio-asset-bundle era as a legacy non-baked save and skips
@@ -1845,7 +2058,9 @@ public class RoomsController(
[HttpGet("api/rooms/v2/createdby/{otherPlayerId:long}")] [HttpGet("api/rooms/v2/createdby/{otherPlayerId:long}")]
[HttpGet("api/rooms/v3/createdby/{otherPlayerId:long}")] [HttpGet("api/rooms/v3/createdby/{otherPlayerId:long}")]
[HttpGet("roomserver/rooms/createdby/{otherPlayerId:long}")] [HttpGet("roomserver/rooms/createdby/{otherPlayerId:long}")]
[HttpGet("roomserver/rooms/ownedby/{otherPlayerId:long}")]
[HttpGet("rooms/createdby/{otherPlayerId:long}")] [HttpGet("rooms/createdby/{otherPlayerId:long}")]
[HttpGet("rooms/ownedby/{otherPlayerId:long}")]
public async Task<IActionResult> CreatedByOther(long otherPlayerId) public async Task<IActionResult> CreatedByOther(long otherPlayerId)
=> Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList()); => Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList());
@@ -1855,7 +2070,36 @@ public class RoomsController(
[HttpGet("roomserver/rooms/visitedby/me")] [HttpGet("roomserver/rooms/visitedby/me")]
[HttpGet("rooms/visitedby/me")] [HttpGet("rooms/visitedby/me")]
[Authorize] [Authorize]
public IActionResult VisitedByMe() => Ok(Array.Empty<object>()); public async Task<IActionResult> VisitedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
return await VisitedBy(pid.Value);
}
[HttpGet("rooms/visitedby/{playerId:long}")]
[HttpGet("roomserver/rooms/visitedby/{playerId:long}")]
public async Task<IActionResult> VisitedBy(long playerId)
{
var ids = await db.RoomVisits.AsNoTracking()
.Where(v => v.PlayerId == playerId)
.OrderByDescending(v => v.LastVisitAt)
.Select(v => v.RoomId)
.Take(100)
.ToListAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.Rooms.AsNoTracking()
.Where(r => ids.Contains(r.Id))
.ToListAsync();
var byId = rows.ToDictionary(r => r.Id);
var ordered = ids
.Select(id => byId.TryGetValue(id, out var room) ? room : null)
.Where(r => r is not null)
.Select(r => r!)
.ToList();
return Ok(await BuildRoomServerListAsync(ordered));
}
[HttpGet("api/rooms/v1/moderatedby/me")] [HttpGet("api/rooms/v1/moderatedby/me")]
[HttpGet("api/rooms/v2/moderatedby/me")] [HttpGet("api/rooms/v2/moderatedby/me")]
@@ -1863,7 +2107,23 @@ public class RoomsController(
[HttpGet("roomserver/rooms/moderatedby/me")] [HttpGet("roomserver/rooms/moderatedby/me")]
[HttpGet("rooms/moderatedby/me")] [HttpGet("rooms/moderatedby/me")]
[Authorize] [Authorize]
public IActionResult ModeratedByMe() => Ok(Array.Empty<object>()); public async Task<IActionResult> ModeratedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
var ids = await db.RoomRoles.AsNoTracking()
.Where(r => r.PlayerId == pid.Value && r.Accepted)
.OrderByDescending(r => r.GrantedAt)
.Select(r => r.RoomId)
.Distinct()
.Take(100)
.ToListAsync();
var rows = await db.Rooms.AsNoTracking()
.Where(r => ids.Contains(r.Id))
.OrderByDescending(r => r.UpdatedAt)
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("api/rooms/v1/favoritedby/me")] [HttpGet("api/rooms/v1/favoritedby/me")]
[HttpGet("api/rooms/v2/favoritedby/me")] [HttpGet("api/rooms/v2/favoritedby/me")]
@@ -1871,7 +2131,13 @@ public class RoomsController(
[HttpGet("roomserver/rooms/favoritedby/me")] [HttpGet("roomserver/rooms/favoritedby/me")]
[HttpGet("rooms/favoritedby/me")] [HttpGet("rooms/favoritedby/me")]
[Authorize] [Authorize]
public IActionResult FavoritedByMe() => Ok(Array.Empty<object>()); public async Task<IActionResult> FavoritedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
var rows = await rooms.BookmarkedByAsync(pid.Value);
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("api/rooms/v1/cheeredby/me")] [HttpGet("api/rooms/v1/cheeredby/me")]
[HttpGet("api/rooms/v2/cheeredby/me")] [HttpGet("api/rooms/v2/cheeredby/me")]
@@ -1879,7 +2145,23 @@ public class RoomsController(
[HttpGet("roomserver/rooms/cheeredby/me")] [HttpGet("roomserver/rooms/cheeredby/me")]
[HttpGet("rooms/cheeredby/me")] [HttpGet("rooms/cheeredby/me")]
[Authorize] [Authorize]
public IActionResult CheeredByMe() => Ok(Array.Empty<object>()); public async Task<IActionResult> CheeredByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
var ids = await db.Cheers.AsNoTracking()
.Where(c => c.FromPlayerId == pid.Value && c.TargetRoomId != 0)
.OrderByDescending(c => c.CheeredAt)
.Select(c => c.TargetRoomId)
.Distinct()
.Take(100)
.ToListAsync();
var rows = await db.Rooms.AsNoTracking()
.Where(r => ids.Contains(r.Id))
.OrderByDescending(r => r.HotScore)
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("api/rooms/v2/mybookmarks")] [HttpGet("api/rooms/v2/mybookmarks")]
[HttpGet("api/rooms/v2/mybookmarkedrooms")] [HttpGet("api/rooms/v2/mybookmarkedrooms")]
@@ -2061,6 +2343,40 @@ public class RoomsController(
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
} }
private IQueryable<RoomEntity> PublicRoomQuery() =>
db.Rooms.AsNoTracking().Where(r =>
r.State == 0 &&
r.Accessibility == 1 &&
!r.IsDormRoom &&
!r.HiddenFromBrowse);
private async Task<List<object>> BuildRoomServerListAsync(IReadOnlyCollection<RoomEntity> roomRows)
{
if (roomRows.Count == 0) return new List<object>();
var ids = roomRows.Select(r => r.Id).Distinct().ToList();
var sceneRows = await db.RoomScenes.AsNoTracking()
.Where(s => ids.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
var roleRows = await db.RoomRoles.AsNoTracking()
.Where(r => ids.Contains(r.RoomId))
.ToListAsync();
var rolesByRoom = roleRows
.GroupBy(r => r.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomRoleEntity>)g.ToList());
return roomRows
.Select(r => BuildRoomServerDetails(
r,
scenesByRoom.GetValueOrDefault(r.Id),
roles: rolesByRoom.GetValueOrDefault(r.Id)))
.ToList();
}
// ── Helpers ────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────
/// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every /// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every
@@ -2585,7 +2901,12 @@ public class RoomsController(
? new List<object>() ? new List<object>()
: p.TagsCsv : p.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t }) .Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
Tag = t,
IsPrimaryGenre = false,
})
.ToList(); .ToList();
// Start from BuildPlaylistUnionEntry for base + PlaylistId, then // Start from BuildPlaylistUnionEntry for base + PlaylistId, then
@@ -2865,6 +3186,15 @@ public class RoomsController(
public string? RoomSceneLocationId { get; set; } public string? RoomSceneLocationId { get; set; }
} }
public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } } public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } }
public sealed class PublishSubRoomSaveRequest
{
public long? SubRoomDataSaveId { get; set; }
public long? SaveId { get; set; }
public long? StagedSubRoomDataSaveId { get; set; }
public string? DataBlob { get; set; }
public string? DataBlobName { get; set; }
public string? Filename { get; set; }
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
@@ -2878,6 +3208,95 @@ public class RoomsController(
public Task<IActionResult> SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) => public Task<IActionResult> SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; }); MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; });
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize]
public Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest? req) =>
MutateScene(roomId, subRoomId, s =>
{
if (req?.CanMatchmakeInto is bool canMatchmakeInto) s.CanMatchmakeInto = canMatchmakeInto;
if (req?.MaxPlayers is int maxPlayers) s.MaxPlayers = Math.Max(1, maxPlayers);
if (req?.IsSandbox is bool isSandbox) s.IsSandbox = isSandbox;
});
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[Authorize]
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
var request = await ReadPublishSubRoomSaveRequestAsync();
var saveId = request.SubRoomDataSaveId ?? request.SaveId ?? request.StagedSubRoomDataSaveId;
var dataBlob = request.DataBlobName ?? request.DataBlob ?? request.Filename;
if (!string.IsNullOrWhiteSpace(dataBlob))
{
var blobExists = await db.RoomDataBlobs.AnyAsync(b => b.RoomId == roomId && b.BlobName == dataBlob);
if (!blobExists) return NotFound(new { error = "blob_not_found", dataBlob });
scene.DataBlobName = dataBlob;
if (scene.OrderIndex == 0) room.CurrentDataBlobName = dataBlob;
}
if (saveId is > 0)
scene.StudioSubRoomDataSaveId = saveId.Value;
scene.DataModifiedAt = DateTime.UtcNow;
room.UpdatedAt = scene.DataModifiedAt;
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
}
private async Task<PublishSubRoomSaveRequest> ReadPublishSubRoomSaveRequestAsync()
{
var request = new PublishSubRoomSaveRequest();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (long.TryParse(FirstFormValue(form, "subRoomDataSaveId", "SubRoomDataSaveId", "saveId", "SaveId", "stagedSubRoomDataSaveId", "StagedSubRoomDataSaveId"), out var saveId))
request.SubRoomDataSaveId = saveId;
request.DataBlobName = FirstFormValue(form, "dataBlobName", "DataBlobName", "dataBlob", "DataBlob", "filename", "Filename");
return request;
}
if (long.TryParse(Request.Query["subRoomDataSaveId"].FirstOrDefault()
?? Request.Query["SubRoomDataSaveId"].FirstOrDefault()
?? Request.Query["saveId"].FirstOrDefault()
?? Request.Query["SaveId"].FirstOrDefault()
?? Request.Query["stagedSubRoomDataSaveId"].FirstOrDefault()
?? Request.Query["StagedSubRoomDataSaveId"].FirstOrDefault(), out var querySaveId))
request.SubRoomDataSaveId = querySaveId;
request.DataBlobName = Request.Query["dataBlobName"].FirstOrDefault()
?? Request.Query["DataBlobName"].FirstOrDefault()
?? Request.Query["dataBlob"].FirstOrDefault()
?? Request.Query["DataBlob"].FirstOrDefault()
?? Request.Query["filename"].FirstOrDefault()
?? Request.Query["Filename"].FirstOrDefault();
if ((Request.ContentLength ?? 0) <= 0) return request;
try
{
var body = await JsonSerializer.DeserializeAsync<PublishSubRoomSaveRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return body ?? request;
}
catch (JsonException)
{
return request;
}
}
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/clone</c> — duplicate /// <summary>POST <c>rooms/{id}/subrooms/{sub}/clone</c> — duplicate
/// a scene (DataBlobName + location pointer). New scene goes at the /// a scene (DataBlobName + location pointer). New scene goes at the
/// end with " Copy" suffix; per-room name uniqueness preserved.</summary> /// end with " Copy" suffix; per-room name uniqueness preserved.</summary>
@@ -3033,8 +3452,7 @@ public class RoomsController(
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid(); if (room.CreatorPlayerId != pid) return Forbid();
var scene = await db.RoomScenes var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound(); if (scene is null) return NotFound();
mutator(scene); mutator(scene);
scene.DataModifiedAt = DateTime.UtcNow; scene.DataModifiedAt = DateTime.UtcNow;
@@ -3042,6 +3460,28 @@ public class RoomsController(
return Ok(SceneWire(scene)); return Ok(SceneWire(scene));
} }
private async Task<RoomSceneEntity?> GetOrCreateSceneForMutationAsync(RoomEntity room, long subRoomId)
{
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
if (scene is not null || subRoomId != 0) return scene;
scene = new RoomSceneEntity
{
RoomId = room.Id,
OrderIndex = 0,
Name = "Home",
RoomSceneLocationId = room.LocationReplicationId,
DataBlobName = CurrentOrSyntheticDataBlobName(room),
MaxPlayers = room.MaxCapacity,
IsSandbox = false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
};
db.RoomScenes.Add(scene);
return scene;
}
private static object SceneWire(RoomSceneEntity s) => new private static object SceneWire(RoomSceneEntity s) => new
{ {
Result = 0, Result = 0,
@@ -3079,6 +3519,9 @@ public class RoomsController(
if (room.IsDormRoom) if (room.IsDormRoom)
return RoomService.ResolveWireRoomDataBlobName(room.Id, room.CurrentDataBlobName); return RoomService.ResolveWireRoomDataBlobName(room.Id, room.CurrentDataBlobName);
if (RoomService.IsBakedOriginalRoom(room) && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
return string.Empty;
return !string.IsNullOrWhiteSpace(room.CurrentDataBlobName) return !string.IsNullOrWhiteSpace(room.CurrentDataBlobName)
? room.CurrentDataBlobName ? room.CurrentDataBlobName
: RoomService.SyntheticDefaultRoomDataBlobName(room.Id); : RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
@@ -527,6 +527,204 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
return null; return null;
} }
private async Task<long?> ReadBareLongAsync(params string[] keys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in keys)
if (long.TryParse(form[key].FirstOrDefault(), out var value)) return value;
return null;
}
foreach (var key in keys)
if (long.TryParse(Request.Query[key].FirstOrDefault(), out var value)) return value;
if ((Request.ContentLength ?? 0) <= 0) return null;
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var key in keys)
{
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt64(out var n)) return n;
if (prop.ValueKind == JsonValueKind.String && long.TryParse(prop.GetString(), out n)) return n;
}
}
catch (JsonException)
{
return null;
}
return null;
}
private async Task<bool?> ReadBareBoolAsync(params string[] keys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in keys)
if (TryParseBool(form[key].FirstOrDefault(), out var value)) return value;
return null;
}
foreach (var key in keys)
if (TryParseBool(Request.Query[key].FirstOrDefault(), out var value)) return value;
if ((Request.ContentLength ?? 0) <= 0) return null;
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return doc.RootElement.ValueKind == JsonValueKind.True
? true
: doc.RootElement.ValueKind == JsonValueKind.False
? false
: null;
foreach (var key in keys)
{
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
if (prop.ValueKind == JsonValueKind.True) return true;
if (prop.ValueKind == JsonValueKind.False) return false;
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n != 0;
if (prop.ValueKind == JsonValueKind.String && TryParseBool(prop.GetString(), out var parsed)) return parsed;
}
}
catch (JsonException)
{
return null;
}
return null;
}
private async Task<string?> ReadStringValueAsync(params string[] keys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in keys)
{
var value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
return null;
}
foreach (var key in keys)
{
var value = Request.Query[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
if ((Request.ContentLength ?? 0) <= 0) return null;
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.String)
return doc.RootElement.GetString();
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var key in keys)
{
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
if (prop.ValueKind == JsonValueKind.String) return prop.GetString();
if (prop.ValueKind == JsonValueKind.Number || prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False)
return prop.ToString();
}
}
catch (JsonException)
{
return null;
}
return null;
}
private async Task<JsonElement?> ReadJsonElementAsync(params string[] stringKeys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var dict = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var key in form.Keys)
{
var value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) dict[key] = value;
}
if (dict.Count == 0) return null;
return JsonSerializer.SerializeToElement(dict);
}
if ((Request.ContentLength ?? 0) > 0)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
return doc.RootElement.Clone();
}
catch (JsonException)
{
return null;
}
}
foreach (var key in stringKeys)
{
var value = Request.Query[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return JsonSerializer.SerializeToElement(value);
}
return null;
}
private static bool TryParseBool(string? raw, out bool value)
{
if (bool.TryParse(raw, out value)) return true;
if (int.TryParse(raw, out var n))
{
value = n != 0;
return true;
}
value = false;
return false;
}
private static List<JsonElement> ReadJsonElementList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new List<JsonElement>();
try
{
return JsonSerializer.Deserialize<JsonElement[]>(json)?.ToList() ?? new List<JsonElement>();
}
catch (JsonException)
{
return new List<JsonElement>();
}
}
private static List<string> ReadStringList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new List<string>();
try
{
return JsonSerializer.Deserialize<string[]>(json)?.Where(v => !string.IsNullOrWhiteSpace(v)).ToList()
?? new List<string>();
}
catch (JsonException)
{
return new List<string>();
}
}
private static string SerializeLimited<T>(IEnumerable<T> values, int maxChars = 4096)
{
var json = JsonSerializer.Serialize(values);
return json.Length <= maxChars ? json : "[]";
}
[HttpPost("rooms/{roomId:long}/cloning")] [HttpPost("rooms/{roomId:long}/cloning")]
[HttpPut("rooms/{roomId:long}/cloning")] [HttpPut("rooms/{roomId:long}/cloning")]
public async Task<IActionResult> BareCloning(long roomId, [FromBody] BareBoolRequest? body, public async Task<IActionResult> BareCloning(long roomId, [FromBody] BareBoolRequest? body,
@@ -569,6 +767,171 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning; if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning;
}); });
[HttpPost("rooms/{roomId:long}/creator")]
[HttpPut("rooms/{roomId:long}/creator")]
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareCreator(long roomId)
{
var creatorId = await ReadBareLongAsync(
"creatorAccountId", "CreatorAccountId",
"creatorPlayerId", "CreatorPlayerId",
"accountId", "AccountId",
"value", "Value");
if (creatorId is null or <= 0) return BadRequest(new { error = "missing_creator" });
var exists = await db.Players.AnyAsync(p => p.Id == creatorId.Value);
if (!exists) return NotFound(new { error = "creator_not_found", creatorId });
return await ApplyAndReturn(roomId, r => r.CreatorPlayerId = creatorId.Value);
}
[HttpPost("rooms/{roomId:long}/allow_new_users")]
[HttpPut("rooms/{roomId:long}/allow_new_users")]
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareAllowNewUsers(long roomId)
{
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (value is bool v) r.AllowNewUsers = v;
});
}
[HttpPost("rooms/{roomId:long}/min_level")]
[HttpPut("rooms/{roomId:long}/min_level")]
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareMinLevel(long roomId)
{
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (value is int v) r.MinLevel = Math.Max(0, v);
});
}
[HttpPost("rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPut("rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
{
var value = await ReadBareIntAsync(
"maxPlayerCalculationMode", "MaxPlayerCalculationMode",
"mode", "Mode",
"value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (value is int v) r.MaxPlayerCalculationMode = Math.Max(0, v);
});
}
[HttpPost("rooms/{roomId:long}/loadscreen")]
[HttpPut("rooms/{roomId:long}/loadscreen")]
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareLoadScreen(long roomId)
{
var payload = await ReadJsonElementAsync("loadScreen", "LoadScreen", "value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (payload is JsonElement p)
r.LoadScreensJson = p.ValueKind == JsonValueKind.Array
? p.GetRawText()
: SerializeLimited(new[] { p });
});
}
[HttpPost("rooms/{roomId:long}/promo_images")]
[HttpPut("rooms/{roomId:long}/promo_images")]
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" });
return await ApplyAndReturn(roomId, r =>
{
var images = ReadStringList(r.PromoImagesJson);
if (!images.Contains(image, StringComparer.OrdinalIgnoreCase))
images.Add(image);
r.PromoImagesJson = SerializeLimited(images);
});
}
[HttpDelete("rooms/{roomId:long}/promo_images")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" });
return await ApplyAndReturn(roomId, r =>
{
var images = ReadStringList(r.PromoImagesJson)
.Where(v => !string.Equals(v, image, StringComparison.OrdinalIgnoreCase))
.ToList();
r.PromoImagesJson = SerializeLimited(images);
});
}
[HttpPost("rooms/{roomId:long}/promo_external")]
[HttpPut("rooms/{roomId:long}/promo_external")]
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
{
var payload = await ReadJsonElementAsync("url", "Url", "value", "Value");
if (payload is null) return BadRequest(new { error = "missing_promo_external" });
return await ApplyAndReturn(roomId, r =>
{
var items = ReadJsonElementList(r.PromoExternalContentJson);
var raw = payload.Value.GetRawText();
if (!items.Any(i => string.Equals(i.GetRawText(), raw, StringComparison.Ordinal)))
items.Add(payload.Value);
r.PromoExternalContentJson = SerializeLimited(items);
});
}
[HttpDelete("rooms/{roomId:long}/promo_external")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
{
var match = await ReadStringValueAsync("id", "Id", "url", "Url", "value", "Value");
if (string.IsNullOrWhiteSpace(match)) return BadRequest(new { error = "missing_promo_external" });
return await ApplyAndReturn(roomId, r =>
{
var items = ReadJsonElementList(r.PromoExternalContentJson)
.Where(i => !PromoExternalMatches(i, match))
.ToList();
r.PromoExternalContentJson = SerializeLimited(items);
});
}
private static bool PromoExternalMatches(JsonElement item, string match)
{
if (string.Equals(item.GetRawText(), match, StringComparison.OrdinalIgnoreCase)) return true;
if (item.ValueKind == JsonValueKind.String)
return string.Equals(item.GetString(), match, StringComparison.OrdinalIgnoreCase);
if (item.ValueKind != JsonValueKind.Object) return false;
foreach (var key in new[] { "Id", "id", "Url", "url", "Value", "value" })
{
if (item.TryGetProperty(key, out var prop) &&
prop.ValueKind == JsonValueKind.String &&
string.Equals(prop.GetString(), match, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary><c>rooms/{id}/comments</c> + <c>voice_chat_encryption</c> /// <summary><c>rooms/{id}/comments</c> + <c>voice_chat_encryption</c>
/// — fields not persisted in the 2020 RoomEntity. Owner-gated /// — fields not persisted in the 2020 RoomEntity. Owner-gated
/// acknowledgement so the watch's settings panel doesn't surface /// acknowledgement so the watch's settings panel doesn't surface
@@ -639,10 +1002,19 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
SupportsWalkVR = source.SupportsWalkVR, SupportsWalkVR = source.SupportsWalkVR,
SupportsTeleportVR = source.SupportsTeleportVR, SupportsTeleportVR = source.SupportsTeleportVR,
AllowsJuniors = source.AllowsJuniors, AllowsJuniors = source.AllowsJuniors,
AllowNewUsers = source.AllowNewUsers,
MinLevel = source.MinLevel,
MaxPlayerCalculationMode = source.MaxPlayerCalculationMode,
LoadScreensJson = source.LoadScreensJson,
PromoImagesJson = source.PromoImagesJson,
PromoExternalContentJson = source.PromoExternalContentJson,
RoomWarningMask = source.RoomWarningMask, RoomWarningMask = source.RoomWarningMask,
CustomRoomWarning = source.CustomRoomWarning, CustomRoomWarning = source.CustomRoomWarning,
DisableMicAutoMute = source.DisableMicAutoMute, DisableMicAutoMute = source.DisableMicAutoMute,
LocationReplicationId = source.LocationReplicationId, LocationReplicationId = source.LocationReplicationId,
IsStudioRoom = source.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio,
StudioSessionId = source.StudioSessionId,
TagsCsv = source.TagsCsv, TagsCsv = source.TagsCsv,
CurrentDataBlobName = source.CurrentDataBlobName, CurrentDataBlobName = source.CurrentDataBlobName,
}; };
@@ -667,6 +667,31 @@ public class AccountsController(
[HttpGet("/account/v1/{accountId:long}/platformid")] [HttpGet("/account/v1/{accountId:long}/platformid")]
public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L }); public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L });
[HttpGet("/accountprivacysettings/{accountId:long}")]
[HttpGet("/account/v1/privacysettings/{accountId:long}")]
[HttpGet("/account/{accountId:long}/privacysettings")]
public IActionResult GetAccountPrivacySettings(long accountId) => Ok(new
{
AccountId = (int)accountId,
IsProfileVisible = true,
IsOnlineStatusVisible = true,
IsActivityVisible = true,
ShowOnlineStatus = true,
ShowActivity = true,
ShowCurrentRoom = true,
ReceiveFriendRequests = true,
ReceiveInvites = true,
ReceiveMessages = true,
WhoCanSeeOnlineStatus = 0,
WhoCanSeeActivity = 0,
WhoCanSeeCurrentRoom = 0,
WhoCanSendFriendRequests = 0,
WhoCanInviteMe = 0,
WhoCanMessageMe = 0,
VoicePrivacy = 0,
TextChatPrivacy = 0,
});
// Previously had a /{*path} catch-all that returned [] / {}. Removed — // Previously had a /{*path} catch-all that returned [] / {}. Removed —
// unknown accounts URLs now 404 so we notice them in logs and wire // unknown accounts URLs now 404 so we notice them in logs and wire
// them properly rather than silently masking missing endpoints. // them properly rather than silently masking missing endpoints.
@@ -1609,16 +1609,18 @@ public class AdminController(
public sealed record PlayMenuTagsRequest( public sealed record PlayMenuTagsRequest(
List<string>? PinnedTags, List<string>? PinnedTags,
List<string>? PopularTags); List<string>? PopularTags,
List<string>? TrendingTags);
[HttpPost("settings/play-menu-tags")] [HttpPost("settings/play-menu-tags")]
public async Task<ActionResult> SetPlayMenuTags([FromBody] PlayMenuTagsRequest body) public async Task<ActionResult> SetPlayMenuTags([FromBody] PlayMenuTagsRequest body)
{ {
var tags = await serverSettings.SetPlayMenuTagsAsync( var tags = await serverSettings.SetPlayMenuTagsAsync(
body.PinnedTags ?? [], body.PinnedTags ?? [],
body.PopularTags ?? []); body.PopularTags ?? [],
body.TrendingTags ?? []);
await LogAsync("play_menu_tags_updated", "system", 0, await LogAsync("play_menu_tags_updated", "system", 0,
$"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)}"); $"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)} trending={string.Join(",", tags.TrendingTags)}");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(tags); return Ok(tags);
} }
@@ -59,7 +59,7 @@ public class RoomZipImportController(
ILogger<RoomZipImportController> logger) : ControllerBase ILogger<RoomZipImportController> logger) : ControllerBase
{ {
private long CurrentAdminId => this.RequireCurrentPlayerId(); private long CurrentAdminId => this.RequireCurrentPlayerId();
private const long MaxArchiveBytes = 12_000_000_000L; private const long MaxArchiveBytes = 25_000_000_000L;
// ── Room-blob normaliser toggle ───────────────────────────────────── // ── Room-blob normaliser toggle ─────────────────────────────────────
// The normaliser does two things now (see RoomBlobNormalizerService): // The normaliser does two things now (see RoomBlobNormalizerService):
@@ -60,6 +60,8 @@ public class CdnController(
private static readonly string ImageDir = private static readonly string ImageDir =
Path.Combine(AppContext.BaseDirectory, "data", "images"); Path.Combine(AppContext.BaseDirectory, "data", "images");
private static readonly Lazy<Dictionary<string, string>> RoomImageAliases =
new(LoadRoomImageAliases);
// Specific route prefixes only — NO global catch-all wildcard. // Specific route prefixes only — NO global catch-all wildcard.
// A previous version had [Route("/{*path:minlength(1)}")] which // A previous version had [Route("/{*path:minlength(1)}")] which
@@ -340,8 +342,8 @@ public class CdnController(
return RespondWithTransforms(s3Bytes, contentType); return RespondWithTransforms(s3Bytes, contentType);
} }
var full = Path.Combine(ImageDir, safe); var full = ResolveLocalImagePath(safe);
if (System.IO.File.Exists(full)) if (full is not null)
{ {
logger.LogInformation("[img] disk hit host={Host} path={Path} → {Full}", logger.LogInformation("[img] disk hit host={Host} path={Path} → {Full}",
Request.Host.Host, path, full); Request.Host.Host, path, full);
@@ -506,10 +508,21 @@ public class CdnController(
// S3 is the only canonical store — DB carries text-only // S3 is the only canonical store — DB carries text-only
// metadata (BlobName, owner, timestamps) and never holds // metadata (BlobName, owner, timestamps) and never holds
// bytes. If S3 misses, the bytes don't exist on this server. // bytes. RRO thumbnail images are the exception for local dev:
// tools/fetch-room-images.py stores them under data/images/, and
// the 2023 client requests them through cdn.* rather than img.*.
if (IsImageName(fileName)) if (IsImageName(fileName))
{ {
var imagePath = ResolveLocalImagePath(fileName);
if (imagePath is not null)
{
var imageBytes = await System.IO.File.ReadAllBytesAsync(imagePath);
logger.LogInformation("[cdn] disk image hit host={Host} file={File} -> {Path} bytes={Bytes}",
Request.Host.Host, fileName, imagePath, imageBytes.Length);
Response.Headers.CacheControl = "public, max-age=300";
return RespondWithTransforms(imageBytes, MimeFromName(fileName));
}
logger.LogWarning("[cdn] image MISS host={Host} file={File} query='{Query}' -> transparent png", logger.LogWarning("[cdn] image MISS host={Host} file={File} query='{Query}' -> transparent png",
Request.Host.Host, fileName, Request.QueryString); Request.Host.Host, fileName, Request.QueryString);
Response.Headers.CacheControl = "public, max-age=60"; Response.Headers.CacheControl = "public, max-age=60";
@@ -630,4 +643,52 @@ public class CdnController(
var ext = Path.GetExtension(name).ToLowerInvariant(); var ext = Path.GetExtension(name).ToLowerInvariant();
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp"; return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp";
} }
private static string? ResolveLocalImagePath(string fileName)
{
var direct = Path.Combine(ImageDir, fileName);
if (System.IO.File.Exists(direct)) return direct;
var baseName = Path.GetFileNameWithoutExtension(fileName);
if (!baseName.StartsWith("image_", StringComparison.OrdinalIgnoreCase))
return null;
var roomName = baseName["image_".Length..];
if (!RoomImageAliases.Value.TryGetValue(roomName, out var mappedName))
return null;
var mapped = Path.Combine(ImageDir, mappedName);
return System.IO.File.Exists(mapped) ? mapped : null;
}
private static Dictionary<string, string> LoadRoomImageAliases()
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "data", "room_images.json"),
Path.Combine(Directory.GetCurrentDirectory(), "data", "room_images.json"),
};
foreach (var path in candidates)
{
if (!System.IO.File.Exists(path)) continue;
try
{
using var fs = System.IO.File.OpenRead(path);
var map = JsonSerializer.Deserialize<Dictionary<string, string>>(fs);
if (map is null) continue;
foreach (var (roomName, imageName) in map)
if (!string.IsNullOrWhiteSpace(roomName) && !string.IsNullOrWhiteSpace(imageName))
result[roomName] = imageName;
break;
}
catch
{
// Fall back to direct filename lookup.
}
}
return result;
}
} }
@@ -1300,6 +1300,8 @@ public class GoToController(
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (RoomService.IsBakedOriginalRoom(room)) return string.Empty;
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id); return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
} }
@@ -1358,6 +1360,7 @@ public class GoToController(
"cyberjunkcity" or "lasertagcyberjunk" => "9d6456ce-6264-48b4-808d-2d96b3d91038", "cyberjunkcity" or "lasertagcyberjunk" => "9d6456ce-6264-48b4-808d-2d96b3d91038",
"recroyalesquads" => "253fa009-6e65-4c90-91a1-7137a56a267f", "recroyalesquads" => "253fa009-6e65-4c90-91a1-7137a56a267f",
"recroyalesolos" => "85b43509-77f7-3884-3a6b-9a4e737d6d11", "recroyalesolos" => "85b43509-77f7-3884-3a6b-9a4e737d6d11",
"recrally" => "56193568-9ae0-498c-8a77-4df79dec91f5",
_ => "76d98498-60a1-430c-ab76-b54a29b7a163", // DormRoom _ => "76d98498-60a1-430c-ab76-b54a29b7a163", // DormRoom
}; };
} }
@@ -329,7 +329,12 @@ public class MatchPlayerController(
return null; return null;
} }
var resolvedDataBlob = await ResolveDataBlobForPresenceAsync(room.RoomId, playerId, room.RoomInstanceId); var resolvedDataBlob = await ResolveDataBlobForPresenceAsync(
room.RoomId,
playerId,
room.RoomInstanceId,
room.SubRoomId,
room.IsPrivate);
if (!string.IsNullOrWhiteSpace(resolvedDataBlob) if (!string.IsNullOrWhiteSpace(resolvedDataBlob)
&& !string.Equals(room.DataBlob, resolvedDataBlob, StringComparison.Ordinal)) && !string.Equals(room.DataBlob, resolvedDataBlob, StringComparison.Ordinal))
{ {
@@ -434,7 +439,9 @@ public class MatchPlayerController(
private async Task<string> ResolveDataBlobForPresenceAsync( private async Task<string> ResolveDataBlobForPresenceAsync(
long roomId, long roomId,
int playerId, int playerId,
long? roomInstanceId = null) long? roomInstanceId = null,
long? subRoomId = null,
bool isPrivateInstance = false)
{ {
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return string.Empty; if (room is null) return string.Empty;
@@ -445,7 +452,7 @@ public class MatchPlayerController(
if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob; if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob;
} }
if (roomInstanceId is > 0) if (isPrivateInstance && roomInstanceId is > 0)
{ {
var instanceBlob = await db.PrivateInstances.AsNoTracking() var instanceBlob = await db.PrivateInstances.AsNoTracking()
.Where(instance => instance.Id == roomInstanceId && instance.RoomId == roomId) .Where(instance => instance.Id == roomInstanceId && instance.RoomId == roomId)
@@ -454,8 +461,19 @@ public class MatchPlayerController(
if (!string.IsNullOrWhiteSpace(instanceBlob)) return instanceBlob; if (!string.IsNullOrWhiteSpace(instanceBlob)) return instanceBlob;
} }
if (subRoomId is >= 0)
{
var sceneBlob = await db.RoomScenes.AsNoTracking()
.Where(scene => scene.RoomId == roomId && scene.OrderIndex == subRoomId)
.Select(scene => scene.DataBlobName)
.FirstOrDefaultAsync();
if (!string.IsNullOrWhiteSpace(sceneBlob)) return sceneBlob;
}
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (RoomService.IsBakedOriginalRoom(room)) return string.Empty;
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id); return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
} }
} }
@@ -0,0 +1,122 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.PlatformNotifications;
[ApiController]
[Authorize]
public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("/preferences")]
[HttpGet("/platformnotifications/preferences")]
public async Task<IActionResult> Preferences()
{
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
return Ok(ToPreferencesWire(row));
}
[HttpPut("/preferences")]
[HttpPost("/preferences")]
[HttpPut("/platformnotifications/preferences")]
[HttpPost("/platformnotifications/preferences")]
public async Task<IActionResult> SetPreferences([FromBody] PlatformNotificationPrefsRequest? body)
{
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
if (row is null)
{
row = new NotificationPrefsEntity
{
PlayerId = Me,
Platform = "platform",
};
db.NotificationPrefs.Add(row);
}
if (body is not null)
{
row.AllowAll = body.AllowAll ?? body.Enabled ?? row.AllowAll;
row.AllowMessage = body.AllowMessages ?? body.Messages ?? row.AllowMessage;
row.AllowFriendRequest = body.AllowFriendRequests ?? body.FriendRequests ?? row.AllowFriendRequest;
row.AllowEventInvite = body.AllowEventInvites ?? body.EventInvites ?? row.AllowEventInvite;
row.AllowAnnouncements = body.AllowAnnouncements ?? body.Announcements ?? row.AllowAnnouncements;
row.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return Ok(ToPreferencesWire(row));
}
[HttpGet("/config/categories")]
[HttpGet("/platformnotifications/config/categories")]
[AllowAnonymous]
public IActionResult Categories()
{
var categories = new[]
{
Category(1, "messages", "Messages"),
Category(2, "friendRequests", "Friend Requests"),
Category(3, "eventInvites", "Event Invites"),
Category(4, "announcements", "Announcements"),
};
return Ok(new
{
Categories = categories,
Results = categories,
TotalResults = categories.Length,
});
}
private static object Category(int id, string key, string name) => new
{
Id = id,
CategoryId = id,
Key = key,
Name = name,
DisplayName = name,
IsEnabled = true,
Enabled = true,
DefaultEnabled = true,
};
private static object ToPreferencesWire(NotificationPrefsEntity? row) => new
{
AllowAll = row?.AllowAll ?? true,
Enabled = row?.AllowAll ?? true,
AllowMessages = row?.AllowMessage ?? true,
Messages = row?.AllowMessage ?? true,
AllowFriendRequests = row?.AllowFriendRequest ?? true,
FriendRequests = row?.AllowFriendRequest ?? true,
AllowEventInvites = row?.AllowEventInvite ?? true,
EventInvites = row?.AllowEventInvite ?? true,
AllowAnnouncements = row?.AllowAnnouncements ?? true,
Announcements = row?.AllowAnnouncements ?? true,
Categories = new[]
{
new { CategoryId = 1, Key = "messages", Enabled = row?.AllowMessage ?? true },
new { CategoryId = 2, Key = "friendRequests", Enabled = row?.AllowFriendRequest ?? true },
new { CategoryId = 3, Key = "eventInvites", Enabled = row?.AllowEventInvite ?? true },
new { CategoryId = 4, Key = "announcements", Enabled = row?.AllowAnnouncements ?? true },
},
};
public sealed class PlatformNotificationPrefsRequest
{
public bool? AllowAll { get; set; }
public bool? Enabled { get; set; }
public bool? AllowMessages { get; set; }
public bool? Messages { get; set; }
public bool? AllowFriendRequests { get; set; }
public bool? FriendRequests { get; set; }
public bool? AllowEventInvites { get; set; }
public bool? EventInvites { get; set; }
public bool? AllowAnnouncements { get; set; }
public bool? Announcements { get; set; }
}
}
@@ -60,6 +60,9 @@ public class RoomEntity
public bool SupportsWalkVR { get; set; } = true; public bool SupportsWalkVR { get; set; } = true;
public bool SupportsTeleportVR { get; set; } = true; public bool SupportsTeleportVR { get; set; } = true;
public bool AllowsJuniors { get; set; } = true; public bool AllowsJuniors { get; set; } = true;
public bool AllowNewUsers { get; set; } = true;
public int MinLevel { get; set; } = 0;
public int MaxPlayerCalculationMode { get; set; } = 0;
/// <summary>Max players the room advertises per instance — flows into the /// <summary>Max players the room advertises per instance — flows into the
/// matchmaking RoomInstance.MaxCapacity and the v4/details synthesized /// matchmaking RoomInstance.MaxCapacity and the v4/details synthesized
@@ -89,6 +92,15 @@ public class RoomEntity
[MaxLength(1024)] [MaxLength(1024)]
public string TagsCsv { get; set; } = string.Empty; public string TagsCsv { get; set; } = string.Empty;
[MaxLength(4096)]
public string LoadScreensJson { get; set; } = string.Empty;
[MaxLength(4096)]
public string PromoImagesJson { get; set; } = string.Empty;
[MaxLength(4096)]
public string PromoExternalContentJson { get; set; } = string.Empty;
public int CheerCount { get; set; } = 0; public int CheerCount { get; set; } = 0;
public int FavoriteCount { get; set; } = 0; public int FavoriteCount { get; set; } = 0;
+14
View File
@@ -106,6 +106,7 @@ public class RoomService(DorkNetDbContext db)
("IsleOfLostSkulls", "The Isle of Lost Skulls", "Can your pirate crew get to the Isle, defeat its fearsome guardian, and escape with the gold?", "7e01cfe0-820a-406f-b1b3-0a5bf575235c", "recroomoriginal,quest,co-op,adventure", "image_IsleOfLostSkulls.png"), ("IsleOfLostSkulls", "The Isle of Lost Skulls", "Can your pirate crew get to the Isle, defeat its fearsome guardian, and escape with the gold?", "7e01cfe0-820a-406f-b1b3-0a5bf575235c", "recroomoriginal,quest,co-op,adventure", "image_IsleOfLostSkulls.png"),
("Crescendo", "Crescendo of the Blood Moon", "Brave the haunted halls of Castle Dracula and survive the night.", "49cb8993-a956-43e2-86f4-1318f279b22a", "recroomoriginal,quest,co-op,adventure", "by3mjs9jbozpdvu6g9aje7jgz.png"), ("Crescendo", "Crescendo of the Blood Moon", "Brave the haunted halls of Castle Dracula and survive the night.", "49cb8993-a956-43e2-86f4-1318f279b22a", "recroomoriginal,quest,co-op,adventure", "by3mjs9jbozpdvu6g9aje7jgz.png"),
("StuntRunner", "Stunt Runner", "A solo platforming gauntlet — sprint, climb and dodge to reach the trophy at the top.", "b7281665-a715-4051-826b-8e08e69c6172", "recroomoriginal,sport,quest,stuntrunner,parkour", "image_StuntRunner.png"), ("StuntRunner", "Stunt Runner", "A solo platforming gauntlet — sprint, climb and dodge to reach the trophy at the top.", "b7281665-a715-4051-826b-8e08e69c6172", "recroomoriginal,sport,quest,stuntrunner,parkour", "image_StuntRunner.png"),
("RecRally", "Rec Rally", "Race across Chaparral with friends in off-road vehicles built for jumps, boosts, and tight turns.", "56193568-9ae0-498c-8a77-4df79dec91f5", "recroomoriginal,featured,sport,recrally,racing,pvp", "image_RecRally.png"),
("Drive-In", "Rec Drive-In", "Watch movies, hang out with friends, or chill at the bar.", "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", "recroomoriginal,featured,hangout,chill", "image_DriveIn.png"), ("Drive-In", "Rec Drive-In", "Watch movies, hang out with friends, or chill at the bar.", "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", "recroomoriginal,featured,hangout,chill", "image_DriveIn.png"),
// Hub / hang rooms — no specific gameplay, just shared spaces. // Hub / hang rooms — no specific gameplay, just shared spaces.
("Park", "The Park", "An outdoor park with picnic tables and lawn games — a chill place to hang out.", "0a864c86-5a71-4e18-8041-8124e4dc9d98", "recroomoriginal,featured,hangout,chill", "image_Park.png"), ("Park", "The Park", "An outdoor park with picnic tables and lawn games — a chill place to hang out.", "0a864c86-5a71-4e18-8041-8124e4dc9d98", "recroomoriginal,featured,hangout,chill", "image_Park.png"),
@@ -190,6 +191,7 @@ public class RoomService(DorkNetDbContext db)
["Quarry"] = "image_Quarry.png", ["Quarry"] = "image_Quarry.png",
["Clearcut"] = "image_Clearcut.png", ["Clearcut"] = "image_Clearcut.png",
["Spillway"] = "image_Spillway.png", ["Spillway"] = "image_Spillway.png",
["RecRally"] = "image_RecRally.png",
}; };
var candidates = new[] var candidates = new[]
{ {
@@ -368,6 +370,7 @@ public class RoomService(DorkNetDbContext db)
("BowlingAlley", "Bowling Alley", "Classic ten-pin bowling. Roll strikes, beat your friends.", "ae929543-9a07-41d5-8ee9-dbbee8c36800", "recroomoriginal,sport", "image_BowlingAlley.png"), ("BowlingAlley", "Bowling Alley", "Classic ten-pin bowling. Roll strikes, beat your friends.", "ae929543-9a07-41d5-8ee9-dbbee8c36800", "recroomoriginal,sport", "image_BowlingAlley.png"),
("Crescendo", "Crescendo of the Blood Moon", "Brave the haunted halls of Castle Dracula and survive the night.", "49cb8993-a956-43e2-86f4-1318f279b22a", "recroomoriginal,quest", "by3mjs9jbozpdvu6g9aje7jgz.png"), ("Crescendo", "Crescendo of the Blood Moon", "Brave the haunted halls of Castle Dracula and survive the night.", "49cb8993-a956-43e2-86f4-1318f279b22a", "recroomoriginal,quest", "by3mjs9jbozpdvu6g9aje7jgz.png"),
("LaserTagHangar", "Laser Tag Hangar", "Teams battle in an industrial warehouse map.", "239e676c-f12f-489f-bf3a-d4c383d692c3", "recroomoriginal,sport", "image_LaserTag.png"), ("LaserTagHangar", "Laser Tag Hangar", "Teams battle in an industrial warehouse map.", "239e676c-f12f-489f-bf3a-d4c383d692c3", "recroomoriginal,sport", "image_LaserTag.png"),
("RecRally", "Rec Rally", "Race across Chaparral with friends in off-road vehicles built for jumps, boosts, and tight turns.", "56193568-9ae0-498c-8a77-4df79dec91f5", "recroomoriginal,featured,sport,recrally,racing,pvp", "image_RecRally.png"),
("MakerRoom", "Maker Room", "A blank-walled canvas. The classic starting point for a Maker Pen build.", "a75f7547-79eb-47c6-8986-6767abcb4f92", "recroomoriginal,template,featured", "image_RecCenter.png"), ("MakerRoom", "Maker Room", "A blank-walled canvas. The classic starting point for a Maker Pen build.", "a75f7547-79eb-47c6-8986-6767abcb4f92", "recroomoriginal,template,featured", "image_RecCenter.png"),
}; };
@@ -641,6 +644,17 @@ public class RoomService(DorkNetDbContext db)
? blobName ? blobName
: SyntheticDefaultRoomDataBlobName(roomId); : SyntheticDefaultRoomDataBlobName(roomId);
public static bool IsBakedOriginalRoom(RoomEntity room)
{
return !room.IsDormRoom
&& !room.IsStudioRoom
&& room.CreatorPlayerId == 1
&& !string.IsNullOrWhiteSpace(room.TagsCsv)
&& room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(tag => string.Equals(tag, "recroomoriginal", StringComparison.OrdinalIgnoreCase));
}
private async Task<bool> IsUsableDormBlobNameAsync(string? blobName, long dormRoomId) private async Task<bool> IsUsableDormBlobNameAsync(string? blobName, long dormRoomId)
{ {
if (string.IsNullOrWhiteSpace(blobName)) return false; if (string.IsNullOrWhiteSpace(blobName)) return false;
@@ -102,9 +102,10 @@ public class ServerSettingsService(DorkNetDbContext db)
public async Task<PlayMenuTagSettings> SetPlayMenuTagsAsync( public async Task<PlayMenuTagSettings> SetPlayMenuTagsAsync(
IReadOnlyList<string> pinned, IReadOnlyList<string> pinned,
IReadOnlyList<string> popular) IReadOnlyList<string> popular,
IReadOnlyList<string>? trending = null)
{ {
var normalized = NormalizePlayMenuTags(pinned, popular); var normalized = NormalizePlayMenuTags(pinned, popular, trending);
var existing = await GetTrackedRowAsync(); var existing = await GetTrackedRowAsync();
existing.PlayMenuTagsJson = JsonSerializer.Serialize(normalized, JsonOptions); existing.PlayMenuTagsJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow; existing.UpdatedAt = DateTime.UtcNow;
@@ -212,7 +213,7 @@ public class ServerSettingsService(DorkNetDbContext db)
row.PlayMenuTagsJson, row.PlayMenuTagsJson,
JsonOptions); JsonOptions);
if (parsed is not null) if (parsed is not null)
return NormalizePlayMenuTags(parsed.PinnedTags, parsed.PopularTags) return NormalizePlayMenuTags(parsed.PinnedTags, parsed.PopularTags, parsed.TrendingTags)
with { UpdatedAt = row.UpdatedAt }; with { UpdatedAt = row.UpdatedAt };
} }
catch catch
@@ -473,6 +474,19 @@ public class ServerSettingsService(DorkNetDbContext db)
"music", "music",
"parkour", "parkour",
], ],
TrendingTags:
[
"featured",
"community",
"makerpen",
"pvp",
"quest",
"music",
"parkour",
"hangout",
"creative",
"recroomoriginal",
],
UpdatedAt: DateTime.UtcNow); UpdatedAt: DateTime.UtcNow);
public static RecCenterDoorSettings DefaultRecCenterDoors() => public static RecCenterDoorSettings DefaultRecCenterDoors() =>
@@ -537,13 +551,14 @@ public class ServerSettingsService(DorkNetDbContext db)
} }
private static PlayMenuTagSettings NormalizePlayMenuTags( private static PlayMenuTagSettings NormalizePlayMenuTags(
IReadOnlyList<string> pinned, IReadOnlyList<string>? pinned,
IReadOnlyList<string> popular) IReadOnlyList<string>? popular,
IReadOnlyList<string>? trending)
{ {
static List<string> Normalize(IReadOnlyList<string> tags, int max) static List<string> Normalize(IReadOnlyList<string>? tags, int max)
{ {
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
return tags return (tags ?? [])
.Select(t => (t ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant()) .Select(t => (t ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant())
.Where(t => t.Length > 0) .Where(t => t.Length > 0)
.Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_')) .Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_'))
@@ -555,9 +570,11 @@ public class ServerSettingsService(DorkNetDbContext db)
var defaults = DefaultPlayMenuTags(); var defaults = DefaultPlayMenuTags();
var pinnedTags = Normalize(pinned, 16); var pinnedTags = Normalize(pinned, 16);
var popularTags = Normalize(popular, 32); var popularTags = Normalize(popular, 32);
var trendingTags = Normalize(trending, 32);
return new PlayMenuTagSettings( return new PlayMenuTagSettings(
pinnedTags.Count > 0 ? pinnedTags : defaults.PinnedTags, pinnedTags.Count > 0 ? pinnedTags : defaults.PinnedTags,
popularTags.Count > 0 ? popularTags : defaults.PopularTags, popularTags.Count > 0 ? popularTags : defaults.PopularTags,
trendingTags.Count > 0 ? trendingTags : defaults.TrendingTags,
DateTime.UtcNow); DateTime.UtcNow);
} }
@@ -669,6 +686,7 @@ public sealed record WeeklyChallengeReward(
public sealed record PlayMenuTagSettings( public sealed record PlayMenuTagSettings(
List<string> PinnedTags, List<string> PinnedTags,
List<string> PopularTags, List<string> PopularTags,
List<string> TrendingTags,
DateTime UpdatedAt); DateTime UpdatedAt);
public sealed record RecCenterDoorSettings( public sealed record RecCenterDoorSettings(
@@ -283,6 +283,18 @@ public static class DatabaseBootstrap
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""IsRoomLinkedToRecRoomStudio"" boolean NOT NULL DEFAULT false;"); @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""IsRoomLinkedToRecRoomStudio"" boolean NOT NULL DEFAULT false;");
await db.Database.ExecuteSqlRawAsync( await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""StudioSessionId"" character varying(128) NOT NULL DEFAULT '';"); @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""StudioSessionId"" character varying(128) NOT NULL DEFAULT '';");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""AllowNewUsers"" boolean NOT NULL DEFAULT true;");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""MinLevel"" integer NOT NULL DEFAULT 0;");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""MaxPlayerCalculationMode"" integer NOT NULL DEFAULT 0;");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""LoadScreensJson"" character varying(4096) NOT NULL DEFAULT '';");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""PromoImagesJson"" character varying(4096) NOT NULL DEFAULT '';");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""PromoExternalContentJson"" character varying(4096) NOT NULL DEFAULT '';");
return; return;
} }
@@ -315,6 +327,48 @@ public static class DatabaseBootstrap
@"ALTER TABLE ""Rooms"" ADD COLUMN ""StudioSessionId"" TEXT NOT NULL DEFAULT '';"); @"ALTER TABLE ""Rooms"" ADD COLUMN ""StudioSessionId"" TEXT NOT NULL DEFAULT '';");
} }
catch { } catch { }
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN ""AllowNewUsers"" INTEGER NOT NULL DEFAULT 1;");
}
catch { }
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN ""MinLevel"" INTEGER NOT NULL DEFAULT 0;");
}
catch { }
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN ""MaxPlayerCalculationMode"" INTEGER NOT NULL DEFAULT 0;");
}
catch { }
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN ""LoadScreensJson"" TEXT NOT NULL DEFAULT '';");
}
catch { }
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN ""PromoImagesJson"" TEXT NOT NULL DEFAULT '';");
}
catch { }
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN ""PromoExternalContentJson"" TEXT NOT NULL DEFAULT '';");
}
catch { }
} }
private static async Task EnsureInventionColumnsAsync(DorkNetDbContext db) private static async Task EnsureInventionColumnsAsync(DorkNetDbContext db)
+12 -3
View File
@@ -13,6 +13,7 @@ import { Plus, RefreshCw, Trash, Upload } from '../components/Icons';
interface PlayMenuTagSettings { interface PlayMenuTagSettings {
pinnedTags: string[]; pinnedTags: string[];
popularTags: string[]; popularTags: string[];
trendingTags: string[];
updatedAt: string; updatedAt: string;
} }
@@ -245,6 +246,7 @@ function PlayMenuTagsPanel({
}) { }) {
const [pinnedTags, setPinnedTags] = useState<string[]>([]); const [pinnedTags, setPinnedTags] = useState<string[]>([]);
const [popularTags, setPopularTags] = useState<string[]>([]); const [popularTags, setPopularTags] = useState<string[]>([]);
const [trendingTags, setTrendingTags] = useState<string[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const toast = useToast(); const toast = useToast();
@@ -252,6 +254,7 @@ function PlayMenuTagsPanel({
if (!tags) return; if (!tags) return;
setPinnedTags(tags.pinnedTags); setPinnedTags(tags.pinnedTags);
setPopularTags(tags.popularTags); setPopularTags(tags.popularTags);
setTrendingTags(tags.trendingTags);
}, [tags]); }, [tags]);
const save = async (reset = false) => { const save = async (reset = false) => {
@@ -260,8 +263,8 @@ function PlayMenuTagsPanel({
await api<PlayMenuTagSettings>('/settings/play-menu-tags', { await api<PlayMenuTagSettings>('/settings/play-menu-tags', {
method: 'POST', method: 'POST',
body: reset body: reset
? { PinnedTags: [], PopularTags: [] } ? { PinnedTags: [], PopularTags: [], TrendingTags: [] }
: { PinnedTags: pinnedTags, PopularTags: popularTags }, : { PinnedTags: pinnedTags, PopularTags: popularTags, TrendingTags: trendingTags },
}); });
toast.push(reset ? 'Play menu tags reset.' : 'Play menu tags saved.', 'success'); toast.push(reset ? 'Play menu tags reset.' : 'Play menu tags saved.', 'success');
onSaved(); onSaved();
@@ -299,7 +302,7 @@ function PlayMenuTagsPanel({
{error && <div className="mt-3 text-sm text-danger">{error}</div>} {error && <div className="mt-3 text-sm text-danger">{error}</div>}
{loading && !tags && <div className="mt-3 text-xs text-ink-400">Loading tags...</div>} {loading && !tags && <div className="mt-3 text-xs text-ink-400">Loading tags...</div>}
{tags && ( {tags && (
<div className="mt-4 grid gap-3 md:grid-cols-2"> <div className="mt-4 grid gap-3 md:grid-cols-3">
<TagListEditor <TagListEditor
label="Pinned tags" label="Pinned tags"
tags={pinnedTags} tags={pinnedTags}
@@ -312,6 +315,12 @@ function PlayMenuTagsPanel({
onChange={setPopularTags} onChange={setPopularTags}
max={32} max={32}
/> />
<TagListEditor
label="Trending tags"
tags={trendingTags}
onChange={setTrendingTags}
max={32}
/>
</div> </div>
)} )}
</div> </div>