diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index c060fd0..173ac3f 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -909,22 +909,30 @@ public class RoomsController( return Ok(BuildRoomServerDetails(room, scenes, roles: roomRoles)); } - /// GET /roomserver/rooms/bulk?name=X&name=Y — bulk - /// lookup by room name. Used by EJDCNGBEICB's room cache to resolve - /// well-known names (RecCenter, etc.) to their RoomServerDetails - /// shape in one round-trip. Returns the same per-room object that - /// /roomserver/rooms/{id} emits, one per requested name; names - /// we can't resolve are silently skipped (watch handles a shorter + /// GET /roomserver/rooms/bulk?name=X&name=Y or + /// ?id=1&id=2 — bulk room cache lookup. Decomp-verified + /// in EJDCNGBEICB: the Int64 overload writes query key id + /// and the string overload writes name. Returns the same + /// per-room object that /roomserver/rooms/{id} emits; + /// unresolved entries are silently skipped (watch handles a shorter /// list fine, but a 404 wedges the room browser). [HttpGet("roomserver/rooms/bulk")] [HttpGet("rooms/bulk")] public async Task RoomServerBulkByName() { var names = Request.Query["name"].Where(n => !string.IsNullOrWhiteSpace(n)).ToArray(); - if (names.Length == 0) return Ok(Array.Empty()); + var ids = Request.Query["id"] + .Where(v => !string.IsNullOrWhiteSpace(v)) + .SelectMany(v => v!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Select(v => long.TryParse(v, out var id) ? id : 0) + .Where(id => id > 0) + .Distinct() + .ToArray(); + if (names.Length == 0 && ids.Length == 0) return Ok(Array.Empty()); var pid = CurrentPlayerId ?? 0; - var results = new List(names.Length); + var results = new List(names.Length + ids.Length); + var emitted = new HashSet(); foreach (var raw in names) { var name = raw!.Trim(); @@ -948,6 +956,34 @@ public class RoomsController( overrideName: DormNameOverride(name)); } + var scenes = await db.RoomScenes + .Where(s => s.RoomId == room.Id) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + var roomRoles = await db.RoomRoles + .Where(r => r.RoomId == room.Id) + .ToListAsync(); + results.Add(BuildRoomServerDetails(room, scenes, roles: roomRoles)); + emitted.Add(room.Id); + } + + foreach (var id in ids) + { + if (!emitted.Add(id)) continue; + + var room = await rooms.GetByIdAsync(id); + if (room is null) continue; + + if (room.IsDormRoom) + { + var dormBlobName = await db.DormStates + .Where(d => d.PlayerId == room.CreatorPlayerId) + .Select(d => d.CurrentDataBlobName) + .FirstOrDefaultAsync() + ?? string.Empty; + room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName); + } + var scenes = await db.RoomScenes .Where(s => s.RoomId == room.Id) .OrderBy(s => s.OrderIndex) diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index f5327d8..cab21b9 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -703,29 +703,41 @@ public class GoToController( private async Task RecordResponseAsync(MatchmakingResponseDto response) { if (this.CurrentPlayerId() is not long playerId) return; - var room = response.RoomInstance; - if (room is null) return; + if (response.RoomInstance is null) return; - var previousRoom = presence.GetRoom(playerId); - var additionalPlayerIds = await GetEligibleAdditionalPlayerIdsAsync(playerId, previousRoom); - await RecordPlayerMoveAsync(playerId, room); - - if (additionalPlayerIds.Count == 0) - return; - - logger.LogInformation( - "[goto-party] player={PlayerId} moving additional players [{AdditionalPlayerIds}] to instance={InstanceId}", - playerId, string.Join(",", additionalPlayerIds), room.RoomInstanceId); - - foreach (var additionalPlayerId in additionalPlayerIds) + var targetRoom = response.RoomInstance; + var currentRoom = presence.GetRoom(playerId); + var deferPresenceCommit = ShouldDeferPresenceCommit(currentRoom, targetRoom); + joinTimeouts.MarkPending(playerId, targetRoom, deferPresenceCommit); + if (deferPresenceCommit) { - var roomForPartyMember = CloneRoomInstance(room); - if (roomForPartyMember.IsPrivate) - await privateInstances.InviteAsync(roomForPartyMember.RoomInstanceId, additionalPlayerId); - - await RecordPlayerMoveAsync(additionalPlayerId, roomForPartyMember); + logger.LogInformation( + "[goto] deferred presence commit player={PlayerId} room={RoomId} subRoom={SubRoomId} fromInstance={FromInstance} toInstance={ToInstance} fromPhoton={FromPhoton} toPhoton={ToPhoton}", + playerId, + targetRoom.RoomId, + targetRoom.SubRoomId, + currentRoom?.RoomInstanceId, + targetRoom.RoomInstanceId, + currentRoom?.PhotonRoomId, + targetRoom.PhotonRoomId); + return; } + presence.SetRoom(playerId, targetRoom); + + // Real visit tracking: upsert the per-(room, player) row so + // we get distinct VisitorCount (unique players ever) AND the + // gross VisitCount (every join, including rejoins). Skip the + // canonical dorm (RoomId=1) so per-day boots don't skew it. + if (targetRoom.RoomId != 1) + await RecordVisitAsync(playerId, targetRoom.RoomId); + + // Push a presence-update notification to every accepted-friend + // so their watch's "friends" list shows the new room. Cheap + // query — Relationships is small, and we only fan out to the + // actually-connected friends. + await NotifyFriendsOfMoveAsync(playerId, targetRoom); + // NOTE: we INTENTIONALLY do NOT self-push SubscriptionUpdateRoom // from /goto. The watch's Rooms.OnSubscriptionUpdateRoom // (Cpp2IL_ISIL/.../RecNet/Rooms.txt:2401-2496) is gated on @@ -744,76 +756,13 @@ public class GoToController( // first one for any (room, subRoom). } - private async Task RecordPlayerMoveAsync(long playerId, RoomInstanceDto room) - { - presence.SetRoom(playerId, room); - joinTimeouts.MarkPending(playerId, room); - - // Real visit tracking: upsert the per-(room, player) row so - // we get distinct VisitorCount (unique players ever) AND the - // gross VisitCount (every join, including rejoins). Skip the - // canonical dorm (RoomId=1) so per-day boots don't skew it. - if (room.RoomId != 1) - await RecordVisitAsync(playerId, room.RoomId); - - // Push a presence-update notification to every accepted-friend - // so their watch's "friends" list shows the new room. Cheap - // query — Relationships is small, and we only fan out to the - // actually-connected friends. - await NotifyFriendsOfMoveAsync(playerId, room); - } - - private async Task> GetEligibleAdditionalPlayerIdsAsync( - long callerPlayerId, - RoomInstanceDto? callerPreviousRoom) - { - if (!Request.HasFormContentType || callerPreviousRoom is null) - return Array.Empty(); - - var form = await Request.ReadFormAsync(); - var rawValues = form["AdditionalPlayerIds"] - .Concat(form["additionalPlayerIds"]) - .Concat(form["additionalPlayerIds[]"]) - .ToArray(); - if (rawValues.Length == 0) - return Array.Empty(); - - var ids = rawValues - .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - .Select(v => long.TryParse(v, out var id) ? id : 0) - .Where(id => id > 0 && id != callerPlayerId) - .Distinct() - .ToList(); - - if (ids.Count == 0) - return Array.Empty(); - - var eligible = new List(ids.Count); - foreach (var id in ids) - { - var current = presence.GetRoom(id); - if (current is not null && IsSameLiveInstance(current, callerPreviousRoom)) - { - eligible.Add(id); - continue; - } - - logger.LogWarning( - "[goto-party] ignoring AdditionalPlayerIds entry caller={CallerId} additional={AdditionalId} callerPrevious={CallerInstance} additionalCurrent={AdditionalInstance}", - callerPlayerId, id, callerPreviousRoom.RoomInstanceId, current?.RoomInstanceId); - } - - return eligible; - } - - private static bool IsSameLiveInstance(RoomInstanceDto a, RoomInstanceDto b) - { - if (a.RoomInstanceId == b.RoomInstanceId) - return true; - - return !string.IsNullOrWhiteSpace(a.PhotonRoomId) - && string.Equals(a.PhotonRoomId, b.PhotonRoomId, StringComparison.OrdinalIgnoreCase); - } + private static bool ShouldDeferPresenceCommit(RoomInstanceDto? currentRoom, RoomInstanceDto targetRoom) => + currentRoom is not null + && targetRoom.IsPrivate + && currentRoom.RoomId == targetRoom.RoomId + && currentRoom.SubRoomId == targetRoom.SubRoomId + && (currentRoom.RoomInstanceId != targetRoom.RoomInstanceId + || !string.Equals(currentRoom.PhotonRoomId, targetRoom.PhotonRoomId, StringComparison.OrdinalIgnoreCase)); /// Mutate the response so it represents a fresh invite-only /// match: forks the PhotonRoomId with a per-(player, nonce) suffix diff --git a/DorkNet.Server/Controllers/Match/MatchController.cs b/DorkNet.Server/Controllers/Match/MatchController.cs index a293770..7a67517 100644 --- a/DorkNet.Server/Controllers/Match/MatchController.cs +++ b/DorkNet.Server/Controllers/Match/MatchController.cs @@ -52,7 +52,18 @@ public class MatchController( var pid = this.RequireCurrentPlayerId(); if (code == 0) { - joinTimeouts.MarkCompleted(pid, instanceId, label); + var completed = joinTimeouts.MarkCompleted(pid, instanceId, label); + if (completed.HasValue) + { + presence.SetRoom(pid, completed.Value.TargetRoom); + logger.LogInformation( + "[joinresult] committed presence player={Player} room={RoomId}/{RoomName} instance={Instance} deferred={Deferred}", + pid, + completed.Value.TargetRoom.RoomId, + completed.Value.TargetRoom.Name, + completed.Value.TargetRoom.RoomInstanceId, + completed.Value.DeferPresenceCommit); + } logger.LogInformation( "[joinresult] player={Player} instance={Instance} OK ({Label})", pid, instanceId, label); diff --git a/DorkNet.Server/Data/images/image_Crescendo.png b/DorkNet.Server/Data/images/image_Crescendo.png new file mode 100644 index 0000000..952978f Binary files /dev/null and b/DorkNet.Server/Data/images/image_Crescendo.png differ diff --git a/DorkNet.Server/Data/room_images.json b/DorkNet.Server/Data/room_images.json index 01fb601..c460707 100644 --- a/DorkNet.Server/Data/room_images.json +++ b/DorkNet.Server/Data/room_images.json @@ -1,6 +1,7 @@ { "3DCharades": "aj7w8q5nrfyt2ssfoovawtz4l.png", "BowlingAlley": "8gwqibu0anm4j0rw54xo2y0yd.png", + "Crescendo": "image_Crescendo.png", "CrimsonCauldron": "ae6yh8p6y8p3cuyqkc4l8wi4z.png", "DiscGolfLake": "86s8y4ihyhx7n9ac41gwahwze.png", "DiscGolfPropulsion": "6wvnxmrmsw98wsttajp0nreci.png", @@ -17,4 +18,4 @@ "Soccer": "apo2sd115tuwueyoprp8jhx7b.png", "StuntRunner": "8j4f516axpnpjgnj342t85vkd.png", "TheRiseofJumbotron": "8x6qaj4nlbudf1412tupxkq9n.png" -} \ No newline at end of file +} diff --git a/DorkNet.Server/DorkNet.Server.csproj b/DorkNet.Server/DorkNet.Server.csproj index 4e2687e..8739260 100644 --- a/DorkNet.Server/DorkNet.Server.csproj +++ b/DorkNet.Server/DorkNet.Server.csproj @@ -145,6 +145,10 @@ data\images\%(Filename)%(Extension) PreserveNewest + + data\images\%(Filename)%(Extension) + PreserveNewest + data\images\%(Filename)%(Extension) PreserveNewest diff --git a/DorkNet.Server/Services/JoinTimeoutService.cs b/DorkNet.Server/Services/JoinTimeoutService.cs index aab31a1..db0b751 100644 --- a/DorkNet.Server/Services/JoinTimeoutService.cs +++ b/DorkNet.Server/Services/JoinTimeoutService.cs @@ -15,7 +15,7 @@ public sealed class JoinTimeoutService( private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); private readonly ConcurrentDictionary _pending = new(); - public void MarkPending(long playerId, RoomInstanceDto? room) + public void MarkPending(long playerId, RoomInstanceDto? room, bool deferPresenceCommit = false) { if (room is null) return; @@ -28,9 +28,8 @@ public sealed class JoinTimeoutService( ClearForPlayer(playerId, "replaced"); var pending = new PendingJoin( - room.RoomInstanceId, - room.RoomId, - room.Name ?? string.Empty, + room, + deferPresenceCommit, new CancellationTokenSource()); _pending[playerId] = pending; @@ -41,26 +40,28 @@ public sealed class JoinTimeoutService( _ = WatchAsync(playerId, pending); } - public void MarkCompleted(long playerId, long instanceId, string outcome) + public (RoomInstanceDto TargetRoom, bool DeferPresenceCommit)? MarkCompleted(long playerId, long instanceId, string outcome) { if (!_pending.TryGetValue(playerId, out var pending) || pending.InstanceId != instanceId) - return; + return null; if (!RemovePending(playerId, pending)) - return; + 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 async Task MarkFailedAsync(long playerId, long instanceId, string outcome) { - MarkCompleted(playerId, instanceId, outcome); + var completed = MarkCompleted(playerId, instanceId, outcome); var current = presence.GetRoom(playerId); - if (current is null || current.RoomInstanceId != instanceId) + 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}", @@ -72,7 +73,7 @@ public sealed class JoinTimeoutService( 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); + playerId, failedRoom.RoomId, failedRoom.Name, instanceId, outcome); } private async Task WatchAsync(long playerId, PendingJoin pending) @@ -95,7 +96,7 @@ public sealed class JoinTimeoutService( return; var current = presence.GetRoom(playerId); - if (current is null || current.RoomInstanceId != pending.InstanceId) + if (!pending.DeferPresenceCommit && (current is null || current.RoomInstanceId != pending.InstanceId)) { logger.LogInformation( "[join-watchdog] timeout skipped player={PlayerId} pending={PendingInstance} current={CurrentInstance}", @@ -107,7 +108,7 @@ public sealed class JoinTimeoutService( 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); + playerId, pending.TargetRoom.RoomId, pending.TargetRoom.Name, pending.InstanceId); } private void ClearForPlayer(long playerId, string reason) @@ -135,8 +136,10 @@ public sealed class JoinTimeoutService( (room.PhotonRoomId?.StartsWith("^dormroom_", StringComparison.OrdinalIgnoreCase) ?? false); private sealed record PendingJoin( - long InstanceId, - long RoomId, - string RoomName, - CancellationTokenSource Cancellation); + RoomInstanceDto TargetRoom, + bool DeferPresenceCommit, + CancellationTokenSource Cancellation) + { + public long InstanceId => TargetRoom.RoomInstanceId; + } }