using System.Diagnostics.Metrics; using System.Linq; using System.Text; using System.Threading.Tasks; using Discord; using LiteDB; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System.Security.Cryptography; using Campus_live_rewrite.api.server.Classes.db_class; using static Campus_live_rewrite.api.server.Classes.db_class.RoomDBClasses; using System.Linq; namespace Campus_live_rewrite.api.dbs { public class RoomDB { public static LiteDatabase rooms_db = new LiteDatabase(Environment.CurrentDirectory + $"/Data/DBs/Rooms.db"); private static readonly object _roomLock = new(); public static readonly ILiteCollection col = rooms_db.GetCollection("Main_rooms"); public static readonly ILiteCollection subroom_col = rooms_db.GetCollection("Main_subrooms"); public static async Task ImportRooms(string path, ulong? ownerOverride = null) { string jsonData = await File.ReadAllTextAsync(path); var hotRooms = JsonConvert.DeserializeObject>(jsonData); if (hotRooms?.Results == null) return; foreach (var room in hotRooms.Results) { // Re-own every imported room to the given account (the @Importer account), so all // uploaded rooms land under one owner regardless of who created them originally. if (ownerOverride.HasValue) room.CreatorAccountId = ownerOverride.Value; AddRoom(room, isImportingFromJson: true); } } // Rec Room exports carry JSON nulls for value-type fields (e.g. "PublishedAt": null); the // server's RoomRoot types those as non-nullable DateTime, so a plain ToObject throws and the // whole room import dies before any blob is placed. Ignoring nulls leaves such fields at // their default instead of throwing. private static readonly Newtonsoft.Json.JsonSerializer _lenientRoomSerializer = Newtonsoft.Json.JsonSerializer.Create(new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); public class RoomExportResult { public int Rooms, Blobs, Images; public readonly List Names = new(); public readonly List Errors = new(); } /// /// Import Rec Room's OFFICIAL room-export format (the one the game's export tool produces): /// /// {Room}/RoomDetails.json # a SINGLE RoomRoot object (NOT {Results:[...]}) /// {Room}/super_room_data(.original).binpb # bytes for RoomRoot.DataBlob (a .meta) /// {Room}/RoomImage.jpg # bytes for RoomRoot.ImageName /// {Room}/SubRoom_{SubRoomId}_{Name}/persisted_room_data(.original).binpb /// # bytes for that subroom's CurrentSave.DataBlob (a .room) /// /// The OLD ImportRooms() only understood the server's own {Results:[...]} dump AND expected the /// .room/.meta blobs to already exist under their content-hash names. A real export has neither: /// RoomDetails.json is a bare object, and the blob bytes live in the .binpb files under DIFFERENT /// names. So a dragged-in export imported 0 rooms (or a room with no build = bare scene). /// /// The blob mapping is verified by hash: RoomRoot.DataBlob's recorded DataBlobHash matches /// super_room_data.binpb, and each CurrentSave.DataBlobHash matches that subroom's /// persisted_room_data.ORIGINAL.binpb (the pre-migration copy is the canonical blob the manifest /// records; the plain .binpb is a migrated variant). We pick, per blob, the .binpb whose SHA-256 /// equals the recorded hash and write it under the DataBlob name into RoomBlobs, so the client's /// integrity check passes and the room actually loads its build. /// public static async Task ImportRoomExport(string dir, ulong? ownerOverride = null) { var res = new RoomExportResult(); string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs"); string imgDir = Path.Combine(Environment.CurrentDirectory, "Data", "Images"); Directory.CreateDirectory(blobDir); Directory.CreateDirectory(imgDir); var manifests = Directory.EnumerateFiles(dir, "RoomDetails.json", SearchOption.AllDirectories).ToList(); if (manifests.Count == 0) { res.Errors.Add("no RoomDetails.json found"); return res; } foreach (var manifest in manifests) { try { string baseDir = Path.GetDirectoryName(manifest)!; var tok = JToken.Parse(await File.ReadAllTextAsync(manifest)); var roomTokens = tok is JObject o && o["Results"] is JArray ra ? ra.Cast().ToList() : tok is JArray arr ? arr.Cast().ToList() : new List { tok }; foreach (var rt in roomTokens) { var room = rt.ToObject(_lenientRoomSerializer); if (room == null) { res.Errors.Add($"{Path.GetFileName(baseDir)}: could not parse RoomDetails.json"); continue; } // Downgrade the room's declared build version to the client's level so the // client doesn't reject it with "playing on an older version of Rec Room than // this room was saved with." Working base rooms declare no minimum; the subroom // CurrentSave versions are zeroed separately in AddRoom. See ConfigCampus. if (Campus_live_rewrite.api.server.ConfigCampus.ClampImportedRoomVersionToClient) { room.MinUgcSubVersion = null; room.UgcSubVersion = null; room.PersistenceVersion = 0; } // DataBlobHash is NOT modelled on RoomRoot/CurrentSave (commented out), so read // the recorded hashes straight off the raw JSON to hash-match the .binpb files. string? roomHash = (string?)rt["DataBlobHash"]; var subHashById = new Dictionary(); if (rt["SubRooms"] is JArray sra) foreach (var st in sra) if (ulong.TryParse((string?)st["SubRoomId"], out var sid)) subHashById[sid] = (string?)st["CurrentSave"]?["DataBlobHash"]; // Room-level blob (super room data) -> RoomBlobs/. if (!string.IsNullOrWhiteSpace(room.DataBlob)) { var src = PickBlobByHash(baseDir, new[] { "super_room_data.original.binpb", "super_room_data.binpb" }, roomHash); if (src != null) { File.Copy(src, Path.Combine(blobDir, room.DataBlob), true); res.Blobs++; } else res.Errors.Add($"{room.Name}: super_room_data.binpb not found for DataBlob {room.DataBlob}"); } // Each subroom's build blob -> RoomBlobs/. foreach (var sub in room.SubRooms ?? new List()) { var cs = sub.CurrentSave; if (cs == null || string.IsNullOrWhiteSpace(cs.DataBlob)) continue; string? subDir = FindSubRoomDir(baseDir, sub.SubRoomId); if (subDir == null) { res.Errors.Add($"{room.Name}/{sub.Name}: SubRoom_{sub.SubRoomId}_* folder missing"); continue; } subHashById.TryGetValue(sub.SubRoomId, out var subHash); var src = PickBlobByHash(subDir, new[] { "persisted_room_data.original.binpb", "persisted_room_data.binpb" }, subHash); if (src != null) { File.Copy(src, Path.Combine(blobDir, cs.DataBlob), true); res.Blobs++; } else res.Errors.Add($"{room.Name}/{sub.Name}: persisted_room_data.binpb not found for DataBlob {cs.DataBlob}"); } // Room thumbnail -> Images/. Skip when the room has no custom image // (ImageName is the shared DefaultRoomImage.jpg) so we don't clobber the global // fallback every other room falls back to. if (!string.IsNullOrWhiteSpace(room.ImageName) && !room.ImageName.StartsWith("Default", StringComparison.OrdinalIgnoreCase)) { var img = Directory.EnumerateFiles(baseDir, "RoomImage.*", SearchOption.TopDirectoryOnly).FirstOrDefault(); if (img != null) { try { File.Copy(img, Path.Combine(imgDir, room.ImageName), true); res.Images++; } catch { } } } if (ownerOverride.HasValue) room.CreatorAccountId = ownerOverride.Value; var id = AddRoom(room, isImportingFromJson: true); if (id != 0) { res.Rooms++; res.Names.Add(room.Name); } else res.Errors.Add($"{room.Name}: not added (a room with that name may already exist)"); } } catch (Exception ex) { res.Errors.Add($"{Path.GetFileName(Path.GetDirectoryName(manifest))}: {ex.Message}"); } } return res; } /// /// Clamp the declared build version of every @Importer-owned room down to the client's level /// (no minimum), so already-imported rooms stop tripping the client's "playing on an older /// version of Rec Room" gate. Idempotent; only touches imported rooms (base rooms are owned /// by other accounts and already declare no minimum). Returns how many rooms were changed. /// public static int ClampImportedRoomsToClient(ulong importerId) { if (!Campus_live_rewrite.api.server.ConfigCampus.ClampImportedRoomVersionToClient || importerId == 0) return 0; int n = 0; lock (_roomLock) { foreach (var room in col.Find(r => r.CreatorAccountId == importerId).ToList()) { bool ch = false; if (room.MinUgcSubVersion != null) { room.MinUgcSubVersion = null; ch = true; } if (room.UgcSubVersion != null) { room.UgcSubVersion = null; ch = true; } if (room.PersistenceVersion != null && room.PersistenceVersion != 0) { room.PersistenceVersion = 0; ch = true; } if (ch) { col.Update(room); n++; } } } return n; } /// /// Wrap a loose .room blob (a single subroom's build data, with no RoomDetails.json) in a /// minimal, joinable @Importer-owned room so it can actually be played. The blob must already /// be present in Data/CDN/RoomBlobs under `roomFile`. The room-level blob points at the shared /// default_room.META (empty room variables). Scene id is taken from the blob when present, else /// the standard MakerPen sandbox scene. Returns the new RoomId, or 0 if a room with that name /// already exists / creation failed. /// public static ulong CreateRoomFromBlob(string roomFile, string roomName, ulong ownerId, string? sceneId = null, int persistenceVersion = 0) { const string SandboxScene = "a75f7547-79eb-47c6-8986-6767abcb4f92"; string scene = string.IsNullOrWhiteSpace(sceneId) ? SandboxScene : sceneId!; var room = new RoomRoot { Name = roomName, Description = "Imported from a loose .room blob.", CreatorAccountId = ownerId, Accessibility = RoomAccessibility.Public, State = RoomState.Active, PublishState = 0, CreatedAt = DateTime.UtcNow, PublishedAt = DateTime.UtcNow, DataBlob = "default_room.META", // shared empty room-level (super) blob ImageName = "DefaultRoomImage.jpg", MaxPlayers = 20, MaxPlayerCalculationMode = 0, AgeRating = 0, MinLevel = 0, CloningAllowed = false, SupportsScreens = true, SupportsWalkVR = true, SupportsTeleportVR = true, SupportsVRLow = true, SupportsQuest2 = true, SupportsMobile = true, SupportsJuniors = false, IsRRO = false, UgcVersion = 1, UgcSubVersion = null, // no minimum -> passes the client version gate MinUgcSubVersion = null, PersistenceVersion = persistenceVersion, // the blob's real save-format version LoadScreens = new List { new LoadScreen { ImageName = "", Title = roomName, Subtitle = "" } }, Stats = new Stats(), Tags = new List(), Roles = new List(), PromoImages = new List(), PromoExternalContent = new List(), RestrictedCircuitsAllowListNames = new List(), SubRooms = new List { new SubRoom { Name = roomName, UnitySceneId = scene, IsSandbox = true, MaxPlayers = 20, Accessibility = RoomAccessibility.Public, ShouldAutoStageSaves = true, AllSaves = new List(), CurrentSave = new CurrentSave { DataBlob = roomFile, Description = "Imported from a loose .room blob.", CreatedAt = DateTime.UtcNow, SavedByAccountId = ownerId, PersistenceVersion = 0, OMVersion = 0, UgcSubVersion = 0, ReferencedUnityAssetIds = new List() } } } }; return AddRoom(room, isImportingFromJson: true); } /// True if any room (room-level DataBlob) or subroom (CurrentSave/AllSaves DataBlob) already points at this blob. public static bool AnyRoomUsesBlob(string blobName) { try { if (col.Exists(r => r.DataBlob == blobName)) return true; foreach (var s in subroom_col.FindAll()) { if (s.CurrentSave != null && string.Equals(s.CurrentSave.DataBlob, blobName, StringComparison.OrdinalIgnoreCase)) return true; if (s.AllSaves != null && s.AllSaves.Any(x => string.Equals(x.DataBlob, blobName, StringComparison.OrdinalIgnoreCase))) return true; } } catch { } return false; } /// /// Remove @Importer rooms that were wrapped around a loose .room blob whose save version is /// newer than the client can load - they only freeze the game and drop the player to the dorm. /// The blob file stays hosted. Only touches loose-blob rooms (our marker), never real imports. /// public static int RemoveTooNewLooseRooms(ulong importerId) { if (importerId == 0) return 0; int max = Campus_live_rewrite.api.server.ConfigCampus.ClientMaxRoomPersistenceVersion; string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs"); int removed = 0; lock (_roomLock) { foreach (var room in col.Find(r => r.CreatorAccountId == importerId).ToList()) { var subs = subroom_col.Find(s => s.RoomId == room.RoomId).ToList(); bool loose = subs.Count == 1 && subs[0].CurrentSave != null && (subs[0].CurrentSave.Description ?? "").Contains("loose .room", StringComparison.OrdinalIgnoreCase); if (!loose) continue; int ver = BlobPersistenceVersion(Path.Combine(blobDir, subs[0].CurrentSave!.DataBlob ?? "")); if (ver > max) { subroom_col.DeleteMany(x => x.RoomId == room.RoomId); col.DeleteMany(x => x.RoomId == room.RoomId); removed++; Console.WriteLine($"[room-version] removed too-new loose room '{room.Name}' (id {room.RoomId}, save v{ver} > client max {max})"); } } } return removed; } /// The .room blob's persistence version = field 1 (a varint) at the very start. 0 if unreadable. public static int BlobPersistenceVersion(string path) { try { using var fs = File.OpenRead(path); int first = fs.ReadByte(); if (first != 0x08) return 0; // expect field 1, wiretype 0 (varint) int result = 0, shift = 0, b; while ((b = fs.ReadByte()) >= 0) { result |= (b & 0x7f) << shift; shift += 7; if ((b & 0x80) == 0) break; if (shift > 28) break; } return result; } catch { return 0; } } public enum LooseWrapStatus { Created, TooNew, AlreadyUsed, NameExists, Error } /// /// Host a single loose .room blob in RoomBlobs and, when it's old enough for the client and not /// already linked to a room, wrap it in a joinable @Importer room. Reusable by the admin upload /// AND the staging scan. Returns what happened. /// public static (LooseWrapStatus status, ulong roomId, int version, string roomName, string? error) WrapLooseRoom(string onDiskPath, ulong ownerId) { string bn = Path.GetFileName(onDiskPath); try { string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs"); Directory.CreateDirectory(blobDir); string dest = Path.Combine(blobDir, bn); if (!File.Exists(dest) || new FileInfo(dest).Length != new FileInfo(onDiskPath).Length) File.Copy(onDiskPath, dest, true); if (AnyRoomUsesBlob(bn)) return (LooseWrapStatus.AlreadyUsed, 0, 0, bn, null); int ver = BlobPersistenceVersion(dest); if (ver > Campus_live_rewrite.api.server.ConfigCampus.ClientMaxRoomPersistenceVersion) return (LooseWrapStatus.TooNew, 0, ver, bn, null); string roomName = Path.GetFileNameWithoutExtension(bn); string? scene = SceneIdFromBlob(dest); ulong id = CreateRoomFromBlob(bn, roomName, ownerId, scene, ver); return id != 0 ? (LooseWrapStatus.Created, id, ver, roomName, null) : (LooseWrapStatus.NameExists, 0, ver, roomName, null); } catch (Exception ex) { return (LooseWrapStatus.Error, 0, 0, bn, ex.Message); } } /// Best-effort: pull the first GUID (the subroom's UnitySceneId) out of a .room blob's header. public static string? SceneIdFromBlob(string path) { try { byte[] head = new byte[Math.Min(4096, (int)new FileInfo(path).Length)]; using (var fs = File.OpenRead(path)) fs.Read(head, 0, head.Length); string ascii = System.Text.Encoding.ASCII.GetString(head); var m = System.Text.RegularExpressions.Regex.Match(ascii, "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"); return m.Success ? m.Value : null; } catch { return null; } } /// True if the directory looks like a Rec Room room export (has RoomDetails.json + a room blob). public static bool LooksLikeRoomExport(string dir) { try { return Directory.EnumerateFiles(dir, "RoomDetails.json", SearchOption.AllDirectories).Any() && Directory.EnumerateFiles(dir, "*room_data*.binpb", SearchOption.AllDirectories).Any(); } catch { return false; } } // Pick, among candidate filenames in dir, the one whose SHA-256 matches expectedHashB64. // Falls back to the first existing candidate (candidates are listed best-first) when no hash // is recorded or none match - better to serve a plausible blob than nothing. private static string? PickBlobByHash(string dir, string[] candidateNames, string? expectedHashB64) { var existing = candidateNames.Select(n => Path.Combine(dir, n)).Where(File.Exists).ToList(); if (existing.Count == 0) return null; if (!string.IsNullOrWhiteSpace(expectedHashB64)) { foreach (var p in existing) { try { using var fs = File.OpenRead(p); var hash = Convert.ToBase64String(SHA256.Create().ComputeHash(fs)); if (string.Equals(hash, expectedHashB64, StringComparison.Ordinal)) return p; } catch { } } } return existing[0]; } private static string? FindSubRoomDir(string baseDir, ulong subRoomId) { try { string prefix = $"SubRoom_{subRoomId}_"; return Directory.EnumerateDirectories(baseDir, "SubRoom_*", SearchOption.AllDirectories) .FirstOrDefault(d => Path.GetFileName(d).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); } catch { return null; } } public static void ExportRooms() { var allRooms = col.FindAll().ToList(); var allSubrooms = subroom_col.FindAll().ToList(); // Option 1: Export rooms with their subrooms attached foreach (var room in allRooms) { room.SubRooms = allSubrooms.Where(s => s.RoomId == room.RoomId).ToList(); } // Build final result var exportData = new Resultslist { Results = allRooms, // No mapping, just raw rooms TotalResults = allRooms.Count }; string outputPath = Path.Combine(Environment.CurrentDirectory, "Data/exported_rooms.json"); string json = JsonConvert.SerializeObject(exportData, Formatting.Indented); File.WriteAllText(outputPath, json); Console.WriteLine($"Exported {allRooms.Count} rooms with subrooms → {outputPath}"); } public static void FixDuplicateDorms() { // Get all dorm rooms var dormRooms = col.Find(x => x.IsDorm).ToList(); // Group dorms by creator var groupedDorms = dormRooms .GroupBy(r => r.CreatorAccountId) .Where(g => g.Count() > 1); foreach (var group in groupedDorms) { // Load player data to get DormRoomId var player = PlayerDB.GetFullAccount(group.FirstOrDefault().CreatorAccountId); if (player == null) continue; // Try to keep the dorm that matches player.DormRoomId var dormToKeep = group.FirstOrDefault(r => r.RoomId == player.player_Extra.DormRoomID); // Fallback: keep oldest dorm if DormRoomId is invalid/missing if (dormToKeep == null) { dormToKeep = group .OrderBy(r => r.CreatedAt) .First(); } // Delete all other dorms var roomsToDelete = group .Where(r => r.RoomId != dormToKeep.RoomId) .ToList(); foreach (var room in roomsToDelete) { subroom_col.DeleteMany(x => x.RoomId == room.RoomId); col.DeleteMany(x => x.RoomId == room.RoomId); Console.WriteLine( $"Deleted duplicate dorm room: {room.Name} (ID: {room.RoomId}) for creator {room.CreatorAccountId}" ); } Console.WriteLine( $"Kept dorm room: {dormToKeep.Name} (ID: {dormToKeep.RoomId}) for creator {group.Key}" ); } } public static string get_subroom_from_id(ulong room_id, ulong subroom_id) { try { var subroom = subroom_col.FindOne(x => x.RoomId == room_id && x.SubRoomId == subroom_id); return subroom?.Name ?? "Unknown Room"; } catch (Exception ex) { Console.WriteLine($"Error in get_subroom_from_id: {ex.Message}"); return "Unknown Room"; } } public static ulong AddRoom(RoomDBClasses.RoomRoot room, bool isImportingFromJson = false) { try { if (room == null) throw new ArgumentNullException(nameof(room)); lock (_roomLock) { // Skip duplicate check if DormRoom if (room.Name != "DormRoom") { var existingRoom = col.FindOne(x => x.Name == room.Name); if (existingRoom != null) return 0; } var highest = col.Query().OrderByDescending(x => x.RoomId).Limit(1).FirstOrDefault(); room.RoomId = highest != null ? highest.RoomId + 1 : 1; if (room.RoomId == 0) { throw new Exception($"Imported room '{room.Name}' has RoomId = 0"); } //room.DataBlobHash = null; if (string.IsNullOrEmpty(room.RankedEntityId)) room.RankedEntityId = room.RoomId.ToString(); if (room.UgcVersion == null) { room.UgcVersion = 1; } if (room.PersistenceVersion == null) { room.PersistenceVersion = 0; } if (room.NeedsSnapshotId == false) { room.CurrentSnapshotId = null; } // Ensure required fields exist if (room.LoadScreens == null) throw new Exception("room.LoadScreens is null!"); if (room.Stats == null) throw new Exception("room.Stats is null!"); // --- Insert the room directly, no mapping --- col.Insert(room); // --- Insert subrooms directly, no mapping --- if (room.SubRooms != null && room.SubRooms.Count > 0) { // ---------------------------- // Get next SubRoomId safely // ---------------------------- var lastSubroom = subroom_col.Query() .OrderByDescending(x => x.SubRoomId) .Limit(1) .FirstOrDefault(); ulong nextSubId = lastSubroom?.SubRoomId + 1 ?? 1; // ---------------------------- // Get next SubRoomDataSaveId globally // ---------------------------- ulong nextSaveId = 1; var allExistingSubrooms = subroom_col.FindAll().ToList(); var maxSaveId = allExistingSubrooms .SelectMany(x => { var saves = new List(); if (x.CurrentSave != null) saves.Add(x.CurrentSave); if (x.AllSaves != null) saves.AddRange(x.AllSaves); return saves; }) .Select(x => x.SubRoomDataSaveId) .DefaultIfEmpty(0UL) .Max(); nextSaveId = maxSaveId + 1; // ---------------------------- // Insert SubRooms // ---------------------------- foreach (var sub in room.SubRooms) { try { ulong subId = nextSubId++; if (isImportingFromJson && subId == 0) throw new Exception($"Imported subroom '{sub.Name}' has SubRoomId = 0"); sub.SubRoomId = subId; sub.RoomId = room.RoomId; // Fix CurrentSave if (sub.CurrentSave != null) { sub.CurrentSave.SubRoomDataSaveId = nextSaveId++; sub.CurrentSave.SubRoomId = sub.SubRoomId; sub.CurrentSave.UgcSubVersion = 0; sub.CurrentSave.PersistenceVersion = 0; } // ...and AllSaves the same way. // // Only CurrentSave was re-keyed. When a room is CLONED the // subroom objects come straight off the source room, so every // AllSaves entry kept the SOURCE subroom's SubRoomId and save // id. GetRoomSaveById looks a save up by SubRoomDataSaveId, so // the clone's saves pointed at another room's ids - the save // either could not be found or resolved to the wrong subroom. // // Harmless on this server today because no template room has a // save to copy, but it breaks the moment one does. if (sub.AllSaves != null) { foreach (var save in sub.AllSaves) { save.SubRoomDataSaveId = nextSaveId++; save.SubRoomId = sub.SubRoomId; } } subroom_col.Insert(sub); Console.WriteLine($"Added subroom: {sub.Name} ({sub.SubRoomId})"); } catch (Exception ex) { Console.WriteLine($"Error adding subroom '{sub.Name}' for room '{room.Name}': {ex.Message}"); } } } Console.WriteLine($"added room: {room.Name} ({room.RoomId})"); return room.RoomId; } } catch (Exception ex) { Console.WriteLine($"Error adding room '{room.Name}': {ex.Message}"); return 0; } } public static RoomDBClasses.RoomRoot? GetRoom(ulong room_id) { // Find the main room var room = col.FindOne(x => x.RoomId == room_id); if (room == null) return null; // Fetch subrooms and attach them directly var subrooms = subroom_col.Find(x => x.RoomId == room_id).ToList(); // Attach subrooms directly, no new object mapping room.SubRooms = subrooms; // Return the database object as-is return room; } public static bool DoesPlayerDormExist(ulong playerId) { var playercol = PlayerDB.PlayerDBFile.GetCollection("players"); var player = playercol.FindOne(x => x.player.Id == playerId); if (player?.player_Extra == null) return false; ulong dormId = (ulong)player.player_Extra.DormRoomID; if (dormId == 0 || dormId == 1) return false; var dormRoom = RoomDB.GetRoom(dormId); return dormRoom != null; } // The dorm template the client asks for by name. Rec Room added multi-dorm // ("My Dorms" -> Create new Dorm) after this server was written, so no room // called CustomDormRoomTemplate was ever seeded - the lookup 404'd and the // client reported "Failed to find ^CustomDormRoomTemplate from server". // Room 1 IS the dorm template (Name "DormRoom", IsDorm, one "Home" subroom) and // is already what clone_dorm_room copies, so point the new name at it. private const ulong DormTemplateRoomId = 1; public static RoomDBClasses.RoomRoot? GetRoomByName(string roomName) { if (!string.IsNullOrEmpty(roomName)) { // The client prefixes template/base room lookups with '^' ("^Park", // "^CustomDormRoomTemplate") but the rooms are stored unprefixed, so an // exact match never hit. Strip it before comparing. roomName = roomName.TrimStart('^'); if (roomName.Equals("CustomDormRoomTemplate", StringComparison.OrdinalIgnoreCase) || roomName.Equals("DormRoomTemplate", StringComparison.OrdinalIgnoreCase)) { var dormTemplate = GetRoom(DormTemplateRoomId); if (dormTemplate != null) { dormTemplate.DataBlob ??= ""; return dormTemplate; } } foreach (RoomDBClasses.RoomRoot subroom in col.FindAll()) { if (roomName.Equals(subroom.Name, StringComparison.OrdinalIgnoreCase)) { var room = GetRoom(subroom.RoomId); if (room.DataBlob is null) { room.DataBlob = ""; } return room; } } } return null; } // "'s DormRoom" instead of the literal "DormRoom" every dorm used to // get. With every dorm sharing one name the client had nothing distinguishing // to show and fell back to just the accessibility tag - a bare "[PRIVATE]". public static string DormRoomName(ulong player_id) { string owner = PlayerDB.GetPlayerNameById(player_id); return string.IsNullOrWhiteSpace(owner) || owner == "Unknown" ? "DormRoom" : $"{owner}'s DormRoom"; } /// /// Keep the player's dorm ROOM named after their current @name ("kyle's DormRoom"). Called /// after a username change; the room's stored Name is what the room list, the in-game /// "go to dorm" tile and the website would otherwise keep showing under the old handle. /// The live dorm INSTANCE name (`@kyle's Dorm`) is rebuilt from the username on every /// dorm matchmake, so it follows on the next visit by itself. /// public static void RenameDormForPlayer(ulong player_id) { try { ulong dormId = PlayerDB.GetDormId(player_id); if (dormId == 0) return; var dorm = col.FindOne(x => x.RoomId == dormId); if (dorm == null || !dorm.IsDorm) return; string wanted = DormRoomName(player_id); if (dorm.Name == wanted) return; dorm.Name = wanted; col.Update(dorm); Console.WriteLine($"[dorm] renamed dorm {dormId} of {player_id} -> \"{wanted}\""); } catch (Exception ex) { Console.WriteLine($"[dorm] rename for {player_id} failed: {ex.Message}"); } } /// Startup pass: bring every existing dorm's name in line with its owner's current @name. public static void RenameAllDormsToOwners() { int fixedCount = 0; foreach (var dorm in col.Find(x => x.IsDorm).ToList()) { if (dorm.CreatorAccountId == 0) continue; string wanted = DormRoomName(dorm.CreatorAccountId); if (wanted == "DormRoom" || dorm.Name == wanted) continue; dorm.Name = wanted; col.Update(dorm); fixedCount++; } if (fixedCount > 0) Console.WriteLine($"[dorm] startup: renamed {fixedCount} dorm(s) to their owners' current @names"); } public static ulong clone_dorm_room(ulong player_id) { RoomDBClasses.RoomRoot room_data = GetRoom(1); var deep = JsonConvert.SerializeObject(room_data); var room_data2 = JsonConvert.DeserializeObject(deep); if (room_data2 == null) { throw new Exception("Base dorm room not found."); } if (room_data2.SubRooms == null || room_data2.SubRooms.Count == 0) { throw new Exception("Base dorm room has no subrooms."); } if (room_data2.Stats == null) { throw new Exception("Base dorm room stats are null."); } room_data2.CreatorAccountId = player_id; room_data2.CloningAllowed = false; // Was: the literal "DormRoom" for every player, so the client had nothing to // show but the accessibility tag - hence a bare "[PRIVATE]". Name it after // the owner so it reads "'s DormRoom [Private]" instead. room_data2.Name = DormRoomName(player_id); room_data2.CreatedAt = DateTime.Now; room_data2.Accessibility = RoomDBClasses.RoomAccessibility.Private; room_data2.IsRRO = false; room_data2.IsDorm = true; room_data2.Stats.CheerCount = 0; room_data2.Stats.FavoriteCount = 0; room_data2.Stats.VisitorCount = 0; room_data2.Stats.VisitCount = 0; room_data2.Roles = new List { new RoomDBClasses.RoleClass { AccountId = (int)player_id, InvitedRole = 0, Role = RoomDBClasses.Role_data.Creator } }; ulong id = AddRoom(room_data2); return id; } public static void GiveCoOwnerToAllRooms(ulong playerId) { var allRooms = col.FindAll().ToList(); foreach (var room in allRooms) { if (room.CreatorAccountId == playerId) continue; if (room.Roles == null) room.Roles = new List(); var existingRole = room.Roles.FirstOrDefault(r => (ulong)r.AccountId == playerId); if (existingRole != null) { if (existingRole.Role != RoomDBClasses.Role_data.Creator) { existingRole.Role = RoomDBClasses.Role_data.CoOwner; } } else { room.Roles.Add(new RoleClass { AccountId = (int)playerId, InvitedRole = 0, Role = RoomDBClasses.Role_data.CoOwner }); } col.Update(room); } Console.WriteLine($"Updated all rooms to grant Co-Owner to player {playerId}."); } public static RoomDBClasses.RoomRoot? IncreaseRoomVisit(ulong roomid) { var room = col.FindOne(x => x.RoomId == roomid); if (room != null) { if (room.Stats == null) room.Stats = new Stats(); if (room.Stats.VisitCount == 0) room.Stats.VisitCount = 1; else room.Stats.VisitCount++; col.Update(room); return room; } return null; } /// /// The hot-rooms candidate set: every non-dorm room, WITHOUT subrooms. The list is /// filtered, sorted and paged by the caller, which then attaches subrooms to just the /// page it returns (AttachSubRooms) - loading them for all ~2,500 candidates to serve 30 /// was what made this endpoint take over a minute. /// public static List GetHotRooms() { return col.Query() .Where(r => !r.IsDorm) .ToList(); } /// /// Populate SubRooms on the given rooms. One indexed lookup per room (RoomId is indexed), /// so call it on the rooms you are actually returning, not on the whole table. /// public static void AttachSubRooms(IEnumerable rooms) { foreach (var room in rooms) room.SubRooms = subroom_col.Find(s => s.RoomId == room.RoomId).ToList(); } /// /// Every non-dorm room with subrooms attached. Subrooms are read in ONE pass and grouped in /// memory rather than one query per room. Admin/maintenance use - the client-facing lists /// should page first and use AttachSubRooms on the page. /// public static List GetAllRooms() { var mainRooms = col.Query() .Where(r => !r.IsDorm) .ToList(); var byRoom = subroom_col.FindAll() .GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => g.ToList()); foreach (var room in mainRooms) room.SubRooms = byRoom.TryGetValue(room.RoomId, out var subs) ? subs : new List(); return mainRooms; } public static void UpdateRoomCheerCount(ulong roomid, int delta) { var room = col.FindOne(x => x.RoomId == roomid); if (room != null) { room.Stats.CheerCount = Math.Max(0, room.Stats.CheerCount + delta); col.Update(room); } } public static void UpdateRoomFavoriteCount(ulong roomid, int delta) { var room = col.FindOne(x => x.RoomId == roomid); if (room != null) { room.Stats.FavoriteCount = Math.Max(0, room.Stats.FavoriteCount + delta); col.Update(room); } } /// /// Attaches each room's LIVE subrooms from the subroom collection. /// /// Rooms carry an embedded SubRooms array, but it is a STALE SNAPSHOT taken when /// the room was created/cloned - saves are written to the subroom collection, not /// back into it. GetRoom() has always overwritten it with live data; the list /// queries did not, so a listing reported the original subroom with /// CurrentSave = null even for rooms that had been saved many times. /// /// That is what made the client treat saved rooms as "never been saved": it hid /// them from Create -> My Rooms, and prompted "Do you want to save?" on exit /// because as far as the listing was concerned no save existed. /// // ---- Room access control ---------------------------------------------- // // RoomAccessibility: Private = 0, Public = 1, Unlisted = 2, Dev_only = 3, // Dev_Unlisted = 4. Private is the DEFAULT for a freshly created room, which is // why almost every player room is private. // // There is no separate invite system on this server - room ROLES are the invite. // Give someone Host/Moderator/CoOwner/Creator on a room and they can see and join // it; without a role a private room is invisible and unjoinable to them. /// The player's role on this room, or null if they have none. public static RoomDBClasses.Role_data? PlayerRoleOn(RoomDBClasses.RoomRoot? room, ulong playerId) { if (room == null) return null; if (room.CreatorAccountId == playerId) return RoomDBClasses.Role_data.Creator; var role = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == playerId); return role?.Role; } /// /// True when the player holds a role that counts as being part of the room - /// owner, co-owner, moderator or host. Explicitly false for Banned and None. /// public static bool PlayerIsMemberOf(RoomDBClasses.RoomRoot? room, ulong playerId) { var role = PlayerRoleOn(room, playerId); if (role == null) return false; switch (role.Value) { case RoomDBClasses.Role_data.Creator: case RoomDBClasses.Role_data.CoOwner: case RoomDBClasses.Role_data.TemporaryCoOwner: case RoomDBClasses.Role_data.Contributor: case RoomDBClasses.Role_data.Moderator: case RoomDBClasses.Role_data.Host: return true; default: return false; // None / Banned } } /// Banned from this specific room (an elapsed timed ban is lifted on the way). public static bool PlayerIsBannedFrom(RoomDBClasses.RoomRoot? room, ulong playerId) => ActiveRoomBan(room, playerId) != null; /// /// The ban currently keeping this player out of the room, or null. A ban is the Banned ROLE /// on the room (which is what every join gate reads) plus a RoomBans record carrying who / /// when / why / until. A timed ban whose BanEndTime has passed is lifted here - the role /// removed and the record marked Elapsed - so a "15 minutes" restriction really ends. /// public static RoomDBClasses.RoomBanDetails? ActiveRoomBan(RoomDBClasses.RoomRoot? room, ulong playerId) { if (room == null) return null; var role = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == playerId); if (role == null || role.Role != RoomDBClasses.Role_data.Banned) return null; var ban = room.RoomBans?.FirstOrDefault(b => b.AccountId == (long)playerId && b.Status == 0); if (ban != null && ban.BanEndTime.HasValue && ban.BanEndTime.Value <= DateTime.UtcNow) { // Elapsed: lift it on the stored document (not on whatever copy we were handed). var stored = col.FindOne(x => x.RoomId == room.RoomId); if (stored != null) { stored.Roles?.RemoveAll(r => (ulong)r.AccountId == playerId && r.Role == RoomDBClasses.Role_data.Banned); foreach (var b in stored.RoomBans ?? new()) if (b.AccountId == (long)playerId && b.Status == 0) b.Status = 1; if (stored.RoomBanDetails?.AccountId == (long)playerId) stored.RoomBanDetails = null; col.Update(stored); } room.Roles!.Remove(role); ban.Status = 1; Console.WriteLine($"[roomban] room {room.RoomId}: ban on {playerId} elapsed, lifted"); return null; } // A Banned role with no record (older data): still a ban, with what we know. return ban ?? new RoomDBClasses.RoomBanDetails { AccountId = (long)playerId, BanStartTime = DateTime.UtcNow, Reason = "", Status = 0 }; } /// /// Ban a player from a room: the Banned role (what the join gates refuse on) plus a record. /// duration null = until someone unbans them. The room's creator can never be banned. /// Any earlier active ban on the same player is closed as Lifted first. /// public static RoomDBClasses.RoomBanDetails? BanFromRoom(ulong roomId, ulong playerId, ulong by, string? reason, TimeSpan? duration) { var room = col.FindOne(x => x.RoomId == roomId); if (room == null || room.CreatorAccountId == playerId) return null; room.Roles ??= new List(); var existing = room.Roles.FirstOrDefault(r => (ulong)r.AccountId == playerId); if (existing != null) { existing.Role = RoomDBClasses.Role_data.Banned; existing.InvitedRole = 0; existing.LastChangedByAccountId = (long)by; } else room.Roles.Add(new RoomDBClasses.RoleClass { AccountId = (long)playerId, Role = RoomDBClasses.Role_data.Banned, InvitedRole = 0, LastChangedByAccountId = (long)by }); room.RoomBans ??= new List(); foreach (var old in room.RoomBans) if (old.AccountId == (long)playerId && old.Status == 0) old.Status = 2; var ban = new RoomDBClasses.RoomBanDetails { AccountId = (long)playerId, BannedByAccountId = (long)by, BanStartTime = DateTime.UtcNow, BanEndTime = duration.HasValue ? DateTime.UtcNow + duration.Value : null, Reason = reason ?? "", Status = 0 }; room.RoomBans.Add(ban); room.RoomBanDetails = ban; col.Update(room); Console.WriteLine($"[roomban] room {roomId} '{room.Name}': {by} banned {playerId} " + $"{(duration.HasValue ? $"for {duration.Value.TotalMinutes:0} min" : "until unbanned")}" + $"{(string.IsNullOrEmpty(reason) ? "" : $" - {reason}")}"); return ban; } /// Lift a room ban. Returns the closed record, or null if they were not banned. public static RoomDBClasses.RoomBanDetails? UnbanFromRoom(ulong roomId, ulong playerId, ulong by) { var room = col.FindOne(x => x.RoomId == roomId); if (room == null) return null; var role = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == playerId && r.Role == RoomDBClasses.Role_data.Banned); var ban = room.RoomBans?.FirstOrDefault(b => b.AccountId == (long)playerId && b.Status == 0); if (role == null && ban == null) return null; if (role != null) room.Roles!.Remove(role); if (ban != null) { ban.Status = 2; ban.UnbannedByAccountId = (long)by; } if (room.RoomBanDetails?.AccountId == (long)playerId) room.RoomBanDetails = null; col.Update(room); Console.WriteLine($"[roomban] room {roomId}: {by} unbanned {playerId}"); return ban ?? new RoomDBClasses.RoomBanDetails { AccountId = (long)playerId, BannedByAccountId = 0, BanStartTime = DateTime.UtcNow, Status = 2, UnbannedByAccountId = (long)by, Reason = "" }; } /// /// One-off repair: room kicks used to be stored as room bans ("Kicked by X" / "Vote /// kicked ..."), so every kick was a permanent ban from the map. Lift those; a real ban /// (issued from the ban route, any other reason) is left alone. Returns how many. /// public static int LiftKickBans() { int lifted = 0; foreach (var room in col.FindAll().Where(r => r.RoomBans != null && r.RoomBans.Count > 0).ToList()) { bool changed = false; foreach (var b in room.RoomBans!.Where(b => b.Status == 0).ToList()) { string reason = b.Reason ?? ""; if (!reason.StartsWith("Kicked by ", StringComparison.OrdinalIgnoreCase) && !reason.StartsWith("Vote kicked", StringComparison.OrdinalIgnoreCase)) continue; b.Status = 2; room.Roles?.RemoveAll(r => r.AccountId == b.AccountId && r.Role == RoomDBClasses.Role_data.Banned); if (room.RoomBanDetails?.AccountId == b.AccountId) room.RoomBanDetails = null; Console.WriteLine($"[roomban] room {room.RoomId} '{room.Name}': lifted ban on {b.AccountId} - it was a kick ({reason})"); changed = true; lifted++; } if (changed) col.Update(room); } return lifted; } /// The room's active bans, newest first. public static List ActiveRoomBans(RoomDBClasses.RoomRoot room) { var list = new List(); foreach (var r in (room.Roles ?? new()).Where(r => r.Role == RoomDBClasses.Role_data.Banned).ToList()) { var b = ActiveRoomBan(room, (ulong)r.AccountId); if (b != null) list.Add(b); } return list.OrderByDescending(b => b.BanStartTime).ToList(); } /// Creator, co-owner, moderator or host on the room, or a staff account. public static bool CanModerateRoom(RoomDBClasses.RoomRoot? room, ulong accountId) { if (room == null) return false; if (room.CreatorAccountId == accountId) return true; if (PlayerDB.CheckDevFlag(accountId) || PlayerDB.CheckModFlag(accountId)) return true; var role = PlayerRoleOn(room, accountId); return role is RoomDBClasses.Role_data.CoOwner or RoomDBClasses.Role_data.TemporaryCoOwner or RoomDBClasses.Role_data.Moderator or RoomDBClasses.Role_data.Host; } /// /// Is the room published? /// /// PublishState is the client's own field (RoomRoot.PublishState) and the save /// request carries AutoPublish. Neither was ever read by this server - every room /// in the DB sits at PublishState = 0, including the stock RRO rooms. /// /// So "published" is treated as: PublishState says so, OR the room is Public / /// Unlisted. That second clause matters - without it every existing room (Rec /// Center, Park, all 54 of account 1's) would read as unpublished and vanish from /// everyone's listings the moment this shipped. /// public static bool RoomHasTag(RoomDBClasses.RoomRoot room, string tag) => room.Tags != null && room.Tags.Any(t => string.Equals(t.Tag, tag, StringComparison.OrdinalIgnoreCase)); // Rooms 2.0 keep their REAL visibility on the "Home" subroom; the room-level Accessibility // is a stale default (Private) for them. Search / hot / CanPlayerSeeRoom all read the // ROOM-level field, so a published 2.0 room was invisible everywhere. This returns the // room's effective accessibility - its Home subroom's (or the first subroom's) - which the // room-level field is kept mirrored to (ModifySubroom + the startup SyncAllRoomAccessibility). public static RoomDBClasses.RoomAccessibility PrimaryAccessibility(RoomDBClasses.RoomRoot room) { var subs = room.SubRooms; if (subs == null || subs.Count == 0) return room.Accessibility; var home = subs.FirstOrDefault(s => string.Equals(s.Name, "Home", StringComparison.OrdinalIgnoreCase)) ?? subs[0]; return home.Accessibility; } // The room-level Accessibility a room SHOULD carry: its Home subroom's, which is what the // owner actually set in-game. Rooms are NOT auto-published, so the accessibility the owner // chose (Public / Unlisted / Private) IS the room's published state - see IsRoomPublished. public static RoomDBClasses.RoomAccessibility EffectiveRoomAccessibility(RoomDBClasses.RoomRoot room) => PrimaryAccessibility(room); // Idempotent migration: give every non-RRO room its effective accessibility, so existing // PUBLISHED Rooms 2.0 become Public/searchable and unpublished ones match their subroom. // RRO rooms already carry a correct room-level Accessibility, so leave them. /// /// Idempotent: player-made rooms cloned before the clone fix carry the template's /// IsDeveloperOwned=true and RankedEntityId (the client hides developer-owned rooms from a /// player's created list). Rec Room Originals and base templates are left alone. /// public static int FixPlayerRoomFlags() { int n = 0; foreach (var room in col.FindAll().ToList()) { if (room == null || room.IsRRO || room.IsDorm) continue; if (RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal")) continue; if (room.CreatorAccountId <= 1) continue; // system / owner-seeded rooms bool changed = false; if (room.IsDeveloperOwned) { room.IsDeveloperOwned = false; changed = true; } if (room.RankedEntityId != room.RoomId.ToString()) { room.RankedEntityId = room.RoomId.ToString(); changed = true; } if (changed) { col.Update(room); n++; } } if (n > 0) Console.WriteLine($"[rooms] cleared template flags (IsDeveloperOwned / RankedEntityId) on {n} player room(s)"); return n; } public static int SyncAllRoomAccessibility() { int n = 0; // Subrooms live in their own collection and are NOT on the rows FindAll hands back, so // EffectiveRoomAccessibility (the Home subroom's setting) used to see no subrooms and // answer the room's own field - this sync never changed anything. One pass over the // subroom collection, grouped by room, instead of a query per room. var subsByRoom = subroom_col.FindAll().GroupBy(s => s.RoomId).ToDictionary(g => g.Key, g => g.ToList()); foreach (var room in col.FindAll().ToList()) { if (room == null || room.IsRRO) continue; if (subsByRoom.TryGetValue(room.RoomId, out var subs)) room.SubRooms = subs; if (room.SubRooms == null || room.SubRooms.Count == 0) continue; var eff = EffectiveRoomAccessibility(room); if (room.Accessibility != eff) { room.Accessibility = eff; col.Update(room); n++; // the embedded subroom copy is refreshed from the subroom collection too Console.WriteLine($"[rooms] {room.RoomId} '{room.Name}': room-level accessibility -> {eff} (from its Home subroom)"); } } return n; } public static bool IsRoomPublished(RoomDBClasses.RoomRoot? room) { if (room == null) return false; // Legacy Rec Room Originals (Rec Center, Park, the base rooms) are always listed. if (room.IsRRO || RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal")) return true; // For a user room (Rooms 2.0 / beta) the ACCESSIBILITY the owner set IS the published // state: Public or Unlisted = visible to non-members; Private = a draft, members-only. // Rooms are NOT auto-published on save, so a freshly created (Private) room stays in the // owner's list only until they deliberately set it Public/Unlisted. var acc = EffectiveRoomAccessibility(room); return acc == RoomDBClasses.RoomAccessibility.Public || acc == RoomDBClasses.RoomAccessibility.Unlisted; } /// /// Does the room appear in LISTS for people who hold no role on it (another player's /// profile, search, hot)? Only a PUBLIC room does - Unlisted is exactly that (reachable by /// invite or link, never listed) and Private is a draft. Rec Room Originals are always listed. /// /// A Rec Room Original - the stock rooms, not a player's creation. public static bool IsRecRoomOriginal(RoomDBClasses.RoomRoot? room) => room != null && (room.IsRRO || RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal")); public static bool IsRoomListed(RoomDBClasses.RoomRoot? room) { if (room == null) return false; if (room.IsRRO || RoomHasTag(room, "base") || RoomHasTag(room, "rro") || RoomHasTag(room, "recroomoriginal")) return true; return EffectiveRoomAccessibility(room) == RoomDBClasses.RoomAccessibility.Public; } /// /// May this player SEE the room (listings, search, someone's portfolio)? /// /// Published rooms are visible to everyone, private or not - a published private /// room advertises itself, it just does not let strangers into your session. /// An UNPUBLISHED room is invisible to anyone without a role on it. /// public static bool CanPlayerSeeRoom(RoomDBClasses.RoomRoot? room, ulong playerId) { if (room == null) return false; if (PlayerIsBannedFrom(room, playerId)) return false; if (PlayerIsMemberOf(room, playerId)) return true; // own/co-own/mod/host return IsRoomListed(room); // others only see what is PUBLIC; unlisted/private stay off their lists } /// /// May this player ENTER the room at all - in an instance of their own if need be? /// /// Published rooms may be entered by anyone: a published PRIVATE room hands the /// visitor their own fresh session (see CanPlayerShareInstance), which is what lets /// them play it with their own friends without getting into the owner's session. /// Unpublished rooms are members-only. /// public static bool CanPlayerEnterRoom(RoomDBClasses.RoomRoot? room, ulong playerId) { if (room == null) return false; if (PlayerIsBannedFrom(room, playerId)) return false; if (PlayerIsMemberOf(room, playerId)) return true; // A dorm is never open, published or not. if (room.IsDorm) return false; return IsRoomPublished(room); } /// /// May this player be dropped into an instance SOMEONE ELSE is already in? /// /// Only for Public rooms, or when they hold a role on the room. This is the line /// between "published private" and "public": both are visible and enterable, but a /// published private room gives each visitor their own session instead of pooling /// strangers into the owner's. /// public static bool CanPlayerShareInstance(RoomDBClasses.RoomRoot? room, ulong playerId) { if (room == null) return false; if (PlayerIsBannedFrom(room, playerId)) return false; if (PlayerIsMemberOf(room, playerId)) return true; return room.Accessibility == RoomDBClasses.RoomAccessibility.Public; } /// Sets the room's publish state (0 = unpublished). Returns false if missing. public static bool SetRoomPublishState(ulong roomId, int publishState) { var room = col.FindOne(x => x.RoomId == roomId); if (room == null) return false; room.PublishState = publishState; if (publishState > 0) room.PublishedAt = DateTime.UtcNow; // Publishing makes a room Public for everyone; unpublishing drops it back to its // subroom's accessibility (and it becomes members-only via CanPlayerSeeRoom). room.Accessibility = EffectiveRoomAccessibility(room); col.Update(room); return true; } public static List AttachSubRooms(List rooms) { if (rooms == null || rooms.Count == 0) return rooms ?? new List(); foreach (var room in rooms) { if (room == null) continue; room.SubRooms = subroom_col.Find(s => s.RoomId == room.RoomId).ToList(); } return rooms; } /// /// Every room the player should see under "My Rooms": ones they created, plus any /// they hold a role on. A room stays listed until the player's role is removed or /// the room is deleted - it is never dropped for being unsaved or unpublished. /// public static List GetRoomsForPlayer(ulong playerId) { // A role of Banned is still "a role", so a room the player had been BANNED from // was listed among their own rooms. Creator always counts; otherwise the role has // to be one that actually makes them part of the room. var rooms = col.FindAll() .Where(r => r != null && (r.CreatorAccountId == playerId || PlayerIsMemberOf(r, playerId))) .ToList(); return AttachSubRooms(rooms); } public static List GetRoomsByCreator(ulong playerId) { var rooms = col.Find(x => x.CreatorAccountId == playerId).ToList(); return AttachSubRooms(rooms); } /// /// A single PAGE of a creator's rooms, ordered newest-first, attaching subrooms ONLY to the /// page returned. GetRoomsByCreator loads EVERY room the creator owns and runs a subroom query /// per room - fine for a normal player, but @Importer owns thousands, so a profile view was /// loading everything at once and overloading the server. This caps it at the DB level. /// public static List GetRoomsByCreatorPaged(ulong playerId, int skip, int take, bool attachSubrooms = true) { if (skip < 0) skip = 0; if (take <= 0) take = 200; var page = col.Query() .Where(x => x.CreatorAccountId == playerId) .OrderByDescending(x => x.RoomId) .Skip(skip) .Limit(take) .ToList(); return attachSubrooms ? AttachSubRooms(page) : page; } /// Count of a creator's rooms - cheap (no room objects loaded). public static int CountRoomsByCreator(ulong playerId) => col.Count(x => x.CreatorAccountId == playerId); /// /// Rooms the player has a role on (Creator/CoOwner/etc.) but did not create - /// what the client's "contributed to" listing expects. /// /// /// Every room this player has a hand in - the ones they CREATED plus the ones they hold a /// role on. This is what the profile's room list is drawn from. /// /// It used to exclude the player's own rooms outright (`CreatorAccountId != playerId`), /// which is why a profile read "32 Rooms" above a list holding exactly one: the single /// room they had been invited to co-own. A creator IS a contributor to their own room - /// the owner is simply the first role on it - so they belong in this list ahead of /// everyone else's. (The Room Showcase is a separate, deliberately-picked handful; it is /// NOT a place to dump these. See RoomController.RoomShowcaseForPlayer.) /// /// Own rooms come first, newest first; then the rooms they were invited to. Capped, because /// this attaches subrooms to every room it returns. /// public static List GetRoomsContributedTo(ulong playerId) { var all = col.FindAll().ToList(); var mine = all.Where(r => r.CreatorAccountId == playerId) .OrderByDescending(r => r.CreatedAt); var invited = all.Where(r => r.CreatorAccountId != playerId && r.Roles != null && r.Roles.Any(x => (ulong)x.AccountId == playerId)); return AttachSubRooms(mine.Concat(invited) .Take(Campus_live_rewrite.api.server.ConfigCampus.ProfileRoomsCap) .ToList()); } public static List Search(string p0, bool isBase = false) { if (string.IsNullOrWhiteSpace(p0)) return new List(); var terms = p0.ToLowerInvariant() .Split(new char[] { ' ', '+' }, StringSplitOptions.RemoveEmptyEntries); // Start with the basic "Not a Dorm" filter var query = col.Query().Where(r => !r.IsDorm); // Only enforce Public accessibility if isBase is false if (!isBase) { query = query.Where(r => r.Accessibility == RoomDBClasses.RoomAccessibility.Public); } foreach (var term in terms) { if (term.StartsWith("#")) { string tag = term.Substring(1); // Using BsonExpression ensures LiteDB looks directly into the array // This looks for any element in the 'Tags' array where the 'Tag' field matches query = query.Where("$.Tags[*].Tag ANY = @0", tag); } else { string nameTerm = term; query = query.Where(r => r.Name.ToLower().Contains(nameTerm)); } } return AttachSubRooms(query.ToList()); } public static ulong GetAllRoomsCount() { return (ulong)col.Count(); } /// All non-dorm rooms WITHOUT attaching subrooms - for listings (admin table) that /// only need top-level fields. Avoids one subroom query per room (brutal at thousands of rooms). public static List GetAllRoomsLite() { return col.Query().Where(r => !r.IsDorm).ToList(); } public static void DeleteAllRoomsFromPlayerId(ulong creatorId) { var roomsToDelete = col.Find(x => x.CreatorAccountId == creatorId).ToList(); foreach (var room in roomsToDelete) { subroom_col.DeleteMany(x => x.RoomId == room.RoomId); col.DeleteMany(x => x.RoomId == room.RoomId); } Console.WriteLine($"Deleted {roomsToDelete.Count} rooms and their subrooms for creator {creatorId}."); } public static RoomDBClasses.RoomRoot? SetRoomName(ulong roomId, string roomName) { var room = col.FindOne(x => x.RoomId == roomId); var existingRoom = col.FindOne(x => x.Name == roomName && x.RoomId != roomId); if (existingRoom != null) { return null; } if (room != null) { try { room.Name = roomName; col.Update(room); } catch (Exception ex) { Console.WriteLine(ex.ToString()); } } return room; } public static RoomDBClasses.RoomRoot? es() { var room = col.FindOne(x => x.RoomId == 53); if (room != null) { try { room.CurrentSnapshotId = "98cfdcad-b680-4b33-e94c-08dd76d7c12b"; col.Update(room); } catch (Exception ex) { Console.WriteLine(ex.ToString()); } } return room; } public static RoomDBClasses.RoomRoot? SetRoomDescription(ulong roomId, string NewDescription) { var room = col.FindOne(x => x.RoomId == roomId); if (room != null) { try { room.Description = NewDescription; col.Update(room); } catch (Exception ex) { Console.WriteLine(ex.ToString()); } } return room; } public static RoomDBClasses.RoomRoot? SetRoomImageName(ulong roomId, string imageName) { var room = col.FindOne(x => x.RoomId == roomId); if (room != null) { try { room.ImageName = imageName; col.Update(room); } catch (Exception ex) { Console.WriteLine(ex.ToString()); } } return room; } public static RoomDBClasses.RoomRoot? SetRoomAccessibility(ulong roomId, RoomDBClasses.RoomAccessibility roomAccessibility) { var room = col.FindOne(x => x.RoomId == roomId); if (room != null) { try { room.Accessibility = roomAccessibility; // The effective accessibility is read from the Home subroom (PrimaryAccessibility), // so the subrooms must follow or "publish" changed nothing anyone could see. foreach (var sr in subroom_col.Find(x => x.RoomId == roomId).ToList()) { sr.Accessibility = roomAccessibility; subroom_col.Update(sr); } bool published = roomAccessibility == RoomDBClasses.RoomAccessibility.Public || roomAccessibility == RoomDBClasses.RoomAccessibility.Unlisted; room.PublishState = published ? 1 : 0; if (published && (room.PublishedAt == default || room.PublishedAt < room.CreatedAt)) room.PublishedAt = DateTime.UtcNow; col.Update(room); Console.WriteLine($"[rooms] room {roomId} ({room.Name}) accessibility -> {roomAccessibility} ({(published ? "published" : "unpublished")})"); } catch (Exception ex) { Console.WriteLine(ex.ToString()); } } return room; } public static RoomDBClasses.RoomRoot? SetRoomCloning(ulong roomId, bool cloningEnabled) { var room = col.FindOne(x => x.RoomId == roomId); if (room != null) { try { room.CloningAllowed = cloningEnabled; col.Update(room); } catch (Exception ex) { Console.WriteLine(ex.ToString()); } } return room; } // supportsScreens=True&supportsWalkVR=False&supportsTeleportVR=True&supportsJuniors=True public static RoomDBClasses.RoomRoot? SetRoomRestrictions(ulong roomId, bool supportsScreens, bool supportsWalkVR, bool supportsTeleportVR, bool supportsJuniors) { var room = col.FindOne(x => x.RoomId == roomId); if (room != null) { try { room.SupportsScreens = supportsScreens; room.SupportsWalkVR = supportsWalkVR; room.SupportsTeleportVR = supportsTeleportVR; room.SupportsJuniors = supportsJuniors; col.Update(room); } catch (Exception ex) { Console.WriteLine(ex.ToString()); } } return room; } public static bool UserCanEditRoom(ulong roomId, ulong accountId) { var room = col.FindOne(x => x.RoomId == roomId); if (room == null) return false; if (room.CreatorAccountId == accountId) return true; return IsCoOwner(room, accountId); } public static RoomDBClasses.RoomRoot? SetRoomLoadscreen(ulong roomid, string? imageName, string? title, string? subtitle) { var room = col.FindOne(x => x.RoomId == roomid); if (room == null) return null; room.LoadScreens = new List { new LoadScreen { ImageName = imageName ?? "", Title = title ?? "", Subtitle = subtitle ?? "" } }; col.Update(room); return room; } public static RoomDBClasses.RoomRoot SetRoomWarningMask(ulong roomId, WarningMaskType warningMask) { var room = col.FindOne(x => x.RoomId == roomId); if (room != null) { try { room.WarningMask = warningMask; col.Update(room); } catch (Exception ex) { Console.WriteLine(ex.ToString()); } } return room; } /// /// The reference's room tag edit (rooms.app.ts / applyRoomTagEdit). Two request shapes reach /// PUT /rooms/{id}/tags and the FIELDS say which: /// - a lone `tag=`: TOGGLE that owner tag (the five "main" tags pvp/quest/game/ /// hangout/art act as radio buttons); /// - anything else is the whole-state save from room settings /// (`autoTag=limitsv2&tag=roleplay&tag=social&primaryGenreTag=roleplay`): `tag` is the /// COMPLETE set of owner (Type 0) tags, `autoTag` ADDS derived Type 1 tags (limitsv2, /// beta - the ones Beta rooms and Limits V2 need to be recognised), `primaryGenreTag` /// flags the genre. /// The previous implementation toggled every posted tag, so a settings save with three /// tags flipped them, and `autoTag` wiped the list to just "beta" as an owner tag. /// public static RoomDBClasses.RoomRoot? ApplyRoomTagEdit(ulong roomId, string? toggle, List? tags, List autoTags, string? primaryGenre) { var room = col.FindOne(x => x.RoomId == roomId); if (room == null) return null; var next = (room.Tags ?? new List()).Where(t => t != null && !string.IsNullOrWhiteSpace(t.Tag)).ToList(); string Key(TagClass t) => t.Tag.Trim().ToLowerInvariant(); var mainTags = new HashSet { "pvp", "quest", "game", "hangout", "art" }; if (tags != null) { var posted = tags.Select(t => t.Trim().ToLowerInvariant()).Where(t => t.Length > 0).Distinct().ToList(); var kept = next.Where(t => t.Type != 0 && !posted.Contains(Key(t))).ToList(); foreach (var tag in posted) kept.Add(next.FirstOrDefault(t => Key(t) == tag) ?? new TagClass { Tag = tag, Type = 0 }); next = kept; } else if (!string.IsNullOrWhiteSpace(toggle)) { string lower = toggle.Trim().ToLowerInvariant(); var existing = next.FirstOrDefault(t => Key(t) == lower); if (existing != null) next.Remove(existing); else { if (mainTags.Contains(lower)) next = next.Where(t => !mainTags.Contains(Key(t))).ToList(); next.Add(new TagClass { Tag = lower, Type = 0 }); } } foreach (var auto in autoTags) { string lower = auto.Trim().ToLowerInvariant(); if (lower.Length == 0) continue; var existing = next.FirstOrDefault(t => Key(t) == lower); if (existing != null) existing.Type = 1; else next.Add(new TagClass { Tag = lower, Type = 1 }); } if (!string.IsNullOrWhiteSpace(primaryGenre)) { string lower = primaryGenre.Trim().ToLowerInvariant(); foreach (var t in next) t.IsPrimaryGenre = null; var chosen = next.FirstOrDefault(t => Key(t) == lower); if (chosen != null) chosen.IsPrimaryGenre = true; else next.Add(new TagClass { Tag = lower, Type = 0, IsPrimaryGenre = true }); } room.Tags = next; col.Update(room); return room; } public static RoomDBClasses.RoomRoot? SetRoomTags(List tags, ulong roomId, int type = 0) { col.EnsureIndex(x => x.RoomId); var room = col.FindOne(x => x.RoomId == roomId); if (room == null) return null; if (tags == null || tags.Count == 0) return room; // Normalize input tags: trim, lowercase, remove empty, distinct var newTags = tags .Select(t => t.Trim().ToLowerInvariant()) .Where(t => !string.IsNullOrWhiteSpace(t)) .Distinct() .ToList(); if (room.Tags == null) room.Tags = new List(); foreach (var newTag in newTags) { var existingTag = room.Tags.FirstOrDefault(t => t.Tag.Equals(newTag, StringComparison.OrdinalIgnoreCase)); if (existingTag != null) { // TagClass exists -> remove it room.Tags.Remove(existingTag); } else { // TagClass does not exist -> add it room.Tags.Add(new TagClass { Tag = newTag, Type = type }); } } col.Update(room); return room; } public static bool DoesRoomExist(ulong roomId) { var room = col.FindOne(x => x.RoomId == roomId); if (room != null) { return true; } else { return false; } } public static ulong? CloneRoom(ulong roomId, string RoomName, ulong playerId) { // ✅ Deep clone to detach from DB references RoomDBClasses.RoomRoot? original = GetRoom(roomId); RoomDBClasses.RoomRoot? room_data = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(original)); if (room_data?.Name == "MakerRoom") goto clone_room_data; if (room_data?.Name == "TheBackDoor") return null; if (room_data?.Name == "DormRoom") return null; if (room_data.CloningAllowed && (room_data.Accessibility == RoomDBClasses.RoomAccessibility.Public || room_data.Accessibility == RoomDBClasses.RoomAccessibility.Unlisted)) goto clone_room_data; if (RoomName == "MakerRoom") goto clone_room_data; if (!room_data.CloningAllowed && room_data.CreatorAccountId != playerId && !room_data.IsDorm) return null; var existingRoom = col.FindOne(x => x.Name == room_data.Name && x.RoomId != room_data.RoomId); if (existingRoom != null) return null; clone_room_data: room_data.RoomId = 0; room_data.CreatorAccountId = playerId; room_data.CloningAllowed = false; room_data.Name = RoomName; room_data.ImageName = "DefaultRoomImage.jpg"; room_data.CreatedAt = DateTime.Now; room_data.Accessibility = RoomDBClasses.RoomAccessibility.Private; room_data.PublishState = 0; // A player's room is not one of Rec Room's own. The template's flags came across on the // copy: IsDeveloperOwned=true is what kept every player-made room out of the profile's // created-rooms list (the client hides developer-owned rooms there), and the template's // RankedEntityId meant dozens of rooms shared one ranking key. room_data.IsDeveloperOwned = false; room_data.IsRecRoomApproved = false; room_data.PublishedAt = default; room_data.RankedEntityId = ""; // The subrooms carry the accessibility the owner actually sets (PrimaryAccessibility // reads the Home subroom), and a template's are Public - so a fresh room was "published" // the moment it was created and every profile listed it. A new room is a private draft // until its owner publishes it from room settings. if (room_data.SubRooms != null) foreach (var sr in room_data.SubRooms) sr.Accessibility = RoomDBClasses.RoomAccessibility.Private; room_data.IsRRO = false; room_data.IsDorm = false; room_data.Stats.CheerCount = 0; room_data.Stats.FavoriteCount = 0; room_data.Stats.VisitorCount = 0; room_data.Stats.VisitCount = 0; room_data.Tags = new List(); room_data.Roles = new List { new RoleClass { AccountId = (int)playerId, InvitedRole = 0, Role = RoomDBClasses.Role_data.Creator } }; ulong NewRoomId = RoomDB.AddRoom(room_data); Console.WriteLine($"[clone_room] New room ID returned from add_room: {NewRoomId}"); if (NewRoomId != 0) { var made = col.FindOne(x => x.RoomId == NewRoomId); if (made != null && made.RankedEntityId != NewRoomId.ToString()) { made.RankedEntityId = NewRoomId.ToString(); col.Update(made); } } return NewRoomId; } public static object? GetRoomSaveById( ulong roomId, ulong subroomId, ulong subRoomDataSaveId, int target, int version) { var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subroomId); if (subroom == null) return null; var save = (subroom.CurrentSave != null && subroom.CurrentSave.SubRoomDataSaveId == subRoomDataSaveId) ? subroom.CurrentSave : subroom.AllSaves? .FirstOrDefault(x => x.SubRoomDataSaveId == subRoomDataSaveId); if (save == null) return null; return MapSave(save, subroomId, target, version); } private class UnityAssetInfo { public string UnityAssetId { get; set; } public int Target { get; set; } public int Version { get; set; } public string Filename { get; set; } public string Hash { get; set; } } private static object MapSave(RoomDBClasses.CurrentSave save, ulong subroomid, int target, int version) { string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "BakedBulk", $"{save.UnityAssetId}_assets.json"); UnityAssetInfo matchingAsset = null; if (!string.IsNullOrEmpty(save.UnityAssetId) && System.IO.File.Exists(path)) { var unityAssets = JsonConvert.DeserializeObject>(System.IO.File.ReadAllText(path)); matchingAsset = unityAssets.FirstOrDefault(u => u.Target == target && u.Version == version); } var result = new Dictionary { ["UnitySubAssets"] = new List(), ["ReferencedUnityAssets"] = new List(), ["SubRoomDataSaveId"] = save.SubRoomDataSaveId, ["SubRoomId"] = subroomid, ["DataBlob"] = save.DataBlob, ["ReferencedUnityAssetIds"] = save.ReferencedUnityAssetIds ?? new List(), ["PersistenceVersion"] = save.PersistenceVersion, ["OMVersion"] = save.OMVersion, ["UgcSubVersion"] = save.UgcSubVersion, ["SavedByAccountId"] = save.SavedByAccountId, ["SavedOnPlatform"] = save.SavedOnPlatform, ["SavedOnDeviceClass"] = save.SavedOnDeviceClass, ["Description"] = save.Description, ["Tags"] = new List(), ["ModerationState"] = save.ModerationState, ["CreatedAt"] = save.CreatedAt }; // Only include UnityAssetId if it exists if (!string.IsNullOrEmpty(save.UnityAssetId)) { result["UnityAssetId"] = save.UnityAssetId; } // Only include UnityAsset if matching asset exists if (matchingAsset != null) { result["UnityAsset"] = matchingAsset.Filename; } return result; } /// /// Sets a subroom's UnitySceneId - the scene the CLIENT loads for that subroom. /// /// This is what decides which map you actually walk into, and it is not the same /// thing as the .room save blob: /// /// - a Rec Room Original's geometry ships INSIDE the client and is selected by /// this id. Such rooms have no save at all and never needed one. /// - a player-built room loads a template scene by this id, then applies its /// save blob on top. /// /// Imported room data carries the id the ORIGINAL service used, which does not /// necessarily mean the same scene in this client build. Rooms whose id resolves to /// a template the client happens to have - a75f7547-79eb-47c6-8986-6767abcb4f92 is /// "Basement" here - load that blank template instead of their real map, which looks /// exactly like a missing save but has nothing to do with blobs. /// /// Changing this only rewrites one field; nothing is downloaded and no file moves, so /// a wrong value costs nothing but another attempt. /// public static RoomDBClasses.SubRoom? GetSubRoom(ulong roomId, ulong subRoomId) => subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId); public static bool SetSubRoomPermissions(ulong roomId, ulong subRoomId, List table) { var sub = GetSubRoom(roomId, subRoomId); if (sub == null) return false; sub.Permissions = table; subroom_col.Update(sub); return true; } public static bool SetSubRoomScene(ulong roomId, ulong subRoomId, string sceneId) { var sub = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId); if (sub == null) return false; string before = sub.UnitySceneId ?? "(none)"; sub.UnitySceneId = sceneId; subroom_col.Update(sub); Console.WriteLine($"[rooms] room {roomId} subroom {subRoomId} scene {before} -> {sceneId}"); return true; } public static RoomDBClasses.RoomRoot? SetRoomSubroomRoomFile( ulong roomId, ulong subRoomId, string roomFile, string roomDesc, ulong playerId, Platforms currentplatform, int persistenceVersion = 0, int omVersion = 0, int ugcSubVersion = 0, string? roomLevelBlob = null) => SetRoomSubroomRoomFile(roomId, subRoomId, roomFile, roomDesc, playerId, currentplatform, persistenceVersion, omVersion, ugcSubVersion, roomLevelBlob, true, out _); /// /// Record a save. PUBLISHED and UNPUBLISHED saves are kept apart (reference rooms /// POST /subrooms/{id}/data): a save lands in the subroom's history and, unless the /// client asked to AutoPublish, is only STAGED (StagedSubRoomDataSaveId) - players keep /// loading the last published CurrentSave until the owner publishes it /// (publish_save). Dorms, and a subroom that has never been published, publish at once. /// public static RoomDBClasses.RoomRoot? SetRoomSubroomRoomFile( ulong roomId, ulong subRoomId, string roomFile, string roomDesc, ulong playerId, Platforms currentplatform, int persistenceVersion, int omVersion, int ugcSubVersion, string? roomLevelBlob, bool autoPublish, out CurrentSave? created) { created = null; var room = col.FindOne(x => x.RoomId == roomId); if (room == null) return null; var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId); if (subroom == null) return room; subroom.AllSaves ??= new List(); // The history holds EVERY save, the published one included (older rows kept the // published save only on CurrentSave - fold it in once). if (subroom.CurrentSave != null && !subroom.AllSaves.Any(s => s.SubRoomDataSaveId == subroom.CurrentSave.SubRoomDataSaveId)) subroom.AllSaves.Add(subroom.CurrentSave); long maxId = subroom.AllSaves.Count > 0 ? subroom.AllSaves.Max(s => (long)s.SubRoomDataSaveId) : 0; if (subroom.CurrentSave != null) maxId = Math.Max(maxId, (long)subroom.CurrentSave.SubRoomDataSaveId); // 💡 Create fresh save object var newSave = new CurrentSave { SubRoomDataSaveId = (ulong)(maxId + 1), SubRoomId = subroom.SubRoomId, DataBlob = roomFile, Description = roomDesc ?? "", SavedByAccountId = playerId, SavedOnPlatform = currentplatform, SavedOnDeviceClass = DeviceClasses.Screen, CreatedAt = DateTime.UtcNow, // These arrive on every save request and used to be dropped, so every // save was stored as 0/0/0. The client compares the versions it wrote // against what the room reports back; 0 never matched, so the room looked // modified the moment it loaded and leaving always prompted // "do you want to save?" even with no changes made. PersistenceVersion = persistenceVersion, OMVersion = omVersion, UgcSubVersion = ugcSubVersion }; subroom.AllSaves.Add(newSave); created = newSave; bool publishNow = autoPublish || room.IsDorm || subroom.CurrentSave == null; if (publishNow) { subroom.CurrentSave = newSave; subroom.StagedSubRoomDataSaveId = null; } else { subroom.StagedSubRoomDataSaveId = (long)newSave.SubRoomDataSaveId; } subroom.CreatorAccountId ??= (long)playerId; // client NREs on a null creator subroom_col.Update(subroom); Console.WriteLine($"[rooms] save {newSave.SubRoomDataSaveId} on room {roomId} subroom {subRoomId} by {playerId}: {(publishNow ? "PUBLISHED" : "staged (unpublished)")}"); // Mirror onto the embedded copy the room document carries, so a room read that does // not re-attach subrooms still shows the staged / published state. var emb = room.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId); if (emb != null) { emb.AllSaves = subroom.AllSaves; emb.CurrentSave = subroom.CurrentSave; emb.StagedSubRoomDataSaveId = subroom.StagedSubRoomDataSaveId; col.Update(room); } // The save request also carries a ROOM-level blob (RoomData.Filename), which // was never read - so RoomRoot.DataBlob kept pointing at whatever stale .meta // it shipped with and never picked up the freshly uploaded one. bool roomChanged = false; if (!string.IsNullOrWhiteSpace(roomLevelBlob) && room.DataBlob != roomLevelBlob) { room.DataBlob = roomLevelBlob; roomChanged = true; } if (ugcSubVersion > 0 && room.UgcSubVersion != ugcSubVersion) { room.UgcSubVersion = ugcSubVersion; roomChanged = true; } if (roomChanged) col.Update(room); return room; } public static RoomDBClasses.RoomRoot? d( ulong roomId, ulong subRoomId, string roomFile) { var room = col.FindOne(x => x.RoomId == roomId); if (room == null) return null; var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId); subroom.CurrentSave.DataBlob = "80c450a999404dbcb792d6d3d2f62a56.room"; subroom_col.Update(subroom); return room; } public static RoomDBClasses.RoomRoot? CreateSubroomForRoom(ulong roomId, string newSubroomName) { var room = col.FindOne(x => x.RoomId == roomId); if (room == null) { Console.WriteLine("Room not found."); return null; } var roomSubrooms = subroom_col.Find(x => x.RoomId == roomId).ToList(); ulong nextSubRoomId = roomSubrooms.Count == 0 ? 1 : roomSubrooms.Max(sr => sr.SubRoomId) + 1; var lastSubroom = roomSubrooms .OrderByDescending(sr => sr.SubRoomId) .FirstOrDefault(); var newSubroom = new SubRoom { RoomId = roomId, Name = newSubroomName, SubRoomId = nextSubRoomId, AllSaves = new List(), CurrentSave = null, Accessibility = RoomDBClasses.RoomAccessibility.Public, UnitySceneId = lastSubroom.UnitySceneId, MaxPlayers = lastSubroom.MaxPlayers, }; subroom_col.Insert(newSubroom); roomSubrooms.Add(newSubroom); col.Update(room); return room; } /// A copy of a subroom under a fresh SubRoomId: same scene, settings and saves. public static SubRoom? CloneSubroom(ulong roomId, ulong subRoomId) { var room = col.FindOne(x => x.RoomId == roomId); var src = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId); if (room == null || src == null) return null; var all = subroom_col.Find(x => x.RoomId == roomId).ToList(); ulong nextId = all.Count == 0 ? 1 : all.Max(sr => sr.SubRoomId) + 1; var copy = new SubRoom { RoomId = roomId, SubRoomId = nextId, Name = (src.Name ?? "Subroom") + " (copy)", Accessibility = src.Accessibility, CreatorAccountId = src.CreatorAccountId, IsSandbox = src.IsSandbox, MaxPlayers = src.MaxPlayers, ShouldAutoStageSaves = src.ShouldAutoStageSaves, UnitySceneId = src.UnitySceneId, CurrentSave = src.CurrentSave, AllSaves = new List(src.AllSaves ?? new List()) }; subroom_col.Insert(copy); room.SubRooms ??= new List(); room.SubRooms.Add(copy); col.Update(room); return copy; } /// Put the room's embedded subroom list in the given order (ids not named keep their relative order at the end). public static void ReorderSubrooms(ulong roomId, List order) { var room = col.FindOne(x => x.RoomId == roomId); if (room?.SubRooms == null) return; var rank = order.Select((id, i) => (id, i)).ToDictionary(t => t.id, t => t.i); room.SubRooms = room.SubRooms.OrderBy(s => rank.TryGetValue(s.SubRoomId, out var r) ? r : int.MaxValue).ToList(); col.Update(room); } /// Make one of a subroom's saved versions the live one (restore). False when unknown. public static bool PublishSubroomSave(ulong roomId, ulong subRoomId, ulong saveId) { var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId); if (subroom == null) return false; var save = (subroom.AllSaves ?? new List()).FirstOrDefault(s => s.SubRoomDataSaveId == saveId) ?? (subroom.CurrentSave?.SubRoomDataSaveId == saveId ? subroom.CurrentSave : null); if (save == null) return false; subroom.CurrentSave = save; if (subroom.StagedSubRoomDataSaveId == (long)saveId) subroom.StagedSubRoomDataSaveId = null; subroom_col.Update(subroom); var room = col.FindOne(x => x.RoomId == roomId); var emb = room?.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId); if (room != null && emb != null) { emb.CurrentSave = save; emb.StagedSubRoomDataSaveId = subroom.StagedSubRoomDataSaveId; col.Update(room); } return true; } public static bool DeleteSubroom(ulong roomId, ulong subRoomId) { var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId); if (subroom == null) return false; subroom_col.Delete(subroom.Id); return true; } public static bool ModifySubroom( ulong roomId, ulong subRoomId, string name, RoomDBClasses.RoomAccessibility accessibility, int maxPlayers) { var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subRoomId); if (subroom == null) return false; subroom.Name = name; subroom.Accessibility = accessibility; subroom.MaxPlayers = maxPlayers; subroom_col.Update(subroom); // Mirror the change onto the room's embedded subroom copy AND the room-level // Accessibility (what Search / hot / visibility read), so setting a Rooms 2.0 room to // Public in-game actually makes it appear in listings/search without a stale field. var room = col.FindOne(x => x.RoomId == roomId); if (room != null) { var emb = room.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId); if (emb != null) { emb.Name = name; emb.Accessibility = accessibility; emb.MaxPlayers = maxPlayers; } // Published rooms stay Public no matter what the subroom is set to; unpublished // rooms mirror the subroom. room.Accessibility = EffectiveRoomAccessibility(room); col.Update(room); } return true; } public static void DeleteRoom(ulong roomId) { subroom_col.DeleteMany(x => x.RoomId == roomId); col.DeleteMany(x => x.RoomId == roomId); } public static RoomDBClasses.SubroomSaveResults? GetRoomSaves(ulong roomId, ulong subroomId, int skip = 0, int take = 20) { var subroom = subroom_col.FindOne(x => x.RoomId == roomId && x.SubRoomId == subroomId); if (subroom == null) return null; var saves = new List(); if (subroom.CurrentSave != null) saves.Add(subroom.CurrentSave); if (subroom.AllSaves != null && subroom.AllSaves.Count > 0) saves.AddRange(subroom.AllSaves); saves = saves .GroupBy(x => x.SubRoomDataSaveId) .Select(g => g.First()) .OrderByDescending(x => x.CreatedAt) .ToList(); var paged = saves .Skip(skip) .Take(take) .ToList(); return new SubroomSaveResults { Results = paged, TotalCount = saves.Count }; } /// A room's role list (by id). Empty if the room or its Roles are unset. public static List GetRoomRoles(ulong roomId) { var room = col.FindOne(x => x.RoomId == roomId); return room?.Roles ?? new List(); } /// Can this player manage a room's roles? Creator, a co-owner, or a dev/admin. public static bool PlayerCanManageRoles(ulong roomId, ulong callerId) { var room = col.FindOne(x => x.RoomId == roomId); if (room == null) return false; if (room.CreatorAccountId == callerId) return true; if (PlayerDB.CheckDevFlag(callerId)) return true; return IsCoOwner(room, callerId); } /// Co-owner or temporary co-owner (both edit like the creator does). public static bool IsCoOwner(RoomDBClasses.RoomRoot room, ulong accountId) => room.Roles?.Any(r => (ulong)r.AccountId == accountId && (r.Role == RoomDBClasses.Role_data.Creator || r.Role == RoomDBClasses.Role_data.CoOwner || r.Role == RoomDBClasses.Role_data.TemporaryCoOwner)) ?? false; /// /// Does the player hold at least (a Role_data value: 10 Host, /// 20 Moderator, 30 CoOwner) in the room? The creator always passes; a banned entry never /// does (reference: verifyRole). /// public static bool PlayerHasRoleAtLeast(ulong roomId, ulong accountId, int role) { var room = col.FindOne(x => x.RoomId == roomId); if (room == null) return false; if (room.CreatorAccountId == accountId) return true; var mine = room.Roles?.FirstOrDefault(r => (ulong)r.AccountId == accountId); if (mine == null || mine.Role == RoomDBClasses.Role_data.Banned || mine.Role == RoomDBClasses.Role_data.None) return false; return (int)mine.Role >= role; } /// Add/update/remove a player's role on a room (by id). None removes it. Returns success. /// Creator, co-owner (temporary too) or contributor: may save the room's contents. public static bool CanEditContent(RoomDBClasses.RoomRoot? room, ulong accountId) { if (room == null) return false; if (room.CreatorAccountId == accountId || IsCoOwner(room, accountId)) return true; return room.Roles?.Any(r => (ulong)r.AccountId == accountId && r.Role == RoomDBClasses.Role_data.Contributor) ?? false; } /// /// Record a PENDING role invite: InvitedRole = the offered role, Role untouched. Nothing is /// granted until the invitee accepts (AcceptRoleInvite). A non-zero InvitedRole is what the /// client renders as "invited, not yet accepted". /// public static bool InviteRoomRole(ulong roomId, long accountId, RoomDBClasses.Role_data role, long invitedBy) { var room = col.FindOne(x => x.RoomId == roomId); if (room == null) return false; room.Roles ??= new List(); var existing = room.Roles.FirstOrDefault(r => r.AccountId == accountId); if (existing != null) { existing.InvitedRole = (int)role; existing.LastChangedByAccountId = invitedBy; } else room.Roles.Add(new RoomDBClasses.RoleClass { AccountId = accountId, Role = RoomDBClasses.Role_data.None, InvitedRole = (int)role, LastChangedByAccountId = invitedBy }); col.Update(room); return true; } /// The role this player has been invited to on the room, or None. public static RoomDBClasses.Role_data PendingRoleInvite(ulong roomId, long accountId) { var room = col.FindOne(x => x.RoomId == roomId); var e = room?.Roles?.FirstOrDefault(r => r.AccountId == accountId); if (e == null || e.InvitedRole == 0) return RoomDBClasses.Role_data.None; return Enum.IsDefined(typeof(RoomDBClasses.Role_data), (byte)e.InvitedRole) ? (RoomDBClasses.Role_data)(byte)e.InvitedRole : RoomDBClasses.Role_data.None; } /// Accept (grant the invited role) or decline (drop the invite) a pending invite. public static bool ResolveRoleInvite(ulong roomId, long accountId, bool accept) { var room = col.FindOne(x => x.RoomId == roomId); var e = room?.Roles?.FirstOrDefault(r => r.AccountId == accountId); if (room == null || e == null || e.InvitedRole == 0) return false; if (accept) { e.Role = Enum.IsDefined(typeof(RoomDBClasses.Role_data), (byte)e.InvitedRole) ? (RoomDBClasses.Role_data)(byte)e.InvitedRole : RoomDBClasses.Role_data.None; e.InvitedRole = 0; if (e.Role == RoomDBClasses.Role_data.None) room.Roles!.Remove(e); } else { e.InvitedRole = 0; if (e.Role == RoomDBClasses.Role_data.None) room.Roles!.Remove(e); } col.Update(room); return true; } public static bool SetRoomRole(ulong roomId, long accountId, RoomDBClasses.Role_data role, long changedBy) { var room = col.FindOne(x => x.RoomId == roomId); if (room == null) return false; room.Roles ??= new List(); var existing = room.Roles.FirstOrDefault(r => r.AccountId == accountId); if (role == RoomDBClasses.Role_data.None) { if (existing != null) room.Roles.Remove(existing); } // Granted directly (no pending-invite step here), so InvitedRole stays 0: a non-zero // InvitedRole is what the client reads as "invited, not yet accepted". else if (existing != null) { existing.Role = role; existing.InvitedRole = 0; existing.LastChangedByAccountId = changedBy; } else { room.Roles.Add(new RoomDBClasses.RoleClass { AccountId = accountId, Role = role, InvitedRole = 0, LastChangedByAccountId = changedBy }); } col.Update(room); return true; } public static bool AddRoleToRoom(string name, int accountId, RoomDBClasses.Role_data roleData, int invitedRole = 0) { var room = col.FindOne(x => x.Name == name); if (room == null) return false; room.Roles ??= new List(); room.Roles.Add(new RoleClass { AccountId = accountId, Role = roleData, InvitedRole = invitedRole }); col.Update(room); return true; } public static bool RemoveRoleFromRoom(string name, int accountId) { var room = col.FindOne(x => x.Name == name); if (room == null) return false; if (room.Roles == null || room.Roles.Count == 0) return false; room.Roles.RemoveAll(r => r.AccountId == accountId); col.Update(room); return true; } public static RoomDBClasses.RoomRoot SetRoomCreatorId(ulong playerid, string roomname) { col.EnsureIndex(x => x.Name); var room = col.FindOne(x => x.Name == roomname); if (room != null) { room.CreatorAccountId = playerid; var ownerRole = room.Roles.FirstOrDefault(r => r.Role == Role_data.Creator); if (ownerRole != null) { ownerRole.AccountId = (long)playerid; } else { } Console.WriteLine($"Setting the creator id to {playerid}..."); col.Update(room); Console.WriteLine($"Success!"); return room; } return null; } public static void Setup() { col.EnsureIndex(x => x.CreatorAccountId); col.EnsureIndex(x => x.RoomId); col.EnsureIndex(x => x.IsDorm); subroom_col.EnsureIndex(x => x.SubRoomId); // Every "subrooms of room X" lookup filters on RoomId. Without this index each one was a // full scan of the subroom table, and the hot-rooms list did one per room - 2,500 scans // per request, 30-70 s each on the VPS. subroom_col.EnsureIndex(x => x.RoomId); col.EnsureIndex(x => x.Name); col.EnsureIndex("TagIndex", "$.Tags[*].Tag"); } } }