using DorkNet.Server.Data; using Microsoft.EntityFrameworkCore; namespace DorkNet.Server.Services; /// /// Online-status visibility enum (2023 client, dump.cs IPLLHDINPBL). Picked in /// the profile's "Set Online Status Visibility" dialog (Assembly-CSharp /// RRUI/Data/BaseAccountModel.txt, ShowChangeOnlineStatusVisibilityDialog) and /// sent as PUT player/statusvisibility form statusVisibility (RecNet.Runtime /// RecNet/Matchmaking.txt:17147-17161). /// public static class PresenceVisibility { public const int Public = 0; public const int FriendsOnly = 1; public const int FavoriteFriendsOnly = 2; public const int Offline = 3; public static bool IsValid(int value) => value is >= Public and <= Offline; } /// /// Tells a player's friends when that player's presence changes, from one /// place, so every transition sends the same frame built from the same state. /// /// Before this existed only the immediate /goto commit pushed anything. /// Deferred commits (matchmake/dorm, private-instance hops) committed in /// reportjoinresult without a push, matchmake/none and logout changed presence /// silently, and SignalR connect/disconnect pushed with isOnline derived /// from "has a room" — so a friend's list showed people in rooms they had left, /// offline while they sat in their dorm, and never noticed a status-visibility /// change. /// /// The client's PresenceUpdate handler (Matchmaking.EGPABEDPEJO, registered at /// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into /// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync /// correction (:18467). A frame about the LOCAL player therefore moves that player, so /// these frames go to friends only, never to the subject. /// public sealed class PresenceBroadcastService( IServiceScopeFactory scopes, PlayerPresenceService presence, OnlinePresenceService connections, NotificationService notifications, ILogger logger) { /// PlayerSettings key for the persisted /// /player/statusvisibility choice. public const string StatusVisibilityPrefKey = "pref:statusVisibility"; /// A player counts as online while they heartbeat or hold a /// SignalR connection. Being in a room is NOT required: the client's /// presence DTO carries isOnline separately (LCEHINCCHOB +0x50) and /// DNKHCBFPLJG reads "online, in no room / the offline room" as a valid /// state, which is exactly what a player in the offline dorm is. public bool IsOnline(long playerId) => presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0; /// Push the player's current presence (online state + committed /// room) to every friend. public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false); /// Push an offline frame regardless of any lingering markers — /// for an explicit logout. public Task PublishOfflineAsync(long playerId) => PublishAsync(playerId, forceOffline: true); /// /// The last SignalR connection dropped but the player may still be /// playing: the socket can be closed under a live session ("RecNet /// HubConnection error: ... TCP Stream closed unexpectedly by the remote /// server") and the client reconnects on its own. Pushing offline right /// away greys the player out for every friend until that reconnect. Wait /// one activity window and only then decide: still silent means the game /// is really gone. /// public void ScheduleOfflineCheck(long playerId) { var delay = PlayerPresenceService.ActivityTtl + TimeSpan.FromSeconds(5); _ = Task.Run(async () => { try { await Task.Delay(delay); if (IsOnline(playerId)) return; await PublishAsync(playerId, forceOffline: true); } catch (Exception ex) { logger.LogWarning(ex, "[presence-fanout] deferred offline check failed player={PlayerId}", playerId); } }); } private async Task PublishAsync(long playerId, bool forceOffline) { if (playerId <= 0) return; try { await using var scope = scopes.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var settings = scope.ServiceProvider.GetRequiredService(); var friendIds = (await RelationshipQueries.EffectiveFriendIdsAsync(db, settings, playerId)) .Where(id => id != playerId) .Distinct() .ToList(); if (friendIds.Count == 0) return; var online = !forceOffline && IsOnline(playerId); var room = online ? presence.GetRoom(playerId) : null; var visibility = await GetStatusVisibilityAsync(db, playerId); // Honour the player's visibility choice the same way GET /player // does (PlayerPresenceController.ResolveHiddenAsync): recipients // who may not see them get the frame a genuinely offline player // produces. Every recipient here is already a friend, so // FriendsOnly needs no split; FavoriteFriendsOnly reaches only the // friends this player favorited (Favorited lives on the // favoriter's own row, RequesterId = favoriter). var favorited = visibility == PresenceVisibility.FavoriteFriendsOnly ? (await db.Relationships.AsNoTracking() .Where(r => r.RequesterId == playerId && r.Favorited && friendIds.Contains(r.TargetId)) .Select(r => r.TargetId) .ToListAsync()).ToHashSet() : new HashSet(); var (visibleTo, hiddenFrom) = SplitRecipients(friendIds, visibility, favorited); logger.LogInformation( "[presence-fanout] player={PlayerId} online={Online} room={RoomId} instance={InstanceId} visibility={Visibility} -> {FriendCount} friend(s), {HiddenCount} hidden", playerId, online, room?.RoomId, room?.RoomInstanceId, visibility, visibleTo.Count, hiddenFrom.Count); if (visibleTo.Count > 0) await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility); if (hiddenFrom.Count > 0) await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility); } catch (Exception ex) { // Best-effort: a fan-out failure must never fail the matchmake, // logout or hub event that triggered it. logger.LogWarning(ex, "[presence-fanout] push failed player={PlayerId}", playerId); } } /// Split a player's friends into those who may see their real /// presence and those who must see them offline, per their status /// visibility. holds the friends the player /// favorited; it only matters for FavoriteFriendsOnly. public static (List VisibleTo, List HiddenFrom) SplitRecipients( IReadOnlyList friendIds, int visibility, IReadOnlySet favorited) => visibility switch { PresenceVisibility.Offline => (new List(), friendIds.ToList()), PresenceVisibility.FavoriteFriendsOnly => ( friendIds.Where(favorited.Contains).ToList(), friendIds.Where(id => !favorited.Contains(id)).ToList()), _ => (friendIds.ToList(), new List()), }; /// Stored status visibility for one player (0 = default). public static async Task GetStatusVisibilityAsync(DorkNetDbContext db, long playerId) { var raw = await db.PlayerSettings.AsNoTracking() .Where(s => s.PlayerId == playerId && s.Key == StatusVisibilityPrefKey) .Select(s => s.Value) .FirstOrDefaultAsync(); return int.TryParse(raw, out var v) && PresenceVisibility.IsValid(v) ? v : PresenceVisibility.Public; } /// Stored status visibility for many players in one query. /// Players with no stored choice are absent from the result. public static async Task> GetStatusVisibilitiesAsync( DorkNetDbContext db, IReadOnlyCollection playerIds) { if (playerIds.Count == 0) return new Dictionary(); var rows = await db.PlayerSettings.AsNoTracking() .Where(s => s.Key == StatusVisibilityPrefKey && playerIds.Contains(s.PlayerId)) .Select(s => new { s.PlayerId, s.Value }) .ToListAsync(); var result = new Dictionary(rows.Count); foreach (var row in rows) { if (int.TryParse(row.Value, out var v) && PresenceVisibility.IsValid(v)) result[row.PlayerId] = v; } return result; } }