1001 lines
54 KiB
C#
1001 lines
54 KiB
C#
using System.Text.Json.Serialization;
|
|
using LiteDB;
|
|
using Campus_live_rewrite.api.server.Classes.db_class;
|
|
|
|
namespace Campus_live_rewrite.api.dbs
|
|
{
|
|
public class HeartbeatDB
|
|
{
|
|
private static readonly LiteDatabase _db = new($"{Environment.CurrentDirectory}/Data/DBs/Heartbeats.db");
|
|
|
|
// Merge this store's write-ahead log back into the data file.
|
|
// See DbMaintenance for why this must be called explicitly.
|
|
public static void Checkpoint() => _db.Checkpoint();
|
|
private static readonly ILiteCollection<PlayerHeartbeatData> _col =
|
|
_db.GetCollection<PlayerHeartbeatData>("players_heartbeat");
|
|
|
|
public static void Setup()
|
|
{
|
|
_col.EnsureIndex(x => x.playerid, unique: true);
|
|
|
|
// Rows written before the key change carry a sequential iD (1, 2, 3...)
|
|
// rather than the player's id, so the next insert for a player whose id
|
|
// matches an old sequential value collides on _id and throws - which is
|
|
// the failure the key change exists to remove.
|
|
//
|
|
// Heartbeats are transient (GetPlayerHeartbeat falls back to
|
|
// CreateDefaultHeartbeat when a player has no row), so dropping stale
|
|
// rows costs nothing and is safer than leaving the collision armed.
|
|
try
|
|
{
|
|
var stale = _col.FindAll().Where(x => x.iD != x.playerid).ToList();
|
|
if (stale.Count > 0)
|
|
{
|
|
foreach (var row in stale) _col.Delete(row.iD);
|
|
Console.WriteLine($"[HeartbeatDB] migrated: dropped {stale.Count} row(s) keyed by row-count instead of playerid");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"[HeartbeatDB] key migration skipped: {ex.Message}");
|
|
}
|
|
|
|
// Correct EVERY stored appVersion to the current client build. The old hardcoded
|
|
// "20250731.01" is NEWER than the 20250718.01 client, so a player whose row still
|
|
// carried it showed to everyone as "ON NEWER VERSION" and couldn't be joined. The
|
|
// per-heartbeat fix only touches a row when that player moves; this catches the
|
|
// offline/stationary rows at startup so nobody is left stale.
|
|
try
|
|
{
|
|
string want = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion;
|
|
var wrong = _col.FindAll().Where(x => x.Heartbeat != null && x.Heartbeat.appVersion != want).ToList();
|
|
foreach (var row in wrong) { row.Heartbeat.appVersion = want; _col.Update(row); }
|
|
if (wrong.Count > 0)
|
|
Console.WriteLine($"[HeartbeatDB] appVersion migration: corrected {wrong.Count} row(s) to {want}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"[HeartbeatDB] appVersion migration skipped: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
public class PlayerHeartbeatData
|
|
{
|
|
// Keyed by playerid. This used to be `(ulong)_col.LongCount() + 1` at every
|
|
// insert, which is not a usable primary key: delete any row and the count
|
|
// drops, so the next insert reuses an existing id and LiteDB throws
|
|
// "Cannot insert duplicate key in unique index '_id'". Two concurrent
|
|
// inserts read the same count and collide identically. A throw there aborts
|
|
// the write, so the heartbeat row silently stops being updated.
|
|
// playerid is already unique (see the index in Setup), so it IS the key.
|
|
[BsonId]
|
|
public ulong iD { get; set; }
|
|
public ulong playerid { get; set; }
|
|
public Heartbeat Heartbeat { get; set; }
|
|
}
|
|
|
|
private static Heartbeat CreateDefaultHeartbeat(ulong playerid, Platforms platform, DeviceClasses deviceClasses) =>
|
|
new()
|
|
{
|
|
playerId = (long)playerid,
|
|
appVersion = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion,
|
|
// 0 = Unknown. Was -1, which is NOT a valid DeviceClasses value - the client's
|
|
// strict presence parser threw on the out-of-range enum and dropped the WHOLE
|
|
// presence array, so a friends/player list built against it (offline friends all
|
|
// get this default) came back EMPTY. Same class of bug as the statusVisibility=4
|
|
// fix below; the reference emits `deviceClass ?? 0`.
|
|
deviceClass = 0,
|
|
isOnline = false,
|
|
platform = 0,
|
|
roomInstance = null,
|
|
statusVisibility = 0, // 0 = Everyone (matches the reference); was 4 (out of range)
|
|
vrMovementMode = 1, // reference default (`?? 1`); 0 was also fine but match it
|
|
};
|
|
|
|
// Where we last DELIBERATELY put each player, held in memory alongside the DB row.
|
|
//
|
|
// The heartbeat answer is authoritative to the client: answer "nowhere" and it
|
|
// decides it was disconnected and walks itself home to its dorm. So a room
|
|
// instance that goes missing by ACCIDENT - a row that never got written, a lost
|
|
// LiteDB update, a restart, a concurrent write - must not be reported as "nowhere".
|
|
//
|
|
// Only UpdatePlayerHeartbeat writes this, so an entry existing means "we put them
|
|
// there on purpose and nothing has deliberately moved them since". A real logout or
|
|
// ban clears the entry (it goes through UpdatePlayerHeartbeat with a null instance),
|
|
// so intentional "nowhere" still answers nowhere.
|
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, RoomInstance> _placedIn = new();
|
|
|
|
// When each player's presence last changed (online/offline or room). The hub re-sends a
|
|
// recently changed presence to a viewer who re-subscribes - see Notifications.EchoPresence.
|
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, DateTime> _presenceChangedAt = new();
|
|
|
|
public static bool PresenceChangedWithin(ulong playerid, TimeSpan window) =>
|
|
_presenceChangedAt.TryGetValue(playerid, out var at) && DateTime.UtcNow - at < window;
|
|
|
|
// Last instance id we answered per player, purely so a CHANGE can be logged. A
|
|
// change the player did not ask for is the drop, and this is the line that names it.
|
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, long> _lastAnswered = new();
|
|
|
|
// ---- Short-term memory that survives a mass drop -------------------------------------
|
|
//
|
|
// An instance only "exists" while some ONLINE heartbeat row points at it. That is fine
|
|
// until the Photon master client (the first player into the instance) leaves: every other
|
|
// client in that Photon room runs its master-switch recovery - a full logout + re-login,
|
|
// then "join the player I was with" - and the drop itself has just wiped all of their
|
|
// rows. So each recovering player asks about a friend whose row is now empty, gets
|
|
// "target offline / not in a room", and lands in their dorm. Seen live 2026-09-11 22:12:
|
|
// 6 leaves ^RecCenter -> 11 and 64 both reset within 6 s -> all three in dorms.
|
|
//
|
|
// These two maps remember, for a few minutes, where each player was LAST placed and
|
|
// which instances were live, independent of the rows. They are consulted only when the
|
|
// live answer is empty, so nothing changes for a player who is simply somewhere else.
|
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<long, (RoomInstance inst, DateTime seen)> _recentInstances = new();
|
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, (RoomInstance inst, DateTime seen)> _lastPlacement = new();
|
|
/// <summary>How long after a drop a player's last instance is still offered for regrouping.</summary>
|
|
public static readonly TimeSpan RegroupWindow = TimeSpan.FromMinutes(2);
|
|
/// <summary>How long an instance is answerable by id after its last occupant's row is cleared.</summary>
|
|
public static readonly TimeSpan InstanceMemory = TimeSpan.FromMinutes(15);
|
|
|
|
// The instance each player was in BEFORE their latest move, so "were these two just
|
|
// together?" can be answered after one of them has already moved on (the party leader
|
|
// has left the room by the time the member asks to follow).
|
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, (long instanceId, DateTime left)> _prevPlacement = new();
|
|
|
|
private static void Remember(ulong playerid, RoomInstance inst)
|
|
{
|
|
if (inst == null || inst.roomInstanceId == 0) return;
|
|
var now = DateTime.UtcNow;
|
|
if (_lastPlacement.TryGetValue(playerid, out var last) && last.inst.roomInstanceId != inst.roomInstanceId)
|
|
_prevPlacement[playerid] = (last.inst.roomInstanceId, now);
|
|
_recentInstances[inst.roomInstanceId] = (inst, now);
|
|
_lastPlacement[playerid] = (inst, now);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Were <paramref name="a"/> and <paramref name="b"/> standing in the same instance within
|
|
/// the regroup window? True when a's current/last instance equals b's current OR the one b
|
|
/// just left. This is what stands in for "same party" on a follow: the client keeps its
|
|
/// party on the Photon side and never posts the member list, so being together a moment
|
|
/// ago is the best evidence the server has.
|
|
/// </summary>
|
|
public static bool WereTogetherRecently(ulong a, ulong b)
|
|
{
|
|
var cutoff = DateTime.UtcNow - RegroupWindow;
|
|
long aInst = _lastPlacement.TryGetValue(a, out var la) && la.seen >= cutoff ? la.inst.roomInstanceId : 0;
|
|
if (aInst == 0) return false;
|
|
if (_lastPlacement.TryGetValue(b, out var lb) && lb.inst.roomInstanceId == aInst) return true;
|
|
if (_prevPlacement.TryGetValue(b, out var pb) && pb.instanceId == aInst && pb.left >= cutoff) return true;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>The instance this player was in within RegroupWindow, or null.</summary>
|
|
public static RoomInstance? RecentInstanceFor(ulong playerid)
|
|
{
|
|
if (!_lastPlacement.TryGetValue(playerid, out var p)) return null;
|
|
return DateTime.UtcNow - p.seen <= RegroupWindow ? p.inst : null;
|
|
}
|
|
|
|
/// <summary>The most recently live instance of a room (public or private) within InstanceMemory, or null.</summary>
|
|
public static RoomInstance? RecentInstanceOfRoomAny(ulong roomId)
|
|
{
|
|
var cutoff = DateTime.UtcNow - InstanceMemory;
|
|
return _recentInstances.Values
|
|
.Where(v => v.seen >= cutoff && v.inst.roomId == (long)roomId && !string.IsNullOrEmpty(v.inst.photonRoomIdYeee))
|
|
.OrderByDescending(v => v.seen)
|
|
.Select(v => v.inst)
|
|
.FirstOrDefault();
|
|
}
|
|
|
|
// ---- games in progress -------------------------------------------------------------
|
|
// PUT roominstance/{id}/inprogress (the client flips it when a round starts / ends). Room
|
|
// matchmaking never places a player into an instance with a game running - the reference's
|
|
// getJoinableInstance filters is_in_progress = 0 - so they get a fresh instance instead of
|
|
// landing mid-round (Dodgeball: spawned on top of the players, acting as if in the game).
|
|
// Following a friend / an invite still lands in their instance, as in the reference.
|
|
// Kept here as well as on the presence rows because the regroup memory holds its own
|
|
// copies; expires so a client that crashed mid-round can't lock an instance for good.
|
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<long, DateTime> _inProgress = new();
|
|
private static readonly TimeSpan InProgressExpiry = TimeSpan.FromMinutes(45);
|
|
|
|
public static void SetInstanceInProgress(long roomInstanceId, bool inProgress)
|
|
{
|
|
if (inProgress) _inProgress[roomInstanceId] = DateTime.UtcNow;
|
|
else _inProgress.TryRemove(roomInstanceId, out _);
|
|
}
|
|
|
|
// ---- broken instances ---------------------------------------------------------------
|
|
// CampusNet reports joiners stuck on the loading screen (POST /photon/stuckload). Such an
|
|
// instance keeps working for the players already in it, but nobody new is matchmade into
|
|
// it for BrokenInstanceWindow - they get another instance instead of the same hang.
|
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<long, DateTime> _broken = new();
|
|
public static readonly TimeSpan BrokenInstanceWindow = TimeSpan.FromMinutes(30);
|
|
|
|
public static void MarkInstanceBroken(long roomInstanceId)
|
|
{
|
|
if (roomInstanceId != 0) _broken[roomInstanceId] = DateTime.UtcNow;
|
|
}
|
|
|
|
public static bool IsInstanceBroken(RoomInstance? inst)
|
|
{
|
|
if (inst == null || !_broken.TryGetValue(inst.roomInstanceId, out var at)) return false;
|
|
if (DateTime.UtcNow - at < BrokenInstanceWindow) return true;
|
|
_broken.TryRemove(inst.roomInstanceId, out _);
|
|
return false;
|
|
}
|
|
|
|
/// <summary>The live (or recently live) instance behind a Photon room name, or null.</summary>
|
|
public static RoomInstance? FindInstanceByPhotonName(string photonRoom)
|
|
{
|
|
if (string.IsNullOrEmpty(photonRoom)) return null;
|
|
var live = _col.FindAll().Select(x => x.Heartbeat?.roomInstance)
|
|
.FirstOrDefault(r => r != null && r.photonRoomIdYeee == photonRoom);
|
|
if (live != null) return live;
|
|
return _recentInstances.Values.Select(v => v.inst).FirstOrDefault(r => r.photonRoomIdYeee == photonRoom);
|
|
}
|
|
|
|
public static bool IsInstanceInProgress(RoomInstance? inst)
|
|
{
|
|
if (inst == null) return false;
|
|
if (_inProgress.TryGetValue(inst.roomInstanceId, out var since))
|
|
{
|
|
if (DateTime.UtcNow - since < InProgressExpiry) return true;
|
|
_inProgress.TryRemove(inst.roomInstanceId, out _);
|
|
return false;
|
|
}
|
|
return inst.isInProgress;
|
|
}
|
|
|
|
/// <summary>The most recently live PUBLIC instance of a room within RegroupWindow, or null.</summary>
|
|
public static RoomInstance? RecentInstanceOfRoom(ulong roomId)
|
|
{
|
|
var cutoff = DateTime.UtcNow - RegroupWindow;
|
|
return _recentInstances.Values
|
|
.Where(v => v.seen >= cutoff && v.inst.roomId == (long)roomId && !v.inst.isPrivate
|
|
&& !IsInstanceInProgress(v.inst)
|
|
&& !string.IsNullOrEmpty(v.inst.photonRoomIdYeee))
|
|
.OrderByDescending(v => v.seen)
|
|
.Select(v => v.inst)
|
|
.FirstOrDefault();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether this player has a stored heartbeat at all. Distinct from "has no room
|
|
/// instance" - a player sitting in a menu has a row with a null instance.
|
|
/// </summary>
|
|
public static bool HasHeartbeatRow(ulong playerid) =>
|
|
_col.FindOne(x => x.playerid == playerid) != null;
|
|
|
|
/// <summary>
|
|
/// Read a player's presence WITHOUT the repair-writes/logging that GetPlayerHeartbeat does -
|
|
/// for read-only surfaces like the admin players list that must not mutate state or spam the
|
|
/// console when they enumerate everyone. Returns whether they're online and their current
|
|
/// room instance (null if nowhere), honoring the in-memory placement fallback.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// The room a player is placed in, for the Tachyon voice lookup. Unlike PeekPresence this does
|
|
/// NOT require a live notification WebSocket: Tachyon only asks while the player is connecting
|
|
/// voice, which already proves they're in the game, and the hub socket is often mid-reconnect
|
|
/// at exactly that moment - answering 0 then isolated the player (no voice) for the whole
|
|
/// visit (53 times in a week). A logout/ban still clears the placement, so "nowhere" stays
|
|
/// nowhere.
|
|
/// </summary>
|
|
public static RoomInstance? PlacementForVoice(ulong playerid)
|
|
{
|
|
if (_placedIn.TryGetValue(playerid, out var placed)) return placed;
|
|
return _col.FindOne(x => x.playerid == playerid)?.Heartbeat?.roomInstance;
|
|
}
|
|
|
|
public static (bool online, RoomInstance? room) PeekPresence(ulong playerid)
|
|
{
|
|
// Online = a LIVE notification WebSocket, not the stored isOnline flag. The flag goes
|
|
// stale on a hard disconnect (shows "online" forever) and is unaffected by - but also
|
|
// doesn't help with - "appear offline". The WebSocket is ground truth: it's opened on
|
|
// connect, removed on disconnect, and is independent of the player's status visibility,
|
|
// so the admin sees who is ACTUALLY connected even if they're appearing offline to
|
|
// everyone else. If they're not connected, they're offline here regardless of the flag.
|
|
bool online = CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(playerid);
|
|
|
|
var hb = _col.FindOne(x => x.playerid == playerid)?.Heartbeat;
|
|
RoomInstance? room = hb?.roomInstance;
|
|
if (room == null && _placedIn.TryGetValue(playerid, out var placed))
|
|
room = placed;
|
|
|
|
// Only surface a room while actually online, so a stale room from a dropped session
|
|
// doesn't read as "still in that room".
|
|
return online ? (true, room) : (false, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Presence of ANOTHER player as it should appear in someone else's friends/player list.
|
|
/// Unlike GetPlayerHeartbeat (which is the player's OWN authoritative heartbeat and
|
|
/// deliberately refuses to answer "nowhere"), this is what OTHERS see, so it is gated on a
|
|
/// LIVE notification WebSocket - the same ground truth PeekPresence uses for the admin panel:
|
|
/// * not actually connected -> isOnline=false, roomInstance=null (fixes "shows online in
|
|
/// a room they aren't in" after a crash/close, where the stored row keeps isOnline=true
|
|
/// and the last room forever, and _placedIn still holds the old instance);
|
|
/// * connected but "appear offline" (statusVisibility 3 = No One) -> also reported offline.
|
|
/// Side-effect-free: never writes the DB or restores _placedIn, so enumerating a whole
|
|
/// friends list doesn't churn repair-writes or spam the console. Enum fields stay valid
|
|
/// (appVersion/deviceClass/etc.) so the client's strict presence parser still accepts it.
|
|
/// </summary>
|
|
public static Heartbeat GetPresenceForList(ulong playerid, bool viewerIsSelf, bool viewerIsFriend, bool viewerIsFavorite, ulong viewerId = 0)
|
|
{
|
|
// An invite is consent to be seen: while a live invite from this player to the viewer
|
|
// exists, the viewer sees them online and in their (private) instance no matter what
|
|
// "Appear Online To" says. Without this the invitee's client refused the invite with
|
|
// "Cannot accept invite. Target player is offline." whenever the inviter wasn't
|
|
// visible to them (non-friend + Friends-only status, or Favorites-only).
|
|
if (!viewerIsSelf && viewerId != 0 && viewerId != playerid
|
|
&& Campus_live_rewrite.api.server.RoomInvites.HasInviteFromTo(playerid, viewerId))
|
|
{
|
|
viewerIsFriend = true;
|
|
viewerIsFavorite = true;
|
|
}
|
|
var stored = _col.FindOne(x => x.playerid == playerid)?.Heartbeat;
|
|
var hb = stored ?? CreateDefaultHeartbeat(playerid, Platforms.Steam, DeviceClasses.Screen);
|
|
|
|
hb.serverTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
|
if (string.IsNullOrEmpty(hb.lastOnline)) hb.lastOnline = DateTime.UtcNow.ToString("o");
|
|
hb.appVersion = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion;
|
|
|
|
// Defensive clamp: a legacy row with an out-of-range deviceClass (-1) or vrMovementMode
|
|
// makes the client's strict presence parser throw and drop the WHOLE friends-list array
|
|
// (see the deviceClass=-1 trap). Never serve one from this read.
|
|
if (hb.deviceClass < 0) hb.deviceClass = 0; // 0 = Unknown
|
|
if (hb.vrMovementMode < 0) hb.vrMovementMode = 1;
|
|
// statusVisibility is 0..3 on the client; the old default row carried 4, and one such
|
|
// entry in a friends-list page made the client drop the WHOLE page ("some friends are
|
|
// missing and their profile won't open"). Clamp on the way out too.
|
|
if (hb.statusVisibility < 0 || hb.statusVisibility > 3) hb.statusVisibility = 0;
|
|
|
|
bool live = CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(playerid);
|
|
|
|
// "Appear Online To" (statusVisibility): 0 Everyone, 1 Friends, 2 Favorites, 3 No One.
|
|
// The player always sees their OWN real presence; others see it only if the setting
|
|
// allows THEM. Changing this in-game (PUT player/statusvisibility) therefore reflects to
|
|
// friends within one presence poll. Unknown/legacy values are treated as Everyone.
|
|
int vis = hb.statusVisibility;
|
|
bool hiddenByStatus = !viewerIsSelf && (
|
|
vis == 3
|
|
|| (vis == 1 && !viewerIsFriend)
|
|
|| (vis == 2 && !viewerIsFavorite));
|
|
// "Appear offline" must not hide a player from the people IN THE SAME INSTANCE: the client builds its player list (and the
|
|
// vote-to-kick menu) from this presence, so a player who read as offline there could not be voted out - an exploit.
|
|
// Anyone standing in the room can see them anyway; friends / the rest of the world still see them offline.
|
|
if (hiddenByStatus && !viewerIsSelf && viewerId != 0 && live && SameInstance(viewerId, hb)) hiddenByStatus = false;
|
|
// A block hides presence both ways: neither of them sees the other online or where.
|
|
if (!viewerIsSelf && viewerId != 0 && !hiddenByStatus && FriendsDB.IsBlockedEitherWay(viewerId, playerid))
|
|
hiddenByStatus = true;
|
|
|
|
// online-in-no-room: a player whose presence says online but names no instance (just
|
|
// kicked, mid-recovery) NREs the 2025 client's friend card (NotInSameInstanceHide
|
|
// dereferences the instance when online) - the card renders blank and the profile
|
|
// won't open. To everyone else that player reads as offline until they land somewhere.
|
|
if (!viewerIsSelf && hb.isOnline && hb.roomInstance == null) hb.isOnline = false;
|
|
|
|
if (!live || hiddenByStatus)
|
|
{
|
|
hb.isOnline = false;
|
|
hb.roomInstance = null;
|
|
}
|
|
// "Full" on the friends list / room card: computed from live presence, never stored.
|
|
if (hb.roomInstance != null) hb.roomInstance.isFull = InstanceIsFull(hb.roomInstance);
|
|
return hb;
|
|
}
|
|
|
|
/// <summary>Is the viewer in the very instance this (stored) heartbeat says the other player is in? Read-only.</summary>
|
|
private static bool SameInstance(ulong viewerId, Heartbeat other)
|
|
{
|
|
long theirs = other.roomInstance?.roomInstanceId ?? 0;
|
|
if (theirs == 0) return false;
|
|
long mine = _col.FindOne(x => x.playerid == viewerId)?.Heartbeat?.roomInstance?.roomInstanceId ?? 0;
|
|
if (mine == 0 && _placedIn.TryGetValue(viewerId, out var placed)) mine = placed?.roomInstanceId ?? 0;
|
|
return mine == theirs;
|
|
}
|
|
|
|
public static Heartbeat GetPlayerHeartbeat(ulong playerid)
|
|
{
|
|
var data = _col.FindOne(x => x.playerid == playerid);
|
|
var hb = data?.Heartbeat;
|
|
// own-heartbeat clamp: the same out-of-range guard GetPresenceForList applies
|
|
if (hb != null)
|
|
{
|
|
if (hb.statusVisibility < 0 || hb.statusVisibility > 3) hb.statusVisibility = 0;
|
|
if (hb.deviceClass < 0) hb.deviceClass = 0;
|
|
if (hb.vrMovementMode < 0) hb.vrMovementMode = 1;
|
|
}
|
|
|
|
if (hb == null)
|
|
{
|
|
hb = CreateDefaultHeartbeat(playerid, Platforms.Steam, DeviceClasses.Screen);
|
|
|
|
// No row at all. If we know where we put them, say that rather than
|
|
// "nowhere", and write the row back so matchmaking agrees with us.
|
|
if (_placedIn.TryGetValue(playerid, out var placed))
|
|
{
|
|
hb.roomInstance = placed;
|
|
hb.isOnline = true;
|
|
Console.WriteLine($"[heartbeat] {playerid}: no row - restored instance " +
|
|
$"{placed.roomInstanceId} ({placed.name}) instead of answering 'nowhere'");
|
|
try { UpdatePlayerHeartbeat(playerid, placed); } catch { }
|
|
}
|
|
}
|
|
else if (hb.roomInstance == null && _placedIn.TryGetValue(playerid, out var placed))
|
|
{
|
|
// Row exists but has lost its room instance while we still believe the
|
|
// player is somewhere. Nothing deliberate nulls it without clearing
|
|
// _placedIn, so this is drift - repair it rather than sending them home.
|
|
hb.roomInstance = placed;
|
|
hb.isOnline = true;
|
|
Console.WriteLine($"[heartbeat] {playerid}: row lost its instance - restored " +
|
|
$"{placed.roomInstanceId} ({placed.name}) instead of answering 'nowhere'");
|
|
try { UpdatePlayerHeartbeat(playerid, placed); } catch { }
|
|
}
|
|
|
|
// serverTime was DECLARED and never assigned anywhere, so every presence
|
|
// heartbeat this server has ever sent carried serverTime = 0 - i.e. 1970. Any
|
|
// client-side check that compares our clock to its own sees a ~56 year skew.
|
|
// Unix SECONDS, matching the JWT iat/exp the client already parses from our
|
|
// tokens and the rest of this codebase (BanDB, moderation timestamps).
|
|
hb.serverTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
|
|
|
// Also never set: null reads as "never seen", which is wrong for a player who
|
|
// is heartbeating at us right now.
|
|
hb.lastOnline = DateTime.UtcNow.ToString("o");
|
|
|
|
long now = hb.roomInstance?.roomInstanceId ?? 0;
|
|
long prev = _lastAnswered.TryGetValue(playerid, out var p) ? p : 0;
|
|
if (now != prev)
|
|
{
|
|
Console.WriteLine($"[heartbeat] {playerid}: answer changed {prev} -> {now} " +
|
|
$"({hb.roomInstance?.name ?? "nowhere"})");
|
|
_lastAnswered[playerid] = now;
|
|
}
|
|
|
|
return hb;
|
|
}
|
|
|
|
public static string GetPlayerHeartbeatRoomName(ulong playerid)
|
|
{
|
|
var data = _col.FindOne(x => x.playerid == playerid);
|
|
return data?.Heartbeat?.roomInstance?.name ?? "nowhere!";
|
|
}
|
|
|
|
public static bool CreatePlayerHeartbeat(ulong playerid, out Heartbeat heartbeat, Platforms platform, DeviceClasses deviceClasses)
|
|
{
|
|
if (_col.Exists(x => x.playerid == playerid))
|
|
{
|
|
heartbeat = null!;
|
|
return false;
|
|
}
|
|
|
|
heartbeat = CreateDefaultHeartbeat(playerid, platform, deviceClasses);
|
|
_col.Insert(new PlayerHeartbeatData
|
|
{
|
|
iD = playerid, // keyed by playerid - see note on PlayerHeartbeatData.iD
|
|
playerid = playerid,
|
|
Heartbeat = heartbeat
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
public static void UpdateVrMovementMode(ulong playerid, int mode)
|
|
{
|
|
var data = _col.FindOne(x => x.playerid == playerid);
|
|
if (data?.Heartbeat == null) return;
|
|
data.Heartbeat.vrMovementMode = mode;
|
|
_col.Update(data);
|
|
}
|
|
|
|
public static void UpdateStatusVisibility(ulong playerid, int visibility)
|
|
{
|
|
var data = _col.FindOne(x => x.playerid == playerid);
|
|
if (data != null)
|
|
{
|
|
data.Heartbeat.statusVisibility = visibility;
|
|
_col.Update(data);
|
|
}
|
|
else
|
|
{
|
|
// Upsert: a player who changes their status before any heartbeat row exists used
|
|
// to have the change silently dropped. Create the row so it persists (and so the
|
|
// change is honored by GetPresenceForList right away).
|
|
var hb = CreateDefaultHeartbeat(playerid, Platforms.Steam, DeviceClasses.Screen);
|
|
hb.statusVisibility = visibility;
|
|
_col.Insert(new PlayerHeartbeatData { iD = playerid, playerid = playerid, Heartbeat = hb });
|
|
}
|
|
Console.WriteLine($"[presence] {playerid} statusVisibility -> {visibility}");
|
|
}
|
|
|
|
public static string GetPlayerCurrentRoomName(ulong playerid)
|
|
{
|
|
var data = _col.FindOne(x => x.playerid == playerid);
|
|
return data?.Heartbeat?.roomInstance?.name ?? "not in a room.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds a live room instance by its id, whoever is in it.
|
|
///
|
|
/// connection-info is asked about a SPECIFIC roomInstanceId and used to answer it
|
|
/// from the caller's own heartbeat instead. That was survivable only while every
|
|
/// public instance shared the Photon room name "room" - any mismatch still landed
|
|
/// the player in the same place. With per-instance names a mismatch sends them to
|
|
/// the wrong Photon room and the load dies with "disconnect during load".
|
|
/// </summary>
|
|
/// <summary>
|
|
/// The live instances of a room with who is standing in each - the owner's "Instances"
|
|
/// tab (reference match GET /room/{id}/instances). Grouped by Photon room, which IS the
|
|
/// instance as far as the client is concerned.
|
|
/// </summary>
|
|
public static List<(RoomInstance inst, List<ulong> players)> LiveInstancesOfRoom(ulong roomId)
|
|
{
|
|
var rows = _col.FindAll()
|
|
.Where(x => x.Heartbeat != null && x.Heartbeat.isOnline && x.Heartbeat.roomInstance != null
|
|
&& x.Heartbeat.roomInstance.roomId == (long)roomId
|
|
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid))
|
|
.ToList();
|
|
return rows
|
|
.GroupBy(x => string.IsNullOrEmpty(x.Heartbeat!.roomInstance!.photonRoomIdYeee) ? x.Heartbeat.roomInstance.roomInstanceId.ToString() : x.Heartbeat.roomInstance.photonRoomIdYeee!)
|
|
.Select(g => (g.First().Heartbeat!.roomInstance!, g.Select(x => x.playerid).Distinct().ToList()))
|
|
.OrderBy(t => t.Item1.roomInstanceId)
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Change a flag on a live instance. Presence keeps a COPY of the instance on every
|
|
/// player's row, so a change (private, in progress) has to land on each of them or the
|
|
/// next heartbeat serves the old value back.
|
|
/// </summary>
|
|
public static int UpdateInstanceEverywhere(long roomInstanceId, Action<RoomInstance> edit)
|
|
{
|
|
int n = 0;
|
|
foreach (var row in _col.FindAll().Where(x => x.Heartbeat?.roomInstance != null && x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId).ToList())
|
|
{
|
|
edit(row.Heartbeat!.roomInstance!);
|
|
_col.Update(row);
|
|
n++;
|
|
}
|
|
if (_recentInstances.TryGetValue(roomInstanceId, out var r)) edit(r.inst);
|
|
return n;
|
|
}
|
|
|
|
/// <summary>
|
|
/// One-off repair of stored presence rows that carry values the client's strict parser
|
|
/// refuses (statusVisibility 4 from the old default, deviceClass -1). Returns how many.
|
|
/// </summary>
|
|
public static int FixOutOfRangePresence()
|
|
{
|
|
int n = 0;
|
|
foreach (var row in _col.FindAll().ToList())
|
|
{
|
|
var hb = row.Heartbeat;
|
|
if (hb == null) continue;
|
|
bool changed = false;
|
|
if (hb.statusVisibility < 0 || hb.statusVisibility > 3) { hb.statusVisibility = 0; changed = true; }
|
|
if (hb.deviceClass < 0) { hb.deviceClass = 0; changed = true; }
|
|
if (hb.vrMovementMode < 0) { hb.vrMovementMode = 1; changed = true; }
|
|
if (changed) { _col.Update(row); n++; }
|
|
}
|
|
return n;
|
|
}
|
|
|
|
/// <summary>Everyone connected whose presence puts them in this room instance right now.</summary>
|
|
public static List<ulong> PlayerIdsInInstance(long roomInstanceId)
|
|
{
|
|
if (roomInstanceId == 0) return new List<ulong>();
|
|
return _col.FindAll()
|
|
.Where(x => x.Heartbeat?.roomInstance != null && x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId
|
|
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid))
|
|
.Select(x => x.playerid).Distinct().ToList();
|
|
}
|
|
|
|
public static RoomInstance? FindInstanceById(long roomInstanceId)
|
|
{
|
|
if (roomInstanceId == 0) return null;
|
|
|
|
var live = _col.FindAll()
|
|
.Where(x => x.Heartbeat?.roomInstance != null
|
|
&& x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId)
|
|
.Select(x => x.Heartbeat!.roomInstance!)
|
|
.FirstOrDefault();
|
|
if (live != null) return live;
|
|
|
|
// Nobody's row points at it right now - but if it was live minutes ago, a client that
|
|
// still holds the id (a recovering one) can be sent back to the same Photon room.
|
|
if (_recentInstances.TryGetValue(roomInstanceId, out var r) && DateTime.UtcNow - r.seen <= InstanceMemory)
|
|
return r.inst;
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A LIVE instance of a given room, whoever is in it - the same (name, isPrivate) the admin
|
|
/// panel shows. Used by the Discord-presence room lookup so it reflects the instance the
|
|
/// player is actually in (works for dorms, which the static room API doesn't describe the
|
|
/// same way). Returns null if nobody is in an instance of that room right now.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// A player's OWN current room + their status-visibility, straight from their heartbeat row
|
|
/// (no WebSocket gating - the caller IS the player, so they're online by definition). Used
|
|
/// by the Discord-presence self endpoint: statusVisibility decides whether the room may be
|
|
/// shown publicly at all, roomInstance is where they are. Honors the placement fallback.
|
|
/// </summary>
|
|
public static (RoomInstance? room, int statusVisibility) GetSelfPresence(ulong playerid)
|
|
{
|
|
var hb = _col.FindOne(x => x.playerid == playerid)?.Heartbeat;
|
|
RoomInstance? room = hb?.roomInstance;
|
|
if (room == null && _placedIn.TryGetValue(playerid, out var placed)) room = placed;
|
|
return (room, hb?.statusVisibility ?? 0);
|
|
}
|
|
|
|
public static RoomInstance? FindInstanceByRoomId(long roomId)
|
|
{
|
|
if (roomId == 0) return null;
|
|
|
|
return _col.FindAll()
|
|
.Where(x => x.Heartbeat?.roomInstance != null
|
|
&& x.Heartbeat.roomInstance.roomId == roomId)
|
|
.Select(x => x.Heartbeat!.roomInstance!)
|
|
.FirstOrDefault();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds a public instance of <paramref name="roomId"/> that someone is already in
|
|
/// and that still has space, so a second player joining the same room LANDS WITH
|
|
/// THEM instead of in a private world of their own.
|
|
///
|
|
/// This is required by the unique Photon room names in Sessions.CreateRoom. While
|
|
/// every public instance shared the constant name "room", players met by accident -
|
|
/// and also met everyone in every other room, which was the bug. Now that names are
|
|
/// per-instance, matchmaking has to deliberately reuse an instance for people to
|
|
/// meet at all.
|
|
///
|
|
/// Returns null when nobody is in the room, or every instance is full - the caller
|
|
/// then creates a fresh one.
|
|
/// </summary>
|
|
public static RoomInstance? FindJoinableInstance(ulong roomId, ulong excludePlayerId = 0, long excludeInstanceId = 0)
|
|
{
|
|
// excludeInstanceId = the instance the player is already in. Never offered back: the
|
|
// client keys the room change off a NEW roomInstanceId, so re-matchmaking into the
|
|
// current one ("Join new instance" in a room with others in it) looped the join.
|
|
var occupants = _col.FindAll()
|
|
.Where(x => x.playerid != excludePlayerId
|
|
&& (excludeInstanceId == 0 || x.Heartbeat?.roomInstance?.roomInstanceId != excludeInstanceId)
|
|
&& x.Heartbeat != null
|
|
&& x.Heartbeat.isOnline
|
|
&& x.Heartbeat.roomInstance != null
|
|
&& x.Heartbeat.roomInstance.roomId == (long)roomId
|
|
&& !x.Heartbeat.roomInstance.isPrivate
|
|
&& !IsInstanceInProgress(x.Heartbeat.roomInstance) // no joining a round mid-game
|
|
&& !IsInstanceBroken(x.Heartbeat.roomInstance) // joiners hang there (CampusNet stuck-load)
|
|
&& !string.IsNullOrEmpty(x.Heartbeat.roomInstance.photonRoomIdYeee)
|
|
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid)
|
|
// an instance they were kicked out of is not offered to them again
|
|
&& !server.InstanceBlocks.IsBlocked(excludePlayerId, x.Heartbeat.roomInstance))
|
|
.Select(x => x.Heartbeat.roomInstance!)
|
|
.ToList();
|
|
|
|
if (occupants.Count == 0)
|
|
{
|
|
// Nobody recorded in the room - but if an instance of it was live moments ago, this
|
|
// is most likely a player recovering from the mass drop above. Put them back in the
|
|
// same Photon room so the group re-forms instead of each opening a fresh instance.
|
|
var recent = RecentInstanceOfRoom(roomId);
|
|
if (recent != null && server.InstanceBlocks.IsBlocked(excludePlayerId, recent)) recent = null;
|
|
if (recent != null && excludeInstanceId != 0 && recent.roomInstanceId == excludeInstanceId) recent = null;
|
|
if (recent != null && IsInstanceBroken(recent)) recent = null;
|
|
if (recent != null)
|
|
Console.WriteLine($"[matchmake] regrouping into recent instance {recent.roomInstanceId} of room {roomId} (photon {recent.photonRoomIdYeee})");
|
|
return recent;
|
|
}
|
|
|
|
// Group by the Photon room - that IS the instance as far as the client is
|
|
// concerned - and pick the fullest one with space, so players gather together
|
|
// rather than scattering one-per-instance.
|
|
var best = occupants
|
|
.GroupBy(r => r.photonRoomIdYeee)
|
|
.Select(g => new { Instance = g.First(), Count = g.Count() })
|
|
.Where(g => g.Count < EffectiveCapacity(g.Instance))
|
|
.OrderByDescending(g => g.Count)
|
|
.FirstOrDefault();
|
|
|
|
return best?.Instance;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Live player count per ROOM id, in one pass over presence - what Room.CCU carries.
|
|
/// Rooms with nobody in them are simply absent from the map.
|
|
/// </summary>
|
|
private static Dictionary<long, int> _ccuCache = new();
|
|
private static DateTime _ccuCacheAt = DateTime.MinValue;
|
|
|
|
public static Dictionary<long, int> LivePlayerCountsByRoom()
|
|
{
|
|
// Cached for a few seconds: this is called once per room response (including the hot
|
|
// list) and a full presence scan per request would be the expensive part of it.
|
|
if ((DateTime.UtcNow - _ccuCacheAt).TotalSeconds < 5) return _ccuCache;
|
|
|
|
var counts = new Dictionary<long, int>();
|
|
foreach (var x in _col.FindAll())
|
|
{
|
|
var inst = x?.Heartbeat?.roomInstance;
|
|
if (x?.Heartbeat == null || !x.Heartbeat.isOnline || inst == null) continue;
|
|
counts[inst.roomId] = counts.TryGetValue(inst.roomId, out var n) ? n + 1 : 1;
|
|
}
|
|
_ccuCache = counts; _ccuCacheAt = DateTime.UtcNow;
|
|
return counts;
|
|
}
|
|
|
|
private static Dictionary<long, int> _connectedCache = new();
|
|
private static DateTime _connectedCacheAt = DateTime.MinValue;
|
|
|
|
/// <summary>
|
|
/// Like <see cref="LivePlayerCountsByRoom"/>, but only players holding a live notification
|
|
/// socket - a crashed or closed game leaves its presence row "online" until the next login,
|
|
/// and those ghosts must not make a room look busy in the Hot / Rising feeds.
|
|
/// </summary>
|
|
public static Dictionary<long, int> ConnectedPlayerCountsByRoom()
|
|
{
|
|
if ((DateTime.UtcNow - _connectedCacheAt).TotalSeconds < 5) return _connectedCache;
|
|
var counts = new Dictionary<long, int>();
|
|
foreach (var x in _col.FindAll())
|
|
{
|
|
var inst = x?.Heartbeat?.roomInstance;
|
|
if (x?.Heartbeat == null || !x.Heartbeat.isOnline || inst == null) continue;
|
|
if (!CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid)) continue;
|
|
counts[inst.roomId] = counts.TryGetValue(inst.roomId, out var n) ? n + 1 : 1;
|
|
}
|
|
_connectedCache = counts; _connectedCacheAt = DateTime.UtcNow;
|
|
return counts;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The cap that actually applies to an instance: the subroom's CURRENT MaxPlayers (what
|
|
/// the room settings show), falling back to the value stamped when the instance was
|
|
/// opened, and finally 4 like the reference. Read live so an owner raising the cap in
|
|
/// room settings frees the running instance instead of it staying "full" at the old
|
|
/// number. Cached briefly - this runs on every heartbeat.
|
|
/// </summary>
|
|
private static readonly Dictionary<(long, long), (int cap, DateTime at)> _capCache = new();
|
|
public static int EffectiveCapacity(RoomInstance? inst)
|
|
{
|
|
if (inst == null) return 0;
|
|
int fromSub = 0;
|
|
var key = (inst.roomId, inst.subRoomId);
|
|
lock (_capCache)
|
|
{
|
|
if (_capCache.TryGetValue(key, out var c) && (DateTime.UtcNow - c.at).TotalSeconds < 20) fromSub = c.cap;
|
|
else
|
|
{
|
|
try { fromSub = RoomDB.GetSubRoom((ulong)inst.roomId, (ulong)inst.subRoomId)?.MaxPlayers ?? 0; } catch { fromSub = 0; }
|
|
_capCache[key] = (fromSub, DateTime.UtcNow);
|
|
if (_capCache.Count > 5000) _capCache.Clear();
|
|
}
|
|
}
|
|
if (fromSub > 0) return fromSub;
|
|
return inst.maxCapacity > 0 ? inst.maxCapacity : 4;
|
|
}
|
|
|
|
/// <summary>Whether the instance is at its player cap (the subroom's MaxPlayers).</summary>
|
|
public static bool InstanceIsFull(RoomInstance? inst)
|
|
{
|
|
if (inst == null) return false;
|
|
int cap = EffectiveCapacity(inst);
|
|
inst.maxCapacity = cap;
|
|
return cap > 0 && CountInInstance(inst.roomInstanceId) >= cap;
|
|
}
|
|
|
|
/// <summary>How many online players' presence points at this instance.</summary>
|
|
// live-connection gate: a presence row stays "online" after a crash / closed game until
|
|
// the player logs in again, so ghosts piled up in an instance and it read as full while
|
|
// half the slots were empty. Only a player holding a live notification socket counts.
|
|
/// <summary>Players in any instance of this room right now (live websocket only).</summary>
|
|
public static int CountInRoom(ulong roomId) =>
|
|
_col.FindAll().Count(x => x.Heartbeat != null && x.Heartbeat.isOnline
|
|
&& x.Heartbeat.roomInstance != null
|
|
&& x.Heartbeat.roomInstance.roomId == (long)roomId
|
|
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid));
|
|
|
|
public static int CountInInstance(long roomInstanceId) =>
|
|
_col.FindAll().Count(x => x.Heartbeat != null && x.Heartbeat.isOnline
|
|
&& x.Heartbeat.roomInstance != null
|
|
&& x.Heartbeat.roomInstance.roomInstanceId == roomInstanceId
|
|
&& CampusLiveSource.Controllers.NotificationsController.WebSockets.ContainsKey(x.playerid));
|
|
|
|
public static void ClearPlayersHeartbeats()
|
|
{
|
|
foreach (var data in _col.FindAll())
|
|
{
|
|
UpdatePlayerHeartbeat(data.playerid, null, false);
|
|
}
|
|
}
|
|
|
|
public static void ClearPlayerHeartbeat(ulong playerId) =>
|
|
UpdatePlayerHeartbeat(playerId, null, false);
|
|
|
|
/// <summary>
|
|
/// When this player was last connected, from the STORED row (GetPlayerHeartbeat rewrites
|
|
/// lastOnline to "now" on every read, so it can't be used for this). Stamped by
|
|
/// StampLastSeen on every placement and on notification-socket close, so for an offline
|
|
/// player it is the moment they disconnected. Null when never seen.
|
|
/// </summary>
|
|
public static string? LastSeen(ulong playerid) => _col.FindOne(x => x.playerid == playerid)?.Heartbeat?.lastOnline;
|
|
|
|
public static void StampLastSeen(ulong playerid)
|
|
{
|
|
try
|
|
{
|
|
var data = _col.FindOne(x => x.playerid == playerid);
|
|
if (data?.Heartbeat == null) return;
|
|
data.Heartbeat.lastOnline = DateTime.UtcNow.ToString("o");
|
|
_col.Update(data);
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
public static void UpdatePlayerHeartbeat(
|
|
ulong playerid,
|
|
RoomInstance? roomInstance,
|
|
bool online = true,
|
|
Platforms platform = Platforms.All,
|
|
DeviceClasses deviceClasses = DeviceClasses.Unknown)
|
|
{
|
|
// Record the DELIBERATE placement before touching the DB. Everything that
|
|
// moves a player goes through here, so this is the one place that can tell
|
|
// "we put them nowhere" apart from "they fell off the record by accident".
|
|
// See _placedIn on GetPlayerHeartbeat.
|
|
if (online && roomInstance != null) { _placedIn[playerid] = roomInstance; Remember(playerid, roomInstance); }
|
|
else _placedIn.TryRemove(playerid, out _);
|
|
|
|
var data = _col.FindOne(x => x.playerid == playerid);
|
|
|
|
// Capture the prior state so we only push a realtime presence update to friends when it
|
|
// ACTUALLY changed (online<->offline, or a different room instance) - the periodic
|
|
// heartbeat doesn't come through here, but matchmake/join/dorm/ban all do, some with the
|
|
// same instance repeatedly.
|
|
long oldInstance = data?.Heartbeat?.roomInstance?.roomInstanceId ?? 0;
|
|
bool wasOnline = data?.Heartbeat?.isOnline ?? false;
|
|
|
|
if (data != null)
|
|
{
|
|
var hb = data.Heartbeat;
|
|
hb.roomInstance = online ? roomInstance : null;
|
|
hb.isOnline = online;
|
|
hb.lastOnline = DateTime.UtcNow.ToString("o"); // see LastSeen
|
|
// Correct any row still carrying the old hardcoded build, so existing players stop
|
|
// showing as a version mismatch on their next heartbeat (not just fresh rows).
|
|
hb.appVersion = Campus_live_rewrite.api.server.ConfigCampus.PresenceAppVersion;
|
|
|
|
_col.Update(data);
|
|
}
|
|
else
|
|
{
|
|
// Create a new heartbeat and assign the current roomInstance
|
|
var hb = CreateDefaultHeartbeat(playerid, platform, deviceClasses);
|
|
hb.roomInstance = roomInstance;
|
|
hb.isOnline = online;
|
|
|
|
_col.Insert(new PlayerHeartbeatData
|
|
{
|
|
iD = playerid, // keyed by playerid - see note on PlayerHeartbeatData.iD
|
|
playerid = playerid,
|
|
Heartbeat = hb
|
|
});
|
|
}
|
|
|
|
long newInstance = (online ? roomInstance?.roomInstanceId : null) ?? 0;
|
|
if (wasOnline != online || oldInstance != newInstance)
|
|
{
|
|
_presenceChangedAt[playerid] = DateTime.UtcNow;
|
|
Campus_live_rewrite.api.server.Notifications.FireAndForget(
|
|
Campus_live_rewrite.api.server.Notifications.PushPresenceToFriends(playerid),
|
|
$"presence-change {playerid}");
|
|
Campus_live_rewrite.api.server.Notifications.FireAndForget(
|
|
Campus_live_rewrite.api.server.Notifications.EchoPresence(playerid),
|
|
$"presence-echo {playerid}");
|
|
}
|
|
}
|
|
|
|
public class Heartbeat
|
|
{
|
|
// The client reads the heartbeat's `serverTime` as a DateTime and syncs its whole
|
|
// "server clock" to it. It used to go out as Unix SECONDS (1790349494), which the client
|
|
// took as DateTime TICKS - its server clock read 0001-01-01 00:02:59 (seen in the running
|
|
// PC client's memory, 2026-09-25). Every "time left" was then ~2000 years, and the PC
|
|
// (x86) converts that to int32 as -2147483648: the reward watch's expiry timer fired at
|
|
// once, auto-picked card 1 and closed ("reward watch closes instantly on PC"). ARM
|
|
// saturates to +int.Max instead, which is why Quest never showed it.
|
|
// So the wire field is a UTC ISO-8601 date; the stored number is kept (DB compat) but
|
|
// never serialized.
|
|
[Newtonsoft.Json.JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
|
public long serverTime { get; set; }
|
|
[BsonIgnore, Newtonsoft.Json.JsonProperty("serverTime"), JsonPropertyName("serverTime")]
|
|
public string serverTimeUtc => DateTime.UtcNow.ToString("o");
|
|
public long playerId { get; set; }
|
|
public int statusVisibility { get; set; }
|
|
public int platform { get; set; }
|
|
public int deviceClass { get; set; }
|
|
public int vrMovementMode { get; set; }
|
|
public RoomInstance roomInstance { get; set; } // Can be null
|
|
public string lastOnline { get; set; } // ISO 8601 string
|
|
public bool isOnline { get; set; }
|
|
public string appVersion { get; set; }
|
|
}
|
|
|
|
public class FullRoomInstance
|
|
{
|
|
public RoomInstance? roomInstance { get; set; }
|
|
public string? correlationId { get; set; }
|
|
}
|
|
|
|
public class RoomInstance
|
|
{
|
|
public long roomInstanceId { get; set; }
|
|
public long roomId { get; set; }
|
|
public long subRoomId { get; set; }
|
|
public string? location { get; set; }
|
|
public int roomInstanceType { get; set; }
|
|
public string? name { get; set; }
|
|
// The event/club this instance belongs to (0 = none). The client reads eventId to
|
|
// show the "This Event" tab on the room panel when you're in an event instance
|
|
// (mirrors the reference RoomInstanceDto's eventId/clubId).
|
|
public long eventId { get; set; }
|
|
public long clubId { get; set; }
|
|
public int maxCapacity { get; set; }
|
|
public bool isFull { get; set; }
|
|
public bool isPrivate { get; set; }
|
|
public bool isInProgress { get; set; }
|
|
[JsonIgnore]
|
|
public string? photonRoomIdYeee { get; set; } // im crack
|
|
public string? voiceServerId { get; set; }
|
|
public string? voiceAuthId { get; set; }
|
|
public int matchmakingPolicy { get; set; }
|
|
|
|
// Whether the Photon Voice stream is encrypted. RecFlare sets this to false on every
|
|
// room instance, and it MUST be present: absent, the client falls back to its own
|
|
// default (encrypt), and then peers encrypt their voice with no shared key exchange
|
|
// set up - they connect fine but hear each other as silence, which is exactly the
|
|
// "mic works, nobody can hear you" symptom. false = plain voice, everyone hears everyone.
|
|
public bool EncryptVoiceChat { get; set; } = false;
|
|
}
|
|
|
|
public enum MatchmakingErrorCode
|
|
{
|
|
Success,
|
|
NoSuchGame,
|
|
PlayerNotOnline,
|
|
InsufficientSpace,
|
|
EventNotStarted,
|
|
EventAlreadyFinished,
|
|
EventCreatorNotReady,
|
|
BlockedFromRoom,
|
|
ProfileLocked,
|
|
NoBirthday,
|
|
MarkedForDelete,
|
|
JuniorNotAllowed,
|
|
Banned,
|
|
AlreadyInBestInstance,
|
|
InsufficientRelationship,
|
|
UpdateRequired = 16,
|
|
AlreadyInTargetInstance,
|
|
RegistrationRequired,
|
|
UGCNotAllowed,
|
|
NoSuchRoom,
|
|
RoomCreatorNotReady,
|
|
RoomIsNotActive,
|
|
RoomBlockedByCreator,
|
|
RoomBlockingCreator,
|
|
RoomIsPrivate,
|
|
RoomInstanceIsPrivate,
|
|
DeviceClassNotSupported = 30,
|
|
DeviceClassNotSupportedByRoomOwner,
|
|
VRMovementModeNotSupportedByRoomOwner,
|
|
EventIsPrivate = 35,
|
|
RoomInviteExpired = 40,
|
|
NoAvailableRegion = 45,
|
|
NotorietyTooPoor = 50,
|
|
BannedFromRoom = 55
|
|
}
|
|
}
|
|
}
|