mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
8be0f90dbc
From the local CodeRabbit review: - Scene ids: the repair and room clone assigned max + 1, which never advanced the Postgres identity sequence (live: sequence 3412, max id 3466), so the next add / clone sub-room would collide on the primary key. The repair now moves the sequence past the highest id at startup, and both let the database assign ids. - Room-level restores (datahistory/restore and the admin restore) now also restore the entry sub-room, whose blob room details serve first. - TouchRoom refreshes the room's presence index too, so players kept alive only by heartbeats no longer drop out of the instance browser after 20 min. - Sub-room permission saves match cells exactly (as the unique index does) and retry when a concurrent editor inserted the same new cell.
692 lines
27 KiB
C#
692 lines
27 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Text.Json;
|
||
using DorkNet.Server.Controllers.Match;
|
||
using Microsoft.Extensions.Logging;
|
||
using StackExchange.Redis;
|
||
|
||
namespace DorkNet.Server.Services;
|
||
|
||
/// <summary>
|
||
/// 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, 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
|
||
/// presence. If the server returns roomInstance:null but the client
|
||
/// knows it's in DormRoom, RecNet.Matchmaking.OnPresenceHeartbeatResponse
|
||
/// logs "presence heartbeat response indicates local presence is
|
||
/// out-of-sync" repeatedly, the client gives up after a few rounds,
|
||
/// and shows the "Error: -2 / We lost the connection to Rec Room"
|
||
/// UI even though no network connection actually dropped.
|
||
///
|
||
/// **Multi-replica**: when <see cref="IConnectionMultiplexer"/> is
|
||
/// available, presence lives in Redis as <c>presence:{playerId}</c>
|
||
/// (JSON-serialised <see cref="RoomInstanceDto"/>). EXPIRE is set to
|
||
/// <see cref="PresenceTtl"/> so a player who silently drops
|
||
/// (uninstall, kill -9) ages out automatically; their next /goto
|
||
/// 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
|
||
/// <see cref="ConcurrentDictionary"/>
|
||
/// — identical to pre-PR-2 behaviour and fine for single-instance
|
||
/// dev workflows.
|
||
///
|
||
/// State is intentionally non-persistent — a server restart drops
|
||
/// every presence record, and clients re-establish via their next
|
||
/// /goto call. Method signatures stay sync because the call sites
|
||
/// (heartbeat, /goto, room-state queries) are too numerous to pay
|
||
/// the cost of an async cascade. Sync StackExchange.Redis ops are
|
||
/// sub-millisecond on the same network at our QPS.
|
||
/// </summary>
|
||
public class PlayerPresenceService
|
||
{
|
||
private readonly ConcurrentDictionary<long, RoomInstanceDto> _local = new();
|
||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localRoomExpires = new();
|
||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localActiveExpires = new();
|
||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localBootDormGraceExpires = new();
|
||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localNoRoomExpires = new();
|
||
private readonly IConnectionMultiplexer? _redis;
|
||
private readonly ILogger<PlayerPresenceService>? _log;
|
||
|
||
/// <summary>
|
||
/// Presence must survive long room loads. The 2020 client can go
|
||
/// quiet for ~45 seconds while downloading/deserializing large
|
||
/// uploaded rooms; a 45s Redis TTL expires right before the next
|
||
/// heartbeat, making the server synthesize dorm fallback presence
|
||
/// and effectively bouncing the player back to dorm. Ten minutes
|
||
/// still ages out abandoned sessions, but does not punish slow
|
||
/// room loads or temporary network stalls.
|
||
/// </summary>
|
||
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);
|
||
|
||
/// <summary>
|
||
/// Right after login the 2023 watch may boot into its cached offline
|
||
/// DormRoom before the live Photon join finishes. During that window
|
||
/// a null roomInstance is safer than returning stale/live presence:
|
||
/// the client treats the offline placeholder as invalid and accepts
|
||
/// null, but a live private dorm forces a resync.
|
||
/// </summary>
|
||
public static TimeSpan BootDormGraceTtl { get; } = TimeSpan.FromSeconds(45);
|
||
|
||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||
{
|
||
// Match the wire format used by the matchmaking response so
|
||
// the round-trip is lossless. Properties are already tagged
|
||
// with [JsonPropertyName] for camelCase wire keys.
|
||
PropertyNamingPolicy = null,
|
||
};
|
||
|
||
public PlayerPresenceService(
|
||
IConnectionMultiplexer? redis = null,
|
||
ILogger<PlayerPresenceService>? log = null)
|
||
{
|
||
_redis = redis;
|
||
_log = log;
|
||
}
|
||
|
||
public void SetRoom(long playerId, RoomInstanceDto room)
|
||
{
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
var json = JsonSerializer.Serialize(room, JsonOpts);
|
||
var db = mux.GetDatabase();
|
||
db.StringSet(Key(playerId), json, PresenceTtl);
|
||
db.KeyDelete(NoRoomKey(playerId));
|
||
// Per-room membership index for the instance browser
|
||
// (EnumerateActiveInstances). Members are validated against
|
||
// the player's live presence on read, so a player who moved
|
||
// on is pruned lazily instead of needing the old room here.
|
||
if (room is not null && room.RoomId > 0)
|
||
{
|
||
db.SetAdd(RoomIndexKey(room.RoomId), playerId);
|
||
db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl);
|
||
}
|
||
_log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
|
||
playerId, room?.Name ?? "<null>", json.Length);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Don't let a Redis blip take down the goto handler — log
|
||
// and fall through. Heartbeat will see no presence and the
|
||
// watch will resync via its next /goto.
|
||
_log?.LogWarning(ex, "[presence] redis SET failed for player={PlayerId}", playerId);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
_local[playerId] = room;
|
||
_localRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||
_localNoRoomExpires.TryRemove(playerId, out _);
|
||
_log?.LogDebug("[presence] set local player={PlayerId} room={Room}",
|
||
playerId, room?.Name ?? "<null>");
|
||
}
|
||
}
|
||
|
||
public void TouchRoom(long playerId)
|
||
{
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
var db = mux.GetDatabase();
|
||
db.KeyExpire(Key(playerId), PresenceTtl);
|
||
// Keep the room's membership index alive as long as its
|
||
// members are: a player refreshed only through here would
|
||
// otherwise outlive the set (SetRoom's 2 × TTL) and drop out
|
||
// of the instance browser and room pushes.
|
||
var json = db.StringGet(Key(playerId));
|
||
if (json.HasValue &&
|
||
JsonSerializer.Deserialize<RoomInstanceDto>(json.ToString(), JsonOpts) is { RoomId: > 0 } room)
|
||
{
|
||
db.SetAdd(RoomIndexKey(room.RoomId), playerId);
|
||
db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + 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 void BeginBootDormGrace(long playerId)
|
||
{
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
mux.GetDatabase().StringSet(BootDormGraceKey(playerId), "1", BootDormGraceTtl);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.LogWarning(ex, "[presence] redis boot grace SET failed for player={PlayerId}", playerId);
|
||
}
|
||
return;
|
||
}
|
||
|
||
_localBootDormGraceExpires[playerId] = DateTimeOffset.UtcNow.Add(BootDormGraceTtl);
|
||
}
|
||
|
||
public bool IsBootDormGraceActive(long playerId)
|
||
{
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
return mux.GetDatabase().KeyExists(BootDormGraceKey(playerId));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.LogWarning(ex, "[presence] redis boot grace GET failed for player={PlayerId}", playerId);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (!_localBootDormGraceExpires.TryGetValue(playerId, out var expires)) return false;
|
||
if (expires > DateTimeOffset.UtcNow) return true;
|
||
_localBootDormGraceExpires.TryRemove(playerId, out _);
|
||
return false;
|
||
}
|
||
|
||
public void EndBootDormGrace(long playerId)
|
||
{
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
mux.GetDatabase().KeyDelete(BootDormGraceKey(playerId));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.LogWarning(ex, "[presence] redis boot grace DEL failed for player={PlayerId}", playerId);
|
||
}
|
||
return;
|
||
}
|
||
|
||
_localBootDormGraceExpires.TryRemove(playerId, out _);
|
||
}
|
||
|
||
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)
|
||
{
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
var db = mux.GetDatabase();
|
||
var key = Key(playerId);
|
||
var v = db.StringGet(key);
|
||
if (!v.HasValue)
|
||
{
|
||
_log?.LogDebug("[presence] miss redis player={PlayerId}", playerId);
|
||
return null;
|
||
}
|
||
return JsonSerializer.Deserialize<RoomInstanceDto>(v.ToString(), JsonOpts);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.LogWarning(ex, "[presence] redis GET failed for player={PlayerId}", playerId);
|
||
return 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)
|
||
{
|
||
var db = mux.GetDatabase();
|
||
db.KeyDelete(Key(playerId));
|
||
db.KeyDelete(ActiveKey(playerId));
|
||
db.KeyDelete(BootDormGraceKey(playerId));
|
||
db.KeyDelete(NoRoomKey(playerId));
|
||
db.SetRemove(ActiveSetKey, playerId);
|
||
}
|
||
else
|
||
{
|
||
_local.TryRemove(playerId, out _);
|
||
_localRoomExpires.TryRemove(playerId, out _);
|
||
_localActiveExpires.TryRemove(playerId, out _);
|
||
_localBootDormGraceExpires.TryRemove(playerId, out _);
|
||
_localNoRoomExpires.TryRemove(playerId, out _);
|
||
}
|
||
}
|
||
|
||
/// <summary>Forget the player's room but keep everything that says they
|
||
/// are still here (activity marker, login lock, boot grace). Used when a
|
||
/// join fails or times out: the player is still online and about to run
|
||
/// the client's fallback chain, so friends must not see them go offline,
|
||
/// they just must not see a room the player never reached.</summary>
|
||
public void ClearRoom(long playerId)
|
||
{
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
mux.GetDatabase().KeyDelete(Key(playerId));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.LogWarning(ex, "[presence] redis room DEL failed for player={PlayerId}", playerId);
|
||
}
|
||
return;
|
||
}
|
||
|
||
_local.TryRemove(playerId, out _);
|
||
_localRoomExpires.TryRemove(playerId, out _);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Record that the player deliberately left online matchmaking: they are
|
||
/// online, but in no room instance.
|
||
///
|
||
/// The 2023 client does this in TryGoToOfflineRoom
|
||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:12677, the only caller of
|
||
/// <c>matchmake/none</c> at :13279): it swaps its cached presence to the
|
||
/// local offline instance (RoomInstanceId -1) and posts matchmake/none,
|
||
/// ignoring the reply (<c><TryGoToOfflineRoom>b__119_0</c> returns a
|
||
/// constant). From then on the heartbeat handler
|
||
/// (Matchmaking.txt:18103, compare at :18415-18467) treats a negative RoomInstanceId like no
|
||
/// room (HCHDEHIGEBE.GIICPDHJOOH = <c>RoomInstanceId < 0</c>) and is
|
||
/// only in sync while the server answers <c>roomInstance: null</c>. Any
|
||
/// room here — the old one, or a synthesised dorm — reads as out-of-sync
|
||
/// and the client cancels its offline load to chase the server's room.
|
||
/// The marker keeps the heartbeat's dorm fallback from firing for this
|
||
/// player until they matchmake somewhere again.
|
||
/// </summary>
|
||
public void SetNoRoom(long playerId)
|
||
{
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
var db = mux.GetDatabase();
|
||
db.KeyDelete(Key(playerId));
|
||
db.StringSet(NoRoomKey(playerId), "1", PresenceTtl);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.LogWarning(ex, "[presence] redis no-room SET failed for player={PlayerId}", playerId);
|
||
}
|
||
return;
|
||
}
|
||
|
||
_local.TryRemove(playerId, out _);
|
||
_localRoomExpires.TryRemove(playerId, out _);
|
||
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||
}
|
||
|
||
/// <summary>True while the player is online with no room after
|
||
/// <see cref="SetNoRoom"/>. Cleared by the next <see cref="SetRoom"/> or
|
||
/// <see cref="Clear"/>.</summary>
|
||
public bool IsInNoRoom(long playerId)
|
||
{
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
return mux.GetDatabase().KeyExists(NoRoomKey(playerId));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.LogWarning(ex, "[presence] redis no-room GET failed for player={PlayerId}", playerId);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (!_localNoRoomExpires.TryGetValue(playerId, out var expires)) return false;
|
||
if (expires > DateTimeOffset.UtcNow) return true;
|
||
_localNoRoomExpires.TryRemove(playerId, out _);
|
||
return false;
|
||
}
|
||
|
||
/// <summary>Keep the no-room marker alive while the player keeps
|
||
/// heartbeating from the offline room.</summary>
|
||
public void TouchNoRoom(long playerId)
|
||
{
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
mux.GetDatabase().KeyExpire(NoRoomKey(playerId), PresenceTtl);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.LogWarning(ex, "[presence] redis no-room EXPIRE failed for player={PlayerId}", playerId);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (_localNoRoomExpires.ContainsKey(playerId))
|
||
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||
}
|
||
|
||
/// <summary>List the live instances of a given room, grouped by
|
||
/// PhotonRoomId (or RoomInstanceId when a row has none), with the player
|
||
/// ids currently presenced in each. Drives the room instance browser
|
||
/// (<c>GET match.*/room/{id}/instances</c>) and the event instance list.
|
||
///
|
||
/// <para>Only players whose presence is still live count: an expired
|
||
/// room presence (<see cref="PresenceTtl"/>) is dropped, and so is a
|
||
/// player whose activity marker (<see cref="ActivityTtl"/>, refreshed by
|
||
/// every heartbeat and join) has lapsed. A client that crashed keeps its
|
||
/// room presence for up to ten minutes, but it is not in the instance and
|
||
/// must not inflate the player count or keep an empty instance listed.</para>
|
||
///
|
||
/// <para><b>Redis.</b> <see cref="SetRoom"/> records each player in a
|
||
/// per-room set (<c>presence-room:{roomId}</c>); this reads that set and
|
||
/// validates every member against their current presence, pruning the
|
||
/// ones that have moved on or expired. Without the index the Redis path
|
||
/// had nothing to enumerate and returned an empty list on every
|
||
/// multi-replica deployment.</para>
|
||
///
|
||
/// <para>Private instances are skipped unless
|
||
/// <paramref name="includePrivate"/> is set; callers that include them
|
||
/// must apply their own visibility rule.</para></summary>
|
||
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(
|
||
long roomId, bool includePrivate = false)
|
||
{
|
||
var members = new List<(long PlayerId, RoomInstanceDto Room)>();
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
var db = mux.GetDatabase();
|
||
var stale = new List<RedisValue>();
|
||
foreach (var member in db.SetMembers(RoomIndexKey(roomId)))
|
||
{
|
||
if (!long.TryParse(member.ToString(), out var playerId))
|
||
{
|
||
stale.Add(member);
|
||
continue;
|
||
}
|
||
var room = GetRoom(playerId);
|
||
if (room is null || room.RoomId != roomId)
|
||
{
|
||
stale.Add(member);
|
||
continue;
|
||
}
|
||
members.Add((playerId, room));
|
||
}
|
||
if (stale.Count > 0)
|
||
db.SetRemove(RoomIndexKey(roomId), stale.ToArray());
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.LogWarning(ex, "[presence] redis room index scan failed room={RoomId}", roomId);
|
||
return Array.Empty<(RoomInstanceDto, List<long>)>();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
foreach (var playerId in _local.Keys.ToList())
|
||
{
|
||
var room = GetRoom(playerId); // honours the room TTL
|
||
if (room is not null && room.RoomId == roomId)
|
||
members.Add((playerId, room));
|
||
}
|
||
}
|
||
|
||
var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
|
||
foreach (var (playerId, room) in members)
|
||
{
|
||
if (room.IsPrivate && !includePrivate) continue;
|
||
if (!IsRecentlyActive(playerId)) continue;
|
||
var key = InstanceKey(room);
|
||
if (!groups.TryGetValue(key, out var entry))
|
||
{
|
||
entry = (room, new List<long>());
|
||
groups[key] = entry;
|
||
}
|
||
entry.PlayerIds.Add(playerId);
|
||
}
|
||
return groups.Values;
|
||
}
|
||
|
||
/// <summary>The identity two presence rows share when they are in the
|
||
/// same live instance: the Photon room, else the instance id.</summary>
|
||
public static string InstanceKey(RoomInstanceDto room)
|
||
=> string.IsNullOrEmpty(room.PhotonRoomId)
|
||
? "<" + room.RoomInstanceId + ">"
|
||
: room.PhotonRoomId;
|
||
|
||
private static string RoomIndexKey(long roomId) => "presence-room:" + roomId;
|
||
|
||
private static string Key(long playerId) => $"presence:{playerId}";
|
||
private static string ActiveKey(long playerId) => $"presence-active:{playerId}";
|
||
private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}";
|
||
private static string NoRoomKey(long playerId) => $"presence-noroom:{playerId}";
|
||
private const string ActiveSetKey = "presence-active";
|
||
|
||
// ── LoginLock single-session enforcement ─────────────────────────
|
||
// The 2020 watch generates a fresh GUID per session and sends it
|
||
// in the `LoginLock` form field on every match.* request. Real
|
||
// Rec Room rejects heartbeats whose lock doesn't match the
|
||
// most-recent /player/login — that's how a second login on the
|
||
// same account kicks the first session. We mirror that by storing
|
||
// the most-recent lock in Redis (or the in-process fallback)
|
||
// keyed by player id, with the same 45-s TTL as presence.
|
||
//
|
||
// SetLock OVERWRITES regardless of prior value — newest login
|
||
// wins. ValidateLock returns false when the caller's lock is stale
|
||
// or absent, which the heartbeat handler maps to a
|
||
// session-terminated response so the watch logs out.
|
||
|
||
private readonly ConcurrentDictionary<long, string> _localLocks = new();
|
||
|
||
public bool SetLock(long playerId, string loginLock)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(loginLock)) return false;
|
||
var replacedActiveSession = false;
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
var db = mux.GetDatabase();
|
||
var key = LockKey(playerId);
|
||
var previous = db.StringGet(key);
|
||
replacedActiveSession = previous.HasValue &&
|
||
!string.Equals(previous.ToString(), loginLock, StringComparison.Ordinal);
|
||
db.StringSet(key, loginLock, PresenceTtl);
|
||
}
|
||
catch (Exception ex) { _log?.LogWarning(ex, "[lock] redis SET failed for player={PlayerId}", playerId); }
|
||
}
|
||
else
|
||
{
|
||
replacedActiveSession = _localLocks.TryGetValue(playerId, out var previous) &&
|
||
!string.Equals(previous, loginLock, StringComparison.Ordinal);
|
||
_localLocks[playerId] = loginLock;
|
||
}
|
||
|
||
if (replacedActiveSession)
|
||
_log?.LogWarning("[lock] concurrent login detected for player={PlayerId}; newest LoginLock wins", playerId);
|
||
return replacedActiveSession;
|
||
}
|
||
|
||
public bool ValidateLock(long playerId, string? loginLock)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(loginLock)) return false;
|
||
if (_redis is { } mux)
|
||
{
|
||
try
|
||
{
|
||
var v = mux.GetDatabase().StringGet(LockKey(playerId));
|
||
if (!v.HasValue) return false;
|
||
// Refresh TTL on each successful validate so the
|
||
// active session keeps its lock alive across heartbeats.
|
||
if (string.Equals(v.ToString(), loginLock, StringComparison.Ordinal))
|
||
{
|
||
mux.GetDatabase().KeyExpire(LockKey(playerId), PresenceTtl);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.LogWarning(ex, "[lock] redis GET failed for player={PlayerId}", playerId);
|
||
return true; // fail open — better double-session than mass kick on Redis blip
|
||
}
|
||
}
|
||
return _localLocks.TryGetValue(playerId, out var stored)
|
||
&& string.Equals(stored, loginLock, StringComparison.Ordinal);
|
||
}
|
||
|
||
private static string LockKey(long playerId) => $"loginlock:{playerId}";
|
||
|
||
// ── Photon region pings ──────────────────────────────────────────
|
||
// The 2020 watch reports its measured Photon-region latencies in a
|
||
// single POST after the matchmaking init handshake (see
|
||
// MatchPlayerController.PhotonRegionPings). We hold the latest
|
||
// reading per player in process memory only — region preferences
|
||
// change infrequently, lose-on-restart is fine, and pushing this
|
||
// through Redis would just bloat the key space for no gain.
|
||
|
||
private readonly ConcurrentDictionary<long, IReadOnlyDictionary<string, int>> _regionPings = new();
|
||
|
||
public void SetPhotonRegionPings(long playerId, IReadOnlyDictionary<string, int> pings)
|
||
{
|
||
if (pings.Count == 0) return;
|
||
_regionPings[playerId] = pings;
|
||
}
|
||
|
||
/// <summary>Returns the region code with the lowest reported ping,
|
||
/// or <c>null</c> when no pings were ever received for the
|
||
/// player (matchmaking falls back to its default region in that
|
||
/// case).</summary>
|
||
public string? GetPreferredPhotonRegion(long playerId)
|
||
{
|
||
if (!_regionPings.TryGetValue(playerId, out var pings) || pings.Count == 0)
|
||
return null;
|
||
string? best = null;
|
||
var bestMs = int.MaxValue;
|
||
foreach (var kv in pings)
|
||
{
|
||
if (kv.Value < bestMs)
|
||
{
|
||
bestMs = kv.Value;
|
||
best = kv.Key;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
}
|