Refactor presence tracking: replace OnlinePresenceService with PlayerPresenceService for improved activity management and streamline message handling logic

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