mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
6ee1d6f9b3
Every change below was checked against the 2023-03-21 client disassembly;
file:line evidence is in the code comments and in
docs/recroom-2023-client-api-complete.md.
Presence
- matchmake/none means "online, in no room" (its only caller is
TryGoToOfflineRoom, which ignores the reply). The server used to keep the
old room or invent a dorm, so the next heartbeat looked out of sync and
the client cancelled its load to chase that room.
- The join watchdog no longer clears presence 30 s into a long room load;
it is a 3 minute backstop that drops the room only.
- One fan-out (PresenceBroadcastService) for every transition: deferred
joins, matchmake/none, logout, failed joins, visibility changes, SignalR
connect/disconnect. isOnline is sent on its own; pushes never go to the
subject, because a self push moves the player.
- Online-status visibility (Public / Friends / Favorites / Appear offline)
is stored and honoured by GET /player and by the live pushes.
Profile
- PUT account/me/{emoji,personalpronouns,identityflags,bannerimage} were
404; added with the client's real form keys and persisted (new Players
columns plus migration). Every account read now returns them, and edits
push AccountUpdate to everyone.
Outfit menu
- Saved outfits kept OutfitSelectionsV2 and CustomAvatarItems, so loading a
slot restores what was saved instead of a stale outfit.
- isCreationAllowedForAccount answers the {Success, Error} envelope the
client parses; minPriceForPublicItem is a bare int; gifts/generate accepts
form bodies; lockeditems returns the full owned-item shape; colourways no
longer share their base item's AvatarItemId.
RecNetResult writes
- About 40 writes look status-only but parse the body as {Success, Error}.
An empty 200 showed "Failed to ..." after the server applied the change,
and a body without Success threw "Error message cannot be null". Clubs,
announcements, keepsakes, comments/read, subscriptions, purchase offers
and privacy settings now answer RecNetResult.
- Login token and device-code grants are real; remoteauth approves the
device code; createlogintoken returns a bare string.
- New: incentivizedreferrals/referrals, account/bulk/{email,phone},
GET strings, account/me/confirmphone, PlayMenuCarouselOrdering_1.
Route inventory and coverage test extended to match.
README now describes the 2023 branch.
189 lines
9.0 KiB
C#
189 lines
9.0 KiB
C#
using DorkNet.Server.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace DorkNet.Server.Services;
|
|
|
|
/// <summary>
|
|
/// Online-status visibility enum (2023 client, dump.cs IPLLHDINPBL). Picked in
|
|
/// the profile's "Set Online Status Visibility" dialog (Assembly-CSharp
|
|
/// RRUI/Data/BaseAccountModel.txt, ShowChangeOnlineStatusVisibilityDialog) and
|
|
/// sent as PUT player/statusvisibility form statusVisibility (RecNet.Runtime
|
|
/// RecNet/Matchmaking.txt:17147-17161).
|
|
/// </summary>
|
|
public static class PresenceVisibility
|
|
{
|
|
public const int Public = 0;
|
|
public const int FriendsOnly = 1;
|
|
public const int FavoriteFriendsOnly = 2;
|
|
public const int Offline = 3;
|
|
|
|
public static bool IsValid(int value) => value is >= Public and <= Offline;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tells a player's friends when that player's presence changes, from one
|
|
/// place, so every transition sends the same frame built from the same state.
|
|
///
|
|
/// Before this existed only the immediate <c>/goto</c> commit pushed anything.
|
|
/// Deferred commits (matchmake/dorm, private-instance hops) committed in
|
|
/// reportjoinresult without a push, matchmake/none and logout changed presence
|
|
/// silently, and SignalR connect/disconnect pushed with <c>isOnline</c> derived
|
|
/// from "has a room" — so a friend's list showed people in rooms they had left,
|
|
/// offline while they sat in their dorm, and never noticed a status-visibility
|
|
/// change.
|
|
///
|
|
/// The client's PresenceUpdate handler (Matchmaking.EGPABEDPEJO, registered at
|
|
/// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into
|
|
/// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync
|
|
/// correction (:18467). A frame about the LOCAL player therefore moves that player, so
|
|
/// these frames go to friends only, never to the subject.
|
|
/// </summary>
|
|
public sealed class PresenceBroadcastService(
|
|
IServiceScopeFactory scopes,
|
|
PlayerPresenceService presence,
|
|
OnlinePresenceService connections,
|
|
NotificationService notifications,
|
|
ILogger<PresenceBroadcastService> logger)
|
|
{
|
|
/// <summary>PlayerSettings key for the persisted
|
|
/// <c>/player/statusvisibility</c> choice.</summary>
|
|
public const string StatusVisibilityPrefKey = "pref:statusVisibility";
|
|
|
|
/// <summary>A player counts as online while they heartbeat or hold a
|
|
/// SignalR connection. Being in a room is NOT required: the client's
|
|
/// presence DTO carries <c>isOnline</c> separately (LCEHINCCHOB +0x50) and
|
|
/// DNKHCBFPLJG reads "online, in no room / the offline room" as a valid
|
|
/// state, which is exactly what a player in the offline dorm is.</summary>
|
|
public bool IsOnline(long playerId) =>
|
|
presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0;
|
|
|
|
/// <summary>Push the player's current presence (online state + committed
|
|
/// room) to every friend.</summary>
|
|
public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false);
|
|
|
|
/// <summary>Push an offline frame regardless of any lingering markers —
|
|
/// for an explicit logout.</summary>
|
|
public Task PublishOfflineAsync(long playerId) => PublishAsync(playerId, forceOffline: true);
|
|
|
|
/// <summary>
|
|
/// The last SignalR connection dropped but the player may still be
|
|
/// playing: the socket can be closed under a live session ("RecNet
|
|
/// HubConnection error: ... TCP Stream closed unexpectedly by the remote
|
|
/// server") and the client reconnects on its own. Pushing offline right
|
|
/// away greys the player out for every friend until that reconnect. Wait
|
|
/// one activity window and only then decide: still silent means the game
|
|
/// is really gone.
|
|
/// </summary>
|
|
public void ScheduleOfflineCheck(long playerId)
|
|
{
|
|
var delay = PlayerPresenceService.ActivityTtl + TimeSpan.FromSeconds(5);
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(delay);
|
|
if (IsOnline(playerId)) return;
|
|
await PublishAsync(playerId, forceOffline: true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex, "[presence-fanout] deferred offline check failed player={PlayerId}", playerId);
|
|
}
|
|
});
|
|
}
|
|
|
|
private async Task PublishAsync(long playerId, bool forceOffline)
|
|
{
|
|
if (playerId <= 0) return;
|
|
try
|
|
{
|
|
await using var scope = scopes.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
|
var settings = scope.ServiceProvider.GetRequiredService<ServerSettingsService>();
|
|
|
|
var friendIds = (await RelationshipQueries.EffectiveFriendIdsAsync(db, settings, playerId))
|
|
.Where(id => id != playerId)
|
|
.Distinct()
|
|
.ToList();
|
|
if (friendIds.Count == 0) return;
|
|
|
|
var online = !forceOffline && IsOnline(playerId);
|
|
var room = online ? presence.GetRoom(playerId) : null;
|
|
var visibility = await GetStatusVisibilityAsync(db, playerId);
|
|
|
|
// Honour the player's visibility choice the same way GET /player
|
|
// does (PlayerPresenceController.ResolveHiddenAsync): recipients
|
|
// who may not see them get the frame a genuinely offline player
|
|
// produces. Every recipient here is already a friend, so
|
|
// FriendsOnly needs no split; FavoriteFriendsOnly reaches only the
|
|
// friends this player favorited (Favorited lives on the
|
|
// favoriter's own row, RequesterId = favoriter).
|
|
var favorited = visibility == PresenceVisibility.FavoriteFriendsOnly
|
|
? (await db.Relationships.AsNoTracking()
|
|
.Where(r => r.RequesterId == playerId && r.Favorited && friendIds.Contains(r.TargetId))
|
|
.Select(r => r.TargetId)
|
|
.ToListAsync()).ToHashSet()
|
|
: new HashSet<long>();
|
|
var (visibleTo, hiddenFrom) = SplitRecipients(friendIds, visibility, favorited);
|
|
|
|
logger.LogInformation(
|
|
"[presence-fanout] player={PlayerId} online={Online} room={RoomId} instance={InstanceId} visibility={Visibility} -> {FriendCount} friend(s), {HiddenCount} hidden",
|
|
playerId, online, room?.RoomId, room?.RoomInstanceId, visibility, visibleTo.Count, hiddenFrom.Count);
|
|
|
|
if (visibleTo.Count > 0)
|
|
await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility);
|
|
if (hiddenFrom.Count > 0)
|
|
await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Best-effort: a fan-out failure must never fail the matchmake,
|
|
// logout or hub event that triggered it.
|
|
logger.LogWarning(ex, "[presence-fanout] push failed player={PlayerId}", playerId);
|
|
}
|
|
}
|
|
|
|
/// <summary>Split a player's friends into those who may see their real
|
|
/// presence and those who must see them offline, per their status
|
|
/// visibility. <paramref name="favorited"/> holds the friends the player
|
|
/// favorited; it only matters for FavoriteFriendsOnly.</summary>
|
|
public static (List<long> VisibleTo, List<long> HiddenFrom) SplitRecipients(
|
|
IReadOnlyList<long> friendIds, int visibility, IReadOnlySet<long> favorited) =>
|
|
visibility switch
|
|
{
|
|
PresenceVisibility.Offline => (new List<long>(), friendIds.ToList()),
|
|
PresenceVisibility.FavoriteFriendsOnly => (
|
|
friendIds.Where(favorited.Contains).ToList(),
|
|
friendIds.Where(id => !favorited.Contains(id)).ToList()),
|
|
_ => (friendIds.ToList(), new List<long>()),
|
|
};
|
|
|
|
/// <summary>Stored status visibility for one player (0 = default).</summary>
|
|
public static async Task<int> GetStatusVisibilityAsync(DorkNetDbContext db, long playerId)
|
|
{
|
|
var raw = await db.PlayerSettings.AsNoTracking()
|
|
.Where(s => s.PlayerId == playerId && s.Key == StatusVisibilityPrefKey)
|
|
.Select(s => s.Value)
|
|
.FirstOrDefaultAsync();
|
|
return int.TryParse(raw, out var v) && PresenceVisibility.IsValid(v) ? v : PresenceVisibility.Public;
|
|
}
|
|
|
|
/// <summary>Stored status visibility for many players in one query.
|
|
/// Players with no stored choice are absent from the result.</summary>
|
|
public static async Task<Dictionary<long, int>> GetStatusVisibilitiesAsync(
|
|
DorkNetDbContext db, IReadOnlyCollection<long> playerIds)
|
|
{
|
|
if (playerIds.Count == 0) return new Dictionary<long, int>();
|
|
var rows = await db.PlayerSettings.AsNoTracking()
|
|
.Where(s => s.Key == StatusVisibilityPrefKey && playerIds.Contains(s.PlayerId))
|
|
.Select(s => new { s.PlayerId, s.Value })
|
|
.ToListAsync();
|
|
var result = new Dictionary<long, int>(rows.Count);
|
|
foreach (var row in rows)
|
|
{
|
|
if (int.TryParse(row.Value, out var v) && PresenceVisibility.IsValid(v)) result[row.PlayerId] = v;
|
|
}
|
|
return result;
|
|
}
|
|
}
|