mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
+334
-200
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user