using Microsoft.EntityFrameworkCore; using System.Collections.Concurrent; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Accounts.Local; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Events; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.RecNet.Notifications; using RadicadeServer.RecNet.Responses; using RadicadeServer.RecNet.Rooms; namespace RadicadeServer.Utils { public static class MatchmakingUtils { public static IDbContextFactory DbFactory = null!; private static readonly ConcurrentDictionary InRoomSessions = new(); public static void MarkInRoom(long playerId) => InRoomSessions[playerId] = 0; public static void ClearInRoom(long playerId) => InRoomSessions.TryRemove(playerId, out _); public static RoomInstance NormalizeRoomInstance(RoomInstance roomInstance) { roomInstance.eventId ??= 0; roomInstance.clubId ??= 0; roomInstance.roomCode ??= string.Empty; roomInstance.location ??= string.Empty; roomInstance.name ??= string.Empty; roomInstance.photonRegion ??= string.Empty; roomInstance.photonRegionId ??= string.Empty; roomInstance.photonRoomId ??= string.Empty; roomInstance.dataBlob ??= string.Empty; return roomInstance; } public static async Task EnsureDormPresenceAsync( RecDBContext db, long playerId, RealPresence presence) { FullRoom? dorm = await RoomUtils.GetOrCreateDorm(playerId); if (dorm == null) { return null; } RoomInstance? dormInstance = null; if (presence.roomInstance.HasValue) { dormInstance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == presence.roomInstance.Value && x.roomId == dorm.RoomId && x.isPrivate); } if (dormInstance == null) { dormInstance = await CreateRoomInstance( dorm.RoomId, true, null, null, null); } if (dormInstance == null) { return null; } var isNewDormVisit = !presence.isOnline || presence.roomInstance != dormInstance.roomInstanceId; presence.roomInstance = dormInstance.roomInstanceId; presence.errorCode = JoinStatus.Success; presence.isOnline = true; MarkInRoom(playerId); if (isNewDormVisit) await RecordRoomVisit(db, playerId, dorm.RoomId); await db.SaveChangesAsync(); return NormalizeRoomInstance(dormInstance); } public static async Task TryStartOrientationAsync( RecDBContext db, long playerId, RealPresence presence) { if (!await NeedsOrientationAsync(db, playerId)) return null; FullRoom? orientation = await RoomUtils.GetRoom("Orientation"); if (orientation == null) { return null; } RoomInstance? instance = await CreateRoomInstance( orientation.RoomId, true, null, null, null); if (instance == null) { return null; } await ClearNeedsOrientationAsync(db, playerId); presence.roomInstance = instance.roomInstanceId; presence.errorCode = JoinStatus.Success; presence.isOnline = true; MarkInRoom(playerId); await RecordRoomVisit(db, playerId, orientation.RoomId); await db.SaveChangesAsync(); return NormalizeRoomInstance(instance); } public static async Task IsPlayerInAnyInstances(long playerId) { await using var ctx = await DbFactory.CreateDbContextAsync(); var presence = await ctx.Presences.FirstOrDefaultAsync(x => x.playerId == playerId); if (presence?.roomInstance == null) return false; var instance = await ctx.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == presence.roomInstance); return instance != null; } public static async Task Matchmake( JoinMode joinMode, string roomName, long playerId, long? subRoomId = null) { await using var ctx = await DbFactory.CreateDbContextAsync(); var presence = await ctx.Presences .FirstOrDefaultAsync(x => x.playerId == playerId); if (presence == null) { return MatchResponse.Error(JoinStatus.UnknownError); } roomName = NormalizeRoomName(roomName); if (string.Equals(roomName, "none", StringComparison.OrdinalIgnoreCase)) { presence.roomInstance = null; presence.errorCode = JoinStatus.Success; ClearInRoom(playerId); await ctx.SaveChangesAsync(); return new MatchResponse { errorCode = JoinStatus.Success, roomInstance = null }; } var profile = await ctx.Profiles .AsNoTracking() .FirstOrDefaultAsync(x => x.accountId == playerId); string? ownDormName = profile == null ? null : $"@{profile.username}'s Dorm"; bool isOwnDormRequest = string.Equals(roomName, "DormRoom", StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrWhiteSpace(ownDormName) && string.Equals(roomName, ownDormName, StringComparison.OrdinalIgnoreCase)); var warningBounce = await PlayerUtils.BlockedByPendingWarningAsync(ctx, playerId, isOwnDormRequest, $"room '{roomName}'"); if (warningBounce != null) return warningBounce; bool isBanned = await ctx.IsBanned(playerId); if (isBanned && !isOwnDormRequest) { var requestedRoom = await RoomUtils.GetRoom(roomName); bool isOwnedDorm = requestedRoom != null && requestedRoom.IsDorm && requestedRoom.CreatorAccountId == playerId; if (!isOwnedDorm) { return new MatchResponse { errorCode = JoinStatus.Banned, roomInstance = null }; } } FullRoom? room; if (isOwnDormRequest) { RoomInstance? orientationInstance = await TryStartOrientationAsync(ctx, playerId, presence); if (orientationInstance != null) { return new MatchResponse { errorCode = JoinStatus.Success, roomInstance = orientationInstance }; } room = await RoomUtils.GetOrCreateDorm(playerId); } else { room = await RoomUtils.GetRoom(roomName); } if (room == null) { return new MatchResponse { errorCode = JoinStatus.RoomIsNotActive, roomInstance = null }; } if (await IsBannedFromRoom(ctx, playerId, room.RoomId)) { return new MatchResponse { errorCode = JoinStatus.BannedFromRoom, roomInstance = null }; } if (room.Accessibility == Accessibility.Private && !await IsAtLeastRole(playerId, room.RoomId, RoleType.CoOwner)) { return MatchResponse.Error(JoinStatus.RoomIsPrivate); } if (presence.roomInstance.HasValue) { await RemovePresence(playerId); } RoomInstance? newInstance = null; if (isOwnDormRequest || joinMode == JoinMode.PrivateNewInstance) { newInstance = await CreateRoomInstance( room.RoomId, true, subRoomId, null, null ); if (newInstance == null) { return new MatchResponse { errorCode = JoinStatus.UnknownError, roomInstance = null }; } } if (newInstance != null) { return await JoinRoom(playerId, newInstance.roomInstanceId); } await ReapEmptyPublicInstancesAsync(ctx, room.RoomId, subRoomId); var query = ctx.RoomInstances.Where(i => !i.isFull && i.roomId == room.RoomId && !i.isPrivate && (!subRoomId.HasValue || i.subRoomId == subRoomId)); int count = await query.CountAsync(); RoomInstance? publicInstance = null; if (count > 0) { int skip = Random.Shared.Next(0, count); publicInstance = await query.Skip(skip).FirstOrDefaultAsync(); } publicInstance ??= await CreateRoomInstance( room.RoomId, false, subRoomId, null, null ); if (publicInstance == null) { return new MatchResponse { errorCode = JoinStatus.UnknownError, roomInstance = null }; } return await JoinRoom(playerId, publicInstance.roomInstanceId); } public static async Task MatchmakeEvent(long playerId, long eventId, JoinMode joinMode = JoinMode.PublicMatchmaking) { await using var ctx = await DbFactory.CreateDbContextAsync(); var presence = await ctx.Presences.FirstOrDefaultAsync(x => x.playerId == playerId); if (presence == null) return MatchResponse.Error(JoinStatus.UnknownError); var ev = await ctx.PlayerEvents.FirstOrDefaultAsync(x => x.PlayerEventId == eventId); if (ev == null) return MatchResponse.Error(JoinStatus.NoSuchRoom); var now = DateTime.UtcNow; if (ev.StartTime > now && ev.CreatorPlayerId != playerId) return MatchResponse.Error(JoinStatus.EventNotStarted); if (ev.EndTime <= now) return MatchResponse.Error(JoinStatus.EventAlreadyFinished); var room = await RoomUtils.GetRoom(ev.RoomId); if (room == null) return MatchResponse.Error(JoinStatus.RoomIsNotActive); if (await ctx.IsBanned(playerId)) return MatchResponse.Error(JoinStatus.Banned); if (await IsBannedFromRoom(ctx, playerId, ev.RoomId)) return MatchResponse.Error(JoinStatus.BannedFromRoom); var eventInvitation = await ctx.Messages.AsNoTracking() .AnyAsync(message => message.ToPlayerId == playerId && message.Type == MessageType.PlayerEventInvitation && message.PlayerEventId == eventId); var eventResponse = await ctx.PlayerEventResponses.AsNoTracking() .FirstOrDefaultAsync(x => x.PlayerEventId == eventId && x.PlayerId == playerId); var hasAccepted = eventResponse?.Type == EventResponses.Yes; var hasDeclined = eventResponse?.Type == EventResponses.No; var hasPendingInvitation = eventResponse?.Type == EventResponses.Pending; var canJoinEvent = ev.CreatorPlayerId == playerId || hasAccepted || ((eventInvitation || hasPendingInvitation) && !hasDeclined); if (ev.Accessibility == Accessibility.Private && !canJoinEvent) return MatchResponse.Error(JoinStatus.EventIsPrivate); if (joinMode == JoinMode.PrivateNewInstance) { var mayUsePrivateRoom = canJoinEvent || await IsAtLeastRole(playerId, ev.RoomId, RoleType.CoOwner); if (room.Accessibility == Accessibility.Private && !mayUsePrivateRoom) return MatchResponse.Error(JoinStatus.RoomIsPrivate); var ownInstance = await CreateRoomInstance(ev.RoomId, true, ev.SubRoomId, null, eventId); if (ownInstance == null) return MatchResponse.Error(JoinStatus.UnknownError); return await JoinRoom(playerId, ownInstance.roomInstanceId, canJoinEvent); } var broadcastInstanceId = ev.BroadcastingRoomInstanceId ?? 0; var hostInstanceId = 0L; if (broadcastInstanceId > 0) { var hostPresence = await ctx.Presences.AsNoTracking() .FirstOrDefaultAsync(x => x.playerId == ev.CreatorPlayerId); hostInstanceId = hostPresence?.roomInstance ?? 0; } RoomInstance? instance = null; if (broadcastInstanceId > 0 && ev.CreatorPlayerId == playerId) { instance = await ctx.RoomInstances.FirstOrDefaultAsync(i => i.roomInstanceId == broadcastInstanceId && !i.isFull) ?? await ctx.RoomInstances.FirstOrDefaultAsync(i => i.roomInstanceId == hostInstanceId && !i.isFull); } instance ??= await ctx.RoomInstances .FirstOrDefaultAsync(i => i.eventId == eventId && !i.isFull && !i.isPrivate && i.roomInstanceId != broadcastInstanceId && i.roomInstanceId != hostInstanceId); if (instance == null) { instance = await CreateRoomInstance(ev.RoomId, false, ev.SubRoomId, null, eventId); if (instance == null) return MatchResponse.Error(JoinStatus.UnknownError); } return await JoinRoom(playerId, instance.roomInstanceId, canJoinEvent); } public static async Task JoinRoom(long playerId, long instanceId, bool allowPrivateRoom = false) { await using var ctx = await DbFactory.CreateDbContextAsync(); if (instanceId < 0) return new MatchResponse { errorCode = JoinStatus.NoSuchRoom }; var roomInstance = await ctx.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == instanceId); var playerPresence = await ctx.Presences.FirstOrDefaultAsync(x => x.playerId == playerId); if (playerPresence == null) return new MatchResponse { roomInstance = null, errorCode = JoinStatus.UnknownError }; if (roomInstance == null) return new MatchResponse { roomInstance = null, errorCode = JoinStatus.NoSuchRoom }; var room = await ctx.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomInstance.roomId); if (room == null) return new MatchResponse { roomInstance = null, errorCode = JoinStatus.NoSuchRoom }; if (await IsBannedFromRoom(ctx, playerId, room.RoomId)) return new MatchResponse { roomInstance = null, errorCode = JoinStatus.BannedFromRoom }; var warningBounce = await PlayerUtils.BlockedByPendingWarningAsync( ctx, playerId, room.IsDorm && room.CreatorAccountId == playerId, $"instance {instanceId} (room {room.RoomId})"); if (warningBounce != null) return warningBounce; if (room.Accessibility == Accessibility.Private && !allowPrivateRoom && !await IsAtLeastRole(playerId, room.RoomId, RoleType.CoOwner)) { return new MatchResponse { roomInstance = null, errorCode = JoinStatus.RoomIsPrivate }; } var playersInInstance = await GetPlayerCountInInstance(instanceId); if (playersInInstance >= roomInstance.maxCapacity) return new MatchResponse { roomInstance = null, errorCode = JoinStatus.InsufficientSpace }; playerPresence.roomInstance = instanceId; playerPresence.isOnline = true; playerPresence.errorCode = JoinStatus.Success; if (roomInstance.eventId is > 0) roomInstance.roomInstanceType = await ctx.PlayerEvents.AnyAsync(ev => ev.PlayerEventId == roomInstance.eventId && ev.IsMultiInstance) ? RoomInstanceType.MultiInstanceEvent : RoomInstanceType.Event; await RecordRoomVisit(ctx, playerId, room.RoomId); await RoomJoinGiftUtils.TryDeliverAsync(ctx, playerId, room); playersInInstance = await GetPlayerCountInInstance(instanceId); if (playersInInstance+1 >= roomInstance.maxCapacity) { roomInstance.isFull = true; ctx.RoomInstances.Update(roomInstance); } else { if (roomInstance.isFull) { roomInstance.isFull = false; ctx.RoomInstances.Update(roomInstance); } } await ctx.SaveChangesAsync(); MarkInRoom(playerId); var hostedEvent = await ctx.PlayerEvents.FirstOrDefaultAsync(ev => ev.PlayerEventId == roomInstance.eventId && ev.CreatorPlayerId == playerId && ev.BroadcastingRoomInstanceId != null && ev.BroadcastingRoomInstanceId != instanceId); if (hostedEvent != null && !await ctx.Presences.AnyAsync(p => p.isOnline && p.roomInstance == hostedEvent.BroadcastingRoomInstanceId)) { hostedEvent.BroadcastingRoomInstanceId = null; await ctx.SaveChangesAsync(); await RadicadeServer.RecNet.Events.PlayerEventNotifications.PushToEventInstancesAsync(ctx, hostedEvent); } return new MatchResponse { roomInstance = roomInstance, errorCode = JoinStatus.Success }; } public static async Task GetLastVisitedRoomNameAsync(RecDBContext ctx, long playerId) { var visit = await ctx.RoomInteractions .Where(x => x.PlayerId == playerId) .OrderByDescending(x => x.LastVisted) .FirstOrDefaultAsync(); if (visit == null) return null; return await ctx.Rooms .Where(x => x.RoomId == visit.RoomId) .Select(x => x.Name) .FirstOrDefaultAsync(); } public static async Task GetLastRewardActivityRoomNameAsync( RecDBContext ctx, long playerId, TimeSpan window) { var since = DateTime.Now - window; var recent = await ctx.RoomInteractions .AsNoTracking() .Where(x => x.PlayerId == playerId && x.LastVisted >= since) .OrderByDescending(x => x.LastVisted) .Take(VisitsConsidered) .Select(x => x.RoomId) .ToListAsync(); if (recent.Count == 0) return null; var names = await ctx.Rooms .AsNoTracking() .Where(x => recent.Contains(x.RoomId)) .Select(x => new { x.RoomId, x.Name }) .ToListAsync(); foreach (var roomId in recent) { var name = names.FirstOrDefault(x => x.RoomId == roomId)?.Name; if (RroRewardPools.IsRewardActivity(name)) return name; } return null; } private const int VisitsConsidered = 25; public static async Task RecordRoomVisit(RecDBContext ctx, long playerId, long roomId) { var existingInteraction = await ctx.RoomInteractions.FirstOrDefaultAsync(x => x.RoomId == roomId && x.PlayerId == playerId); bool isNewVisitor = existingInteraction == null || !existingInteraction.Visited; if (existingInteraction != null) { existingInteraction.Visited = true; existingInteraction.VisitCount++; existingInteraction.LastVisted = DateTime.Now; } else { await ctx.AddAsync(new PlayerInteractionDB { Cheered = false, Favorited = false, PlayerId = playerId, RoomId = roomId, VisitCount = 1, Visited = true }); } var roomStats = await ctx.RoomStats.FirstOrDefaultAsync(x => x.RoomId == roomId); if (roomStats == null) { roomStats = new RoomStats { RoomId = roomId }; await ctx.AddAsync(roomStats); } roomStats.VisitCount++; if (isNewVisitor) { roomStats.VisitorCount++; } await QuestRewardDelivery.DeliverOnArrivalAsync(ctx, playerId, roomId); } public static async Task CreateRoomInstance(FullRoom room, bool isPrivate, long? subRoomId, long? clubId, long? eventId) { return await CreateRoomInstance(room.RoomId, isPrivate, subRoomId, clubId, eventId); } public static async Task CreateRoomInstance( long roomId, bool isPrivate, long? subRoomId, long? clubId, long? eventId) { await using var ctx = await DbFactory.CreateDbContextAsync(); SubRoom? subRoom = null; FullRoom? room = await RoomUtils.GetRoom(roomId); RoomInstance? roomInstance = null; if (room == null) { return null; } if (subRoomId == null) { subRoom = await ctx.SubRooms.FirstOrDefaultAsync(x => x.RoomId == room.RoomId); } else { subRoom = await ctx.SubRooms.FirstOrDefaultAsync(x => x.RoomId == room.RoomId && x.SubRoomId == subRoomId); } if (subRoom == null) return null; RoomInstanceType instanceType = RoomInstanceType.Public; if (isPrivate) instanceType = RoomInstanceType.Private; if (room.Name == "DormRoom") instanceType = RoomInstanceType.Dormroom; if (clubId != null) instanceType = RoomInstanceType.Clubhouse; if (eventId != null) instanceType = await ctx.PlayerEvents.AnyAsync(ev => ev.PlayerEventId == eventId && ev.IsMultiInstance) ? RoomInstanceType.MultiInstanceEvent : RoomInstanceType.Event; string roomName = "^" + room.Name; if (room.IsDorm) { Profile? roomCreator = await ctx.Profiles.AsNoTracking().FirstOrDefaultAsync(p => p.accountId == room.CreatorAccountId); if (roomCreator != null) { roomName = $"@{roomCreator.username}'s Dorm"; } } roomInstance = new RoomInstance { subRoomId = subRoom.SubRoomId, roomId = roomId, clubId = clubId, eventId = eventId, dataBlob = subRoom.DataBlob, EncryptVoiceChat = room.EncryptVoiceChat, isFull = false, isInProgress = false, isPrivate = (room.Name == "DormRoom" && room.IsDorm) ? true : isPrivate, maxCapacity = subRoom.MaxPlayers, name = roomName, photonRegion = "us", photonRegionId = "us", photonRoomId = CreatePhotonRoomId(), roomCode = "", location = subRoom.UnitySceneId, roomInstanceType = instanceType }; await ctx.RoomInstances.AddAsync(roomInstance); await ctx.SaveChangesAsync(); return roomInstance; } public static async Task> GetPlayersInInstance(long instanceId) { using var ctx = DbFactory.CreateDbContext(); return await ctx.Presences.Where((p) => p.roomInstance == instanceId).ToListAsync(); } public static async Task GetPlayerCountInInstance(long instanceId) { using var ctx = DbFactory.CreateDbContext(); return await ctx.Presences.AsNoTracking().Where(x => x.roomInstance == instanceId).CountAsync(); } public static async Task ReapEmptyPublicInstancesAsync(RecDBContext ctx, long roomId, long? subRoomId) { var candidates = await ctx.RoomInstances .Where(i => i.roomId == roomId && !i.isPrivate && i.roomInstanceType == RoomInstanceType.Public && (!subRoomId.HasValue || i.subRoomId == subRoomId)) .ToListAsync(); if (candidates.Count == 0) { return 0; } var ids = candidates.Select(x => x.roomInstanceId).ToList(); var occupied = await ctx.Presences .Where(p => p.isOnline && p.roomInstance != null && ids.Contains(p.roomInstance.Value)) .Select(p => p.roomInstance!.Value) .Distinct() .ToListAsync(); var empty = candidates .Where(x => !occupied.Contains(x.roomInstanceId)) .ToList(); if (empty.Count == 0) { return 0; } ctx.RoomInstances.RemoveRange(empty); await ctx.SaveChangesAsync(); return empty.Count; } public static async Task RemovePresence(long playerId) { await using var ctx = await DbFactory.CreateDbContextAsync(); var player = ctx.Presences.FirstOrDefault((x) => x.playerId == playerId); if (player?.roomInstance == null) return; long roomInstanceId = player.roomInstance.Value; player.roomInstance = null; var remainingPlayers = await GetPlayerCountInInstance(roomInstanceId); if (remainingPlayers <= 0) { var roomInstance = await ctx.RoomInstances.FirstOrDefaultAsync((x) => x.roomInstanceId == roomInstanceId); if (roomInstance != null) ctx.RoomInstances.Remove(roomInstance); } await ctx.SaveChangesAsync(); } public static async Task IsAtLeastRole(long playerId, long roomId, RoleType minimumRole) { await using var ctx = await DbFactory.CreateDbContextAsync(); var role = await RoomUtils.GetRoleForPlayer(playerId, roomId); var isOwner = await ctx.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.CreatorAccountId == playerId && r.RoomId == roomId) != null; return (role?.Role >= minimumRole) || isOwner; } public static async Task CanModerateRoom(long playerId, long roomId) { if (await IsAtLeastRole(playerId, roomId, RoleType.Moderator)) return true; await using var ctx = await DbFactory.CreateDbContextAsync(); var highRanks = (await ctx.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == "HighRanks"))?.Value; if (string.IsNullOrWhiteSpace(highRanks)) return false; foreach (var entry in highRanks.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) if (long.TryParse(entry, out var highRankId) && highRankId == playerId) return true; return false; } public static async Task IsBannedFromRoom(RecDBContext ctx, long playerId, long roomId) { var ban = await ctx.RoomBans.AsNoTracking().FirstOrDefaultAsync(x => x.roomId == roomId && x.bannedPlayerId == playerId); if (ban == null) return false; if (ban.expiresAt.HasValue && ban.expiresAt.Value <= DateTime.UtcNow) { ctx.RoomBans.Remove(ban); await ctx.SaveChangesAsync(); return false; } if (await IsAtLeastRole(playerId, roomId, RoleType.CoOwner)) return false; return true; } public static async Task KickPlayerFromRoom(long playerId, long roomId, string reason) { await using var ctx = await DbFactory.CreateDbContextAsync(); var presence = await ctx.Presences.FirstOrDefaultAsync(x => x.playerId == playerId); if (presence?.roomInstance == null) return; var instance = await ctx.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == presence.roomInstance); if (instance == null || instance.roomId != roomId) return; var dorm = await RoomUtils.GetOrCreateDorm(playerId); if (dorm == null) return; var dormInstance = await CreateRoomInstance(dorm.RoomId, true, null, null, null); if (dormInstance == null) return; presence.roomInstance = dormInstance.roomInstanceId; presence.errorCode = JoinStatus.Success; presence.isOnline = true; MarkInRoom(playerId); await ctx.SaveChangesAsync(); try { await Notify.SendNotify(playerId, new Notification { Id = NotificationType.ModerationKick, Msg = reason }); } catch (Exception) { } try { await Notify.SendNotify(playerId, new Notification { Id = "PresenceUpdate", Msg = await Presence.CreateAsync(ctx, presence, dormInstance) }); } catch (Exception) { } } private static async Task NeedsOrientationAsync(RecDBContext ctx, long playerId) { var settings = await ctx.PlayerSettings .AsNoTracking() .FirstOrDefaultAsync(x => x.AccountId == playerId); if (settings == null) return false; var list = settings.DeserializeSettings(); var marker = list.FirstOrDefault(x => x.Key == "Radicade.NeedsOrientation"); return marker != null && (marker.Value == "1" || string.Equals(marker.Value, "true", StringComparison.OrdinalIgnoreCase)); } private static async Task ClearNeedsOrientationAsync(RecDBContext ctx, long playerId) { var settings = await ctx.PlayerSettings .FirstOrDefaultAsync(x => x.AccountId == playerId); if (settings == null) return; var list = settings.DeserializeSettings(); if (list.RemoveAll(x => x.Key == "Radicade.NeedsOrientation") > 0) { settings.Settings = list.ToJson(); await ctx.SaveChangesAsync(); } } private static string NormalizeRoomName(string roomName) { if (string.IsNullOrWhiteSpace(roomName)) { return string.Empty; } string normalized = roomName.Replace("+", " ").Trim(); if (normalized.StartsWith("^", StringComparison.Ordinal)) { normalized = normalized[1..].TrimStart(); } try { normalized = Uri.UnescapeDataString(normalized); } catch (UriFormatException) { } return normalized; } public static string CreatePhotonRoomId() { return $"Radicade{Random.Shared.Next(232)}-{Guid.NewGuid().ToString().Replace("-", "")}"; } } }