using System.Collections.Concurrent;
using DorkNet.Server.Controllers.Match;
namespace DorkNet.Server.Services;
///
/// Tracks pending /goto joins so a failed Photon join cannot leave the
/// 2020 watch stuck forever on the "Joining room" overlay.
///
public sealed class JoinTimeoutService(
PlayerPresenceService presence,
NotificationService notifications,
ILogger logger)
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
private readonly ConcurrentDictionary _pending = new();
public void MarkPending(long playerId, RoomInstanceDto? room)
{
if (room is null) return;
if (IsDorm(room))
{
ClearForPlayer(playerId, "dorm");
return;
}
ClearForPlayer(playerId, "replaced");
var pending = new PendingJoin(
room.RoomInstanceId,
room.RoomId,
room.Name ?? string.Empty,
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);
}
public void MarkCompleted(long playerId, long instanceId, string outcome)
{
if (!_pending.TryGetValue(playerId, out var pending) || pending.InstanceId != instanceId)
return;
if (!RemovePending(playerId, pending))
return;
pending.Cancellation.Cancel();
logger.LogInformation(
"[join-watchdog] completed player={PlayerId} instance={InstanceId} outcome={Outcome}",
playerId, instanceId, outcome);
}
public async Task MarkFailedAsync(long playerId, long instanceId, string outcome)
{
MarkCompleted(playerId, instanceId, outcome);
var current = presence.GetRoom(playerId);
if (current is null || current.RoomInstanceId != instanceId)
{
logger.LogInformation(
"[join-watchdog] failure ignored player={PlayerId} instance={InstanceId} current={CurrentInstance}",
playerId, instanceId, current?.RoomInstanceId);
return;
}
presence.Clear(playerId);
await notifications.KickPlayerAsync(playerId, "Room join failed. Returning to dorm.");
logger.LogWarning(
"[join-watchdog] kicked failed join player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId} outcome={Outcome}",
playerId, current.RoomId, current.Name, instanceId, outcome);
}
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 (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.Clear(playerId);
await notifications.KickPlayerAsync(playerId, "Room join timed out. Returning to dorm.");
logger.LogWarning(
"[join-watchdog] timed out player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId}",
playerId, pending.RoomId, pending.RoomName, 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>)_pending)
.Remove(new KeyValuePair(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(
long InstanceId,
long RoomId,
string RoomName,
CancellationTokenSource Cancellation);
}