using System; using LiteDB; using Mocha2023.Classes; using Mocha2023.Classes.DBs.DBClasses; using Mocha2023.Controllers; using static Mocha2023.Classes.DBs.DBClasses.PlayerDBClasses; using System.Globalization; namespace Mocha2023.Classes.DBs { public class PlayerDB { private static readonly object SocialLock = new(); private static readonly object ProgressionLock = new(); private static readonly object PartyLock = new(); private static readonly object GiftLock = new(); private static long PartySequence = DateTime.UtcNow.Ticks & long.MaxValue; private static long GiftSequence = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); private static long CheerSequence = DateTime.UtcNow.Ticks & long.MaxValue; private const int RoomLevelSeconds = 15 * 60; private const int MaximumLevel = 50; private const int MaximumCreditableHeartbeatGapSeconds = 120; private const int PresenceFreshnessSeconds = 120; private const int MaximumPendingGiftPackages = 200; public const int CheerCreditLimit = 15; private const long CheerCreditRefreshSeconds = 5 * 60 * 60; public static LiteDatabase PlayerDBFile = new LiteDatabase(Path.Combine(Program.dataDir, "DBs", "Players.db")); public static readonly ILiteCollection Players = PlayerDBFile.GetCollection("Players"); private const string RRPlusSettingKey = "RecRoomPlus.IsActive"; private const string InitialTokensSettingKey = "Mocha.InitialTokensGranted"; private const string AlpacaShirtOwnershipResetSettingKey = "Mocha.AlpacaShirtOwnershipResetV1"; public const long AlpacaShirtAvatarItemId = 1521; public const string AlpacaShirtAvatarItemDescriptor = "d0a9262f-5504-46a7-bb10-7507503db58e,941c046e-4e95-49f8-a7d7-19071fcc3c94,0440f08f-ef1d-49d8-942b-523056e8bb45,703ff56b-560d-4ff4-8c63-d195e879a328"; public const int InitialTokenBalance = 1_000; private const string PlaceholderDisplayName = "Displaynamecouldbeverylong"; private static readonly Random LevelBoxRng = new(); private static int GetLevelBoxRarity(int level) => level switch { <= 19 => 10, <= 29 => 20, <= 39 => 30, <= 49 => 40, _ => 50 }; static PlayerDB() { LiteDbMaintenance.StartPeriodicCheckpoint("Players.db", PlayerDBFile); try { int repaired = RepairPlaceholderDisplayNames(); if (repaired > 0) { Console.WriteLine( $"[ACCOUNT DISPLAYNAME MIGRATION] repaired={repaired}"); } } catch (Exception exception) { Console.WriteLine( $"[ACCOUNT DISPLAYNAME MIGRATION] failed={exception.Message}"); } } private static int RepairPlaceholderDisplayNames() { int repaired = 0; foreach (FullPlayer account in Players.FindAll().ToList()) { Player? player = account?.Player; if (player == null) continue; string currentDisplayName = player.DisplayName?.Trim() ?? string.Empty; if (!string.IsNullOrWhiteSpace(currentDisplayName) && !IsPlaceholderDisplayName(currentDisplayName)) { continue; } string username = player.Username?.Trim().TrimStart('@') ?? string.Empty; string replacement = string.IsNullOrWhiteSpace(username) ? $"Player{account.PlayerId}" : username; if (string.Equals( player.DisplayName, replacement, StringComparison.Ordinal)) { continue; } player.DisplayName = replacement; if (Players.Update(account)) repaired++; } return repaired; } private static bool IsPlaceholderDisplayName(string? value) { return string.Equals( value?.Trim(), PlaceholderDisplayName, StringComparison.OrdinalIgnoreCase); } public static FullPlayer CreateAccount( Platforms platform, ulong platformId, bool isJunior, long? accountId = null, bool completeAccountCreation = true) { string username = NameGen.GetRandomName(); var newPlayerData = new Player { Username = username, DisplayName = username, CreatedAt = DateTime.UtcNow, LastLoginAt = DateTime.UtcNow, ProfileImage = "DefaultPFP.png", AvailableUsernameChanges = 3, IsJunior = isJunior, Level = 1, XP = 0, PlayerExtra = new PlayerExtra { Currencies = new List { new PlayerCurrency { CurrencyType = CurrencyType.RecCenterTokens, BalanceType = BalanceType.NonPurchasedDefault, Balance = InitialTokenBalance } }, Settings = new List { new Setting { Key = InitialTokensSettingKey, Value = "True" }, new Setting { Key = AlpacaShirtOwnershipResetSettingKey, Value = "True" }, new Setting { Key = "Recroom.AccountCreation.HasStarted", Value = "True" }, new Setting { Key = "Recroom.AccountCreation.HasChosenUsername", Value = completeAccountCreation ? "True" : "False" }, new Setting { Key = "Recroom.AccountCreation.HasCreatedPassword", Value = completeAccountCreation ? "True" : "False" }, new Setting { Key = "Recroom.AccountCreation.HasFinished", Value = completeAccountCreation ? "True" : "False" }, new Setting { Key = "TUTORIAL_COMPLETE_MASK", Value = completeAccountCreation ? "57" : "0" }, new Setting { Key = "HAS_COMPLETED_ORIENTATION", Value = "False" }, new Setting { Key = RRPlusSettingKey, Value = "False" } } } }; var newFullPlayer = new FullPlayer { PlayerId = accountId ?? 0, PlatformIds = new List { new mPlatformID { Platform = platform, PlatformId = platformId } }, Player = newPlayerData, PlayerRoles = new List(), AuthToken = Guid.NewGuid().ToString() }; Players.Insert(newFullPlayer); EquipmentInventoryStore.EnsureInitialized(newFullPlayer.PlayerId); PlayerInventoryStore.EnsureInitialized( newFullPlayer.PlayerId, newFullPlayer.Player.PlayerExtra?.AvatarItems); RoomDB.GetOrCreatePlayerDorm(newFullPlayer.PlayerId); return newFullPlayer; } public static bool HasRRPlus(long playerId) { if (playerId <= 0) return false; var player = Players.FindById(playerId); if (player?.Player == null) return false; var setting = player.Player.PlayerExtra?.Settings? .FirstOrDefault(x => string.Equals( x.Key, RRPlusSettingKey, StringComparison.OrdinalIgnoreCase)); return setting != null && string.Equals(setting.Value, "True", StringComparison.OrdinalIgnoreCase); } public static bool SetRRPlus(long playerId, bool enabled) { if (playerId <= 0) return false; var player = Players.FindById(playerId); if (player?.Player == null) return false; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Settings ??= new List(); var setting = player.Player.PlayerExtra.Settings .FirstOrDefault(x => string.Equals( x.Key, RRPlusSettingKey, StringComparison.OrdinalIgnoreCase)); if (setting == null) { player.Player.PlayerExtra.Settings.Add(new Setting { Key = RRPlusSettingKey, Value = enabled ? "True" : "False" }); } else { setting.Key = RRPlusSettingKey; setting.Value = enabled ? "True" : "False"; } return Players.Update(player); } public static int ForceRRPlusForAllPlayers() { Console.WriteLine( "[RR+ MEMBERSHIP] ForceRRPlusForAllPlayers called but is disabled - no players were updated."); return 0; } public static bool ChangeAccountId( long currentAccountId, long newAccountId) { if (currentAccountId <= 0 || newAccountId <= 0 || currentAccountId == newAccountId) { return false; } if (Players.FindById(newAccountId) != null) return false; var player = Players.FindById(currentAccountId); if (player == null) return false; Players.Delete(currentAccountId); player.PlayerId = newAccountId; Players.Insert(player); return true; } public static bool GetLogins( Platforms platform, ulong platformId, out List accounts) { var results = Players .Find(x => x.PlatformIds .Select(p => p.PlatformId) .Any(id => id == platformId)) .Where(p => p.PlatformIds.Any( pid => pid.Platform == platform)) .OrderByDescending(x => x.Player.LastLoginAt) .ToList(); accounts = results .Select(p => new CachedLogins { accountId = p.PlayerId, lastLoginTime = p.Player.LastLoginAt, platform = platform, platformId = platformId.ToString(), requirePassword = false }) .ToList(); return accounts.Count > 0; } private static PlayerDTOBase MapToDTO( FullPlayer player, bool accountMe) { int platformFlags = player.PlatformIds?.Aggregate( 0, (acc, pid) => acc | (int)pid.Platform) ?? 0; var p = player.Player ?? new Player(); PlayerDTOBase dto = accountMe ? new PlayerMeDTO() : new PlayerDTO(); dto.accountId = player.PlayerId; dto.username = p.Username; dto.displayEmoji = p.DisplayEmoji ?? ""; dto.displayName = GetClientDisplayName(p, player.PlayerId); dto.profileImage = p.ProfileImage; dto.bannerImage = p.BannerImage; dto.isJunior = p.IsJunior; dto.createdAt = p.CreatedAt; dto.platforms = platformFlags; dto.personalPronouns = p.PersonalPronouns; dto.identityFlags = p.IdentityFlags; if (accountMe && dto is PlayerMeDTO meDto) { meDto.availableUsernameChanges = p.AvailableUsernameChanges; meDto.birthday = p.Birthday; meDto.email = p.Email; meDto.phone = null; } return dto; } private static string GetClientDisplayName(Player player, long playerId) { string displayName = player.DisplayName?.Trim() ?? string.Empty; if (!string.IsNullOrWhiteSpace(displayName) && !IsPlaceholderDisplayName(displayName)) { return displayName; } string username = player.Username?.Trim().TrimStart('@') ?? string.Empty; return string.IsNullOrWhiteSpace(username) ? $"Player{playerId}" : username; } public static List GetAccountsBulk( List playerIds, long? callerId = null) { var players = Players .Find(x => playerIds.Contains(x.PlayerId)) .ToList(); var results = players .Select(p => MapToDTO( p, callerId.HasValue && p.PlayerId == callerId.Value)) .ToList(); if (playerIds.Contains(1) && !results.Any(account => account.accountId == 1)) { results.Add(BuildCoachSystemAccount()); } return results .OrderBy(a => a.accountId) .ToList(); } public static PlayerDTOBase BuildCoachSystemAccount() => new PlayerDTO { accountId = 1, username = "Coach", displayName = "Coach", profileImage = "DefaultPFP.png", bannerImage = null, displayEmoji = string.Empty, isJunior = false, platforms = 0, personalPronouns = 0, identityFlags = 0, createdAt = new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc) }; public static PlayerMeDTO? GetAccountMe(long accountId) { var player = Players.FindById(accountId); if (player == null) return null; return MapToDTO(player, true) as PlayerMeDTO; } public static bool UpdateUsername( long playerId, string username, bool initialAccountSetup = false) { var player = Players.FindById(playerId); if (player?.Player == null) return false; username = username.Trim().TrimStart('@'); if (username.Length is < 3 or > 20 || username.Any(ch => !char.IsLetterOrDigit(ch) && ch != '_')) { return false; } if (Players.FindAll().Any(candidate => candidate.PlayerId != playerId && string.Equals( candidate.Player?.Username, username, StringComparison.OrdinalIgnoreCase))) { return false; } if (!initialAccountSetup && player.Player.AvailableUsernameChanges <= 0) return false; player.Player.Username = username; if (initialAccountSetup) { player.Player.DisplayName = username; SetSettingValue( player, "Recroom.AccountCreation.HasChosenUsername", "True"); } else { player.Player.AvailableUsernameChanges--; } return Players.Update(player); } public static bool SetPassword( long playerId, string newPassword, string? oldPassword = null) { var player = Players.FindById(playerId); if (player?.Player == null || string.IsNullOrEmpty(newPassword) || newPassword.Length < PasswordSecurity.MinPasswordLength || newPassword.Length > PasswordSecurity.MaxPasswordLength) return false; if (!string.IsNullOrEmpty(player.Password) && !PasswordSecurity.Verify(oldPassword, player.Password, out _)) { return false; } player.Password = PasswordSecurity.Hash(newPassword); SetSettingValue( player, "Recroom.AccountCreation.HasCreatedPassword", "True"); return Players.Update(player); } public static bool HasPassword(long playerId) { var player = Players.FindById(playerId); return !string.IsNullOrEmpty(player?.Password); } public static bool UpdateBirthday(long playerId, DateTime birthday) { var player = Players.FindById(playerId); if (player?.Player == null || birthday.Date > DateTime.UtcNow.Date) return false; player.Player.Birthday = birthday.Date; var today = DateTime.UtcNow.Date; int age = today.Year - birthday.Year; if (birthday.Date > today.AddYears(-age)) age--; player.Player.IsJunior = age < 13; return Players.Update(player); } private static void SetSettingValue( FullPlayer player, string key, string value) { if (player.Player == null) return; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Settings ??= new List(); var setting = player.Player.PlayerExtra.Settings.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase)); if (setting == null) { player.Player.PlayerExtra.Settings.Add(new Setting { Key = key, Value = value }); } else { setting.Value = value; } } public static bool UpdateDisplayName( long playerId, string displayName) { var player = Players.FindById(playerId); if (player?.Player == null) return false; displayName = displayName?.Trim() ?? string.Empty; player.Player.DisplayName = string.IsNullOrWhiteSpace(displayName) || IsPlaceholderDisplayName(displayName) ? GetClientDisplayName(player.Player, playerId) : displayName; return Players.Update(player); } public static bool UpdatePersonalPronouns(long playerId, int personalPronouns) { if (personalPronouns < 0 || (personalPronouns & ~0x3F) != 0) return false; var player = Players.FindById(playerId); if (player?.Player == null) return false; player.Player.PersonalPronouns = personalPronouns; return Players.Update(player); } public static bool UpdateIdentityFlags( long playerId, int identityFlags) { if (identityFlags < 0) return false; var player = Players.FindById(playerId); if (player?.Player == null) return false; player.Player.IdentityFlags = identityFlags; return Players.Update(player); } public static IReadOnlyList GetWishlist(long accountId) { var player = Players.FindById(accountId); string? stored = player?.Player?.PlayerExtra?.Settings? .FirstOrDefault(setting => string.Equals( setting.Key, "Store.Wishlist", StringComparison.OrdinalIgnoreCase))?.Value; if (string.IsNullOrWhiteSpace(stored)) return Array.Empty(); return stored.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(value => int.TryParse(value, out int id) ? id : 0) .Where(id => id > 0) .Distinct() .Take(500) .ToList(); } public static bool SetWishlistItem(long accountId, int purchasableItemId, bool wished) { if (purchasableItemId <= 0) return false; var player = Players.FindById(accountId); if (player?.Player == null) return false; var ids = GetWishlist(accountId).ToHashSet(); if (wished) { if (ids.Count >= 500 && !ids.Contains(purchasableItemId)) return false; ids.Add(purchasableItemId); } else { ids.Remove(purchasableItemId); } SetSettingValue( player, "Store.Wishlist", string.Join(',', ids.OrderBy(value => value))); return Players.Update(player); } public static bool SetAvatar( long accountId, Avatar avatar) { var player = Players.FindById(accountId); if (player?.Player == null) return false; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Avatar = avatar; return Players.Update(player); } public static void SetPlayerSetting( string key, string value, long playerId) { key = key?.Trim() ?? string.Empty; value ??= string.Empty; if (string.IsNullOrWhiteSpace(key) || key.Length > 128 || value.Length > 4_096 || key.Any(char.IsControl) || value.Any(ch => ch == '\0') || playerId <= 0) return; if (key is "SplitTestAssignedSegments" or "Growth.LastEmailPromptTime") { return; } var player = Players.FindById(playerId); if (player?.Player == null) return; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Settings ??= new List(); var settings = player.Player.PlayerExtra.Settings; var existingSetting = settings.FirstOrDefault( s => s.Key == key); if (existingSetting != null) { existingSetting.Value = value; } else { settings.Add(new Setting { Key = key, Value = value }); } if (string.Equals( key, "HAS_COMPLETED_ORIENTATION", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out bool completedOrientation) && completedOrientation) { SetSettingValue( player, "Recroom.AccountCreation.HasFinished", "True"); SetSettingValue(player, "TUTORIAL_COMPLETE_MASK", "57"); } Players.Update(player); } public static bool NeedsOrientation(FullPlayer? player) { var settings = player?.Player?.PlayerExtra?.Settings; if (settings == null) return false; var orientation = settings.FirstOrDefault(setting => string.Equals( setting.Key, "HAS_COMPLETED_ORIENTATION", StringComparison.OrdinalIgnoreCase)); if (orientation != null) { return !bool.TryParse(orientation.Value, out bool completed) || !completed; } var accountCreation = settings.FirstOrDefault(setting => string.Equals( setting.Key, "Recroom.AccountCreation.HasFinished", StringComparison.OrdinalIgnoreCase)); return accountCreation != null && !string.Equals( accountCreation.Value, "True", StringComparison.OrdinalIgnoreCase); } public static List GetProgressionBulk( List playerIds) { var players = Players .Find(x => playerIds.Contains(x.PlayerId)) .ToList(); return players .Select(p => new PlayerProgressionDTO { PlayerId = p.PlayerId, Level = p.Player?.Level ?? 1, XP = p.Player?.XP ?? 0 }) .ToList(); } public static PlayerProgressionDTO? SetProgression( long playerId, int level, int xp) { if (playerId <= 0) return null; int oldLevel; PlayerProgressionDTO result; lock (ProgressionLock) { var player = Players.FindById(playerId); if (player?.Player == null) return null; oldLevel = player.Player.Level; player.Player.Level = Math.Clamp(level, 1, 50); player.Player.XP = Math.Max(0, xp); if (!Players.Update(player)) return null; result = new PlayerProgressionDTO { PlayerId = playerId, Level = player.Player.Level, XP = player.Player.XP }; } _ = NotiController.NotifyProgressionAsync( playerId, oldLevel, result.Level, result.XP); if (result.Level > oldLevel) GrantLevelUpBox(playerId, oldLevel, result.Level); return result; } public static PlayerProgressionDTO? AddExperience( long playerId, int xpToAdd, int? resultingLevel = null) { var player = Players.FindById(playerId); if (player?.Player == null) return null; int level = resultingLevel ?? player.Player.Level; int xp = Math.Max(0, player.Player.XP + xpToAdd); return SetProgression(playerId, level, xp); } public static List GetPendingGiftPackages(long playerId) { if (playerId <= 0) return new List(); lock (GiftLock) { var player = Players.FindById(playerId); if (player?.Player == null) return new List(); player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.PendingGiftPackages ??= new List(); bool repaired = RepairPendingGiftIdsNoLock( player.Player.PlayerExtra.PendingGiftPackages); if (repaired) Players.Update(player); return player.Player.PlayerExtra.PendingGiftPackages .OrderBy(gift => gift.GiftPackageId) .ToList(); } } public static ClearOutgoingGiftsResult ClearPendingGiftsFromSender( long fromPlayerId) { var result = new ClearOutgoingGiftsResult(); if (fromPlayerId <= 0) return result; lock (GiftLock) { foreach (var account in Players.FindAll()) { if (account?.Player?.PlayerExtra?.PendingGiftPackages == null || account.Player.PlayerExtra.PendingGiftPackages.Count == 0) continue; int removed = account.Player.PlayerExtra.PendingGiftPackages .RemoveAll(gift => gift.FromPlayerId == fromPlayerId); if (removed <= 0) continue; if (Players.Update(account)) { result.RemovedBoxes += removed; result.AffectedPlayers++; } } } return result; } public class ClearOutgoingGiftsResult { public int RemovedBoxes { get; set; } public int AffectedPlayers { get; set; } } public static bool RemoveGiftPackage(long playerId, long giftPackageId) { if (playerId <= 0 || giftPackageId <= 0) return false; lock (GiftLock) { var player = Players.FindById(playerId); var pending = player?.Player?.PlayerExtra?.PendingGiftPackages; if (pending == null) return false; int removed = pending.RemoveAll(gift => gift.GiftPackageId == giftPackageId); return removed > 0 && Players.Update(player!); } } public static int ClearPendingGiftPackages(long playerId) { if (playerId <= 0) return 0; lock (GiftLock) { var player = Players.FindById(playerId); var pending = player?.Player?.PlayerExtra?.PendingGiftPackages; if (pending == null || pending.Count == 0) return 0; int removed = pending.Count; pending.Clear(); return Players.Update(player!) ? removed : 0; } } public static GiftPackage? QueueGiftPackage( long playerId, GiftPackage gift) { if (playerId <= 0 || gift == null) return null; lock (GiftLock) { var player = Players.FindById(playerId); if (player?.Player == null) return null; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.PendingGiftPackages ??= new List(); if (player.Player.PlayerExtra.PendingGiftPackages.Count >= MaximumPendingGiftPackages) { Console.WriteLine( $"[GIFT QUEUE] recipient={playerId} rejected=queue_full " + $"limit={MaximumPendingGiftPackages}"); return null; } if (gift.GiftPackageId <= 0 || gift.GiftPackageId > int.MaxValue || player.Player.PlayerExtra.PendingGiftPackages.Any(value => value.GiftPackageId == gift.GiftPackageId)) { gift.GiftPackageId = NextGiftPackageIdNoLock( player.Player.PlayerExtra.PendingGiftPackages); } gift.PlayerId = checked((int)playerId); player.Player.PlayerExtra.PendingGiftPackages.Add(gift); return Players.Update(player) ? gift : null; } } public enum FriendotronGiftStatus { Success, InvalidRequest, SenderNotFound, RecipientNotFound, DailyLimitReached, RecipientQueueFull, PersistenceFailed } public static FriendotronGiftStatus QueueFriendotronGift( long senderPlayerId, long recipientPlayerId, GiftPackage gift, out GiftPackage? queuedGift, out DateTime nextAvailableAtUtc) { queuedGift = null; nextAvailableAtUtc = DateTime.UtcNow; if (senderPlayerId is <= 0 or > int.MaxValue || recipientPlayerId is <= 0 or > int.MaxValue || gift == null) { return FriendotronGiftStatus.InvalidRequest; } lock (GiftLock) { FullPlayer? sender = Players.FindById(senderPlayerId); if (sender?.Player == null) return FriendotronGiftStatus.SenderNotFound; FullPlayer? recipient = senderPlayerId == recipientPlayerId ? sender : Players.FindById(recipientPlayerId); if (recipient?.Player == null) return FriendotronGiftStatus.RecipientNotFound; sender.Player.PlayerExtra ??= new PlayerExtra(); recipient.Player.PlayerExtra ??= new PlayerExtra(); recipient.Player.PlayerExtra.PendingGiftPackages ??= new List(); long utcDay = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / (24L * 60L * 60L); nextAvailableAtUtc = DateTimeOffset.FromUnixTimeSeconds( checked((utcDay + 1) * 24L * 60L * 60L)) .UtcDateTime; if (sender.Player.PlayerExtra.FriendotronLastSentUtcDay >= utcDay) { return FriendotronGiftStatus.DailyLimitReached; } if (recipient.Player.PlayerExtra.PendingGiftPackages.Count >= MaximumPendingGiftPackages) { return FriendotronGiftStatus.RecipientQueueFull; } if (gift.GiftPackageId <= 0 || gift.GiftPackageId > int.MaxValue || recipient.Player.PlayerExtra.PendingGiftPackages.Any(value => value.GiftPackageId == gift.GiftPackageId)) { gift.GiftPackageId = NextGiftPackageIdNoLock( recipient.Player.PlayerExtra.PendingGiftPackages); } gift.FromPlayerId = checked((int)senderPlayerId); gift.PlayerId = checked((int)recipientPlayerId); recipient.Player.PlayerExtra.PendingGiftPackages.Add(gift); long previousSentUtcDay = sender.Player.PlayerExtra.FriendotronLastSentUtcDay; sender.Player.PlayerExtra.FriendotronLastSentUtcDay = utcDay; int expectedUpdates = senderPlayerId == recipientPlayerId ? 1 : 2; int updated = expectedUpdates == 1 ? (Players.Update(sender) ? 1 : 0) : Players.Update(new[] { sender, recipient }); if (updated != expectedUpdates) { recipient.Player.PlayerExtra.PendingGiftPackages.Remove(gift); sender.Player.PlayerExtra.FriendotronLastSentUtcDay = previousSentUtcDay; return FriendotronGiftStatus.PersistenceFailed; } queuedGift = gift; return FriendotronGiftStatus.Success; } } private static bool RepairPendingGiftIdsNoLock( List gifts) { bool changed = false; var used = new HashSet(); foreach (GiftPackage gift in gifts) { if (gift.GiftPackageId > 0 && gift.GiftPackageId <= int.MaxValue && used.Add(gift.GiftPackageId)) { GiftSequence = Math.Max(GiftSequence, gift.GiftPackageId); continue; } gift.GiftPackageId = NextGiftPackageIdNoLock(gifts, used); used.Add(gift.GiftPackageId); changed = true; } return changed; } private static long NextGiftPackageIdNoLock( IEnumerable gifts, HashSet? reserved = null) { var used = reserved ?? gifts .Where(value => value.GiftPackageId > 0 && value.GiftPackageId <= int.MaxValue) .Select(value => value.GiftPackageId) .ToHashSet(); for (int attempt = 0; attempt < 100000; attempt++) { GiftSequence++; if (GiftSequence <= 0 || GiftSequence > int.MaxValue) GiftSequence = 100000; if (!used.Contains(GiftSequence)) return GiftSequence; } throw new InvalidOperationException( "Unable to allocate a client-safe gift package ID."); } public static GiftPackage? ConsumeGiftPackage( long playerId, long giftPackageId) { if (playerId <= 0 || giftPackageId <= 0) return null; lock (GiftLock) { var player = Players.FindById(playerId); if (player?.Player == null) return null; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.PendingGiftPackages ??= new List(); player.Player.PlayerExtra.AvatarItems ??= new List(); GiftPackage? gift = player.Player.PlayerExtra.PendingGiftPackages.FirstOrDefault( value => value.GiftPackageId == giftPackageId); if (gift == null) return null; if (!string.IsNullOrWhiteSpace(gift.AvatarItemDesc) && !player.Player.PlayerExtra.AvatarItems.Contains( gift.AvatarItemDesc, StringComparer.OrdinalIgnoreCase)) { player.Player.PlayerExtra.AvatarItems.Add( gift.AvatarItemDesc); PlayerInventoryStore.SetAvatarItemOwned( playerId, gift.AvatarItemDesc, avatarItemId: 0, friendlyName: null, owned: true, legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems); } if (!string.IsNullOrWhiteSpace(gift.ConsumableItemDesc)) { PlayerInventoryStore.AddConsumable( playerId, gift.ConsumableItemDesc, consumableItemId: 0, friendlyName: null, amount: Math.Clamp(gift.ConsumableQuantity, 1, 100000)); } if (!string.IsNullOrWhiteSpace(gift.EquipmentPrefabName) && !string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid)) { EquipmentInventoryStore.AddOwnedItem( playerId, gift.EquipmentPrefabName, gift.EquipmentModificationGuid, friendlyName: gift.FriendlyName, tooltip: gift.Tooltip, rarity: gift.Rarity > 0 ? gift.Rarity : null, thumbnailImage: gift.ThumbnailImage); } if (gift.Currency != 0) { player.Player.PlayerExtra.Currencies ??= new List(); CurrencyType currencyType = Enum.IsDefined(typeof(CurrencyType), gift.CurrencyType) ? (CurrencyType)gift.CurrencyType : CurrencyType.RecCenterTokens; PlayerCurrency? currency = player.Player.PlayerExtra.Currencies.FirstOrDefault( value => value.CurrencyType == currencyType); if (currency == null) { currency = new PlayerCurrency { CurrencyType = currencyType, BalanceType = BalanceType.NonPurchasedDefault }; player.Player.PlayerExtra.Currencies.Add(currency); } long adjustedBalance = (long)currency.Balance + gift.Currency; currency.Balance = (int)Math.Clamp( adjustedBalance, (long)int.MinValue, (long)int.MaxValue); } if (gift.XP > 0) player.Player.XP = Math.Max(0, player.Player.XP + gift.XP); player.Player.PlayerExtra.PendingGiftPackages.Remove(gift); return Players.Update(player) ? gift : null; } } public static PartySnapshot? GetPartySnapshot(long playerId) { lock (PartyLock) return GetPartySnapshotNoLock(playerId); } public static PartySnapshot? InviteToParty( long inviterPlayerId, long targetPlayerId) { if (inviterPlayerId <= 0 || targetPlayerId <= 0 || inviterPlayerId == targetPlayerId) { return null; } PartySnapshot? party; lock (PartyLock) { var inviter = Players.FindById(inviterPlayerId); var target = Players.FindById(targetPlayerId); if (inviter?.Player == null || target?.Player == null) return null; party = EnsurePartyNoLock(inviterPlayerId); if (party == null || party.MemberPlayerIds.Contains(targetPlayerId)) return party; target.Player.PlayerExtra ??= new PlayerExtra(); target.Player.PlayerExtra.PendingPartyInvites ??= new List(); DateTime expiresBefore = DateTime.UtcNow.AddMinutes(-10); target.Player.PlayerExtra.PendingPartyInvites.RemoveAll(invite => invite.CreatedAt < expiresBefore || invite.PartyId == party.PartyId || invite.InviterPlayerId == inviterPlayerId); target.Player.PlayerExtra.PendingPartyInvites.Add(new PartyInviteRecord { PartyId = party.PartyId, InviterPlayerId = inviterPlayerId, CreatedAt = DateTime.UtcNow }); if (!Players.Update(target)) return null; } return party; } public static PartySnapshot? AcceptPartyInvite( long inviteePlayerId, long inviterPlayerId) { if (inviteePlayerId <= 0 || inviterPlayerId <= 0) return null; PartySnapshot? party; List notifyPlayerIds; lock (PartyLock) { var invitee = Players.FindById(inviteePlayerId); var inviter = Players.FindById(inviterPlayerId); if (invitee?.Player == null || inviter?.Player == null) return null; invitee.Player.PlayerExtra ??= new PlayerExtra(); invitee.Player.PlayerExtra.PendingPartyInvites ??= new List(); PartyInviteRecord? invite = invitee.Player.PlayerExtra.PendingPartyInvites .Where(value => value.CreatedAt >= DateTime.UtcNow.AddMinutes(-10)) .OrderByDescending(value => value.CreatedAt) .FirstOrDefault(value => value.InviterPlayerId == inviterPlayerId); party = EnsurePartyNoLock(inviterPlayerId); if (party == null || invite == null || invite.PartyId != party.PartyId || party.MemberPlayerIds.Count >= 8) { return null; } RemovePlayerFromPartyNoLock(inviteePlayerId); party.MemberPlayerIds = party.MemberPlayerIds .Append(inviteePlayerId) .Where(value => value > 0) .Distinct() .ToList(); invitee.Player.PlayerExtra.PendingPartyInvites.RemoveAll(value => value.PartyId == party.PartyId || value.InviterPlayerId == inviterPlayerId || value.CreatedAt < DateTime.UtcNow.AddMinutes(-10)); Players.Update(invitee); PersistPartyNoLock(party); notifyPlayerIds = party.MemberPlayerIds.ToList(); } _ = NotiController.NotifyPartyUpdatedAsync( notifyPlayerIds, CreatePartyNotificationPayload(party)); return party; } public static PartySnapshot? LeaveParty(long playerId) { if (playerId <= 0) return null; PartySnapshot? remainingParty; List notifyPlayerIds; lock (PartyLock) { PartySnapshot? existing = GetPartySnapshotNoLock(playerId); if (existing == null) { ClearPartyStateNoLock(playerId); return null; } notifyPlayerIds = existing.MemberPlayerIds.ToList(); remainingParty = RemovePlayerFromPartyNoLock(playerId); } var payload = remainingParty ?? new PartySnapshot { PartyId = 0, LeaderPlayerId = 0, MemberPlayerIds = new List() }; _ = NotiController.NotifyPartyUpdatedAsync( notifyPlayerIds.Append(playerId), CreatePartyNotificationPayload(payload)); return remainingParty; } public static bool DeclinePartyInvite( long inviteePlayerId, long inviterPlayerId) { lock (PartyLock) { var invitee = Players.FindById(inviteePlayerId); if (invitee?.Player == null) return false; invitee.Player.PlayerExtra ??= new PlayerExtra(); invitee.Player.PlayerExtra.PendingPartyInvites ??= new List(); int removed = invitee.Player.PlayerExtra.PendingPartyInvites.RemoveAll(value => value.InviterPlayerId == inviterPlayerId || value.CreatedAt < DateTime.UtcNow.AddMinutes(-10)); return removed > 0 && Players.Update(invitee); } } public static List GetPendingPartyInvites(long playerId) { lock (PartyLock) { var player = Players.FindById(playerId); if (player?.Player == null) return new List(); player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.PendingPartyInvites ??= new List(); player.Player.PlayerExtra.PendingPartyInvites.RemoveAll(value => value.CreatedAt < DateTime.UtcNow.AddMinutes(-10)); Players.Update(player); return player.Player.PlayerExtra.PendingPartyInvites .OrderByDescending(value => value.CreatedAt) .ToList(); } } private static PartySnapshot? EnsurePartyNoLock(long playerId) { PartySnapshot? existing = GetPartySnapshotNoLock(playerId); if (existing != null) return existing; var player = Players.FindById(playerId); if (player?.Player == null) return null; long partyId = Interlocked.Increment(ref PartySequence); if (partyId <= 0) { PartySequence = DateTime.UtcNow.Ticks & long.MaxValue; partyId = Interlocked.Increment(ref PartySequence); } var party = new PartySnapshot { PartyId = partyId, LeaderPlayerId = playerId, MemberPlayerIds = new List { playerId } }; PersistPartyNoLock(party); return party; } private static PartySnapshot? GetPartySnapshotNoLock(long playerId) { var player = Players.FindById(playerId); if (player?.Player == null) return null; player.Player.PlayerExtra ??= new PlayerExtra(); PlayerExtra extra = player.Player.PlayerExtra; extra.PartyMemberPlayerIds ??= new List(); if (extra.PartyId <= 0) return null; List members = extra.PartyMemberPlayerIds .Append(playerId) .Where(value => value > 0 && Players.FindById(value)?.Player != null) .Distinct() .ToList(); if (members.Count == 0) return null; long leader = members.Contains(extra.PartyLeaderPlayerId) ? extra.PartyLeaderPlayerId : members[0]; return new PartySnapshot { PartyId = extra.PartyId, LeaderPlayerId = leader, MemberPlayerIds = members }; } private static PartySnapshot? RemovePlayerFromPartyNoLock(long playerId) { PartySnapshot? party = GetPartySnapshotNoLock(playerId); if (party == null) { ClearPartyStateNoLock(playerId); return null; } party.MemberPlayerIds.RemoveAll(value => value == playerId); ClearPartyStateNoLock(playerId); if (party.MemberPlayerIds.Count <= 1) { foreach (long remainingPlayerId in party.MemberPlayerIds) ClearPartyStateNoLock(remainingPlayerId); return null; } if (party.LeaderPlayerId == playerId || !party.MemberPlayerIds.Contains(party.LeaderPlayerId)) { party.LeaderPlayerId = party.MemberPlayerIds[0]; } PersistPartyNoLock(party); return party; } private static void PersistPartyNoLock(PartySnapshot party) { party.MemberPlayerIds = party.MemberPlayerIds .Where(value => value > 0) .Distinct() .ToList(); if (!party.MemberPlayerIds.Contains(party.LeaderPlayerId)) party.LeaderPlayerId = party.MemberPlayerIds.FirstOrDefault(); foreach (long memberPlayerId in party.MemberPlayerIds) { var member = Players.FindById(memberPlayerId); if (member?.Player == null) continue; member.Player.PlayerExtra ??= new PlayerExtra(); member.Player.PlayerExtra.PartyId = party.PartyId; member.Player.PlayerExtra.PartyLeaderPlayerId = party.LeaderPlayerId; member.Player.PlayerExtra.PartyMemberPlayerIds = party.MemberPlayerIds.ToList(); Players.Update(member); } } private static object CreatePartyNotificationPayload(PartySnapshot party) => new { partyId = party.PartyId, leaderPlayerId = party.LeaderPlayerId, memberPlayerIds = party.MemberPlayerIds.ToArray() }; private static void ClearPartyStateNoLock(long playerId) { var player = Players.FindById(playerId); if (player?.Player == null) return; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.PartyId = 0; player.Player.PlayerExtra.PartyLeaderPlayerId = 0; player.Player.PlayerExtra.PartyMemberPlayerIds = new List(); Players.Update(player); } public static List GetReputationBulk( List playerIds) { lock (SocialLock) { var allPlayers = Players.FindAll().ToList(); var players = allPlayers .Where(x => playerIds.Contains(x.PlayerId)) .ToList(); long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var result = new List(players.Count); foreach (FullPlayer p in players) { var rep = p.Player?.Reputation ?? new Reputation(); if (p.Player != null) { p.Player.Reputation = rep; if (RefreshCheerCreditsNoLock(rep, p.PlayerId, now)) Players.Update(p); } bool isDeveloper = HasRole( p, PlayerRoles.Developer); bool isCommunityTeam = HasRole( p, PlayerRoles.CommunityTeam); bool hasStaffBadgeAccess = isDeveloper || isCommunityTeam; result.Add(new Reputation { AccountId = p.PlayerId, IsCheerful = hasStaffBadgeAccess || rep.IsCheerful, Noteriety = rep.Noteriety, SelectedCheer = NormalizeSelectedStaffBadge( rep.SelectedCheer, isDeveloper, isCommunityTeam), CheerCredit = Math.Clamp( rep.CheerCredit, 0, CheerCreditLimit), CheerGeneral = hasStaffBadgeAccess ? Math.Max(10, rep.CheerGeneral) : rep.CheerGeneral, CheerHelpful = hasStaffBadgeAccess ? Math.Max(10, rep.CheerHelpful) : rep.CheerHelpful, CheerCreative = hasStaffBadgeAccess ? Math.Max(10, rep.CheerCreative) : rep.CheerCreative, CheerGreatHost = hasStaffBadgeAccess ? Math.Max(10, rep.CheerGreatHost) : rep.CheerGreatHost, CheerSportsman = hasStaffBadgeAccess ? Math.Max(10, rep.CheerSportsman) : rep.CheerSportsman, SubscriberCount = RelationshipDB.GetSubscriberCount(p.PlayerId), SubscribedCount = RelationshipDB.GetSubscribedCount(p.PlayerId) }); } return result; } } public static bool GrantDeveloperCheerAccess( FullPlayer account, bool selectDeveloperBadge) { return GrantStaffCheerAccess( account, CheerCategory.RecRoomDeveloper, selectDeveloperBadge); } public static bool GrantCommunityTeamCheerAccess( FullPlayer account, bool selectCommunityTeamBadge) { return GrantStaffCheerAccess( account, CheerCategory.RecRoomCommunityTeam, selectCommunityTeamBadge); } private static bool GrantStaffCheerAccess( FullPlayer account, CheerCategory staffBadge, bool selectStaffBadge) { if (account.Player == null || staffBadge is not ( CheerCategory.RecRoomDeveloper or CheerCategory.RecRoomCommunityTeam)) { return false; } account.Player.Reputation ??= new Reputation(); Reputation reputation = account.Player.Reputation; bool changed = false; if (!reputation.IsCheerful) { reputation.IsCheerful = true; changed = true; } if (reputation.CheerGeneral < 10) { reputation.CheerGeneral = 10; changed = true; } if (reputation.CheerHelpful < 10) { reputation.CheerHelpful = 10; changed = true; } if (reputation.CheerCreative < 10) { reputation.CheerCreative = 10; changed = true; } if (reputation.CheerGreatHost < 10) { reputation.CheerGreatHost = 10; changed = true; } if (reputation.CheerSportsman < 10) { reputation.CheerSportsman = 10; changed = true; } if (selectStaffBadge && reputation.SelectedCheer != staffBadge) { reputation.SelectedCheer = staffBadge; changed = true; } return changed; } private static bool HasRole( FullPlayer account, PlayerRoles role) { return account.PlayerRoles?.Contains(role) == true; } private static CheerCategory NormalizeSelectedStaffBadge( CheerCategory selectedCheer, bool isDeveloper, bool isCommunityTeam) { if (selectedCheer == CheerCategory.RecRoomDeveloper && !isDeveloper) { return CheerCategory.General; } if (selectedCheer == CheerCategory.RecRoomCommunityTeam && !isCommunityTeam) { return CheerCategory.General; } return selectedCheer; } public static int EnsureSocialDefaultsForAllPlayers() { lock (SocialLock) { var allPlayers = Players.FindAll().ToList(); int updated = 0; updated += RelationshipDB.MigrateLegacyData(allPlayers); foreach (var account in allPlayers) { if (account.Player == null) continue; bool changed = false; account.Player.Relationships ??= new List(); account.Player.ReceivedCheers ??= new List(); account.Player.SubscribedAccountIds ??= new List(); account.Player.Reputation ??= new Reputation(); if (account.Player.Reputation.AccountId != account.PlayerId) { account.Player.Reputation.AccountId = account.PlayerId; changed = true; } changed |= RefreshCheerCreditsNoLock( account.Player.Reputation, account.PlayerId, DateTimeOffset.UtcNow.ToUnixTimeSeconds()); bool isDeveloper = HasRole( account, PlayerRoles.Developer); bool isCommunityTeam = HasRole( account, PlayerRoles.CommunityTeam); Reputation reputation = account.Player.Reputation; bool hasNoEarnedCheer = reputation.CheerGeneral == 0 && reputation.CheerHelpful == 0 && reputation.CheerCreative == 0 && reputation.CheerGreatHost == 0 && reputation.CheerSportsman == 0; if (isDeveloper) { changed |= GrantDeveloperCheerAccess( account, selectDeveloperBadge: hasNoEarnedCheer && reputation.SelectedCheer == CheerCategory.General); } else if (isCommunityTeam) { changed |= GrantCommunityTeamCheerAccess( account, selectCommunityTeamBadge: hasNoEarnedCheer && reputation.SelectedCheer == CheerCategory.General); } CheerCategory normalizedSelectedCheer = NormalizeSelectedStaffBadge( reputation.SelectedCheer, isDeveloper, isCommunityTeam); if (normalizedSelectedCheer != reputation.SelectedCheer) { reputation.SelectedCheer = normalizedSelectedCheer; changed = true; } List authoritativeRelationships = RelationshipDB.GetRelationships(account.PlayerId); if (!RelationshipListsMatch( account.Player.Relationships, authoritativeRelationships)) { account.Player.Relationships = authoritativeRelationships; changed = true; } if (changed && Players.Update(account)) updated++; } return updated; } } private static bool RelationshipListsMatch( IReadOnlyCollection first, IReadOnlyCollection second) { if (first.Count != second.Count) return false; var firstByPlayer = first .GroupBy(value => value.PlayerID) .ToDictionary(group => group.Key, group => group.First()); if (firstByPlayer.Count != first.Count) return false; foreach (PlayerRelationship expected in second) { if (!firstByPlayer.TryGetValue(expected.PlayerID, out PlayerRelationship? actual) || actual.Id != expected.Id || actual.RelationshipType != expected.RelationshipType || actual.Favorited != expected.Favorited || actual.Muted != expected.Muted || actual.Ignored != expected.Ignored) { return false; } } return true; } private static PlayerDBClasses.RoomInstance CloneRoomInstance( PlayerDBClasses.RoomInstance source) { return new PlayerDBClasses.RoomInstance { encryptVoiceChat = source.encryptVoiceChat, clubId = source.clubId, dataBlob = source.dataBlob, eventId = source.eventId, isFull = source.isFull, isInProgress = source.isInProgress, isPrivate = source.isPrivate, location = source.location, maxCapacity = source.maxCapacity, Name = source.Name, photonRegion = source.photonRegion, photonRegionId = source.photonRegionId, photonRoomId = source.photonRoomId, roomCode = source.roomCode, roomId = source.roomId, roomInstanceId = source.roomInstanceId, roomInstanceType = source.roomInstanceType, subRoomId = source.subRoomId, subRoomDataSaveId = source.subRoomDataSaveId, createdAt = source.createdAt }; } public static int EnsureInitialTokensForAllPlayers() { lock (SocialLock) { int updated = 0; foreach (var account in Players.FindAll().ToList()) { if (account.Player == null) continue; account.Player.PlayerExtra ??= new PlayerExtra(); account.Player.PlayerExtra.Settings ??= new List(); account.Player.PlayerExtra.Currencies ??= new List(); bool alreadyGranted = account.Player.PlayerExtra.Settings .Any(setting => string.Equals( setting.Key, InitialTokensSettingKey, StringComparison.OrdinalIgnoreCase) && string.Equals( setting.Value, "True", StringComparison.OrdinalIgnoreCase)); if (alreadyGranted) continue; var tokenCurrency = account.Player.PlayerExtra.Currencies .FirstOrDefault(currency => currency.CurrencyType == CurrencyType.RecCenterTokens); if (tokenCurrency == null) { tokenCurrency = new PlayerCurrency { CurrencyType = CurrencyType.RecCenterTokens, BalanceType = BalanceType.NonPurchasedDefault, Balance = InitialTokenBalance }; account.Player.PlayerExtra.Currencies.Add(tokenCurrency); } else if (tokenCurrency.Balance <= 0) { tokenCurrency.Balance = InitialTokenBalance; tokenCurrency.BalanceType = BalanceType.NonPurchasedDefault; } account.Player.PlayerExtra.Settings.Add(new Setting { Key = InitialTokensSettingKey, Value = "True" }); if (Players.Update(account)) updated++; } return updated; } } public static int ResetAlpacaShirtOwnershipForExistingPlayers() { lock (SocialLock) { int ownershipsRemoved = 0; foreach (var account in Players.FindAll().ToList()) { if (account.Player == null) continue; account.Player.PlayerExtra ??= new PlayerExtra(); account.Player.PlayerExtra.Settings ??= new List(); account.Player.PlayerExtra.AvatarItems ??= new List(); bool resetAlreadyApplied = account.Player.PlayerExtra.Settings .Any(setting => string.Equals( setting.Key, AlpacaShirtOwnershipResetSettingKey, StringComparison.OrdinalIgnoreCase) && string.Equals( setting.Value, "True", StringComparison.OrdinalIgnoreCase)); if (resetAlreadyApplied) continue; int removedForAccount = account.Player.PlayerExtra.AvatarItems .RemoveAll(descriptor => string.Equals( descriptor, AlpacaShirtAvatarItemDescriptor, StringComparison.OrdinalIgnoreCase)); account.Player.PlayerExtra.Settings.Add(new Setting { Key = AlpacaShirtOwnershipResetSettingKey, Value = "True" }); if (Players.Update(account)) ownershipsRemoved += removedForAccount; } return ownershipsRemoved; } } public static bool OwnsAvatarItem( long playerId, string avatarItemDescriptor) { if (string.IsNullOrWhiteSpace(avatarItemDescriptor)) return false; lock (SocialLock) { var legacyItems = Players.FindById(playerId)? .Player?.PlayerExtra?.AvatarItems; return PlayerInventoryStore.OwnsAvatarItem( playerId, avatarItemDescriptor, legacyAvatarDescriptors: legacyItems); } } public static bool RemoveAvatarItem( long playerId, string avatarItemDescriptor, long avatarItemId = 0) { if (playerId <= 0 || (string.IsNullOrWhiteSpace(avatarItemDescriptor) && avatarItemId <= 0)) { return false; } lock (SocialLock) { var player = Players.FindById(playerId); if (player?.Player == null) return false; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.AvatarItems ??= new List(); if (!string.IsNullOrWhiteSpace(avatarItemDescriptor)) { player.Player.PlayerExtra.AvatarItems.RemoveAll(value => string.Equals( value, avatarItemDescriptor, StringComparison.OrdinalIgnoreCase)); } bool saved = PlayerInventoryStore.SetAvatarItemOwned( playerId, avatarItemDescriptor, avatarItemId, friendlyName: null, owned: false, legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems); return saved && Players.Update(player); } } public static List GetRelationships(long playerId) { return Players.FindById(playerId)?.Player == null ? new List() : RelationshipDB.GetRelationships(playerId); } public static PlayerRelationship? SetRelationshipFlags( long sourcePlayerId, long targetPlayerId, bool? ignored = null, bool? muted = null, bool? favorited = null) { if (!ValidateRelationshipPlayers(sourcePlayerId, targetPlayerId)) return null; PlayerRelationship relationship; lock (SocialLock) { relationship = RelationshipDB.SetFlags( sourcePlayerId, targetPlayerId, ignored: ignored, muted: muted, favorited: favorited); if (!SynchronizeLegacyRelationshipNoLock(sourcePlayerId, targetPlayerId)) return null; } return relationship; } private static bool ApplyBanStateToHeartbeat( FullPlayer player, long playerId, out Heartbeat heartbeat) { player.Player ??= new Player(); player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Heartbeat ??= new Heartbeat(); heartbeat = player.Player.PlayerExtra.Heartbeat; heartbeat.playerId = playerId; var moderation = player.Player.PlayerExtra.ModerationBlockDetails; if (moderation?.IsBan != true) { if (heartbeat.errorCode == MatchmakingErrorCode.Banned) { heartbeat.errorCode = MatchmakingErrorCode.Success; } return false; } long now = DateTimeOffset.UtcNow .ToUnixTimeSeconds(); bool permanent = moderation.Duration <= 0; bool active = permanent || moderation.ModerationSetUnixTime + moderation.Duration > now; if (!active) { moderation.IsBan = false; moderation.Duration = 0; moderation.Message = ""; moderation.ModerationSetUnixTime = 0; moderation.BannedByPlayerId = 0; if (heartbeat.errorCode == MatchmakingErrorCode.Banned) { heartbeat.errorCode = MatchmakingErrorCode.Success; } return false; } heartbeat.isOnline = false; heartbeat.roomInstance = null; heartbeat.errorCode = MatchmakingErrorCode.Banned; heartbeat.lastHeartbeatUnixTime = now; return true; } public static PlayerRelationship? SendFriendRequest( long sourcePlayerId, long targetPlayerId) { if (!ValidateRelationshipPlayers(sourcePlayerId, targetPlayerId)) return null; PlayerRelationship? result; lock (SocialLock) { PlayerRelationship? existing = RelationshipDB.GetRelationship( sourcePlayerId, targetPlayerId); if (existing?.RelationshipType is RelationshipType.OutgoingFriendRequest or RelationshipType.Friend) { result = existing; } else { result = RelationshipDB.SendFriendRequest( sourcePlayerId, targetPlayerId, out _); } if (!SynchronizeLegacyRelationshipNoLock(sourcePlayerId, targetPlayerId)) return null; } return result; } public static PlayerRelationship? AcceptFriendRequest( long receiverPlayerId, long requesterPlayerId) { if (!ValidateRelationshipPlayers(receiverPlayerId, requesterPlayerId)) return null; PlayerRelationship? result; lock (SocialLock) { result = RelationshipDB.AcceptFriendRequest( receiverPlayerId, requesterPlayerId); if (result == null) return null; if (!SynchronizeLegacyRelationshipNoLock(receiverPlayerId, requesterPlayerId)) return null; } return result; } public static PlayerRelationship? AddFriendRequest( long sourcePlayerId, long targetPlayerId) => SendFriendRequest(sourcePlayerId, targetPlayerId); private static bool ValidateRelationshipPlayers( long firstPlayerId, long secondPlayerId) { if (firstPlayerId <= 0 || secondPlayerId <= 0 || firstPlayerId == secondPlayerId) { return false; } return Players.FindById(firstPlayerId)?.Player != null && Players.FindById(secondPlayerId)?.Player != null; } private static bool SynchronizeLegacyRelationshipNoLock( long firstPlayerId, long secondPlayerId) { FullPlayer? first = Players.FindById(firstPlayerId); FullPlayer? second = Players.FindById(secondPlayerId); if (first?.Player == null || second?.Player == null) return false; first.Player.Relationships ??= new List(); second.Player.Relationships ??= new List(); SynchronizeOneLegacyRelationship( first.Player.Relationships, RelationshipDB.GetRelationship(firstPlayerId, secondPlayerId), secondPlayerId); SynchronizeOneLegacyRelationship( second.Player.Relationships, RelationshipDB.GetRelationship(secondPlayerId, firstPlayerId), firstPlayerId); return Players.Update(first) && Players.Update(second); } private static void SynchronizeOneLegacyRelationship( List relationships, PlayerRelationship? authoritative, long otherPlayerId) { relationships.RemoveAll(value => value.PlayerID == otherPlayerId); if (authoritative != null && authoritative.RelationshipType != RelationshipType.None) relationships.Add(authoritative); } private static long CreateRelationshipId( long firstPlayerId, long secondPlayerId) => RelationshipDB.GetStableNumericId(firstPlayerId, secondPlayerId); public static bool SetSubscription( long subscriberPlayerId, long targetPlayerId, bool subscribe) { if (subscriberPlayerId == targetPlayerId) return false; lock (SocialLock) { var subscriber = Players.FindById(subscriberPlayerId); var target = Players.FindById(targetPlayerId); if (subscriber?.Player == null || target?.Player == null) return false; if (!RelationshipDB.SetSubscription( subscriberPlayerId, targetPlayerId, subscribe)) return false; subscriber.Player.SubscribedAccountIds ??= new List(); subscriber.Player.Reputation ??= new Reputation(); target.Player.Reputation ??= new Reputation(); if (subscribe) { if (!subscriber.Player.SubscribedAccountIds.Contains(targetPlayerId)) subscriber.Player.SubscribedAccountIds.Add(targetPlayerId); } else { subscriber.Player.SubscribedAccountIds.RemoveAll(value => value == targetPlayerId); } subscriber.Player.Reputation.SubscribedCount = RelationshipDB.GetSubscribedCount(subscriberPlayerId); Players.Update(subscriber); target.Player.Reputation.SubscriberCount = RelationshipDB.GetSubscriberCount(targetPlayerId); Players.Update(target); return true; } } public static int GetSubscriberCount(long targetPlayerId) { return RelationshipDB.GetSubscriberCount(targetPlayerId); } public static int GetCurrencyBalance( long playerId, CurrencyType currencyType) { var player = Players.FindById(playerId); return player?.Player?.PlayerExtra?.Currencies? .FirstOrDefault(currency => currency.CurrencyType == currencyType)?.Balance ?? 0; } public static List<(CurrencyType CurrencyType, int Balance)> GetAllCurrencyBalances( long playerId) { var player = Players.FindById(playerId); var currencies = player?.Player?.PlayerExtra?.Currencies ?? new List(); var storedBalances = currencies .GroupBy(currency => currency.CurrencyType) .ToDictionary(group => group.Key, group => group.First().Balance); return Enum.GetValues() .Where(currency => currency != CurrencyType.Invalid) .Select(currency => ( currency, storedBalances.TryGetValue(currency, out int balance) ? balance : 0)) .ToList(); } public static int? SetCurrencyBalance( long playerId, CurrencyType currencyType, int amount, bool add) { lock (SocialLock) { var player = Players.FindById(playerId); if (player?.Player == null) return null; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Currencies ??= new List(); var currency = player.Player.PlayerExtra.Currencies .FirstOrDefault(value => value.CurrencyType == currencyType); if (currency == null) { currency = new PlayerCurrency { CurrencyType = currencyType, BalanceType = BalanceType.NonPurchasedDefault }; player.Player.PlayerExtra.Currencies.Add(currency); } long requestedBalance = add ? (long)currency.Balance + amount : amount; currency.Balance = (int)Math.Clamp( requestedBalance, 0L, int.MaxValue); return Players.Update(player) ? currency.Balance : null; } } public static bool TrySpendCurrency( long playerId, CurrencyType currencyType, int amount, out int newBalance) { newBalance = 0; if (amount < 0) return false; lock (SocialLock) { var player = Players.FindById(playerId); if (player?.Player == null) return false; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Currencies ??= new List(); var currency = player.Player.PlayerExtra.Currencies .FirstOrDefault(value => value.CurrencyType == currencyType); newBalance = currency?.Balance ?? 0; if (amount == 0) return true; if (currency == null || currency.Balance < amount) return false; int originalBalance = currency.Balance; currency.Balance -= amount; newBalance = currency.Balance; if (Players.Update(player)) return true; currency.Balance = originalBalance; newBalance = originalBalance; return false; } } public static bool TryPurchaseAvatarItem( long playerId, string avatarItemDescriptor, int price, out int newBalance, out bool alreadyOwned) { newBalance = 0; alreadyOwned = false; if (price < 0 || string.IsNullOrWhiteSpace(avatarItemDescriptor)) return false; lock (SocialLock) { var player = Players.FindById(playerId); if (player?.Player == null) return false; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Currencies ??= new List(); player.Player.PlayerExtra.AvatarItems ??= new List(); var currency = player.Player.PlayerExtra.Currencies .FirstOrDefault(value => value.CurrencyType == CurrencyType.RecCenterTokens); newBalance = currency?.Balance ?? 0; alreadyOwned = PlayerInventoryStore.OwnsAvatarItem( playerId, avatarItemDescriptor, legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems); if (alreadyOwned) return true; if (currency == null || currency.Balance < price) return false; currency.Balance -= price; player.Player.PlayerExtra.AvatarItems.Add(avatarItemDescriptor); PlayerInventoryStore.SetAvatarItemOwned( playerId, avatarItemDescriptor, avatarItemId: 0, friendlyName: null, owned: true, legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems); newBalance = currency.Balance; return Players.Update(player); } } public static bool GrantAvatarItem( long playerId, string avatarItemDescriptor) { if (playerId <= 0 || string.IsNullOrWhiteSpace(avatarItemDescriptor)) return false; lock (SocialLock) { var player = Players.FindById(playerId); if (player?.Player == null) return false; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.AvatarItems ??= new List(); if (player.Player.PlayerExtra.AvatarItems.Contains( avatarItemDescriptor, StringComparer.OrdinalIgnoreCase)) { PlayerInventoryStore.SetAvatarItemOwned( playerId, avatarItemDescriptor, avatarItemId: 0, friendlyName: null, owned: true, legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems); return true; } player.Player.PlayerExtra.AvatarItems.Add(avatarItemDescriptor); PlayerInventoryStore.SetAvatarItemOwned( playerId, avatarItemDescriptor, avatarItemId: 0, friendlyName: null, owned: true, legacyAvatarDescriptors: player.Player.PlayerExtra.AvatarItems); return Players.Update(player); } } public static bool IsSubscribed(long subscriberPlayerId, long targetPlayerId) { return RelationshipDB.IsSubscribed(subscriberPlayerId, targetPlayerId); } public static bool RemoveFriend(long sourcePlayerId, long targetPlayerId) { bool removed; lock (SocialLock) { var source = Players.FindById(sourcePlayerId); var target = Players.FindById(targetPlayerId); if (source?.Player == null || target?.Player == null) return false; source.Player.Relationships ??= new List(); target.Player.Relationships ??= new List(); source.Player.Relationships.RemoveAll(value => value.PlayerID == targetPlayerId); target.Player.Relationships.RemoveAll(value => value.PlayerID == sourcePlayerId); Players.Update(source); Players.Update(target); removed = RelationshipDB.RemoveFriend(sourcePlayerId, targetPlayerId); } return removed; } public static PlayerCheerRecord? GivePlayerCheer( long giverPlayerId, long receiverPlayerId, CheerCategory category, out NotificationDB.ClientNotification? notification, bool anonymous = false) { notification = null; if (giverPlayerId == receiverPlayerId || !IsPublicCheerCategory(category)) { return null; } PlayerCheerRecord? cheer; lock (SocialLock) { var giver = Players.FindById(giverPlayerId); var receiver = Players.FindById(receiverPlayerId); if (giver?.Player == null || receiver?.Player == null) return null; giver.Player.Reputation ??= new Reputation(); receiver.Player.ReceivedCheers ??= new List(); receiver.Player.Reputation ??= new Reputation(); long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); RefreshCheerCreditsNoLock( giver.Player.Reputation, giverPlayerId, now); if (giver.Player.Reputation.CheerCredit <= 0) return null; cheer = new PlayerCheerRecord { CheerId = Interlocked.Increment(ref CheerSequence), GiverPlayerId = giverPlayerId, ReceiverPlayerId = receiverPlayerId, CheerCategory = category, Anonymous = anonymous, Status = CheerStatus.Pending, CreatedAt = DateTime.UtcNow }; notification = NotificationDB.CreatePlayerCheer( giverPlayerId, receiverPlayerId, (int)category, anonymous); cheer.NotificationId = notification.Id; receiver.Player.ReceivedCheers.Add(cheer); var reputation = receiver.Player.Reputation; reputation.AccountId = receiverPlayerId; reputation.IsCheerful = true; giver.Player.Reputation.AccountId = giverPlayerId; giver.Player.Reputation.CheerCredit--; switch (category) { case CheerCategory.General: reputation.CheerGeneral++; break; case CheerCategory.Helpful: reputation.CheerHelpful++; break; case CheerCategory.Sportmanship: reputation.CheerSportsman++; break; case CheerCategory.GreatHost: reputation.CheerGreatHost++; break; case CheerCategory.Creative: reputation.CheerCreative++; break; } if (Players.Update(new[] { giver, receiver }) != 2) { NotificationDB.DeleteMessages( receiverPlayerId, new[] { notification.Id }); return null; } } return cheer; } private static bool RefreshCheerCreditsNoLock( Reputation reputation, long accountId, long nowUnixTime) { bool changed = false; if (reputation.AccountId != accountId) { reputation.AccountId = accountId; changed = true; } if (reputation.CheerCreditRefreshAtUnixTime <= 0) { reputation.CheerCredit = CheerCreditLimit; reputation.CheerCreditRefreshAtUnixTime = nowUnixTime + CheerCreditRefreshSeconds; return true; } if (nowUnixTime >= reputation.CheerCreditRefreshAtUnixTime) { long elapsed = nowUnixTime - reputation.CheerCreditRefreshAtUnixTime; long completedIntervals = (elapsed / CheerCreditRefreshSeconds) + 1; reputation.CheerCredit = CheerCreditLimit; reputation.CheerCreditRefreshAtUnixTime += completedIntervals * CheerCreditRefreshSeconds; changed = true; } int clamped = Math.Clamp( reputation.CheerCredit, 0, CheerCreditLimit); if (clamped != reputation.CheerCredit) { reputation.CheerCredit = clamped; changed = true; } return changed; } public static int ResolvePlayerCheers( long receiverPlayerId, IEnumerable cheerOrNotificationIds, CheerStatus status) { if (receiverPlayerId <= 0 || status == CheerStatus.Pending) { return 0; } HashSet ids = cheerOrNotificationIds .Where(value => value > 0) .ToHashSet(); if (ids.Count == 0) return 0; lock (SocialLock) { var receiver = Players.FindById(receiverPlayerId); if (receiver?.Player == null) return 0; receiver.Player.ReceivedCheers ??= new List(); List matching = receiver.Player.ReceivedCheers .Where(value => value.Status == CheerStatus.Pending && (ids.Contains(value.CheerId) || ids.Contains(value.NotificationId))) .ToList(); if (matching.Count == 0) return 0; DateTime handledAt = DateTime.UtcNow; foreach (PlayerCheerRecord cheer in matching) { cheer.Status = status; cheer.HandledAt = handledAt; } return Players.Update(receiver) ? matching.Count : 0; } } public static List GetPendingPlayerCheers( long receiverPlayerId) { if (receiverPlayerId <= 0) return new List(); lock (SocialLock) { var receiver = Players.FindById(receiverPlayerId); return receiver?.Player?.ReceivedCheers? .Where(value => value.Status == CheerStatus.Pending) .OrderByDescending(value => value.CreatedAt) .ToList() ?? new List(); } } private static bool IsPublicCheerCategory( CheerCategory category) => category is CheerCategory.General or CheerCategory.Helpful or CheerCategory.Sportmanship or CheerCategory.GreatHost or CheerCategory.Creative; public static Heartbeat GetPlayerHeartbeat( long playerId, long? viewingPlayerId = null) { var player = Players.FindOne( x => x.PlayerId == playerId); return CreateClientHeartbeat( player, playerId, DateTimeOffset.UtcNow.ToUnixTimeSeconds(), viewingPlayerId); } public static List GetPlayerHeartbeatsBulk( List playerIds, long? viewingPlayerId = null) { long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var players = Players .Find(x => playerIds.Contains(x.PlayerId)) .ToList(); return players .Select(p => CreateClientHeartbeat( p, p.PlayerId, now, viewingPlayerId)) .ToList(); } public static long[] GetActiveSameInstancePlayerIds(long subjectPlayerId) { if (subjectPlayerId <= 0) return Array.Empty(); Heartbeat? subjectHeartbeat = Players.FindById(subjectPlayerId)? .Player?.PlayerExtra?.Heartbeat; long roomInstanceId = subjectHeartbeat?.roomInstance?.roomInstanceId ?? 0; if (roomInstanceId <= 0) return Array.Empty(); long activeAfter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - PresenceFreshnessSeconds; return Players.FindAll() .Where(account => { if (account.PlayerId == subjectPlayerId) return false; Heartbeat? heartbeat = account.Player?.PlayerExtra?.Heartbeat; return heartbeat?.isOnline == true && heartbeat.lastHeartbeatUnixTime >= activeAfter && heartbeat.roomInstance?.roomInstanceId == roomInstanceId; }) .Select(account => account.PlayerId) .Distinct() .ToArray(); } private static Heartbeat CreateClientHeartbeat( FullPlayer? player, long playerId, long now, long? viewingPlayerId) { Heartbeat stored = player?.Player?.PlayerExtra?.Heartbeat ?? new Heartbeat(); bool isFresh = stored.lastHeartbeatUnixTime > 0 && stored.lastHeartbeatUnixTime >= now - PresenceFreshnessSeconds; bool isOnline = stored.isOnline && isFresh; RoomInstance? roomInstance = isOnline && stored.roomInstance != null ? CloneRoomInstance(stored.roomInstance) : null; bool viewerSharesRestrictedInstance = false; if (roomInstance != null && viewingPlayerId.HasValue && viewingPlayerId.Value != playerId) { Heartbeat? viewerHeartbeat = Players .FindById(viewingPlayerId.Value)? .Player?.PlayerExtra?.Heartbeat; viewerSharesRestrictedInstance = viewerHeartbeat?.isOnline == true && viewerHeartbeat.roomInstance?.roomInstanceId == roomInstance.roomInstanceId; } if (roomInstance != null && viewingPlayerId.HasValue && viewingPlayerId.Value != playerId && Sessions.IsRestrictedInstance(roomInstance) && !viewerSharesRestrictedInstance && !NotificationDB.HasActiveRoomInvite( viewingPlayerId.Value, playerId, roomInstance.roomInstanceId)) { roomInstance = null; } if (roomInstance != null && roomInstance.roomInstanceType != RoomInstanceType.Dormroom && roomInstance.roomId > 0) { var room = RoomDB.GetRoom(roomInstance.roomId); if (room != null && !string.IsNullOrWhiteSpace(room.Name)) roomInstance.Name = $"^{room.Name.Trim().TrimStart('^')}"; } return new Heartbeat { appVersion = stored.appVersion, deviceClass = stored.deviceClass, errorCode = stored.errorCode, isOnline = isOnline, playerId = playerId, roomInstance = roomInstance, statusVisibility = isOnline ? stored.statusVisibility : StatusVisibility.Offline, vrMovementMode = stored.vrMovementMode, lastHeartbeatUnixTime = stored.lastHeartbeatUnixTime }; } public static void RecordRoomVisit( long playerId, long roomId) { var player = Players.FindById(playerId); if (player?.Player == null) return; player.Player.VisitedRooms ??= new List(); if (!player.Player.VisitedRooms.Contains(roomId)) player.Player.VisitedRooms.Add(roomId); player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.RoomVisits ??= new List(); var existingVisit = player.Player.PlayerExtra.RoomVisits .FirstOrDefault(v => v.RoomId == roomId); bool isNewVisitor = existingVisit == null; if (existingVisit != null) { existingVisit.VisitedAt = DateTime.UtcNow; } else { player.Player.PlayerExtra.RoomVisits.Add( new RoomVisit { RoomId = roomId, VisitedAt = DateTime.UtcNow }); } Players.Update(player); var room = RoomDB.GetRoom(roomId); if (room != null) { room.Stats ??= new RoomDBClasses.Stats(); room.Stats.VisitCount++; if (isNewVisitor) room.Stats.VisitorCount++; RoomDB.Rooms.Update(room); } } public static bool ToggleCheer( long playerId, long roomId, out bool isNowCheered) { var player = Players.FindById(playerId); isNowCheered = false; if (player?.Player == null) return false; player.Player.CheeredRooms ??= new List(); bool wasCheered = player.Player.CheeredRooms.Contains(roomId); if (wasCheered) { player.Player.CheeredRooms.Remove(roomId); } else { player.Player.CheeredRooms.Add(roomId); } isNowCheered = !wasCheered; Players.Update(player); var room = RoomDB.GetRoom(roomId); if (room != null) { room.Stats ??= new RoomDBClasses.Stats(); room.Stats.CheerCount = Math.Max( 0, room.Stats.CheerCount + (wasCheered ? -1 : 1)); RoomDB.Rooms.Update(room); } return true; } public static bool ToggleFavorite( long playerId, long roomId, out bool isNowFavorited) { var player = Players.FindById(playerId); isNowFavorited = false; if (player?.Player == null) return false; player.Player.FavoritedRooms ??= new List(); bool wasFavorited = player.Player.FavoritedRooms.Contains(roomId); if (wasFavorited) { player.Player.FavoritedRooms.Remove(roomId); } else { player.Player.FavoritedRooms.Add(roomId); } isNowFavorited = !wasFavorited; Players.Update(player); var room = RoomDB.GetRoom(roomId); if (room != null) { room.Stats ??= new RoomDBClasses.Stats(); room.Stats.FavoriteCount = Math.Max( 0, room.Stats.FavoriteCount + (wasFavorited ? -1 : 1)); RoomDB.Rooms.Update(room); } return true; } public static ( List Results, int Total) SearchAccounts( string query, int skip, int take) { if (string.IsNullOrWhiteSpace(query)) { return ( new List(), 0 ); } var q = query .Trim() .ToLowerInvariant(); var matches = Players .FindAll() .Where(p => (p.Player?.Username? .ToLowerInvariant() .Contains(q) ?? false) || (p.Player?.DisplayName? .ToLowerInvariant() .Contains(q) ?? false)) .OrderBy(p => p.Player?.Username) .ToList(); int total = matches.Count; var page = matches .Skip(skip) .Take(take) .Select(p => MapToDTO(p, false)) .ToList(); return (page, total); } public static Heartbeat? UpdatePlayerHeartbeat( long playerId, RoomInstance? roomInstance, bool online = true, Platforms platform = Platforms.All, DeviceClasses deviceClasses = DeviceClasses.Unknown) { var player = Players.FindById(playerId); if (player == null) return null; player.Player ??= new Player(); player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Heartbeat ??= new Heartbeat(); var hb = player.Player.PlayerExtra.Heartbeat; long? oldRoomInstanceId = hb.roomInstance?.roomInstanceId; long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); hb.playerId = playerId; hb.roomInstance = online ? roomInstance : null; hb.errorCode = MatchmakingErrorCode.Success; hb.isOnline = online; hb.lastHeartbeatUnixTime = now; long newRoomInstanceId = online ? roomInstance?.roomInstanceId ?? 0 : 0; if (player.Player.PlayerExtra.LevelingRoomInstanceId != newRoomInstanceId) { ResetRoomLevelTimer( player.Player.PlayerExtra, newRoomInstanceId, newRoomInstanceId > 0 ? now : 0); } Players.Update(player); if (oldRoomInstanceId != hb.roomInstance?.roomInstanceId) { Console.WriteLine( $"[ROOM MEMBERSHIP] player={playerId} " + $"oldInstance={oldRoomInstanceId?.ToString() ?? "none"} " + $"newInstance={hb.roomInstance?.roomInstanceId.ToString() ?? "none"} " + "broadcast=false"); } return hb; } public static Heartbeat? LeaveCurrentRoom(long playerId) { var player = Players.FindById(playerId); if (player == null) return null; player.Player ??= new Player(); player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Heartbeat ??= new Heartbeat(); var heartbeat = player.Player.PlayerExtra.Heartbeat; long? oldRoomInstanceId = heartbeat.roomInstance?.roomInstanceId; heartbeat.playerId = playerId; heartbeat.isOnline = true; heartbeat.roomInstance = null; heartbeat.lastHeartbeatUnixTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); ResetRoomLevelTimer(player.Player.PlayerExtra, 0, 0); Players.Update(player); Console.WriteLine( $"[ROOM LEAVE] player={playerId} " + $"instance={oldRoomInstanceId?.ToString() ?? "none"} " + "isolated=true presencePush=controller-awaited"); return heartbeat; } public static Heartbeat? TouchPlayerHeartbeat(long playerId) { int? oldProgressionLevel = null; int? newProgressionLevel = null; int progressionXp = 0; Heartbeat? result; lock (ProgressionLock) { var player = Players.FindById(playerId); if (player == null) return null; bool oldOnline = player.Player?.PlayerExtra?.Heartbeat?.isOnline ?? false; MatchmakingErrorCode? oldErrorCode = player.Player?.PlayerExtra?.Heartbeat?.errorCode; if (ApplyBanStateToHeartbeat( player, playerId, out var heartbeat)) { if (player.Player?.PlayerExtra != null) ResetRoomLevelTimer( player.Player.PlayerExtra, 0, 0); Players.Update(player); if (oldOnline != heartbeat.isOnline || oldErrorCode != heartbeat.errorCode) { _ = NotiController.NotifyPlayerPresenceUpdatedAsync( playerId); } return heartbeat; } heartbeat.playerId = playerId; heartbeat.isOnline = true; heartbeat.errorCode = MatchmakingErrorCode.Success; long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); heartbeat.lastHeartbeatUnixTime = now; if (player.Player != null && ApplyRoomTimeLeveling( player.Player, now, out int awardedFromLevel, out int awardedToLevel)) { oldProgressionLevel = awardedFromLevel; newProgressionLevel = awardedToLevel; progressionXp = player.Player.XP; } Players.Update(player); if (oldOnline != heartbeat.isOnline || oldErrorCode != heartbeat.errorCode) { _ = NotiController.NotifyPlayerPresenceUpdatedAsync( playerId); } result = heartbeat; } if (oldProgressionLevel.HasValue && newProgressionLevel.HasValue) { Console.WriteLine( $"[ROOM LEVEL] player={playerId} " + $"level={oldProgressionLevel.Value}->{newProgressionLevel.Value} " + $"interval={RoomLevelSeconds}s"); GrantLevelUpBox( playerId, oldProgressionLevel.Value, newProgressionLevel.Value); _ = NotiController.NotifyProgressionAsync( playerId, oldProgressionLevel.Value, newProgressionLevel.Value, progressionXp); } return result; } public static Heartbeat? ResumePlayerHeartbeat(long playerId) { lock (ProgressionLock) { var player = Players.FindById(playerId); if (player == null) return null; player.Player ??= new Player(); player.Player.PlayerExtra ??= new PlayerExtra(); Heartbeat heartbeat = player.Player.PlayerExtra.Heartbeat ??= new Heartbeat(); if (ApplyBanStateToHeartbeat( player, playerId, out heartbeat)) { ResetRoomLevelTimer( player.Player.PlayerExtra, 0, 0); Players.Update(player); return heartbeat; } long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); bool existingPresenceIsFresh = heartbeat.isOnline && heartbeat.lastHeartbeatUnixTime > 0 && heartbeat.lastHeartbeatUnixTime >= now - PresenceFreshnessSeconds; if (!existingPresenceIsFresh) { heartbeat.roomInstance = null; ResetRoomLevelTimer( player.Player.PlayerExtra, 0, 0); } heartbeat.playerId = playerId; heartbeat.isOnline = true; heartbeat.errorCode = MatchmakingErrorCode.Success; heartbeat.lastHeartbeatUnixTime = now; Players.Update(player); return heartbeat; } } public static void SetPlayerOffline(long playerId) { var player = Players.FindById(playerId); var heartbeat = player?.Player?.PlayerExtra?.Heartbeat; if (player == null || heartbeat == null) return; long? preservedRoomInstanceId = heartbeat.roomInstance?.roomInstanceId; heartbeat.isOnline = false; heartbeat.lastHeartbeatUnixTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (player.Player?.PlayerExtra != null) ResetRoomLevelTimer(player.Player.PlayerExtra, 0, 0); Players.Update(player); Console.WriteLine( $"[PLAYER OFFLINE] player={playerId} " + $"preservedInstance={preservedRoomInstanceId?.ToString() ?? "none"} " + "broadcast=controller-awaited"); } private static bool ApplyRoomTimeLeveling( Player player, long now, out int oldLevel, out int newLevel) { oldLevel = player.Level; newLevel = player.Level; player.PlayerExtra ??= new PlayerExtra(); Heartbeat heartbeat = player.PlayerExtra.Heartbeat ??= new Heartbeat(); long roomInstanceId = heartbeat.isOnline ? heartbeat.roomInstance?.roomInstanceId ?? 0 : 0; if (roomInstanceId <= 0 || player.Level >= MaximumLevel) { ResetRoomLevelTimer( player.PlayerExtra, roomInstanceId, roomInstanceId > 0 ? now : 0); return false; } if (player.PlayerExtra.LevelingRoomInstanceId != roomInstanceId) { ResetRoomLevelTimer( player.PlayerExtra, roomInstanceId, now); return false; } long previous = player.PlayerExtra.LevelingLastHeartbeatUnixTime; player.PlayerExtra.LevelingLastHeartbeatUnixTime = now; if (previous <= 0 || now <= previous) return false; long elapsed = now - previous; if (elapsed > MaximumCreditableHeartbeatGapSeconds) { return false; } player.PlayerExtra.LevelingRoomActiveSeconds = Math.Min( RoomLevelSeconds, player.PlayerExtra.LevelingRoomActiveSeconds + checked((int)elapsed)); if (player.PlayerExtra.LevelingRoomActiveSeconds < RoomLevelSeconds) { return false; } oldLevel = player.Level; player.Level = Math.Min(MaximumLevel, player.Level + 1); newLevel = player.Level; player.PlayerExtra.LevelingRoomActiveSeconds = 0; return newLevel > oldLevel; } private static void GrantLevelUpBox( long playerId, int oldLevel, int newLevel) { if (playerId <= 0 || newLevel <= oldLevel) return; var player = Players.FindById(playerId); if (player?.Player == null) return; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Currencies ??= new List(); for (int level = oldLevel + 1; level <= newLevel; level++) { int rarity = GetLevelBoxRarity(level); APIController.StorefrontAdminItem? boxItem = APIController.GetWebsiteStorefrontItems() .Where(item => item.Rarity == rarity && !string.IsNullOrWhiteSpace(item.ConsumableItemDesc)) .OrderBy(_ => LevelBoxRng.Next()) .FirstOrDefault(); if (boxItem != null) { PlayerInventoryStore.AddConsumable( playerId, boxItem.ConsumableItemDesc, consumableItemId: 0, friendlyName: boxItem.FriendlyName, amount: 1); } APIController.StorefrontAdminItem? avatarBoxItem = APIController.GetWebsiteStorefrontItems() .Where(item => item.Rarity == rarity && !string.IsNullOrWhiteSpace(item.AvatarItemDesc)) .OrderBy(_ => LevelBoxRng.Next()) .FirstOrDefault() ?? APIController.GetWebsiteStorefrontItems() .Where(item => !string.IsNullOrWhiteSpace(item.AvatarItemDesc)) .OrderBy(_ => LevelBoxRng.Next()) .FirstOrDefault(); if (avatarBoxItem != null) { PlayerInventoryStore.SetAvatarItemOwned( playerId, avatarBoxItem.AvatarItemDesc, avatarBoxItem.AvatarItemId, avatarBoxItem.FriendlyName, owned: true); } PlayerCurrency? currency = player.Player.PlayerExtra.Currencies.FirstOrDefault( value => value.CurrencyType == CurrencyType.RecCenterTokens); if (currency == null) { currency = new PlayerCurrency { CurrencyType = CurrencyType.RecCenterTokens, BalanceType = BalanceType.NonPurchasedDefault }; player.Player.PlayerExtra.Currencies.Add(currency); } long adjustedBalance = (long)currency.Balance + 1000; currency.Balance = (int)Math.Clamp( adjustedBalance, (long)int.MinValue, (long)int.MaxValue); Console.WriteLine( $"[LEVEL BOX] player={playerId} level={level} " + $"rarity={rarity} box={boxItem?.FriendlyName ?? "none"} " + $"avatarItem={avatarBoxItem?.FriendlyName ?? "none"} " + "tokens=1000"); } Players.Update(player); } private static void ResetRoomLevelTimer( PlayerExtra playerExtra, long roomInstanceId, long lastHeartbeatUnixTime) { playerExtra.LevelingRoomInstanceId = roomInstanceId; playerExtra.LevelingRoomActiveSeconds = 0; playerExtra.LevelingLastHeartbeatUnixTime = lastHeartbeatUnixTime; } public static void BanPlayer( long playerId, int durationSeconds, string reason, ulong bannedByPlayerId = 0) { var player = Players.FindById(playerId); if (player?.Player == null) return; player.Player.PlayerExtra ??= new PlayerExtra(); string appealCode = GenerateAppealCode(); string reasonWithAppeal = $"{reason}\n\nAppeal this ban: /recnet/banappeal?code={appealCode}"; player.Player.PlayerExtra.ModerationBlockDetails = new ModerationBlockDetails { ReportCategory = ReportCategory.CoC_Discrimination, Duration = durationSeconds, IsBan = true, Message = reasonWithAppeal, ModerationSetUnixTime = DateTimeOffset.UtcNow .ToUnixTimeSeconds(), BannedByPlayerId = bannedByPlayerId, AppealCode = appealCode }; ApplyBanStateToHeartbeat( player, playerId, out _); Players.Update(player); NotiController.ForceDisconnectPlayer(playerId); } private const string AppealCodeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; private static string GenerateAppealCode() { Span buffer = stackalloc char[10]; for (int i = 0; i < buffer.Length; i++) buffer[i] = AppealCodeChars[System.Random.Shared.Next(AppealCodeChars.Length)]; return new string(buffer); } public static bool UnbanPlayer(long playerId) { var player = Players.FindById(playerId); if (player? .Player? .PlayerExtra? .ModerationBlockDetails == null) { return false; } player.Player.PlayerExtra.ModerationBlockDetails = new ModerationBlockDetails(); Players.Update(player); return true; } public static bool UpdateEquipment( long playerId, List equipment) { var player = Players.FindById(playerId); if (player?.Player == null) return false; player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Equipment = equipment; return Players.Update(player); } public static string? GetEmail(long playerId) { var player = Players.FindById(playerId); return player?.Player?.Email; } public static bool UpdateDisplayEmoji( long playerId, string displayEmoji) { if (playerId <= 0) return false; var player = Players.FindById(playerId); if (player?.Player == null) return false; player.Player.DisplayEmoji = displayEmoji ?? ""; return Players.Update(player); } public static bool UpdateEmail( long playerId, string email) { var player = Players.FindById(playerId); if (player?.Player == null) return false; player.Player.Email = email; return Players.Update(player); } public static bool IsPlayerBanned( long playerId, out ModerationBlockDetails? details) { details = null; var player = Players.FindById(playerId); var mod = player? .Player? .PlayerExtra? .ModerationBlockDetails; if (mod == null || mod.IsBan != true) return false; long now = DateTimeOffset.UtcNow .ToUnixTimeSeconds(); bool permanentBan = mod.Duration <= 0; bool stillBanned = permanentBan || (mod.ModerationSetUnixTime + mod.Duration) > now; details = stillBanned ? mod : null; return stillBanned; } } }