fix: presence, profile edits, outfit menu and RecNetResult writes for the 2023 client

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.
This commit is contained in:
Alexa
2026-10-15 14:08:11 +03:00
parent 36e64e99ba
commit 6ee1d6f9b3
45 changed files with 6666 additions and 448 deletions
+46
View File
@@ -0,0 +1,46 @@
using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
/// <summary>
/// Single projection from <see cref="PlayerEntity"/> to the account wire
/// DTOs. Every account read (account/me, account/{id}, account/bulk,
/// account/search, account/create) and the AccountUpdate /
/// SelfAccountUpdate pushes go through here so a profile field written by
/// one endpoint is visible on all of them — previously each call site built
/// its own DTO and the 2023 profile fields were never emitted anywhere.
/// </summary>
public static class AccountDtos
{
public static RecNetAccount Public(PlayerEntity p) => Fill(new RecNetAccount(), p);
public static RecNetSelfAccount Self(PlayerEntity p)
{
var a = Fill(new RecNetSelfAccount(), p);
a.Email = p.Email ?? string.Empty;
a.Phone = p.Phone ?? string.Empty;
a.Birthday = p.Birthday ?? new DateTime(2000, 1, 1);
a.JuniorState = p.IsJunior ? 1 : 0;
a.ParentAccountId = null;
return a;
}
private static T Fill<T>(T a, PlayerEntity p) where T : RecNetAccount
{
a.AccountId = (int)p.Id;
a.RawUsername = p.Username;
a.Username = p.Username;
a.DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? p.Username : p.DisplayName;
a.ProfileImage = p.ProfileImageName ?? string.Empty;
a.TreatAsJunior = p.IsJunior;
a.HasBirthday = true;
a.Platforms = 1;
a.CreatedAt = p.CreatedAt;
a.DisplayEmoji = p.DisplayEmoji ?? string.Empty;
a.BannerImage = p.BannerImageName ?? string.Empty;
a.PersonalPronouns = p.PersonalPronouns;
a.IdentityFlags = p.IdentityFlags;
return a;
}
}
@@ -0,0 +1,109 @@
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
/// <summary>
/// OAuth device-authorization grant as the 2023 client drives it.
///
/// 1. The device that wants to sign in calls <c>connect/deviceauthorization</c>
/// and shows the player the <c>user_code</c> (reader
/// <c>RecNet.Runtime/AMPFGJPKHOD.txt:399-598</c>: device_code, user_code,
/// verification_uri(_complete), expires_in, interval).
/// 2. It polls <c>connect/token</c> with
/// <c>grant_type=urn:ietf:params:oauth:grant-type:device_code</c> and
/// <c>device_code</c> (<c>LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451</c>).
/// A 400 whose <c>error</c> is <c>authorization_pending</c> or
/// <c>slow_down</c> means "keep polling"
/// (<c>LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116</c>); anything else
/// ends the attempt.
/// 3. A signed-in client approves the code with
/// <c>account/me/remoteauth</c> (form field <c>code</c>).
///
/// State lives in one PlayerSettings row per authorization, owned by the system
/// account: key <c>deviceauthorization:{device_code}</c>, value
/// <c>{user_code}|{expiresAtUtc:O}</c>, plus <c>|{approverAccountId}</c> once
/// approved. Redeeming deletes the row, so a device code is single-use.
/// </summary>
public static class DeviceAuthorizations
{
public const string KeyPrefix = "deviceauthorization:";
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15);
public enum RedeemStatus { Invalid, Expired, Pending, Approved }
public sealed record Created(string DeviceCode, string UserCode, int ExpiresInSeconds);
public static async Task<Created> CreateAsync(DorkNetDbContext db, DateTime nowUtc)
{
var deviceCode = Guid.NewGuid().ToString("N");
var userCode = deviceCode[..8].ToUpperInvariant();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = RelationshipQueries.SystemAccountId,
Key = KeyPrefix + deviceCode,
Value = $"{userCode}|{(nowUtc + Lifetime):O}",
});
await db.SaveChangesAsync();
return new Created(deviceCode, userCode, (int)Lifetime.TotalSeconds);
}
/// <summary>Bind the pending authorization whose user code is
/// <paramref name="userCode"/> to <paramref name="approverId"/>. False when
/// no live, unapproved authorization carries that code.</summary>
public static async Task<bool> ApproveAsync(
DorkNetDbContext db, string userCode, long approverId, DateTime nowUtc)
{
var code = NormaliseUserCode(userCode);
if (code.Length == 0) return false;
var prefix = code + "|";
var rows = await db.PlayerSettings
.Where(s => s.Key.StartsWith(KeyPrefix) && s.Value.StartsWith(prefix))
.ToListAsync();
foreach (var row in rows)
{
var parts = row.Value.Split('|');
if (parts.Length < 2 || !TryParseExpiry(parts[1], out var expires) || expires < nowUtc)
continue;
if (parts.Length >= 3 && parts[2].Length > 0) continue; // already approved
row.Value = $"{parts[0]}|{parts[1]}|{approverId}";
await db.SaveChangesAsync();
return true;
}
return false;
}
/// <summary>One poll from the device. Approved authorizations are consumed
/// and yield the approving account.</summary>
public static async Task<(RedeemStatus Status, long AccountId)> RedeemAsync(
DorkNetDbContext db, string? deviceCode, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(deviceCode)) return (RedeemStatus.Invalid, 0);
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.Key == KeyPrefix + deviceCode.Trim());
if (row is null) return (RedeemStatus.Invalid, 0);
var parts = row.Value.Split('|');
if (parts.Length < 2 || !TryParseExpiry(parts[1], out var expires) || expires < nowUtc)
{
db.PlayerSettings.Remove(row);
await db.SaveChangesAsync();
return (RedeemStatus.Expired, 0);
}
if (parts.Length < 3 || !long.TryParse(parts[2], out var approver))
return (RedeemStatus.Pending, 0);
db.PlayerSettings.Remove(row);
await db.SaveChangesAsync();
return (RedeemStatus.Approved, approver);
}
private static string NormaliseUserCode(string? raw) =>
new string((raw ?? string.Empty).Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant();
private static bool TryParseExpiry(string raw, out DateTime expiresUtc) =>
DateTime.TryParse(raw, null, System.Globalization.DateTimeStyles.RoundtripKind, out expiresUtc);
}
+31 -4
View File
@@ -10,9 +10,27 @@ namespace DorkNet.Server.Services;
public sealed class JoinTimeoutService(
PlayerPresenceService presence,
NotificationService notifications,
PresenceBroadcastService fanout,
ILogger<JoinTimeoutService> logger)
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
/// <summary>
/// How long a join may stay unreported before the server gives up on it.
///
/// This used to be 30 seconds, which is shorter than a real 2023 room
/// load: the client reports its join result only after the room's assets
/// have downloaded and the player has spawned, and a large maker room
/// regularly takes longer than that (in the field: matchmake/room at
/// 09:16:35, Photon joined at 09:16:41, the load still running at 09:17:33).
/// The watchdog then cleared presence mid-load, the next heartbeat found
/// no room and answered with the dorm, and the client logged "presence
/// heartbeat response indicates local presence is out-of-sync" and
/// cancelled the load ("OnPlayerPresenceUpdate is cancelling room load").
///
/// The 2023 client reports its own failures (reportjoinresult with
/// FailedTo*) and runs its fallback chain, so this is only a backstop for
/// a client that silently stopped mid-join.
/// </summary>
public static TimeSpan Timeout { get; } = TimeSpan.FromMinutes(3);
private readonly ConcurrentDictionary<long, PendingJoin> _pending = new();
public void MarkPending(long playerId, RoomInstanceDto? room, bool deferPresenceCommit = false)
@@ -40,6 +58,10 @@ public sealed class JoinTimeoutService(
_ = WatchAsync(playerId, pending);
}
/// <summary>Drop the player's pending join without touching presence —
/// the client abandoned it (matchmake/none, logout).</summary>
public void Cancel(long playerId, string reason) => ClearForPlayer(playerId, reason);
public (RoomInstanceDto TargetRoom, bool DeferPresenceCommit)? GetPending(long playerId)
{
if (!_pending.TryGetValue(playerId, out var pending)) return null;
@@ -75,11 +97,15 @@ public sealed class JoinTimeoutService(
return Task.CompletedTask;
}
presence.Clear(playerId);
// Room only: the player is still online and about to run the
// client's fallback chain. A full Clear also dropped the activity
// marker, so friends saw them go offline after every failed join.
presence.ClearRoom(playerId);
logger.LogWarning(
"[join-watchdog] cleared failed join player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId} outcome={Outcome}",
playerId, failedRoom.RoomId, failedRoom.Name, instanceId, outcome);
return Task.CompletedTask;
// Whatever room friends were last told about is gone; correct them.
return fanout.PublishCurrentAsync(playerId);
}
private async Task WatchAsync(long playerId, PendingJoin pending)
@@ -110,8 +136,9 @@ public sealed class JoinTimeoutService(
return;
}
presence.Clear(playerId);
presence.ClearRoom(playerId);
await notifications.KickPlayerAsync(playerId, "Room join timed out. Returning to dorm.");
await fanout.PublishCurrentAsync(playerId);
logger.LogWarning(
"[join-watchdog] timed out player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId}",
playerId, pending.TargetRoom.RoomId, pending.TargetRoom.Name, pending.InstanceId);
+53 -47
View File
@@ -170,45 +170,25 @@ public class NotificationService(
}
}
/// <summary>Push fresh account payloads to the affected player's
/// watch. The 2020 client caches Account/SelfAccount objects in memory;
/// without this, username and display-name edits persist server-side
/// but the current session keeps rendering the stale cached names until
/// a full relog rebuilds the account cache.</summary>
/// <summary>Push fresh account payloads after a profile mutation.
/// The owner gets <c>SelfAccountUpdate</c> (private self DTO) and every
/// connected client gets <c>AccountUpdate</c> (public DTO). The 2023
/// client subscribes to both strings (RecNet.Runtime
/// OPMAPIOEIFG.txt:267 "AccountUpdate" → Action&lt;Account&gt;, :279
/// "SelfAccountUpdate" → Action&lt;SelfAccount&gt;) and refreshes its
/// cached account from the payload — the profile-edit PUTs return no
/// account, so this push is the ONLY way the new emoji / banner /
/// pronouns / identity flags reach the editor's own UI and anyone else
/// already looking at the profile. Sending AccountUpdate to the owner
/// alone (the previous behaviour) left every other player rendering the
/// stale cached account until relog.</summary>
public Task AccountChanged(PlayerEntity player)
{
var account = new RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = string.IsNullOrWhiteSpace(player.DisplayName) ? player.Username : player.DisplayName,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = player.IsJunior,
HasBirthday = true,
Platforms = 1,
CreatedAt = player.CreatedAt,
};
var selfAccount = new RecNetSelfAccount
{
AccountId = account.AccountId,
RawUsername = account.RawUsername,
Username = account.Username,
DisplayName = account.DisplayName,
ProfileImage = account.ProfileImage,
TreatAsJunior = account.TreatAsJunior,
HasBirthday = account.HasBirthday,
Platforms = account.Platforms,
CreatedAt = account.CreatedAt,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
Birthday = player.Birthday ?? new DateTime(2000, 1, 1),
JuniorState = player.IsJunior ? 1 : 0,
ParentAccountId = null,
};
var account = AccountDtos.Public(player);
var selfAccount = AccountDtos.Self(player);
return Task.WhenAll(
NotifyAsync(player.Id, PushNotificationId.SubscriptionUpdateProfile, account),
BroadcastAsync(PushNotificationId.SubscriptionUpdateProfile, account),
NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
@@ -271,17 +251,49 @@ public class NotificationService(
/// the runtime-type path can pick the wrong contract under certain
/// option combos and emit an empty <c>{}</c> body — which silently
/// drops the presence update at the watch.</summary>
public async Task PlayerPresenceChanged(IEnumerable<long> friendIds, long playerId, RoomInstanceDto? room)
public async Task PlayerPresenceChanged(
IEnumerable<long> friendIds,
long playerId,
RoomInstanceDto? room,
bool? isOnline = null,
int statusVisibility = 0)
{
var groups = friendIds.Select(NotifyHub.GroupForPlayer).ToArray();
if (groups.Length == 0) return;
var redacted = RoomInstanceDto.Redact(room);
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
logger.LogDebug(
"[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}",
playerId, groups.Length, payload);
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
}
/// <summary>The PresenceUpdate envelope for one player, as a JSON string.
///
/// <paramref name="isOnline"/> is independent of the room: the 2023
/// presence DTO stores it in its own field (LCEHINCCHOB +0x50,
/// RecNet.Runtime/LCEHINCCHOB.txt:635-653) and DNKHCBFPLJG treats
/// "online with no room" as a real state — a player in the offline dorm
/// after matchmake/none. Deriving it from the room, as this used to, sent
/// such players to their friends as OFFLINE. When omitted it still falls
/// back to "has a room" for callers that only know about room moves.
/// An offline frame never carries a room.</summary>
public static string BuildPresenceUpdatePayload(
long playerId,
RoomInstanceDto? room,
bool? isOnline = null,
int statusVisibility = 0)
{
var online = isOnline ?? room is not null;
var redacted = online ? RoomInstanceDto.Redact(room) : null;
var msg = new System.Text.Json.Nodes.JsonObject
{
["playerId"] = (int)playerId,
// Everyone=0 (was 1=FriendsOnly, which hid you from non-friends).
["statusVisibility"] = 0,
// The player's stored /player/statusvisibility choice (default
// 0 = Everyone), the same value their own heartbeat reports.
["statusVisibility"] = statusVisibility,
["deviceClass"] = 0,
["vrMovementMode"] = 0,
["roomInstance"] = redacted is null
@@ -307,7 +319,7 @@ public class NotificationService(
["EncryptVoiceChat"] = redacted.EncryptVoiceChat,
},
// 2020.12 PlayerPresence.Deserialize requires these.
["isOnline"] = redacted is not null,
["isOnline"] = online,
// 2023.03.21 client build version; must match the joining
// client's own version or it reports a version mismatch. MUST be
// a STRING ("20230317"): the client's presence DTO reads
@@ -325,13 +337,7 @@ public class NotificationService(
["Id"] = WireName(PushNotificationId.SubscriptionUpdatePresence),
["Msg"] = msg,
};
var payload = envelope.ToJsonString();
logger.LogDebug(
"[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}",
playerId, groups.Length, payload);
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
return envelope.ToJsonString();
}
public Task PlayerCameOnline(IEnumerable<long> friendIds, long playerId, RoomInstanceDto? room) =>
@@ -48,6 +48,7 @@ public class PlayerPresenceService
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;
@@ -101,7 +102,9 @@ public class PlayerPresenceService
try
{
var json = JsonSerializer.Serialize(room, JsonOpts);
mux.GetDatabase().StringSet(Key(playerId), json, PresenceTtl);
var db = mux.GetDatabase();
db.StringSet(Key(playerId), json, PresenceTtl);
db.KeyDelete(NoRoomKey(playerId));
_log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
playerId, room?.Name ?? "<null>", json.Length);
}
@@ -117,6 +120,7 @@ public class PlayerPresenceService
{
_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>");
}
@@ -333,6 +337,7 @@ public class PlayerPresenceService
db.KeyDelete(Key(playerId));
db.KeyDelete(ActiveKey(playerId));
db.KeyDelete(BootDormGraceKey(playerId));
db.KeyDelete(NoRoomKey(playerId));
db.SetRemove(ActiveSetKey, playerId);
}
else
@@ -341,9 +346,119 @@ public class PlayerPresenceService
_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>&lt;TryGoToOfflineRoom&gt;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 &lt; 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 currently-active instances of a given room,
/// grouped by PhotonRoomId, with the player ids currently presenced
/// in each. Drives the watch's RoomInstanceBrowser
@@ -386,6 +501,7 @@ public class PlayerPresenceService
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 ─────────────────────────
@@ -0,0 +1,188 @@
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;
}
}