diff --git a/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs b/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs
index 7db6530..6c2ee52 100644
--- a/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs
+++ b/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs
@@ -463,7 +463,9 @@ public class InventoryController(
CreatedAt = inv.AcquiredAt,
ActiveDurationMinutes = (int?)null,
inv.IsActive,
- IsTransferable = true,
+ // Same as GET api/consumables/v2/getUnlocked, so a push never flips
+ // what the inventory list says.
+ IsTransferable = false,
};
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs
index 59e967e..d85cd22 100644
--- a/DorkNet.Server/Controllers/Admin/AdminController.cs
+++ b/DorkNet.Server/Controllers/Admin/AdminController.cs
@@ -3520,9 +3520,15 @@ public class AdminController(
.Where(sc => sc.RoomId == room.Id)
.OrderBy(sc => sc.OrderIndex)
.ToListAsync();
+ // With every role grant: the client replaces its cached room with
+ // this, so a payload without them would drop everyone's roles.
+ var forceJoinRoles = await db.RoomRoles
+ .Where(r => r.RoomId == room.Id)
+ .ToListAsync();
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateRoom,
- DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(room, forceJoinScenes));
+ DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(
+ room, forceJoinScenes, roles: forceJoinRoles));
await LogAsync("force_join", "player", id,
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs
index fb9ea87..2da8e0f 100644
--- a/DorkNet.Server/Controllers/Match/GoToController.cs
+++ b/DorkNet.Server/Controllers/Match/GoToController.cs
@@ -880,8 +880,11 @@ public class GoToController(
callerId, playerId, theirRoom.RoomInstanceId);
// Tell the OWNER someone tried to follow them in, as the
// client's own "wants to join" message (RequestGameInvite,
- // from the caller), so they can send an invite.
- if (callerId is long requester)
+ // from the caller), so they can send an invite. At most once
+ // per requester, owner and instance per cooldown, so retrying
+ // doesn't fill the owner's inbox.
+ if (callerId is long requester
+ && JoinRequestCooldown.TryStart(requester, playerId, theirRoom.RoomInstanceId))
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester);
return Ok(new MatchmakingResponseDto
@@ -1670,3 +1673,35 @@ public class RoomInstanceDto
};
}
}
+
+/// Rate limit for "wants to join" messages: one per (requester,
+/// owner, room instance) per . Later retries and other
+/// instances still go through. Per replica, which is enough to stop a
+/// retry loop from flooding an inbox.
+internal static class JoinRequestCooldown
+{
+ public static readonly TimeSpan Window = TimeSpan.FromMinutes(5);
+ private static readonly System.Collections.Concurrent.ConcurrentDictionary<(long, long, long), DateTime> Last = new();
+
+ /// True when no request for this key was sent within the window
+ /// (and records this one); false while it is cooling down.
+ public static bool TryStart(long requester, long owner, long roomInstanceId)
+ {
+ var now = DateTime.UtcNow;
+ if (Last.Count > 10_000)
+ foreach (var (key, at) in Last)
+ if (now - at > Window) Last.TryRemove(key, out _);
+
+ var k = (requester, owner, roomInstanceId);
+ while (true)
+ {
+ if (!Last.TryGetValue(k, out var prev))
+ {
+ if (Last.TryAdd(k, now)) return true;
+ continue;
+ }
+ if (now - prev < Window) return false;
+ if (Last.TryUpdate(k, now, prev)) return true;
+ }
+ }
+}
diff --git a/DorkNet.Server/Hubs/NotifyHub.cs b/DorkNet.Server/Hubs/NotifyHub.cs
index 04e01fc..301e132 100644
--- a/DorkNet.Server/Hubs/NotifyHub.cs
+++ b/DorkNet.Server/Hubs/NotifyHub.cs
@@ -161,6 +161,12 @@ public class NotifyHub(
private const string SubscriptionsKey = "presence-subscriptions";
+ /// Most players one connection may subscribe to. The client sends
+ /// its relationships plus the players around it (dozens); anything far
+ /// past that is not a real client, and each id costs a group membership
+ /// on the backplane.
+ public const int MaxPresenceSubscriptions = 5000;
+
/// SubscribeToPlayers(PlayerSubscriptionListDTO {PlayerIds:
/// List<int>}) (RecNet.Runtime OMHPBIEHOHN.txt:3430): the full set
/// of players this client wants presence for — its relationships, the
@@ -187,6 +193,13 @@ public class NotifyHub(
return;
}
if (me is long self) requested.Remove(self);
+ if (requested.Count > MaxPresenceSubscriptions)
+ {
+ logger.LogWarning(
+ "[hub] SubscribeToPlayers over the {Max} limit player={PlayerId} conn={ConnectionId} count={Count}; kept the current set",
+ MaxPresenceSubscriptions, me, Context.ConnectionId, requested.Count);
+ return;
+ }
var current = Context.Items.TryGetValue(SubscriptionsKey, out var existing) && existing is HashSet set
? set
@@ -232,15 +245,12 @@ public class NotifyHub(
var list = payload;
if (payload.ValueKind == JsonValueKind.Object)
{
- var found = false;
- foreach (var prop in payload.EnumerateObject())
- {
- if (!string.Equals(prop.Name, "PlayerIds", StringComparison.OrdinalIgnoreCase)) continue;
- list = prop.Value;
- found = true;
- break;
- }
- if (!found) return false;
+ var match = payload.EnumerateObject()
+ .Where(prop => string.Equals(prop.Name, "PlayerIds", StringComparison.OrdinalIgnoreCase))
+ .Select(prop => (JsonElement?)prop.Value)
+ .FirstOrDefault();
+ if (match is not JsonElement value) return false;
+ list = value;
}
if (list.ValueKind != JsonValueKind.Array) return false;
foreach (var item in list.EnumerateArray())
diff --git a/DorkNet.Server/Services/OnlinePresenceService.cs b/DorkNet.Server/Services/OnlinePresenceService.cs
index a783fee..1d0bc26 100644
--- a/DorkNet.Server/Services/OnlinePresenceService.cs
+++ b/DorkNet.Server/Services/OnlinePresenceService.cs
@@ -148,6 +148,34 @@ public class OnlinePresenceService
lock (set) return set.ToArray();
}
+ /// Connection ids of every player in ,
+ /// in one go: the Redis lookups are issued together, so the multiplexer
+ /// pipelines them into one round trip instead of one per player.
+ public async Task> GetConnectionsAsync(IEnumerable playerIds)
+ {
+ var ids = playerIds.Distinct().ToArray();
+ if (ids.Length == 0) return Array.Empty();
+ if (_redis is { } mux)
+ {
+ try
+ {
+ var db = mux.GetDatabase();
+ var sets = await Task.WhenAll(ids.Select(id => db.SetMembersAsync(ConnsKey(id))));
+ return sets.SelectMany(s => s).Select(v => v.ToString()).ToArray();
+ }
+ catch
+ {
+ // Redis sick → fall through to the local fallback, as
+ // GetConnections does.
+ }
+ }
+ var result = new List();
+ foreach (var id in ids)
+ if (_localConns.TryGetValue(id, out var set))
+ lock (set) result.AddRange(set);
+ return result;
+ }
+
/// Snapshot of currently-online player ids across the
/// cluster. Returns an empty array when nobody's online; never null.
/// Callers typically .ToHashSet() the result for membership
diff --git a/DorkNet.Server/Services/PresenceBroadcastService.cs b/DorkNet.Server/Services/PresenceBroadcastService.cs
index 6288c8a..2257216 100644
--- a/DorkNet.Server/Services/PresenceBroadcastService.cs
+++ b/DorkNet.Server/Services/PresenceBroadcastService.cs
@@ -143,8 +143,11 @@ public sealed class PresenceBroadcastService(
// offline frame otherwise. Friends were served above and the
// subject must never get a frame about themselves, so their
// connections are excluded.
- var exclude = connections.GetConnections(playerId)
- .Concat(friendIds.SelectMany(connections.GetConnections))
+ // Only friends who are online have connections; one call for the
+ // online set, then one pipelined lookup for all of them.
+ var onlineIds = connections.OnlinePlayerIds().ToHashSet();
+ var exclude = (await connections.GetConnectionsAsync(
+ friendIds.Where(onlineIds.Contains).Append(playerId)))
.Distinct()
.ToList();
var publicToAll = visibility == PresenceVisibility.Public && online;