From 31c53b90704f1d2b90b125d333e553261f761216 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:37:19 +0300 Subject: [PATCH] Refactor presence tracking: replace OnlinePresenceService with PlayerPresenceService for improved activity management and streamline message handling logic --- .../API/Messages/V2/MessagesController.cs | 34 +++- .../Controllers/Admin/AdminController.cs | 15 +- .../Controllers/Match/GoToController.cs | 15 +- .../Controllers/Match/MatchController.cs | 6 + .../Match/MatchPlayerController.cs | 8 + .../Match/PlayerPresenceController.cs | 19 +-- DorkNet.Server/Data/Entities/MessageEntity.cs | 4 +- .../Services/PlayerPresenceService.cs | 161 +++++++++++++++++- 8 files changed, 221 insertions(+), 41 deletions(-) diff --git a/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs b/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs index 3626eba..1d55b08 100644 --- a/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs +++ b/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs @@ -22,7 +22,6 @@ public class MessagesController( DorkNetDbContext db, NotificationService notifications, PrivateInstanceService privateInstances, - OnlinePresenceService onlinePresence, PlayerPresenceService presence, ServerSettingsService serverSettings, ILogger logger) : ControllerBase @@ -42,6 +41,7 @@ public class MessagesController( take = Math.Clamp(take, 1, 200); var rows = await db.Messages .Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me) + .Where(m => m.Type >= 0) // Defensive filter for the empty-body ghost rows that // accumulated before SendFormMessage rejected them. The // watch's MessagesScreenItem renders TextMessage (type 30) @@ -65,7 +65,7 @@ public class MessagesController( public async Task>> Unread() { var rows = await db.Messages - .Where(m => m.RecipientPlayerId == Me && m.ReadAt == null) + .Where(m => m.RecipientPlayerId == Me && m.ReadAt == null && m.Type >= 0) // Same ghost-row filter as GetMessages above — keep the // unread badge count honest by hiding empty TextMessages. .Where(m => m.Type != 30 || (m.Body != null && m.Body != "")) @@ -82,7 +82,7 @@ public class MessagesController( public List? MessageIds { get; set; } } - /// POST api/messages/v3/delete — hard-delete one or + /// POST api/messages/v3/delete — delete one or /// more messages addressed to the caller. The 2020 watch's inbox /// "delete" action posts JSON {"MessageIds":[8]} (plural /// array, verified from the live trace where the body was @@ -90,7 +90,9 @@ public class MessagesController( /// some older / SPA call sites still post id or /// messageId as a form/query. Accept all three shapes and /// scope every delete to RecipientPlayerId == Me so the - /// sender can't unsend. + /// sender can't unsend. Game invites are tombstoned instead of + /// hard-deleted because the watch accepts an invite by firing this + /// delete request and /goto/invite/{id} in parallel. [HttpPost("/api/messages/v3/delete")] [HttpPost("/api/messages/v1/delete")] [Consumes("application/json")] @@ -128,10 +130,27 @@ public class MessagesController( { ids = ids.Distinct().ToList(); if (ids.Count == 0) return BadRequest(new { error = "missing_id" }); - var deleted = await db.Messages + + var inviteRows = await db.Messages + .Where(m => ids.Contains(m.Id) + && m.RecipientPlayerId == Me + && (m.Type == 6 || m.Type == 7)) + .ToListAsync(); + var now = DateTime.UtcNow; + foreach (var invite in inviteRows) + { + invite.Type = -invite.Type; + invite.ReadAt ??= now; + } + + var hardDeleted = await db.Messages .Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me) + .Where(m => m.Type != 6 && m.Type != 7 && m.Type != -6 && m.Type != -7) .ExecuteDeleteAsync(); - return Ok(new { success = true, error = "", deleted }); + if (inviteRows.Count > 0) + await db.SaveChangesAsync(); + + return Ok(new { success = true, error = "", deleted = hardDeleted + inviteRows.Count }); } /// POST api/messages/v3/markRead — bulk @@ -537,11 +556,10 @@ public class MessagesController( { // Honors the global-friends toggle (everyone is a friend when on). var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, Me); - var online = onlinePresence.OnlinePlayerIds().ToHashSet(); return Ok(friendIds.Select(id => new { AccountId = id, - Online = online.Contains(id), + Online = presence.IsRecentlyActive(id), })); } diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 6b4e97f..bb986ba 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -75,7 +75,6 @@ public class AdminController( q = q.Where(p => p.Username.Contains(query) || p.DisplayName.Contains(query)); var page = await q.Skip(skip).Take(take).ToListAsync(); - var onlineSet = onlinePresence.OnlinePlayerIds().ToHashSet(); return Ok(page.Select(p => new { p.Id, @@ -94,7 +93,7 @@ public class AdminController( p.Level, p.XP, p.ProfileImageName, - Online = onlineSet.Contains(p.Id), + Online = playerPresence.IsRecentlyActive(p.Id), })); } @@ -133,7 +132,7 @@ public class AdminController( p.Level, p.XP, p.ProfileImageName, - Online = onlinePresence.OnlinePlayerIds().Contains(p.Id), + Online = playerPresence.IsRecentlyActive(p.Id), Balances = balances, Avatar = avatar, }); @@ -1169,7 +1168,7 @@ public class AdminController( .Select(r => new { r.Id, r.CreatorPlayerId, r.IsDormRoom }) .FirstOrDefaultAsync(); - var onlinePids = onlinePresence.OnlinePlayerIds().ToList(); + var onlinePids = playerPresence.RecentlyActivePlayerIds().ToList(); var byInstance = new Dictionary pids)>(); foreach (var pid in onlinePids) { @@ -1235,7 +1234,7 @@ public class AdminController( // route the pulled player into. If nobody's online in the // instance anymore the instance has effectively died; reject. DorkNet.Server.Controllers.Match.RoomInstanceDto? sample = null; - foreach (var pid in onlinePresence.OnlinePlayerIds()) + foreach (var pid in playerPresence.RecentlyActivePlayerIds()) { var r = playerPresence.GetRoom(pid); if (r is not null && r.RoomId == id && r.RoomInstanceId == instanceId) @@ -1271,7 +1270,7 @@ public class AdminController( { var reason = body?.Reason ?? "Instance closed by admin"; var kicked = new List(); - foreach (var pid in onlinePresence.OnlinePlayerIds().ToList()) + foreach (var pid in playerPresence.RecentlyActivePlayerIds().ToList()) { var r = playerPresence.GetRoom(pid); if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue; @@ -1787,7 +1786,7 @@ public class AdminController( var activeIpBans = await db.IpBans.CountAsync(b => b.Until == null || b.Until > now); - var online = onlinePresence.OnlinePlayerIds().ToArray(); + var online = playerPresence.RecentlyActivePlayerIds().ToArray(); var onlineSet = online.ToHashSet(); var currentRoomsByPlayer = online @@ -2569,7 +2568,7 @@ public class AdminController( [HttpGet("instances")] public async Task ListInstances() { - var onlineIds = onlinePresence.OnlinePlayerIds().ToHashSet(); + var onlineIds = playerPresence.RecentlyActivePlayerIds().ToHashSet(); if (onlineIds.Count == 0) return Ok(Array.Empty()); // Bulk-pull names so we can render usernames per participant. diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index 3193b91..ddc588f 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -402,7 +402,7 @@ public class GoToController( var invite = await db.Messages.FirstOrDefaultAsync(m => m.Id == inviteId && m.RecipientPlayerId == callerId.Value - && (m.Type == 6 || m.Type == 7)); + && (m.Type == 6 || m.Type == 7 || m.Type == -6 || m.Type == -7)); long? roomInstanceId = null; if (invite is not null && TryParseRoomInstanceId(invite.Body, out var parsed)) @@ -413,12 +413,12 @@ public class GoToController( // parallel — verified in the live server log: // 09:53:47.161Z DELETE messageIds=[60] // 09:53:47.245Z /goto/invite/60 → [goto-invite] invite 60 not found - // DELETE usually wins the race, so the message row is gone - // by the time we run FirstOrDefaultAsync. The - // (instance, player) → LatestInviteMessageId mapping that - // SendInvite writes onto the PrivateInstanceInviteeEntity - // survives the message deletion and lets us recover - // roomInstanceId here. + // Current deletes tombstone invite rows so this should only + // be needed for invite rows that were hard-deleted by older + // builds. The (instance, player) → LatestInviteMessageId + // mapping that SendInvite writes onto the + // PrivateInstanceInviteeEntity survives those old deletions + // and lets us recover roomInstanceId here. if (roomInstanceId is null) { var inviteeRoomInstance = await db.PrivateInstanceInvitees @@ -741,6 +741,7 @@ public class GoToController( } presence.SetRoom(playerId, targetRoom); + presence.MarkActive(playerId); // Real visit tracking: upsert the per-(room, player) row so // we get distinct VisitorCount (unique players ever) AND the diff --git a/DorkNet.Server/Controllers/Match/MatchController.cs b/DorkNet.Server/Controllers/Match/MatchController.cs index 7a67517..29c59bb 100644 --- a/DorkNet.Server/Controllers/Match/MatchController.cs +++ b/DorkNet.Server/Controllers/Match/MatchController.cs @@ -56,6 +56,7 @@ public class MatchController( if (completed.HasValue) { presence.SetRoom(pid, completed.Value.TargetRoom); + presence.MarkActive(pid); logger.LogInformation( "[joinresult] committed presence player={Player} room={RoomId}/{RoomName} instance={Instance} deferred={Deferred}", pid, @@ -64,6 +65,11 @@ public class MatchController( completed.Value.TargetRoom.RoomInstanceId, completed.Value.DeferPresenceCommit); } + else + { + presence.MarkActive(pid); + presence.TouchRoom(pid); + } logger.LogInformation( "[joinresult] player={Player} instance={Instance} OK ({Label})", pid, instanceId, label); diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index eb5fe97..ea76f26 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -128,6 +128,11 @@ public class MatchPlayerController( // an HTTP 401 mid-heartbeat. var playerId = TryGetCurrentPlayerId(); var room = await GetPresenceRoomForResponseAsync(playerId); + if (playerId != 0) + { + presence.MarkActive(playerId); + if (room is not null) presence.TouchRoom(playerId); + } return Ok(new PresenceDto { PlayerId = playerId, @@ -273,6 +278,7 @@ public class MatchPlayerController( "[player-heartbeat] synthesised dorm fallback player={PlayerId} room={RoomId} dataBlob={DataBlob} (presence cache miss)", playerId, fallback.RoomId, fallback.DataBlob); presence.SetRoom(playerId, fallback); + presence.MarkActive(playerId); return fallback; } log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId); @@ -291,6 +297,8 @@ public class MatchPlayerController( presence.SetRoom(playerId, room); } } + presence.MarkActive(playerId); + presence.TouchRoom(playerId); log.LogInformation( "[player-heartbeat] player={PlayerId} room={RoomId} instance={InstanceId} subRoom={SubRoomId} location={Location} photon={PhotonRoomId} dataBlob={DataBlob}", diff --git a/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs b/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs index ad04074..74acfc2 100644 --- a/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs +++ b/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs @@ -36,8 +36,7 @@ namespace DorkNet.Server.Controllers.Match; /// [ApiController] public class PlayerPresenceController( - PlayerPresenceService presence, - OnlinePresenceService onlinePresence) : ControllerBase + PlayerPresenceService presence) : ControllerBase { [HttpGet("/player")] public ActionResult> GetPresences() @@ -83,19 +82,19 @@ public class PlayerPresenceController( // privacy concern actually applies. var callerId = this.CurrentPlayerId(); var callerRoom = callerId is long cid ? presence.GetRoom(cid) : null; - var onlineIds = onlinePresence.OnlinePlayerIds().ToHashSet(); var result = ids.Select(id => { var room = presence.GetRoom(id); // Other players need both a room presence and an active - // notify connection. Presence keys intentionally live for - // minutes to survive long room loads, so using them alone - // leaves friends stuck online after a graceful disconnect. - // For self, keep using room presence: boot-time bulk probes - // can race the SignalR connection, and returning self as - // offline there can desync the heartbeat cache. + // live signal. Use the recent-heartbeat marker as the + // truth source: SignalR can drop while the player is still + // heartbeating, or linger after the game is gone. Presence + // keys intentionally live for minutes to survive long room + // loads, so using them alone leaves friends stuck online + // after a graceful disconnect. var isSelf = callerId == id; - var isOnline = room is not null && (isSelf || onlineIds.Contains(id)); + var active = isSelf || presence.IsRecentlyActive(id); + var isOnline = room is not null && active; return new PlayerPresenceDto { PlayerId = (int)id, diff --git a/DorkNet.Server/Data/Entities/MessageEntity.cs b/DorkNet.Server/Data/Entities/MessageEntity.cs index 3a22520..cd28959 100644 --- a/DorkNet.Server/Data/Entities/MessageEntity.cs +++ b/DorkNet.Server/Data/Entities/MessageEntity.cs @@ -26,7 +26,9 @@ public class MessageEntity /// GameInvite[V2], 3/7 = PartyActivitySwitch[V2], 10 = /// RequestGameInvite (the "ask to join" flow), 1 = /// GameInviteDeclined, 11 = RequestGameInviteDeclined, 20 = - /// FriendStatusOnline. + /// FriendStatusOnline. Negative invite types are tombstones kept + /// only so the parallel accept/delete flow can still resolve + /// /goto/invite/{id}. public int Type { get; set; } = 30; /// Optional RecNet room id carried by invite and party diff --git a/DorkNet.Server/Services/PlayerPresenceService.cs b/DorkNet.Server/Services/PlayerPresenceService.cs index 2838506..df2de05 100644 --- a/DorkNet.Server/Services/PlayerPresenceService.cs +++ b/DorkNet.Server/Services/PlayerPresenceService.cs @@ -10,7 +10,8 @@ namespace DorkNet.Server.Services; /// Cross-replica tracker for which RoomInstance each authenticated /// player most recently went to via /goto. The presence heartbeat /// reads this back so the client's local state matches what the -/// server says. +/// server says, and is the only passive path that extends the room +/// TTL. /// /// Why this exists: every ~5s the client POSTs /player/heartbeat, /// expecting a PlayerPresence response that mirrors its own cached @@ -26,8 +27,11 @@ namespace DorkNet.Server.Services; /// (JSON-serialised ). EXPIRE is set to /// so a player who silently drops /// (uninstall, kill -9) ages out automatically; their next /goto -/// refreshes the value AND the TTL. When no Redis is configured, -/// falls back to a process-local +/// refreshes the value and heartbeat refreshes the TTL. Read-only +/// presence queries intentionally do not refresh it, otherwise admin +/// pages and friends-list polls keep stale rooms alive forever. When +/// no Redis is configured, falls back to a process-local +/// /// — identical to pre-PR-2 behaviour and fine for single-instance /// dev workflows. /// @@ -41,6 +45,8 @@ namespace DorkNet.Server.Services; public class PlayerPresenceService { private readonly ConcurrentDictionary _local = new(); + private readonly ConcurrentDictionary _localRoomExpires = new(); + private readonly ConcurrentDictionary _localActiveExpires = new(); private readonly IConnectionMultiplexer? _redis; private readonly ILogger? _log; @@ -55,6 +61,13 @@ public class PlayerPresenceService /// public static TimeSpan PresenceTtl { get; } = TimeSpan.FromMinutes(10); + /// + /// Short online-status marker refreshed by heartbeat/join activity. + /// This smooths over flaky SignalR notify sockets without letting a + /// stale room presence make someone look online for the full room TTL. + /// + public static TimeSpan ActivityTtl { get; } = TimeSpan.FromSeconds(90); + private static readonly JsonSerializerOptions JsonOpts = new() { // Match the wire format used by the matchmaking response so @@ -93,11 +106,124 @@ public class PlayerPresenceService else { _local[playerId] = room; + _localRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl); _log?.LogDebug("[presence] set local player={PlayerId} room={Room}", playerId, room?.Name ?? ""); } } + public void TouchRoom(long playerId) + { + if (_redis is { } mux) + { + try + { + mux.GetDatabase().KeyExpire(Key(playerId), PresenceTtl); + } + catch (Exception ex) + { + _log?.LogWarning(ex, "[presence] redis EXPIRE failed for player={PlayerId}", playerId); + } + return; + } + + if (_local.ContainsKey(playerId)) + _localRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl); + } + + public void MarkActive(long playerId) + { + if (_redis is { } mux) + { + try + { + mux.GetDatabase().StringSet(ActiveKey(playerId), "1", ActivityTtl); + mux.GetDatabase().SetAdd(ActiveSetKey, playerId); + } + catch (Exception ex) + { + _log?.LogWarning(ex, "[presence] redis active SET failed for player={PlayerId}", playerId); + } + return; + } + + _localActiveExpires[playerId] = DateTimeOffset.UtcNow.Add(ActivityTtl); + } + + public IReadOnlyCollection RecentlyActivePlayerIds() + { + if (_redis is { } mux) + { + try + { + var db = mux.GetDatabase(); + var members = db.SetMembers(ActiveSetKey); + if (members.Length == 0) return Array.Empty(); + + var activeIds = new List(members.Length); + var stale = new List(); + foreach (var member in members) + { + if (!long.TryParse(member.ToString(), out var playerId)) + { + stale.Add(member); + continue; + } + + if (db.KeyExists(ActiveKey(playerId))) + activeIds.Add(playerId); + else + stale.Add(member); + } + + if (stale.Count > 0) + db.SetRemove(ActiveSetKey, stale.ToArray()); + return activeIds; + } + catch (Exception ex) + { + _log?.LogWarning(ex, "[presence] redis active scan failed"); + return Array.Empty(); + } + } + + var now = DateTimeOffset.UtcNow; + var active = new List(); + foreach (var kv in _localActiveExpires) + { + if (kv.Value > now) + { + active.Add(kv.Key); + } + else + { + _localActiveExpires.TryRemove(kv.Key, out _); + } + } + return active; + } + + public bool IsRecentlyActive(long playerId) + { + if (_redis is { } mux) + { + try + { + return mux.GetDatabase().KeyExists(ActiveKey(playerId)); + } + catch (Exception ex) + { + _log?.LogWarning(ex, "[presence] redis active GET failed for player={PlayerId}", playerId); + return false; + } + } + + if (!_localActiveExpires.TryGetValue(playerId, out var expires)) return false; + if (expires > DateTimeOffset.UtcNow) return true; + _localActiveExpires.TryRemove(playerId, out _); + return false; + } + public RoomInstanceDto? GetRoom(long playerId) { if (_redis is { } mux) @@ -112,7 +238,6 @@ public class PlayerPresenceService _log?.LogDebug("[presence] miss redis player={PlayerId}", playerId); return null; } - db.KeyExpire(key, PresenceTtl); return JsonSerializer.Deserialize(v.ToString(), JsonOpts); } catch (Exception ex) @@ -121,13 +246,33 @@ public class PlayerPresenceService return null; } } - return _local.TryGetValue(playerId, out var room) ? room : null; + if (!_local.TryGetValue(playerId, out var room)) return null; + if (_localRoomExpires.TryGetValue(playerId, out var expires) + && expires <= DateTimeOffset.UtcNow) + { + _local.TryRemove(playerId, out _); + _localRoomExpires.TryRemove(playerId, out _); + _localActiveExpires.TryRemove(playerId, out _); + return null; + } + return room; } public void Clear(long playerId) { - if (_redis is { } mux) mux.GetDatabase().KeyDelete(Key(playerId)); - else _local.TryRemove(playerId, out _); + if (_redis is { } mux) + { + var db = mux.GetDatabase(); + db.KeyDelete(Key(playerId)); + db.KeyDelete(ActiveKey(playerId)); + db.SetRemove(ActiveSetKey, playerId); + } + else + { + _local.TryRemove(playerId, out _); + _localRoomExpires.TryRemove(playerId, out _); + _localActiveExpires.TryRemove(playerId, out _); + } } /// List the currently-active instances of a given room, @@ -170,6 +315,8 @@ public class PlayerPresenceService } private static string Key(long playerId) => $"presence:{playerId}"; + private static string ActiveKey(long playerId) => $"presence-active:{playerId}"; + private const string ActiveSetKey = "presence-active"; // ── LoginLock single-session enforcement ───────────────────────── // The 2020 watch generates a fresh GUID per session and sends it