Files
DorkNet/DorkNet.Server/Services/JoinTimeoutService.cs
Alexa 6ee1d6f9b3 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.
2026-10-15 14:08:11 +03:00

179 lines
7.2 KiB
C#

using System.Collections.Concurrent;
using DorkNet.Server.Controllers.Match;
namespace DorkNet.Server.Services;
/// <summary>
/// Tracks pending /goto joins so a failed Photon join cannot leave the
/// 2020 watch stuck forever on the "Joining room" overlay.
/// </summary>
public sealed class JoinTimeoutService(
PlayerPresenceService presence,
NotificationService notifications,
PresenceBroadcastService fanout,
ILogger<JoinTimeoutService> logger)
{
/// <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)
{
if (room is null) return;
if (IsDorm(room) && !deferPresenceCommit)
{
ClearForPlayer(playerId, "dorm");
return;
}
ClearForPlayer(playerId, "replaced");
var pending = new PendingJoin(
room,
deferPresenceCommit,
new CancellationTokenSource());
_pending[playerId] = pending;
logger.LogInformation(
"[join-watchdog] pending player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId} timeout={TimeoutSeconds}s",
playerId, room.RoomId, room.Name, room.RoomInstanceId, Timeout.TotalSeconds);
_ = 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;
return (pending.TargetRoom, pending.DeferPresenceCommit);
}
public (RoomInstanceDto TargetRoom, bool DeferPresenceCommit)? MarkCompleted(long playerId, long instanceId, string outcome)
{
if (!_pending.TryGetValue(playerId, out var pending) || pending.InstanceId != instanceId)
return null;
if (!RemovePending(playerId, pending))
return null;
pending.Cancellation.Cancel();
logger.LogInformation(
"[join-watchdog] completed player={PlayerId} instance={InstanceId} outcome={Outcome}",
playerId, instanceId, outcome);
return (pending.TargetRoom, pending.DeferPresenceCommit);
}
public Task MarkFailedAsync(long playerId, long instanceId, string outcome)
{
var completed = MarkCompleted(playerId, instanceId, outcome);
var current = presence.GetRoom(playerId);
var failedRoom = completed.HasValue ? completed.Value.TargetRoom : current;
if (failedRoom is null || (!completed.HasValue && current?.RoomInstanceId != instanceId))
{
logger.LogInformation(
"[join-watchdog] failure ignored player={PlayerId} instance={InstanceId} current={CurrentInstance}",
playerId, instanceId, current?.RoomInstanceId);
return Task.CompletedTask;
}
// 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);
// Whatever room friends were last told about is gone; correct them.
return fanout.PublishCurrentAsync(playerId);
}
private async Task WatchAsync(long playerId, PendingJoin pending)
{
try
{
await Task.Delay(Timeout, pending.Cancellation.Token);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
logger.LogWarning(ex, "[join-watchdog] delay failed player={PlayerId}", playerId);
return;
}
if (!RemovePending(playerId, pending))
return;
var current = presence.GetRoom(playerId);
if (!pending.DeferPresenceCommit && (current is null || current.RoomInstanceId != pending.InstanceId))
{
logger.LogInformation(
"[join-watchdog] timeout skipped player={PlayerId} pending={PendingInstance} current={CurrentInstance}",
playerId, pending.InstanceId, current?.RoomInstanceId);
return;
}
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);
}
private void ClearForPlayer(long playerId, string reason)
{
while (_pending.TryGetValue(playerId, out var pending))
{
if (!RemovePending(playerId, pending))
continue;
pending.Cancellation.Cancel();
logger.LogDebug(
"[join-watchdog] cleared player={PlayerId} instance={InstanceId} reason={Reason}",
playerId, pending.InstanceId, reason);
return;
}
}
private bool RemovePending(long playerId, PendingJoin pending) =>
((ICollection<KeyValuePair<long, PendingJoin>>)_pending)
.Remove(new KeyValuePair<long, PendingJoin>(playerId, pending));
private static bool IsDorm(RoomInstanceDto room) =>
room.RoomId == 1 ||
string.Equals(room.Name, "DormRoom", StringComparison.OrdinalIgnoreCase) ||
(room.PhotonRoomId?.StartsWith("^dormroom_", StringComparison.OrdinalIgnoreCase) ?? false);
private sealed record PendingJoin(
RoomInstanceDto TargetRoom,
bool DeferPresenceCommit,
CancellationTokenSource Cancellation)
{
public long InstanceId => TargetRoom.RoomInstanceId;
}
}