using System; using System.Linq; using LiteDB; using Mocha2023.Classes; using Mocha2023.Classes.DBs.DBClasses; using static Mocha2023.Classes.DBs.DBClasses.RoomDBClasses; using SysJson = System.Text.Json; namespace Mocha2023.Classes.DBs { public class RoomDB { public const int CompatibleLegacyPersistenceVersion = 38; public const int CompatibleLegacyUgcSubVersion = 0; public const int CompatibleLegacyOMVersion = 0; public static int NormalizeLegacyPersistenceVersion(int? sourceVersion) { int value = sourceVersion.GetValueOrDefault(CompatibleLegacyPersistenceVersion); return value <= 0 ? CompatibleLegacyPersistenceVersion : Math.Min(value, CompatibleLegacyPersistenceVersion); } public static int NormalizeLegacyUgcSubVersion(int sourceVersion) { return CompatibleLegacyUgcSubVersion; } public static int NormalizeLegacyOMVersion(int sourceVersion) { return CompatibleLegacyOMVersion; } public static LiteDatabase RoomDBFile = new LiteDatabase(Path.Combine(Program.dataDir, "DBs", "Rooms.db")); public static readonly ILiteCollection Rooms = RoomDBFile.GetCollection("Rooms"); public static readonly ILiteCollection SubRoomDataSaves = RoomDBFile.GetCollection("SubRoomDataSaves"); public static readonly ILiteCollection RoomBans = RoomDBFile.GetCollection("RoomBans"); private static readonly object SaveIdLock = new(); private static readonly object DormCreationLock = new(); private static readonly object SubRoomMutationLock = new(); private static readonly string[] CanonicalBaseRoomNames = { "PerformanceHall", "MakerRoom", "Park", "Lounge", "RecCenter" }; static RoomDB() { LiteDbMaintenance.StartPeriodicCheckpoint("Rooms.db", RoomDBFile); RepairCanonicalBaseRoomMetadata(); } private static string NormalizeCanonicalRoomName(string? name) => new((name ?? string.Empty) .Trim() .TrimStart('^') .Where(char.IsLetterOrDigit) .Select(char.ToLowerInvariant) .ToArray()); public static bool IsCanonicalBaseRoom(Room? room) { if (room == null) return false; if (room.IsBaseRoom) return true; string normalized = NormalizeCanonicalRoomName(room.Name); return CanonicalBaseRoomNames.Any(name => NormalizeCanonicalRoomName(name) == normalized); } public static int GetCanonicalBaseRoomOrder(Room room) { string normalized = NormalizeCanonicalRoomName(room.Name); int index = Array.FindIndex( CanonicalBaseRoomNames, name => NormalizeCanonicalRoomName(name) == normalized); return index < 0 ? int.MaxValue : index; } public static int RepairCanonicalBaseRoomMetadata() { int changed = 0; foreach (Room room in Rooms.FindAll().Where(IsCanonicalBaseRoom).ToList()) { bool dirty = false; if (!room.IsBaseRoom) { room.IsBaseRoom = true; dirty = true; } if (room.IsDorm) { room.IsDorm = false; dirty = true; } if (!room.IsRRO) { room.IsRRO = true; dirty = true; } if (room.Accessibility != RoomAccessibility.Public) { room.Accessibility = RoomAccessibility.Public; dirty = true; } if (room.State != RoomState.Active) { room.State = RoomState.Active; dirty = true; } room.Tags ??= new List(); if (!room.Tags.Any(tag => string.Equals( tag.Tag, "base", StringComparison.OrdinalIgnoreCase))) { room.Tags.Add(new Tags { Tag = "base", Type = TagType.Auto }); dirty = true; } if (!room.Tags.Any(tag => string.Equals( tag.Tag, "rro", StringComparison.OrdinalIgnoreCase))) { room.Tags.Add(new Tags { Tag = "rro", Type = TagType.AGOnly }); dirty = true; } if (dirty && Rooms.Update(room)) changed++; } return changed; } public static bool CanPlayerAccessRoom(Room? room, long accountId) { if (room == null || accountId <= 0 || room.State == RoomState.MarkedForDelete || room.State == RoomState.Moderation_Closed) { return false; } if (IsCanonicalBaseRoom(room)) return true; if (room.IsDorm) return room.CreatorAccountId == accountId || IsConfirmedLiveGuest(room, accountId); if (room.Accessibility != RoomAccessibility.Private) return true; return IsRoomCollaborator(room, accountId) || IsConfirmedLiveGuest(room, accountId); } private static bool IsConfirmedLiveGuest(Room room, long accountId) { PlayerDBClasses.Heartbeat heartbeat = PlayerDB.GetPlayerHeartbeat(accountId); PlayerDBClasses.RoomInstance? instance = heartbeat.roomInstance; if (!heartbeat.isOnline || instance == null || instance.roomId != room.RoomId) return false; return Sessions.IsConfirmedParticipant(accountId, instance.roomInstanceId); } public static bool CanPlayerAccessSubRoom( Room? room, SubRooms? subRoom, long accountId) { if (!CanPlayerAccessRoom(room, accountId) || room == null || subRoom == null) { return false; } if (IsCanonicalBaseRoom(room)) return true; return subRoom.Accessibility != RoomAccessibility.Private || IsRoomCollaborator(room, accountId) || IsConfirmedLiveGuest(room, accountId); } private static bool IsRoomCollaborator(Room room, long accountId) { return room.CreatorAccountId == accountId || room.Roles?.Any(role => role.AccountId == accountId && role.Role is Role.Host or Role.Moderator or Role.CoOwner or Role.TemporaryCoOwner or Role.Creator) == true; } public static RoomBan BanPlayerFromRoom(long roomId, long accountId, long bannedByAccountId, string? reason) { var existing = RoomBans.FindOne(b => b.RoomId == roomId && b.AccountId == accountId); if (existing != null) return existing; var ban = new RoomBan { RoomId = roomId, AccountId = accountId, BannedByAccountId = bannedByAccountId, Reason = reason, BannedAt = DateTime.UtcNow }; RoomBans.Insert(ban); Console.WriteLine($"[ROOM BAN] room={roomId} account={accountId} bannedBy={bannedByAccountId}"); return ban; } public static bool UnbanPlayerFromRoom(long roomId, long accountId) { int removed = RoomBans.DeleteMany(b => b.RoomId == roomId && b.AccountId == accountId); if (removed > 0) Console.WriteLine($"[ROOM UNBAN] room={roomId} account={accountId}"); return removed > 0; } public static bool IsPlayerBannedFromRoom(long roomId, long accountId) { return RoomBans.Exists(b => b.RoomId == roomId && b.AccountId == accountId); } public static List GetActiveBans(long roomId) { return RoomBans.Find(b => b.RoomId == roomId).ToList(); } public static SubRoomDataSave CreateSubRoomDataSave(long roomId, long subRoomId, long accountId, string dataBlob, int persistenceVersion, bool isPublished, string? dataBlobHash = null, string? description = null, string? roomDataBlob = null) { lock (SaveIdLock) { return InsertSubRoomDataSave( roomId, subRoomId, accountId, dataBlob, persistenceVersion, isPublished, dataBlobHash, description, roomDataBlob); } } public static SubRoomDataSave GetOrCreateSubRoomDataSave( long roomId, long subRoomId, long accountId, string dataBlob, int persistenceVersion, bool isPublished, string? dataBlobHash, string? description, string? roomDataBlob, out bool created) { lock (SaveIdLock) { var existing = SubRoomDataSaves .Find(save => save.RoomId == roomId && save.SubRoomId == subRoomId) .OrderByDescending(save => save.CreatedAt) .ThenByDescending(save => save.SubRoomDataSaveId) .FirstOrDefault(save => save.SavedByAccountId == accountId && string.Equals( save.DataBlob, dataBlob, StringComparison.OrdinalIgnoreCase) && string.Equals( save.RoomDataBlob, roomDataBlob, StringComparison.OrdinalIgnoreCase) && save.PersistenceVersion == persistenceVersion && string.Equals( save.Description ?? string.Empty, description ?? string.Empty, StringComparison.Ordinal)); if (existing != null) { bool changed = false; if (string.IsNullOrWhiteSpace(existing.DataBlobHash) && !string.IsNullOrWhiteSpace(dataBlobHash)) { existing.DataBlobHash = dataBlobHash; changed = true; } if (isPublished && !existing.IsPublished) { existing.IsPublished = true; changed = true; } if (changed && !SubRoomDataSaves.Update(existing)) { throw new InvalidOperationException( $"Failed to update existing subroom save {existing.SubRoomDataSaveId}."); } created = false; return existing; } created = true; return InsertSubRoomDataSave( roomId, subRoomId, accountId, dataBlob, persistenceVersion, isPublished, dataBlobHash, description, roomDataBlob); } } private static SubRoomDataSave InsertSubRoomDataSave( long roomId, long subRoomId, long accountId, string dataBlob, int persistenceVersion, bool isPublished, string? dataBlobHash, string? description, string? roomDataBlob) { long nextId = SubRoomDataSaves.Count() == 0 ? 1 : Convert.ToInt64(SubRoomDataSaves.Max(x => x.SubRoomDataSaveId)) + 1; var save = new SubRoomDataSave { SubRoomDataSaveId = nextId, RoomId = roomId, SubRoomId = subRoomId, SavedByAccountId = accountId, DataBlob = dataBlob, RoomDataBlob = roomDataBlob, DataBlobHash = dataBlobHash, PersistenceVersion = persistenceVersion, Description = description, CreatedAt = DateTime.UtcNow, IsPublished = isPublished }; SubRoomDataSaves.Insert(save); return save; } public static SubRoomDataSave? EnsureSubRoomDataSave(Room room, SubRooms subRoom) { ArgumentNullException.ThrowIfNull(room); ArgumentNullException.ThrowIfNull(subRoom); if (subRoom.SubRoomDataSaveId > 0) { var current = SubRoomDataSaves.FindById(subRoom.SubRoomDataSaveId); if (IsUsablePersistencePair(room, subRoom, current)) { NormalizeSubRoomDataSaveHash(current!); SynchronizePersistencePointers(room, subRoom, current!); return current; } Console.WriteLine( $"[CV2 SNAPSHOT GUARD] rejected current save room={room.RoomId} " + $"subroom={subRoom.SubRoomId} save={subRoom.SubRoomDataSaveId}"); } var newestValidSave = SubRoomDataSaves .Find(save => save.RoomId == room.RoomId && save.SubRoomId == subRoom.SubRoomId) .OrderByDescending(save => save.CreatedAt) .ThenByDescending(save => save.SubRoomDataSaveId) .FirstOrDefault(save => IsUsablePersistencePair(room, subRoom, save)); if (newestValidSave != null) { NormalizeSubRoomDataSaveHash(newestValidSave); SynchronizePersistencePointers(room, subRoom, newestValidSave); Console.WriteLine( $"[CV2 SNAPSHOT REPAIR] room={room.RoomId} subroom={subRoom.SubRoomId} " + $"save={newestValidSave.SubRoomDataSaveId}"); return newestValidSave; } if (HasCompleteDirectBlobPair(room, subRoom)) { var save = CreateSubRoomDataSave( room.RoomId, subRoom.SubRoomId, subRoom.SavedByAccountId, subRoom.DataBlob!, room.PersistenceVersion, false, roomDataBlob: room.DataBlob); NormalizeSubRoomDataSaveHash(save); SynchronizePersistencePointers(room, subRoom, save); Console.WriteLine( $"[CV2 SNAPSHOT REPAIR] synthesized save room={room.RoomId} " + $"subroom={subRoom.SubRoomId} save={save.SubRoomDataSaveId}"); return save; } bool clearedInvalidSavePointer = subRoom.SubRoomDataSaveId != 0; subRoom.SubRoomDataSave = null; subRoom.SubRoomDataSaveId = 0; if (clearedInvalidSavePointer) Rooms.Update(room); Console.WriteLine( $"[CV2 SNAPSHOT GUARD] fresh scene room={room.RoomId} " + $"subroom={subRoom.SubRoomId} roomBlob={room.DataBlob ?? "null"} " + $"subRoomBlob={subRoom.DataBlob ?? "null"}"); return null; } public static bool TryGetPersistencePair( Room room, SubRooms subRoom, out SubRoomDataSave? save, out string roomDataBlob, out string subRoomDataBlob) { save = EnsureSubRoomDataSave(room, subRoom); if (!IsUsablePersistencePair(room, subRoom, save)) { roomDataBlob = string.Empty; subRoomDataBlob = string.Empty; return false; } roomDataBlob = string.IsNullOrWhiteSpace(save!.RoomDataBlob) ? string.Empty : save.RoomDataBlob; subRoomDataBlob = save.DataBlob; return true; } private static bool IsBakedUnitySave(SubRoomDataSave? save) => save != null && !string.IsNullOrWhiteSpace(save.UnityAssetId); private static bool IsUsablePersistencePair( Room room, SubRooms subRoom, SubRoomDataSave? save) { if (save == null || save.RoomId != room.RoomId || save.SubRoomId != subRoom.SubRoomId || string.IsNullOrWhiteSpace(save.DataBlob)) { return false; } if (IsBakedUnitySave(save)) { string bakedRoomBlob = string.IsNullOrWhiteSpace(save.RoomDataBlob) ? save.DataBlob : save.RoomDataBlob; return RoomBlobExists(save.DataBlob) && RoomBlobExists(bakedRoomBlob); } if (string.IsNullOrWhiteSpace(save.RoomDataBlob)) { return RoomBlobExists(save.DataBlob); } if (string.Equals( save.RoomDataBlob, save.DataBlob, StringComparison.OrdinalIgnoreCase)) { return false; } return RoomBlobExists(save.RoomDataBlob) && RoomBlobExists(save.DataBlob); } private static bool HasCompleteDirectBlobPair(Room room, SubRooms subRoom) { return !string.IsNullOrWhiteSpace(room.DataBlob) && !string.IsNullOrWhiteSpace(subRoom.DataBlob) && !string.Equals( room.DataBlob, subRoom.DataBlob, StringComparison.OrdinalIgnoreCase) && RoomBlobExists(room.DataBlob) && RoomBlobExists(subRoom.DataBlob); } private static void SynchronizePersistencePointers( Room room, SubRooms subRoom, SubRoomDataSave save) { bool changed = false; string? effectiveRoomBlob; if (IsBakedUnitySave(save)) { effectiveRoomBlob = string.IsNullOrWhiteSpace(save.RoomDataBlob) ? save.DataBlob : save.RoomDataBlob; } else { effectiveRoomBlob = string.IsNullOrWhiteSpace(save.RoomDataBlob) ? null : save.RoomDataBlob; } if (!string.Equals(room.DataBlob, effectiveRoomBlob, StringComparison.Ordinal)) { room.DataBlob = effectiveRoomBlob; changed = true; } if (!string.Equals(subRoom.DataBlob, save.DataBlob, StringComparison.Ordinal)) { subRoom.DataBlob = save.DataBlob; changed = true; } if (subRoom.SubRoomDataSaveId != save.SubRoomDataSaveId) { subRoom.SubRoomDataSaveId = save.SubRoomDataSaveId; changed = true; } subRoom.SubRoomDataSave = save; if (changed) Rooms.Update(room); } private static void NormalizeSubRoomDataSaveHash(SubRoomDataSave save) { string? actualHash = ComputeBlobHashBase64(save.DataBlob); if (actualHash == null || string.Equals(save.DataBlobHash, actualHash, StringComparison.Ordinal)) { return; } string? oldHash = save.DataBlobHash; save.DataBlobHash = actualHash; SubRoomDataSaves.Update(save); Console.WriteLine( $"[ROOM HASH REPAIR] save={save.SubRoomDataSaveId} " + $"blob={save.DataBlob} old={oldHash ?? "null"} new={actualHash}"); } private static string? ComputeBlobHashBase64(string? filename) { if (!RoomBlobExists(filename)) return null; string path = Path.Combine( Program.dataDir, "CDN", "room", Path.GetFileName(filename)); using var stream = File.OpenRead(path); byte[] digest = System.Security.Cryptography.SHA256.HashData(stream); return Convert.ToBase64String(digest); } public static Room? PrepareRoomForClient(Room? room) { if (room == null) return null; room.PersistenceVersion = NormalizeLegacyPersistenceVersion( room.PersistenceVersion); room.UgcVersion = 1; var normalizedFields = new List(); if (string.IsNullOrWhiteSpace(room.Name)) { room.Name = $"Room{room.RoomId}"; normalizedFields.Add(nameof(room.Name)); } if (room.Description == null) { room.Description = string.Equals( room.Name, "RecCenter", StringComparison.OrdinalIgnoreCase) ? "A social hub to meet and mingle with friends new and old!" : "No description has been added yet."; normalizedFields.Add(nameof(room.Description)); } if (string.IsNullOrWhiteSpace(room.ImageName)) { room.ImageName = "DefaultRoomImage.jpg"; normalizedFields.Add(nameof(room.ImageName)); } if (string.IsNullOrWhiteSpace(room.RankedEntityId)) { room.RankedEntityId = room.RoomId.ToString(); normalizedFields.Add(nameof(room.RankedEntityId)); } room.Stats ??= new Stats(); room.SubRooms ??= new List(); room.Roles ??= new List(); room.Tags ??= new List(); room.PromoImages ??= new List(); room.PromoExternalContent ??= new List(); room.LoadScreens ??= new List(); if (IsCanonicalBaseRoom(room)) { room.IsBaseRoom = true; room.IsDorm = false; room.IsRRO = true; room.Accessibility = RoomAccessibility.Public; room.State = RoomState.Active; if (!room.Tags.Any(tag => string.Equals( tag.Tag, "base", StringComparison.OrdinalIgnoreCase))) { room.Tags.Add(new Tags { Tag = "base", Type = TagType.Auto }); } if (!room.Tags.Any(tag => string.Equals( tag.Tag, "rro", StringComparison.OrdinalIgnoreCase))) { room.Tags.Add(new Tags { Tag = "rro", Type = TagType.AGOnly }); } } bool hasBetaTag = room.Tags.Any(tag => string.Equals( tag.Tag, "beta", StringComparison.OrdinalIgnoreCase)); if (room.CreativeToolsBetaEnabled && !hasBetaTag) { room.Tags.Add(new Tags { Tag = "beta", Type = TagType.Auto }); } else if (!room.CreativeToolsBetaEnabled && hasBetaTag) { room.CreativeToolsBetaEnabled = true; } ResetInvalidPersonalDormToFreshState(room); foreach (var subRoom in room.SubRooms) { subRoom.Name ??= string.Empty; subRoom.UnitySceneId ??= string.Empty; subRoom.Permissions ??= new List(); subRoom.SubRoomDataSave = EnsureSubRoomDataSave(room, subRoom); if (subRoom.SubRoomDataSave != null) { subRoom.SubRoomDataSave.DataBlob ??= string.Empty; subRoom.SubRoomDataSave.Description ??= room.Description; subRoom.SubRoomDataSave.PersistenceVersion = NormalizeLegacyPersistenceVersion( subRoom.SubRoomDataSave.PersistenceVersion); subRoom.SubRoomDataSave.UgcSubVersion = NormalizeLegacyUgcSubVersion( subRoom.SubRoomDataSave.UgcSubVersion); subRoom.SubRoomDataSave.OMVersion = NormalizeLegacyOMVersion( subRoom.SubRoomDataSave.OMVersion); } } foreach (var tag in room.Tags) tag.Tag ??= string.Empty; foreach (var externalContent in room.PromoExternalContent) externalContent.Reference ??= string.Empty; foreach (var loadScreen in room.LoadScreens) { loadScreen.ImageName ??= string.Empty; loadScreen.Title ??= string.Empty; loadScreen.Subtitle ??= string.Empty; } if (normalizedFields.Count > 0) { Console.WriteLine( $"[ROOM DTO NORMALIZE] room={room.RoomId} name={room.Name} " + $"fields={string.Join(',', normalizedFields)}"); } return room; } private static void ResetInvalidPersonalDormToFreshState(Room room) { if (!room.IsDorm || room.RoomId == 1 || room.CreatorAccountId <= 0 || room.SubRooms == null || room.SubRooms.Count == 0) { return; } bool roomBlobSet = !string.IsNullOrWhiteSpace(room.DataBlob); bool anySubRoomBlobSet = room.SubRooms.Any(subRoom => !string.IsNullOrWhiteSpace(subRoom.DataBlob)); bool hasMissingFile = (roomBlobSet && !RoomBlobExists(room.DataBlob)) || room.SubRooms.Any(subRoom => !string.IsNullOrWhiteSpace(subRoom.DataBlob) && !RoomBlobExists(subRoom.DataBlob)); bool hasAliasedPayload = roomBlobSet && room.SubRooms.Any(subRoom => string.Equals(room.DataBlob, subRoom.DataBlob, StringComparison.OrdinalIgnoreCase)); bool hasIncompletePair = roomBlobSet != anySubRoomBlobSet; if (!hasMissingFile && !hasAliasedPayload && !hasIncompletePair) return; room.DataBlob = null; room.PersistenceVersion = 0; foreach (var subRoom in room.SubRooms) { subRoom.DataBlob = null; subRoom.SubRoomDataSaveId = 0; subRoom.SubRoomDataSave = null; } SubRoomDataSaves.DeleteMany(save => save.RoomId == room.RoomId); Rooms.Update(room); Console.WriteLine( $"[DORM FRESH] account={room.CreatorAccountId} room={room.RoomId} " + "discarded invalid persistence data"); } private static bool RoomBlobExists(string? filename) { if (string.IsNullOrWhiteSpace(filename)) return false; string safeName = Path.GetFileName(filename); return string.Equals(safeName, filename, StringComparison.Ordinal) && File.Exists(Path.Combine(Program.dataDir, "CDN", "room", safeName)); } public static List PrepareRoomsForClient(IEnumerable rooms) { var prepared = rooms.ToList(); foreach (var room in prepared) PrepareRoomForClient(room); return prepared; } public static async Task ImportRooms(string path) { try { string jsonData = await File.ReadAllTextAsync(path); var options = new SysJson.JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = SysJson.JsonCommentHandling.Skip, AllowTrailingCommas = true }; var roomsList = SysJson.JsonSerializer.Deserialize>(jsonData, options); if (roomsList == null) return; foreach (var room in roomsList) { if (room == null) continue; if (Rooms.FindById(room.RoomId) != null) continue; await AddRoom(room, log: true, shouldAssignNewIds: false); } } catch (Exception ex) { Console.WriteLine($"[Import Error] Failed to import JSON: {ex.Message}"); } } public static async Task AddRoom(Room newRoom, bool log = false, bool shouldAssignNewIds = true) { if (newRoom == null) return false; return await Task.Run(() => { try { if (shouldAssignNewIds || newRoom.RoomId <= 0) newRoom.RoomId = GetNextRoomId(); newRoom.RankedEntityId = newRoom.RoomId.ToString(); newRoom.CreatedAt = newRoom.CreatedAt == default ? DateTime.UtcNow : newRoom.CreatedAt; newRoom.Stats ??= new Stats(); newRoom.SubRooms ??= new List(); newRoom.Roles ??= new List(); newRoom.Tags ??= new List(); newRoom.PromoImages ??= new List(); newRoom.PromoExternalContent ??= new List(); newRoom.LoadScreens ??= new List(); if (IsCanonicalBaseRoom(newRoom)) { newRoom.IsBaseRoom = true; newRoom.IsDorm = false; newRoom.IsRRO = true; newRoom.Accessibility = RoomAccessibility.Public; newRoom.State = RoomState.Active; if (!newRoom.Tags.Any(tag => string.Equals( tag.Tag, "base", StringComparison.OrdinalIgnoreCase))) { newRoom.Tags.Add(new Tags { Tag = "base", Type = TagType.Auto }); } if (!newRoom.Tags.Any(tag => string.Equals( tag.Tag, "rro", StringComparison.OrdinalIgnoreCase))) { newRoom.Tags.Add(new Tags { Tag = "rro", Type = TagType.AGOnly }); } } long nextSubRoomId = GetNextSubRoomId(); foreach (var sub in newRoom.SubRooms) { if (shouldAssignNewIds || sub.SubRoomId <= 0) sub.SubRoomId = nextSubRoomId++; sub.RoomId = newRoom.RoomId; sub.Name ??= "Home"; sub.UnitySceneId ??= string.Empty; sub.Permissions ??= new List(); } Rooms.Insert(newRoom); if (log) Console.WriteLine($"[DB] Added room: {newRoom.Name} (ID: {newRoom.RoomId})"); return true; } catch (Exception ex) { Console.WriteLine($"[DB Error] Failed to add room {newRoom.Name}: {ex}"); return false; } }); } public static long GetNextRoomId() { if (Rooms.Count() == 0) return 1; var maxId = Rooms.Max(x => x.RoomId); return Convert.ToInt64(maxId) + 1; } public static bool ChangeRoomId(long oldRoomId, long newRoomId, out string error) { error = string.Empty; if (newRoomId <= 0) { error = "The new room ID must be a positive number."; return false; } if (newRoomId == oldRoomId) { error = "That's already this room's ID."; return false; } lock (SubRoomMutationLock) { Room? room = Rooms.FindById(oldRoomId); if (room == null) { error = "Room not found."; return false; } if (Rooms.Exists(candidate => candidate.RoomId == newRoomId)) { error = $"Room {newRoomId} already exists."; return false; } room.RoomId = newRoomId; room.RankedEntityId = newRoomId.ToString(); if (room.SubRooms != null) { foreach (SubRooms subRoom in room.SubRooms) subRoom.RoomId = newRoomId; } Rooms.Insert(room); Rooms.Delete(oldRoomId); foreach (SubRoomDataSave save in SubRoomDataSaves.Find(value => value.RoomId == oldRoomId).ToList()) { save.RoomId = newRoomId; SubRoomDataSaves.Update(save); } foreach (RoomBan ban in RoomBans.Find(value => value.RoomId == oldRoomId).ToList()) { ban.RoomId = newRoomId; RoomBans.Update(ban); } return true; } } public static long GetNextSubRoomId() { lock (SubRoomMutationLock) { long maxSafeSubRoomId = 0; int ignoredImportedIds = 0; foreach (var room in Rooms.FindAll()) { if (room.SubRooms == null) continue; foreach (var subRoom in room.SubRooms) { long id = subRoom.SubRoomId; if (id <= 0 || id > int.MaxValue) { ignoredImportedIds++; continue; } if (id > maxSafeSubRoomId) maxSafeSubRoomId = id; } } if (maxSafeSubRoomId >= int.MaxValue) throw new InvalidOperationException( "No client-safe SubRoomId values remain in the positive Int32 range."); long nextId = maxSafeSubRoomId + 1; Console.WriteLine( $"[SUBROOM ID ALLOCATOR] next={nextId} " + $"maxSafeExisting={maxSafeSubRoomId} ignoredImported={ignoredImportedIds}"); return nextId; } } public static Room? AddSubRoom(long roomId, long accountId, string requestedName) { lock (SubRoomMutationLock) { var room = Rooms.FindById(roomId); if (room == null) return null; room.SubRooms ??= new List(); string name = GetUniqueSubRoomName( room, string.IsNullOrWhiteSpace(requestedName) ? "New Room" : requestedName.Trim()); var template = room.SubRooms.FirstOrDefault(subRoom => string.Equals(subRoom.Name, "Home", StringComparison.OrdinalIgnoreCase)) ?? room.SubRooms.FirstOrDefault(); var subRoom = new SubRooms { SubRoomId = GetNextSubRoomId(), RoomId = room.RoomId, Name = name, DataBlob = null, SubRoomDataSaveId = 0, IsSandbox = template?.IsSandbox ?? true, MaxPlayers = Math.Max(1, template?.MaxPlayers ?? room.MaxPlayers), Accessibility = template?.Accessibility ?? room.Accessibility, UnitySceneId = template?.UnitySceneId ?? string.Empty, Permissions = ClonePermissions(template?.Permissions), SavedByAccountId = accountId }; room.SubRooms.Add(subRoom); room.UgcVersion = Math.Max(1, room.UgcVersion + 1); Rooms.Update(room); Console.WriteLine( $"[SUBROOM ADD] room={roomId} subroom={subRoom.SubRoomId} name={subRoom.Name} by={accountId}"); return PrepareRoomForClient(room); } } public static Room? CloneSubRoom(long roomId, long subRoomId, long accountId) { lock (SubRoomMutationLock) { var room = Rooms.FindById(roomId); var source = room?.SubRooms?.FirstOrDefault(subRoom => subRoom.SubRoomId == subRoomId); if (room == null || source == null) return null; string name = GetUniqueSubRoomName(room, $"{source.Name} Copy"); var clone = new SubRooms { SubRoomId = GetNextSubRoomId(), RoomId = room.RoomId, Name = name, DataBlob = source.DataBlob, SubRoomDataSaveId = 0, IsSandbox = source.IsSandbox, MaxPlayers = Math.Max(1, source.MaxPlayers), Accessibility = source.Accessibility, UnitySceneId = source.UnitySceneId ?? string.Empty, Permissions = ClonePermissions(source.Permissions), SavedByAccountId = accountId }; room.SubRooms!.Add(clone); room.UgcVersion = Math.Max(1, room.UgcVersion + 1); Rooms.Update(room); Console.WriteLine( $"[SUBROOM CLONE] room={roomId} source={subRoomId} clone={clone.SubRoomId} name={clone.Name} by={accountId}"); return PrepareRoomForClient(room); } } private static string GetUniqueSubRoomName(Room room, string requestedName) { string baseName = requestedName.Trim(); if (baseName.Length > 50) baseName = baseName[..50].TrimEnd(); if (string.IsNullOrWhiteSpace(baseName)) baseName = "New Room"; var usedNames = (room.SubRooms ?? new List()) .Select(subRoom => subRoom.Name) .Where(name => !string.IsNullOrWhiteSpace(name)) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (!usedNames.Contains(baseName)) return baseName; for (int suffix = 2; suffix < 10_000; suffix++) { string suffixText = $" {suffix}"; int prefixLength = Math.Min(baseName.Length, 50 - suffixText.Length); string candidate = baseName[..prefixLength].TrimEnd() + suffixText; if (!usedNames.Contains(candidate)) return candidate; } return $"Room {Guid.NewGuid():N}"; } private static List ClonePermissions( IEnumerable? permissions) { return permissions?.Select(permission => new SubRoomPermission { Override = permission.Override, Permission = permission.Permission, Role = permission.Role, Type = permission.Type, Value = permission.Value }).ToList() ?? new List(); } public static Room GetRoom(long roomId) { return PrepareRoomForClient(Rooms.FindById(roomId)); } public static Room GetRoomByName(string name) { Room? room = Rooms.FindOne(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); if (room == null) { string normalized = NormalizeCanonicalRoomName(name); if (CanonicalBaseRoomNames.Any(canonical => NormalizeCanonicalRoomName(canonical) == normalized)) { room = Rooms.FindAll().FirstOrDefault(candidate => NormalizeCanonicalRoomName(candidate.Name) == normalized); } } return PrepareRoomForClient(room); } public static List GetRoomsByNames(List names) { if (names == null || names.Count == 0) return new List(); return PrepareRoomsForClient(Rooms.Find(room => names.Contains(room.Name, StringComparer.OrdinalIgnoreCase) )); } public static Room? GetOrCreatePlayerDorm(long accountId) { if (accountId <= 0) return null; lock (DormCreationLock) { var existing = Rooms.Find(room => room.IsDorm && room.RoomId != 1 && room.CreatorAccountId == accountId && room.State != RoomState.MarkedForDelete) .OrderBy(room => room.RoomId) .FirstOrDefault(); if (existing != null) { existing.Roles ??= new List(); if (!existing.Roles.Any(role => role.AccountId == accountId && role.Role == Role.Creator)) { existing.Roles.RemoveAll(role => role.AccountId == accountId); existing.Roles.Add(new Roles { AccountId = accountId, Role = Role.Creator, InvitedRole = Role.None }); Rooms.Update(existing); } return PrepareRoomForClient(existing); } var template = Rooms.FindById(1) ?? Rooms.FindOne(room => room.IsDorm); if (template == null) return null; long roomId = GetNextRoomId(); long nextSubRoomId = GetNextSubRoomId(); var dorm = new Room { RoomId = roomId, IsDorm = true, MaxPlayerCalculationMode = template.MaxPlayerCalculationMode, MaxPlayers = template.MaxPlayers, CloningAllowed = false, DisableMicAutoMute = template.DisableMicAutoMute, DisableRoomComments = template.DisableRoomComments, EncryptVoiceChat = template.EncryptVoiceChat, ToxmodEnabled = template.ToxmodEnabled, LoadScreenLocked = template.LoadScreenLocked, PersistenceVersion = template.PersistenceVersion, AutoLocalizeRoom = template.AutoLocalizeRoom, IsDeveloperOwned = false, Name = "DormRoom", Description = template.Description, ImageName = template.ImageName, WarningMask = template.WarningMask, CustomWarning = template.CustomWarning, CreatorAccountId = accountId, State = RoomState.Active, Accessibility = RoomAccessibility.Private, SupportsLevelVoting = template.SupportsLevelVoting, IsRRO = template.IsRRO, SupportsScreens = template.SupportsScreens, SupportsWalkVR = template.SupportsWalkVR, SupportsTeleportVR = template.SupportsTeleportVR, SupportsVRLow = template.SupportsVRLow, SupportsQuest2 = template.SupportsQuest2, SupportsMobile = template.SupportsMobile, SupportsJuniors = template.SupportsJuniors, MinLevel = template.MinLevel, CreatedAt = DateTime.UtcNow, Stats = new Stats(), RankedEntityId = roomId.ToString(), RankingContext = null, DataBlob = template.DataBlob, UgcVersion = Math.Max(1, template.UgcVersion), Tags = template.Tags? .Select(tag => new Tags { Tag = tag.Tag, Type = tag.Type }) .ToList() ?? new List(), PromoImages = template.PromoImages?.ToList() ?? new List(), PromoExternalContent = template.PromoExternalContent? .Select(item => new PromoExternalContent { Type = item.Type, Reference = item.Reference }) .ToList() ?? new List(), LoadScreens = template.LoadScreens? .Select(screen => new LoadScreens { ImageName = screen.ImageName, Title = screen.Title, Subtitle = screen.Subtitle }) .ToList() ?? new List(), Roles = new List { new Roles { AccountId = accountId, Role = Role.Creator, InvitedRole = Role.None } } }; dorm.SubRooms = (template.SubRooms ?? new List()) .Select(subRoom => new SubRooms { SubRoomId = nextSubRoomId++, RoomId = roomId, Name = subRoom.Name, DataBlob = subRoom.DataBlob, SubRoomDataSaveId = 0, IsSandbox = subRoom.IsSandbox, MaxPlayers = subRoom.MaxPlayers, Accessibility = RoomAccessibility.Private, UnitySceneId = subRoom.UnitySceneId, Permissions = ClonePermissions(subRoom.Permissions), SavedByAccountId = accountId }) .ToList(); if (dorm.SubRooms.Count == 0) return null; Rooms.Insert(dorm); Console.WriteLine( $"[DORM CREATE] account={accountId} room={roomId} " + $"subrooms={dorm.SubRooms.Count}"); return PrepareRoomForClient(dorm); } } public static async Task EnsureCanonicalBaseRooms(string path) { if (!File.Exists(path)) return 0; string jsonData = await File.ReadAllTextAsync(path); var options = new SysJson.JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = SysJson.JsonCommentHandling.Skip, AllowTrailingCommas = true }; var importedRooms = SysJson.JsonSerializer.Deserialize>(jsonData, options) ?? new(); var templates = importedRooms .Where(IsCanonicalBaseRoom) .OrderBy(GetCanonicalBaseRoomOrder) .ToList(); int changed = 0; foreach (var template in templates) { var existing = GetRoomByName(template.Name); if (existing != null) { existing.IsDorm = false; existing.IsRRO = true; existing.IsBaseRoom = true; existing.Accessibility = RoomAccessibility.Public; existing.State = RoomState.Active; existing.Tags ??= new List(); if (!existing.Tags.Any(tag => string.Equals(tag.Tag, "base", StringComparison.OrdinalIgnoreCase))) { existing.Tags.Add(new Tags { Tag = "base", Type = TagType.Auto }); changed++; } if (!existing.Tags.Any(tag => string.Equals(tag.Tag, "rro", StringComparison.OrdinalIgnoreCase))) { existing.Tags.Add(new Tags { Tag = "rro", Type = TagType.AGOnly }); changed++; } Rooms.Update(existing); continue; } if (await AddRoom( template, log: true, shouldAssignNewIds: true)) { changed++; } } return changed; } public static int EnsurePlayerDormsForAllPlayers() { int created = 0; foreach (long accountId in PlayerDB.Players.FindAll() .Select(player => player.PlayerId) .Where(accountId => accountId > 0)) { bool alreadyExisted = Rooms.Exists(room => room.IsDorm && room.RoomId != 1 && room.CreatorAccountId == accountId && room.State != RoomState.MarkedForDelete); if (GetOrCreatePlayerDorm(accountId) != null && !alreadyExisted) created++; } return created; } public static int DeletePlayerDorms(long accountId) { if (accountId <= 0) return 0; lock (DormCreationLock) { var roomIds = Rooms.Find(room => room.IsDorm && room.RoomId != 1 && room.CreatorAccountId == accountId) .Select(room => room.RoomId) .ToList(); return roomIds.Count(roomId => Rooms.Delete(roomId)); } } public static int RemoveOrphanedPlayerDorms() { var accountIds = PlayerDB.Players.FindAll() .Select(player => player.PlayerId) .Where(accountId => accountId > 0) .ToHashSet(); lock (DormCreationLock) { var orphanedRoomIds = Rooms.Find(room => room.IsDorm && room.RoomId != 1 && room.CreatorAccountId > 0 && !accountIds.Contains(room.CreatorAccountId)) .Select(room => room.RoomId) .ToList(); return orphanedRoomIds.Count(roomId => Rooms.Delete(roomId)); } } public static (List Results, int Total) GetHotRooms(string tag, int skip, int take) { RepairCanonicalBaseRoomMetadata(); skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 100); string t = string.IsNullOrWhiteSpace(tag) ? "hot" : tag.Trim().ToLowerInvariant(); IEnumerable query = Rooms.FindAll() .Where(r => !r.IsDorm && r.State != RoomState.MarkedForDelete); switch (t) { case "hot": query = query.Where(r => r.Accessibility == RoomAccessibility.Public); break; case "rro": case "recroomoriginal": query = query.Where(r => r.Accessibility == RoomAccessibility.Public && (r.IsRRO || (r.Tags?.Any(x => string.Equals(x.Tag, "rro", StringComparison.OrdinalIgnoreCase) || string.Equals(x.Tag, "recroomoriginal", StringComparison.OrdinalIgnoreCase)) ?? false))); break; case "base": query = query.Where(r => r.Accessibility == RoomAccessibility.Public && IsCanonicalBaseRoom(r)); break; case "new": query = query.Where(r => r.Accessibility == RoomAccessibility.Public); break; default: query = query.Where(r => r.Accessibility == RoomAccessibility.Public && (r.Tags?.Any(x => string.Equals(x.Tag, t, StringComparison.OrdinalIgnoreCase)) ?? false)); break; } query = t switch { "new" => query.OrderByDescending(r => r.CreatedAt) .ThenByDescending(r => r.RoomId), "base" => query.OrderBy(GetCanonicalBaseRoomOrder), _ => query.OrderByDescending(r => r.Stats?.VisitCount ?? 0) .ThenByDescending(r => r.CreatedAt) }; var all = query.ToList(); var page = PrepareRoomsForClient(all.Skip(skip).Take(take)); return ( page, all.Count ); } public static Room? CloneRoom(Room source, long ownerId, string? newName) { if (source == null || ownerId <= 0 || !source.CloningAllowed) return null; try { long nextRoomId = GetNextRoomId(); long nextSubRoomId = GetNextSubRoomId(); string requestedName = string.IsNullOrWhiteSpace(newName) ? $"{source.Name} Copy" : newName.Trim(); string uniqueName = requestedName; int suffix = 2; while (GetRoomByName(uniqueName) != null) uniqueName = $"{requestedName} {suffix++}"; var clonedRoom = new Room { RoomId = nextRoomId, IsDorm = false, MaxPlayerCalculationMode = source.MaxPlayerCalculationMode, MaxPlayers = source.MaxPlayers, CloningAllowed = true, DisableMicAutoMute = source.DisableMicAutoMute, DisableRoomComments = source.DisableRoomComments, EncryptVoiceChat = source.EncryptVoiceChat, ToxmodEnabled = source.ToxmodEnabled, LoadScreenLocked = false, PersistenceVersion = source.PersistenceVersion, AutoLocalizeRoom = source.AutoLocalizeRoom, IsDeveloperOwned = false, Name = uniqueName, Description = source.Description, ImageName = source.ImageName, WarningMask = source.WarningMask, CustomWarning = source.CustomWarning, CreatorAccountId = ownerId, State = RoomState.Active, Accessibility = RoomAccessibility.Private, SupportsLevelVoting = source.SupportsLevelVoting, IsRRO = false, SupportsScreens = source.SupportsScreens, SupportsWalkVR = source.SupportsWalkVR, SupportsTeleportVR = source.SupportsTeleportVR, SupportsVRLow = source.SupportsVRLow, SupportsQuest2 = source.SupportsQuest2, SupportsMobile = source.SupportsMobile, SupportsJuniors = source.SupportsJuniors, MinLevel = source.MinLevel, CreatedAt = DateTime.UtcNow, Stats = new Stats(), RankedEntityId = nextRoomId.ToString(), RankingContext = null, UgcVersion = Math.Max(1, source.UgcVersion), Tags = source.Tags? .Where(t => !string.Equals(t.Tag, "rro", StringComparison.OrdinalIgnoreCase) && !string.Equals(t.Tag, "base", StringComparison.OrdinalIgnoreCase)) .Select(t => new Tags { Tag = t.Tag, Type = t.Type }) .ToList() ?? new List(), PromoImages = source.PromoImages?.ToList() ?? new List(), PromoExternalContent = source.PromoExternalContent? .Select(x => new PromoExternalContent { Type = x.Type, Reference = x.Reference }) .ToList() ?? new List(), LoadScreens = source.LoadScreens? .Select(x => new LoadScreens { ImageName = x.ImageName, Title = x.Title, Subtitle = x.Subtitle }) .ToList() ?? new List(), Roles = new List { new Roles { AccountId = ownerId, Role = Role.Creator, InvitedRole = Role.None } }, DataBlob = source.DataBlob }; clonedRoom.SubRooms = (source.SubRooms ?? new List()) .Select(sr => new SubRooms { SubRoomId = nextSubRoomId++, RoomId = nextRoomId, Name = sr.Name, DataBlob = sr.DataBlob, SubRoomDataSaveId = 0, IsSandbox = sr.IsSandbox, MaxPlayers = sr.MaxPlayers, Accessibility = sr.Accessibility, UnitySceneId = sr.UnitySceneId, Permissions = ClonePermissions(sr.Permissions), SavedByAccountId = ownerId }) .ToList(); if (clonedRoom.SubRooms.Count == 0) return null; Rooms.Insert(clonedRoom); Console.WriteLine($"[DB] Cloned room {source.RoomId} -> {clonedRoom.RoomId} ({clonedRoom.Name})"); return clonedRoom; } catch (Exception ex) { Console.WriteLine($"[DB Error] Failed to clone room {source.Name}: {ex}"); return null; } } public static bool DeleteRoom(long roomId) { try { return Rooms.Delete(roomId); } catch (Exception ex) { Console.WriteLine($"[DB Error] Failed to delete room {roomId}: {ex.Message}"); return false; } } public static Room? RenameRoom(long roomId, string newName) { var room = Rooms.FindById(roomId); if (room == null) return null; room.Name = newName; try { Rooms.Update(room); return room; } catch (Exception ex) { Console.WriteLine($"[DB Error] Failed to rename room {roomId}: {ex.Message}"); return null; } } public static (int RoomsOwned, int SubRoomSaves, int RoomBans) RenameAccountReferences( long oldAccountId, long newAccountId) { if (oldAccountId <= 0 || newAccountId <= 0 || oldAccountId == newAccountId) return (0, 0, 0); int roomsOwned = 0; foreach (Room room in Rooms.Find(value => value.CreatorAccountId == oldAccountId).ToList()) { room.CreatorAccountId = newAccountId; if (Rooms.Update(room)) roomsOwned++; } int subRoomSaves = 0; foreach (SubRoomDataSave save in SubRoomDataSaves .Find(value => value.SavedByAccountId == oldAccountId) .ToList()) { save.SavedByAccountId = newAccountId; if (SubRoomDataSaves.Update(save)) subRoomSaves++; } int roomBans = 0; foreach (RoomBan ban in RoomBans .Find(value => value.AccountId == oldAccountId || value.BannedByAccountId == oldAccountId) .ToList()) { if (ban.AccountId == oldAccountId) ban.AccountId = newAccountId; if (ban.BannedByAccountId == oldAccountId) ban.BannedByAccountId = newAccountId; if (RoomBans.Update(ban)) roomBans++; } return (roomsOwned, subRoomSaves, roomBans); } } }