diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index 49ba54e..69b1dbb 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -36,6 +36,7 @@ public class GoToController( RoomService rooms, DorkNet.Server.Data.DorkNetDbContext db, NotificationService notifications, + JoinTimeoutService joinTimeouts, ILogger logger) : ControllerBase { [HttpPost("/goto/room/{roomName}")] @@ -687,8 +688,10 @@ public class GoToController( private async Task RecordResponseAsync(MatchmakingResponseDto response) { if (this.CurrentPlayerId() is not long playerId) return; + if (response.RoomInstance is null) return; presence.SetRoom(playerId, response.RoomInstance); + joinTimeouts.MarkPending(playerId, response.RoomInstance); // Real visit tracking: upsert the per-(room, player) row so // we get distinct VisitorCount (unique players ever) AND the diff --git a/DorkNet.Server/Controllers/Match/MatchController.cs b/DorkNet.Server/Controllers/Match/MatchController.cs index 43379b5..a293770 100644 --- a/DorkNet.Server/Controllers/Match/MatchController.cs +++ b/DorkNet.Server/Controllers/Match/MatchController.cs @@ -20,6 +20,7 @@ public class MatchController( GameSessionService sessionService, PlayerPresenceService presence, PrivateInstanceService privateInstances, + JoinTimeoutService joinTimeouts, DorkNetDbContext db, ILogger logger) : ControllerBase { @@ -33,9 +34,11 @@ public class MatchController( /// response body and just needs 2xx (Matchmaking.txt:6668-6699), so /// we mostly use this for visibility into Photon-side issues — /// without it, all we see in the catch-all is an opaque "200, empty - /// reply" and no clue why a player crashed back to dorm. + /// reply" and no clue why a player crashed back to dorm. Failure + /// reports also clear the pending /goto room and push a ModerationKick + /// so the watch leaves the "Joining room" overlay instead of softlocking. [HttpPost("/roominstance/{instanceId:long}/reportjoinresult")] - public IActionResult ReportJoinResult(long instanceId, [FromForm(Name = "result")] int? result) + public async Task ReportJoinResult(long instanceId, [FromForm(Name = "result")] int? result) { var code = result ?? -1; var label = code switch @@ -49,12 +52,14 @@ public class MatchController( var pid = this.RequireCurrentPlayerId(); if (code == 0) { + joinTimeouts.MarkCompleted(pid, instanceId, label); logger.LogInformation( "[joinresult] player={Player} instance={Instance} OK ({Label})", pid, instanceId, label); } else { + await joinTimeouts.MarkFailedAsync(pid, instanceId, label); logger.LogWarning( "[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label}) — Photon-side error, check Photon AppId/region/auth", pid, instanceId, code, label); diff --git a/DorkNet.Server/Services/JoinTimeoutService.cs b/DorkNet.Server/Services/JoinTimeoutService.cs new file mode 100644 index 0000000..aab31a1 --- /dev/null +++ b/DorkNet.Server/Services/JoinTimeoutService.cs @@ -0,0 +1,142 @@ +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); +} diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index 5a12084..ad5692f 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -148,6 +148,7 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // S3-compatible object storage (Garage in production, MinIO/disk in dev)