diff --git a/DorkNet.Server.Tests/CapturingHubContext.cs b/DorkNet.Server.Tests/CapturingHubContext.cs new file mode 100644 index 0000000..6430fe8 --- /dev/null +++ b/DorkNet.Server.Tests/CapturingHubContext.cs @@ -0,0 +1,78 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using DorkNet.Server.Hubs; +using Microsoft.AspNetCore.SignalR; + +namespace DorkNet.Server.Tests; + +/// +/// Stand-in for the notify hub that records every push instead of sending it, +/// so tests can assert on the exact frames the game client would receive. +/// A frame is the hub method plus its single string argument, parsed back into +/// the {Id, Msg} envelope. +/// +public sealed class CapturingHubContext : IHubContext +{ + private readonly ConcurrentQueue _sent = new(); + + public IHubClients Clients => new CapturingClients(this); + public IGroupManager Groups { get; } = new NoopGroups(); + + public IReadOnlyList Sent => _sent.ToArray(); + + /// Frames sent to a target such as group:player:12, in + /// order, optionally filtered by envelope Id. + public IReadOnlyList To(string target, string? id = null) => + _sent.Where(f => f.Target == target && (id is null || f.Id == id)).ToList(); + + public static string PlayerGroup(long playerId) => $"group:{NotifyHub.GroupForPlayer(playerId)}"; + + internal void Record(string target, IReadOnlyList? excluded, string method, object?[] args) => + _sent.Enqueue(new SentFrame(target, excluded ?? Array.Empty(), method, + args.Length == 1 && args[0] is string s ? s : JsonSerializer.Serialize(args))); + + public sealed record SentFrame(string Target, IReadOnlyList Excluded, string Method, string Payload) + { + public JsonElement Envelope => JsonDocument.Parse(Payload).RootElement; + public string? Id => Envelope.TryGetProperty("Id", out var id) ? id.GetString() : null; + public JsonElement Msg => Envelope.GetProperty("Msg"); + } + + private sealed class CapturingClients(CapturingHubContext owner) : IHubClients + { + private IClientProxy Proxy(string target, IReadOnlyList? excluded = null) => + new CapturingProxy(owner, target, excluded); + + public IClientProxy All => Proxy("all"); + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Proxy("all", excludedConnectionIds); + public IClientProxy Client(string connectionId) => Proxy($"conn:{connectionId}"); + public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy($"conns:{string.Join(",", connectionIds)}"); + public IClientProxy Group(string groupName) => Proxy($"group:{groupName}"); + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => + Proxy($"group:{groupName}", excludedConnectionIds); + public IClientProxy Groups(IReadOnlyList groupNames) => new FanOutProxy(groupNames.Select(g => Proxy($"group:{g}")).ToList()); + public IClientProxy User(string userId) => Proxy($"user:{userId}"); + public IClientProxy Users(IReadOnlyList userIds) => Proxy($"users:{string.Join(",", userIds)}"); + } + + private sealed class CapturingProxy(CapturingHubContext owner, string target, IReadOnlyList? excluded) : IClientProxy + { + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) + { + owner.Record(target, excluded, method, args); + return Task.CompletedTask; + } + } + + private sealed class FanOutProxy(IReadOnlyList proxies) : IClientProxy + { + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) => + Task.WhenAll(proxies.Select(p => p.SendCoreAsync(method, args, cancellationToken))); + } + + private sealed class NoopGroups : IGroupManager + { + public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; + } +} diff --git a/DorkNet.Server.Tests/NotificationContractTests.cs b/DorkNet.Server.Tests/NotificationContractTests.cs new file mode 100644 index 0000000..af18fb6 --- /dev/null +++ b/DorkNet.Server.Tests/NotificationContractTests.cs @@ -0,0 +1,198 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using DorkNet.Server.Controllers.API.Inventory; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Hubs; +using DorkNet.Server.Services; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace DorkNet.Server.Tests; + +/// +/// Push frames checked against the 2023-03-21 client's notification readers +/// (RecNet.Runtime). Every push is the hub method "Notification" with one +/// JSON string {"Id":"<key>","Msg":{…}}; the client looks the Id +/// up case-sensitively and parses Msg strictly into the handler's DTO, so a +/// wrong key or shape drops the push without any error. +/// +public sealed class NotificationContractTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public NotificationContractTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Friend_request_pushes_each_side_its_own_relationship_and_an_inbox_message() + { + var (factory, hub) = WithCapturingHub(); + using var setup = ApiClient(factory); + var sender = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + using var client = ApiClient(factory, sender); + (await client.PostAsync($"/api/relationships/v2/sendfriendrequest?id={target.PlayerId}", null)) + .EnsureSuccessStatusCode(); + + // RelationshipChanged "1" = the Relationship row from each side's view. + var toTarget = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1")); + Assert.Equal("Notification", toTarget.Method); + Assert.Equal(sender.PlayerId, toTarget.Msg.GetProperty("PlayerID").GetInt64()); + Assert.Equal(2, toTarget.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestReceived + Assert.False(toTarget.Msg.TryGetProperty("Reason", out _)); + + var toSender = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1")); + Assert.Equal(target.PlayerId, toSender.Msg.GetProperty("PlayerID").GetInt64()); + Assert.Equal(1, toSender.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestSent + + // MessageReceived "2" — the "X sent you a friend request" inbox row. + var invite = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "2")); + Assert.Equal(4, invite.Msg.GetProperty("Type").GetInt32()); // FriendInvite + Assert.Equal(sender.PlayerId, invite.Msg.GetProperty("FromPlayerId").GetInt64()); + + using var targetClient = ApiClient(factory, target); + (await targetClient.PostAsync($"/api/relationships/v2/acceptfriendrequest?id={sender.PlayerId}", null)) + .EnsureSuccessStatusCode(); + + Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1")[^1] + .Msg.GetProperty("RelationshipType").GetInt32()); + Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1")[^1] + .Msg.GetProperty("RelationshipType").GetInt32()); + var accepted = hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "2")[^1]; + Assert.Equal(40, accepted.Msg.GetProperty("Type").GetInt32()); // FriendRequestAccepted + } + + [Fact] + public async Task Favorite_pushes_the_callers_updated_row_to_the_caller_only() + { + var (factory, hub) = WithCapturingHub(); + using var setup = ApiClient(factory); + var me = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var other = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + using var client = ApiClient(factory, me); + (await client.GetAsync($"/api/relationships/v1/favorite?id={other.PlayerId}")).EnsureSuccessStatusCode(); + + var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(me.PlayerId), "1")); + Assert.Equal(other.PlayerId, frame.Msg.GetProperty("PlayerID").GetInt64()); + Assert.Equal(1, frame.Msg.GetProperty("Favorited").GetInt32()); + Assert.Empty(hub.To(CapturingHubContext.PlayerGroup(other.PlayerId), "1")); + } + + [Fact] + public async Task Kick_is_the_flat_moderation_block_with_the_reason_in_VoteKickReason() + { + var (factory, hub) = WithCapturingHub(); + await using var scope = factory.Services.CreateAsyncScope(); + var notifications = scope.ServiceProvider.GetRequiredService(); + + await notifications.KickPlayerAsync(42, "Too loud", gameSessionId: 777); + + var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(42), "22")); + var msg = frame.Msg; + // The handler only shows a reason for ReportCategory 10 + !IsHostKick. + Assert.Equal(10, msg.GetProperty("ReportCategory").GetInt32()); + Assert.False(msg.GetProperty("IsHostKick").GetBoolean()); + Assert.Equal("Too loud", msg.GetProperty("VoteKickReason").GetString()); + Assert.Equal(777, msg.GetProperty("GameSessionId").GetInt64()); + Assert.Equal(0, msg.GetProperty("Duration").GetInt32()); + Assert.False(msg.TryGetProperty("Reason", out _)); // the old nested 2020 shape + } + + [Theory] + [InlineData(NotificationService.LogoutReason.SessionTakeover, 2)] + [InlineData(NotificationService.LogoutReason.ForciblyLoggedOut, 3)] + [InlineData(NotificationService.LogoutReason.Banned, 4)] + public async Task Logout_reason_is_the_numeric_enum(NotificationService.LogoutReason reason, int wire) + { + var (factory, hub) = WithCapturingHub(); + await using var scope = factory.Services.CreateAsyncScope(); + var notifications = scope.ServiceProvider.GetRequiredService(); + + await notifications.LogoutAsync(43, reason); + + var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(43), "6")); + var value = frame.Msg.GetProperty("Reason"); + Assert.Equal(JsonValueKind.Number, value.ValueKind); + Assert.Equal(wire, value.GetInt32()); + } + + [Fact] + public async Task Presence_for_subscribers_goes_to_the_subjects_presence_group_minus_excluded_connections() + { + var (factory, hub) = WithCapturingHub(); + await using var scope = factory.Services.CreateAsyncScope(); + var notifications = scope.ServiceProvider.GetRequiredService(); + + await notifications.PresenceToSubscribersAsync(55, room: null, isOnline: true, statusVisibility: 0, + excludeConnectionIds: ["self-conn", "friend-conn"]); + + var frame = Assert.Single(hub.To($"group:{NotifyHub.PresenceGroupForPlayer(55)}", "PresenceUpdate")); + Assert.Equal(["self-conn", "friend-conn"], frame.Excluded); + Assert.Equal(55, frame.Msg.GetProperty("playerId").GetInt32()); + Assert.True(frame.Msg.GetProperty("isOnline").GetBoolean()); + Assert.Equal(JsonValueKind.String, frame.Msg.GetProperty("appVersion").ValueKind); + } + + [Theory] + [InlineData("""{"PlayerIds":[1,2,3]}""", new long[] { 1, 2, 3 })] + [InlineData("""{"playerIds":["4",5]}""", new long[] { 4, 5 })] + [InlineData("""[6,7]""", new long[] { 6, 7 })] + [InlineData("""{"PlayerIds":[]}""", new long[0])] + public void SubscribeToPlayers_payload_is_read_in_any_casing(string json, long[] expected) + { + Assert.True(NotifyHub.TryReadPlayerIds(JsonDocument.Parse(json).RootElement, out var ids)); + Assert.Equal(expected.OrderBy(x => x), ids.OrderBy(x => x)); + } + + [Fact] + public void Unreadable_SubscribeToPlayers_payload_is_rejected_not_read_as_empty() + { + Assert.False(NotifyHub.TryReadPlayerIds(JsonDocument.Parse("""{"Channels":[]}""").RootElement, out _)); + } + + [Fact] + public void Consumable_mapping_push_has_exactly_the_clients_keys() + { + var wire = JsonSerializer.SerializeToElement(InventoryController.ConsumableMappingWire(new PlayerInventoryEntity + { + Id = 9, + ItemSlug = "some-consumable", + Quantity = 3, + })); + + var keys = wire.EnumerateObject().Select(p => p.Name).ToHashSet(); + Assert.Equal( + new HashSet { "Id", "ConsumableItemDesc", "Count", "InitialCount", "CreatedAt", "ActiveDurationMinutes", "IsActive", "IsTransferable" }, + keys); + Assert.Equal(JsonValueKind.String, wire.GetProperty("ConsumableItemDesc").ValueKind); + Assert.Equal(3, wire.GetProperty("Count").GetInt32()); + } + + private (WebApplicationFactory Factory, CapturingHubContext Hub) WithCapturingHub() + { + var hub = new CapturingHubContext(); + var factory = _factory.WithWebHostBuilder(builder => + builder.ConfigureServices(services => + { + services.RemoveAll>(); + services.AddSingleton>(hub); + })); + return (factory, hub); + } + + private HttpClient ApiClient(WebApplicationFactory factory, GameClientSession? session = null) + { + var client = factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); + if (session is not null) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } +} diff --git a/DorkNet.Server/Controllers/API/FreeGifts/FreeGiftsController.cs b/DorkNet.Server/Controllers/API/FreeGifts/FreeGiftsController.cs index fa79373..aeeb463 100644 --- a/DorkNet.Server/Controllers/API/FreeGifts/FreeGiftsController.cs +++ b/DorkNet.Server/Controllers/API/FreeGifts/FreeGiftsController.cs @@ -56,9 +56,12 @@ public class FreeGiftsController(DorkNetDbContext db, NotificationService notifi await db.SaveChangesAsync(); foreach (var row in created) { + // The full gift package, as GET api/avatar/v2/gifts lists it: the + // client consumes a gift by its Id, which the old hand-built + // payload didn't carry. await notifications.NotifyAsync(row.RecipientPlayerId, PushNotificationId.GiftPackageReceived, - new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message }); + DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(row.Gift)); } return Ok(new { Success = true, Sent = created.Count }); diff --git a/DorkNet.Server/Controllers/API/Images/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/ImagesController.cs index 163490b..dd7c971 100644 --- a/DorkNet.Server/Controllers/API/Images/ImagesController.cs +++ b/DorkNet.Server/Controllers/API/Images/ImagesController.cs @@ -1295,39 +1295,14 @@ public class ImagesController( .Select(s => long.TryParse(s, out var v) ? v : 0L) .Where(v => v > 0); - private Task NotifyAccountImageChangedAsync(PlayerEntity player) - { - var account = new DorkNet.Models.Auth.RecNetAccount - { - AccountId = (int)player.Id, - RawUsername = player.Username, - Username = player.Username, - DisplayName = player.DisplayName ?? player.Username, - ProfileImage = player.ProfileImageName ?? string.Empty, - TreatAsJunior = player.IsJunior, - HasBirthday = true, - Platforms = 1, - }; - var selfAccount = new DorkNet.Models.Auth.RecNetSelfAccount - { - AccountId = account.AccountId, - RawUsername = account.RawUsername, - Username = account.Username, - DisplayName = account.DisplayName, - ProfileImage = account.ProfileImage, - TreatAsJunior = account.TreatAsJunior, - HasBirthday = account.HasBirthday, - Platforms = account.Platforms, - Email = player.Email ?? string.Empty, - Phone = player.Phone ?? string.Empty, - Birthday = player.Birthday, - JuniorState = player.IsJunior ? 1 : 0, - ParentAccountId = null, - }; - return Task.WhenAll( - notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account), - notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount)); - } + /// Push the new profile image through the same account pushes + /// as every other profile edit: the owner's SelfAccountUpdate and an + /// AccountUpdate to everyone. A hand-built account here left out the + /// emoji, banner, pronouns, identity flags and creation date, so an image + /// change wiped them from the owner's cached account, and nobody else saw + /// the new picture. + private Task NotifyAccountImageChangedAsync(PlayerEntity player) => + notifications.AccountChanged(player); private static string NormaliseTaggedIds(string? csv, long exceptId) { diff --git a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs index bfc06a2..2bdcd6e 100644 --- a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs +++ b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs @@ -1095,11 +1095,9 @@ public class InventionsController( }); inv.CheerCount += 1; await db.SaveChangesAsync(); - - if (inv.CreatorPlayerId != pid) - await notifications.NotifyAsync(inv.CreatorPlayerId, - PushNotificationId.InventionModerationStateChanged, - new { Type = "cheer", inv.Id, From = pid, inv.CheerCount }); + // No push: InventionModerationStateChanged ("100") means "your + // inventions' moderation state changed" and takes no payload; sent + // on every cheer it only made the creator refetch for nothing. } else if (!req.Cheer && existing is not null) { diff --git a/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs b/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs index 2b63e37..6c2ee52 100644 --- a/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs +++ b/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs @@ -279,11 +279,11 @@ public class InventoryController( if (row.Quantity == 0) db.PlayerInventory.Remove(row); await db.SaveChangesAsync(); + // A consume always changes an existing mapping, so it is always + // ConsumableMappingRemoved (71) carrying the count that is left; + // Added (70) would make the client treat it as a new stack. await notifications.NotifyAsync(pid, - row.Quantity > 0 - ? PushNotificationId.ConsumableMappingAdded - : PushNotificationId.ConsumableMappingRemoved, - new { row.Id, row.ItemSlug, row.Quantity }); + PushNotificationId.ConsumableMappingRemoved, ConsumableMappingWire(row)); return Ok(new { row.Id, Count = row.Quantity }); } @@ -332,8 +332,7 @@ public class InventoryController( if (source.Quantity == 0) db.PlayerInventory.Remove(source); await db.SaveChangesAsync(); await notifications.NotifyAsync(recipient, - PushNotificationId.ConsumableMappingAdded, - new { target.Id, target.ItemSlug, target.Quantity }); + PushNotificationId.ConsumableMappingAdded, ConsumableMappingWire(target)); return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity }); } @@ -449,6 +448,26 @@ public class InventoryController( }; } + /// ConsumableMapping push payload ("70" Added / "71" Removed): + /// exactly the client's mapping DTO CGCALDBHLGD (formatter + /// JICIOMDBPDO) — Id (Int64), ConsumableItemDesc (STRING, not an + /// object), Count, InitialCount, CreatedAt, ActiveDurationMinutes (Int32?), + /// IsActive, IsTransferable. The old {Id, ItemSlug, Quantity} parsed + /// as a mapping with no item and a count of 0. + public static object ConsumableMappingWire(PlayerInventoryEntity inv) => new + { + inv.Id, + ConsumableItemDesc = StoreService.TryGetConsumableItemDesc(inv.ItemSlug, out var desc) ? desc : inv.ItemSlug, + Count = inv.Quantity, + InitialCount = inv.Quantity, // the original purchase count isn't tracked + CreatedAt = inv.AcquiredAt, + ActiveDurationMinutes = (int?)null, + inv.IsActive, + // 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) { var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc) diff --git a/DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs b/DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs index 1b18de8..3a944eb 100644 --- a/DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs +++ b/DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs @@ -152,8 +152,6 @@ public class InGameModerationController( foreach (var target in ids) { logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target); - // Push the ModerationKick shape the 2020.12 client expects: - // Msg.Reason must be a ModerationBlockDetail object. await notifications.KickPlayerAsync(target, "Instant kick by moderator."); } return Ok(new { success = true, error = "" }); @@ -249,21 +247,14 @@ public class InGameModerationController( "[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}", caller, target, reason, p.BannedUntil, displayReason); - // Push the ban as a ModerationKick (22) with IsBan set, NOT as - // ModerationRoomBan (24): the 2023-03-21 client's moderation manager - // registers only 22, "ModerationUnkick" and 23 - // (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler - // for 24, so that push landed nowhere and the banned player kept playing - // until their next login. The 22 handler reads Msg.Reason as a - // ModerationBlockDetail, which ModerationKickPayload already produces. - await notifications.KickPlayerAsync( - target, - // `reason` is the numeric report category; DisplayReason is the - // moderator's text and is what the ban dialog shows. - string.IsNullOrWhiteSpace(displayReason) - ? "You have been banned from this room." - : displayReason.Trim(), - isBan: true); + // Sign the banned player out with Logout "6" (Reason Banned). Not + // ModerationRoomBan (24): the 2023-03-21 client registers no handler + // for it (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335 register only + // 22, "ModerationUnkick" and 23), so that push landed nowhere. Not a + // ModerationKick either: a kick only sends the player to their dorm + // and they kept playing until their next login. The ban check refuses + // that next login. + await notifications.LogoutAsync(target, NotificationService.LogoutReason.Banned); return Ok(new { success = true, error = "" }); } diff --git a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs index 261da81..3e507be 100644 --- a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs +++ b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs @@ -34,6 +34,7 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents; public class PlayerEventsController( DorkNetDbContext db, NotificationService notifications, + SystemNotificationService systemNotifications, PlayerPresenceService presence) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); @@ -817,7 +818,7 @@ public class PlayerEventsController( var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; await ApplyBodyToExtrasAsync(extras); await SaveExtrasAsync(evt, extras); - await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated); + await NotifyEventUpdatedAsync(evt); return Ok(await WrapperAsync(evt)); } @@ -901,10 +902,7 @@ public class PlayerEventsController( // Tell the creator someone RSVP'd. They can ignore but the // watch will refresh their event detail card. - if (ev.CreatorPlayerId != Me) - await notifications.NotifyAsync(ev.CreatorPlayerId, - PushNotificationId.PlayerEventResponseChanged, - new { ev.Id, From = Me, body.Response }); + await NotifyResponseChangedAsync(ev, row); // Left as the historical shape on purpose: this route is 2020-only // (the 2023 client GETs .../responses and POSTs v1/respond instead), // so there is no 2023 DTO to match here. @@ -947,10 +945,15 @@ public class PlayerEventsController( var existing = await db.PlayerEventResponses .FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt); if (existing is null) - db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response }); + { + existing = new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response }; + db.PlayerEventResponses.Add(existing); + } else existing.Response = response; await db.SaveChangesAsync(); + var responded = await db.PlayerEvents.FirstAsync(e => e.Id == evt); + await NotifyResponseChangedAsync(responded, existing); return RespondResult(legacyForm, 0 /*Success*/); } @@ -968,9 +971,18 @@ public class PlayerEventsController( var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId"); if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/); - await db.PlayerEventResponses + var deleted = await db.PlayerEventResponses .Where(r => r.PlayerId == Me && r.EventId == evt) .ExecuteDeleteAsync(); + if (deleted > 0) + { + // PlayerEventResponseDeleted "84" carries only PlayerEventId. + var creator = await db.PlayerEvents.Where(e => e.Id == evt) + .Select(e => (long?)e.CreatorPlayerId).FirstOrDefaultAsync(); + if (creator is long c && c != Me) + await notifications.NotifyAsync(c, PushNotificationId.PlayerEventResponseDeleted, + new { PlayerEventId = evt }); + } return RespondResult(legacyForm, 0 /*Success*/); } @@ -993,6 +1005,7 @@ public class PlayerEventsController( // has to echo the event that was deleted. var extras = (await LoadExtrasAsync(new[] { evt }))[id]; var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id); + var notify = await AttendeeIdsAsync(evt); db.PlayerEvents.Remove(evt); await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync(); @@ -1000,6 +1013,7 @@ public class PlayerEventsController( .Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id)) .ExecuteDeleteAsync(); await db.SaveChangesAsync(); + await NotifyEventDeletedAsync(id, notify); return Ok(Wrapper(evt, extras, attendees)); } @@ -1021,6 +1035,7 @@ public class PlayerEventsController( evt.Title = Clamp(value.Trim(), 128); evt.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); + await NotifyEventUpdatedAsync(evt); return Ok(await WrapperAsync(evt)); } @@ -1034,6 +1049,7 @@ public class PlayerEventsController( evt.Description = Clamp(value.Trim(), 2000); evt.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); + await NotifyEventUpdatedAsync(evt); return Ok(await WrapperAsync(evt)); } @@ -1056,6 +1072,7 @@ public class PlayerEventsController( var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId"); await SaveExtrasAsync(evt, extras); + await NotifyEventUpdatedAsync(evt); return Ok(await WrapperAsync(evt)); } @@ -1072,6 +1089,7 @@ public class PlayerEventsController( if (start is null && end is null) return BadRequest("missing_time"); evt.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); + await NotifyEventUpdatedAsync(evt); return Ok(await WrapperAsync(evt)); } @@ -1085,6 +1103,7 @@ public class PlayerEventsController( var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; extras.ImageName = Clamp(value.Trim(), 128); await SaveExtrasAsync(evt, extras); + await NotifyEventUpdatedAsync(evt); return Ok(await WrapperAsync(evt)); } @@ -1099,6 +1118,7 @@ public class PlayerEventsController( var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; extras.Accessibility = accessibility; await SaveExtrasAsync(evt, extras); + await NotifyEventUpdatedAsync(evt); return Ok(await WrapperAsync(evt)); } @@ -1120,6 +1140,7 @@ public class PlayerEventsController( var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; extras.Tags = tags; await SaveExtrasAsync(evt, extras); + await NotifyEventUpdatedAsync(evt); return Ok(await WrapperAsync(evt)); } @@ -1158,6 +1179,7 @@ public class PlayerEventsController( ?? extras.CanRequestBroadcastPermissions; await SaveExtrasAsync(evt, extras); + await NotifyEventUpdatedAsync(evt); return Ok(await WrapperAsync(evt)); } @@ -1188,6 +1210,7 @@ public class PlayerEventsController( } await SaveExtrasAsync(evt, extras); + await NotifyEventUpdatedAsync(evt); return Ok(await WrapperAsync(evt)); } @@ -1197,6 +1220,7 @@ public class PlayerEventsController( var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); if (ev is null) return NotFound(); if (ev.CreatorPlayerId != Me) return Forbid(); + var notify = await AttendeeIdsAsync(ev); db.PlayerEvents.Remove(ev); // Cascade-clean the responses; SQLite has no FK cascade // configured here so do it manually. @@ -1206,6 +1230,7 @@ public class PlayerEventsController( await db.PlayerSettings .Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId)) .ExecuteDeleteAsync(); + await NotifyEventDeletedAsync(eventId, notify); return Ok(); } @@ -1244,6 +1269,7 @@ public class PlayerEventsController( var failed = new List(); var sender = Me; var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}."; + var eventWire = await ToWireOneAsync(ev); foreach (var rid in req.InvitedPlayerIds.Distinct()) { var exists = await db.Players.AnyAsync(p => p.Id == rid); @@ -1252,17 +1278,15 @@ public class PlayerEventsController( failed.Add(new { InvitedPlayerId = rid, Result = 1 }); continue; } - db.Messages.Add(new MessageEntity - { - SenderPlayerId = sender, - RecipientPlayerId = rid, - Body = inviteText, - }); - await notifications.NotifyAsync(rid, - PushNotificationId.PlayerEventCreated, - new { ev.Id, ev.Title, ev.StartsAt, From = sender }); + // The invite as a text message from the host, stored and pushed + // live (it used to be stored only, so it appeared after a relog). + await systemNotifications.SendAsync(rid, + SystemNotificationService.MessageType.TextMessage, fromPlayerId: sender, data: inviteText); + // And the event itself, so it shows in the invitee's event list: + // PlayerEventCreated "80" carries the full event (MDCBEPJCJPO) — + // the same object GET api/playerevents/v2/{id} returns. + await notifications.NotifyAsync(rid, PushNotificationId.PlayerEventCreated, eventWire); } - await db.SaveChangesAsync(); return Ok(new { FailedInvites = failed, Result = 0 }); } @@ -1289,21 +1313,56 @@ public class PlayerEventsController( "BroadcastingRoomInstanceId", "broadcastingRoomInstanceId"); await SaveExtrasAsync(evt, extras); - // Attendees' event cards key off BroadcastingRoomInstanceId, so nudge - // them to re-fetch instead of sending a chat message. - await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged); + // Attendees' event cards key off BroadcastingRoomInstanceId, so send + // them the updated event. + await NotifyEventUpdatedAsync(evt); return Ok(await WrapperAsync(evt)); } - private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id) - { - var recipients = await db.PlayerEventResponses + /// Everyone who answered the event with anything but NotGoing, + /// except its creator. + private async Task> AttendeeIdsAsync(PlayerEventEntity evt) => + (await db.PlayerEventResponses .Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse) .Select(r => r.PlayerId) .Distinct() - .ToListAsync(); - foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId)) - await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title }); + .ToListAsync()) + .Where(p => p != evt.CreatorPlayerId) + .ToList(); + + /// PlayerEventUpdated "81" to the attendees, carrying the full + /// event (MDCBEPJCJPO, formatter CENEMCMGDKG) — the same object + /// GET api/playerevents/v2/{id} returns; the client replaces its cached + /// event with it. The old {PlayerEventId, Title} overwrote the + /// cached event with an empty name and default times, and the + /// PlayerEventStateChanged (85) used for broadcasts has no client handler + /// at all. + private async Task NotifyEventUpdatedAsync(PlayerEventEntity evt) + { + var recipients = await AttendeeIdsAsync(evt); + if (recipients.Count == 0) return; + var wire = await ToWireOneAsync(evt); + foreach (var playerId in recipients) + await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventUpdated, wire); + } + + /// PlayerEventDeleted "82" ({PlayerEventId}) to the players who + /// were attending, collected before the rows were removed. + private async Task NotifyEventDeletedAsync(long eventId, IEnumerable recipients) + { + foreach (var playerId in recipients) + await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventDeleted, + new { PlayerEventId = eventId }); + } + + /// PlayerEventResponseChanged "83" to the event's creator: + /// {PlayerEvent, PlayerEventResponse} (COAGAJPELCG), each the same + /// object the event and response REST routes return. + private async Task NotifyResponseChangedAsync(PlayerEventEntity evt, PlayerEventResponseEntity response) + { + if (evt.CreatorPlayerId == response.PlayerId) return; + await notifications.NotifyAsync(evt.CreatorPlayerId, PushNotificationId.PlayerEventResponseChanged, + new { PlayerEvent = await ToWireOneAsync(evt), PlayerEventResponse = ResponseWire(response) }); } // ── Report ─────────────────────────────────────────────────────────── diff --git a/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs b/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs index 9d18383..4cb2bdd 100644 --- a/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs +++ b/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs @@ -48,7 +48,11 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2; /// [ApiController] [Authorize] -public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase +public class RelationshipsController( + DorkNetDbContext db, + NotificationService notifications, + SystemNotificationService systemNotifications, + ServerSettingsService settings) : ControllerBase { // RelationshipType — int values match Relationship.RelationshipType // in the patched dump.cs. @@ -226,7 +230,9 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no } await db.SaveChangesAsync(); - await notifications.FriendRequestReceived(id, me); + await PushPairAsync(me, id); + // The "X sent you a friend request" row in the target's notifications. + await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendInvite, fromPlayerId: me); return Ok(BuildRelationship(row, me)); } @@ -251,7 +257,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no rel.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - await notifications.FriendRequestAccepted(id, me); + await PushPairAsync(me, id); + await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me); return Ok(BuildRelationship(rel, me)); } @@ -285,7 +292,7 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no db.Relationships.RemoveRange(removable); await db.SaveChangesAsync(); - await notifications.FriendRemoved(id, me); + await PushPairAsync(me, id); var remaining = pair.Except(removable).ToList(); return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining)); @@ -333,6 +340,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no existing.UpdatedAt = DateTime.UtcNow; } await db.SaveChangesAsync(); + await PushPairAsync(me, id); + await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me); return Ok(BuildRelationship(existing, me)); } @@ -411,6 +420,10 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no if (ignored is bool i) row.Ignored = i; row.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); + // The HTTP reply is only an ack; the updated row reaches the client + // through RelationshipChanged. A preference is the caller's own bit, + // so only the caller is told. + await PushPairAsync(me, otherId, notifyOther: false); return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty }); } @@ -511,6 +524,36 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no // ── helpers ────────────────────────────────────────────────────── + /// Push RelationshipChanged for the pair (, + /// ) after a change, each side getting its OWN view + /// of the relationship. Without this a friend request, accept or unfriend + /// only showed up after the friends list was refetched, and the client + /// never re-subscribed to the new friend's presence. + private async Task PushPairAsync(long me, long other, bool notifyOther = true) + { + var pair = await db.Relationships + .Where(r => (r.RequesterId == me && r.TargetId == other) || + (r.RequesterId == other && r.TargetId == me)) + .ToListAsync(); + var globalFriends = await settings.IsGlobalFriendsEnabledAsync(); + + await notifications.RelationshipChanged(me, ViewOf(me, other, pair, globalFriends)); + if (notifyOther) + await notifications.RelationshipChanged(other, ViewOf(other, me, pair, globalFriends)); + } + + /// The relationship from 's side, the way + /// v2/get would list it. + private static Dictionary ViewOf( + long viewer, long other, List pair, bool globalFriends) + { + if (globalFriends + && other != RelationshipQueries.SystemAccountId + && !pair.Any(r => r.Status == EntityStatus.Blocked)) + return SynthFriend(other, pair.FirstOrDefault(r => r.RequesterId == viewer), viewer); + return pair.Count == 0 ? EmptyRelationship(other) : BuildMerged(viewer, other, pair); + } + private async Task> FriendIdsAsync(long playerId) { var rows = await db.Relationships diff --git a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs index 17bfd51..ce692fa 100644 --- a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs +++ b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs @@ -156,9 +156,19 @@ public class StorefrontsBuyController( await db.SaveChangesAsync(); // Push the buyer's balance update so their wallet UI refreshes. + // StorefrontBalancePurchase "62" is PurchaseBalanceModificationDTO + // {BalanceAddType, Delta, Balance, Platform, CurrencyType}; Balance + // is the absolute new balance, Delta only feeds the client's log. await notifications.NotifyAsync(pid, PushNotificationId.StorefrontBalancePurchase, - new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug }); + new + { + BalanceAddType = 0, + Delta = (int)-item.Price, + Balance = newBalance, + Platform = 0, + req.CurrencyType, + }); if (isGift) { @@ -166,24 +176,13 @@ public class StorefrontsBuyController( // GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts // and pop the "you got a gift" toast; the gift box itself // appears next time the recipient opens their inventory. + // GiftPackageReceived "30" carries the client's full gift package + // (FHMABOHAEED) — the same object GET api/avatar/v2/gifts lists. + // A hand-picked subset left out PlatformsToSpawnOn (-1 = every + // platform), so the box could count as spawning nowhere. await notifications.NotifyAsync(recipient, PushNotificationId.GiftPackageReceived, - new - { - Id = gift.Id, - Platform = -1, - Xp = 0, - Level = 1, - GiftRarity = 0, - GiftContext = gift.GiftContext, - FromPlayerId = gift.FromPlayerId, - AvatarItemType = gift.AvatarItemType, - AvatarItemDesc = avatarItemDesc, - ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty, - EquipmentPrefabName = gift.EquipmentPrefabName, - EquipmentModificationGuid = gift.EquipmentModificationGuid, - Message = gift.Message ?? string.Empty, - }); + DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift)); } // For gift purchases the buyer doesn't get a box — their flow diff --git a/DorkNet.Server/Controllers/Accounts/AccountsController.cs b/DorkNet.Server/Controllers/Accounts/AccountsController.cs index d6c7af9..fa33d73 100644 --- a/DorkNet.Server/Controllers/Accounts/AccountsController.cs +++ b/DorkNet.Server/Controllers/Accounts/AccountsController.cs @@ -154,7 +154,7 @@ public class AccountsController( playerId, username, summary); } - await notifications.KickPlayerAsync(playerId, "This account has been deleted."); + await notifications.LogoutAsync(playerId, NotificationService.LogoutReason.ForciblyLoggedOut); presence.Clear(playerId); return Ok(new RecNetResult { Success = true, Error = string.Empty }); diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 6a5fb11..d85cd22 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -29,6 +29,7 @@ namespace DorkNet.Server.Controllers.Admin; public class AdminController( DorkNetDbContext db, NotificationService notifications, + SystemNotificationService systemNotifications, LevelService level, CommunityBoardService communityBoard, RoomBlobNormalizerService roomBlobNormalizer, @@ -160,7 +161,9 @@ public class AdminController( await LogAsync("ban_player", "player", id, body.Reason ?? ""); await db.SaveChangesAsync(); - await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned."); + // A ban must sign the player out (a kick only sends them to the dorm); + // the next login is refused by the ban check. + await notifications.LogoutAsync(id, NotificationService.LogoutReason.Banned); playerPresence.Clear(id); return Ok(new { player.Id, player.BannedUntil }); @@ -243,7 +246,7 @@ public class AdminController( await db.SaveChangesAsync(); await tx.CommitAsync(); - await notifications.KickPlayerAsync(id, "Your account has been removed by an admin."); + await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut); playerPresence.Clear(id); return Ok(new @@ -1570,10 +1573,13 @@ public class AdminController( return Ok(new { ok = true }); } - /// Push fresh RoomDetails after role/owner mutations. The - /// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts, - /// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom - /// stores a replacement. + /// Push fresh room details after role/owner mutations, to everyone + /// in the room plus the player whose role changed. The client caches the + /// room's Roles and role-gated UI reads that cache. The 2023 "RoomUpdate" + /// handler (IBEOONPEELF.txt:507) reads the FULL room (FGCPNAACHIK) — the + /// shape GET roomserver/rooms/{id} returns. The 2020 {Room, Scenes} + /// wrapper parsed as RoomId 0 with no roles, so role changes never + /// reached anyone until they re-entered the room. private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason) { var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); @@ -1587,7 +1593,7 @@ public class AdminController( .ToListAsync(); var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController - .BuildRoomDetails(room, scenes, roles: roles); + .BuildRoomServerDetails(room, scenes, roles: roles); var recipients = onlinePresence.OnlinePlayerIds() .Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId) @@ -1739,7 +1745,7 @@ public class AdminController( { var r = playerPresence.GetRoom(pid); if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue; - await notifications.KickPlayerAsync(pid, reason); + await notifications.KickPlayerAsync(pid, reason, gameSessionId: instanceId); playerPresence.Clear(pid); kicked.Add(pid); } @@ -2396,7 +2402,9 @@ public class AdminController( var reason = string.IsNullOrWhiteSpace(body?.Reason) ? "Kicked by an admin." : body!.Reason!.Trim(); - await notifications.KickPlayerAsync(id, reason); + // The room instance lets the client announce the kick to that room. + await notifications.KickPlayerAsync(id, reason, + gameSessionId: playerPresence.GetRoom(id)?.RoomInstanceId ?? 0); playerPresence.Clear(id); await LogAsync("kick_player", "player", id, body?.Reason ?? ""); await db.SaveChangesAsync(); @@ -3328,10 +3336,11 @@ public class AdminController( player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword); await LogAsync("set_password", "player", id, ""); await db.SaveChangesAsync(); - // Push a logout so any active sessions on the old password get - // dropped — the watch re-authenticates with the new password. - await notifications.NotifyAsync(id, PushNotificationId.Logout, - new { Reason = "AdminPasswordReset" }); + // Log out any session still on the old password; the client has to + // sign in again with the new one. The reason must be the numeric + // enum: the string "AdminPasswordReset" failed the client's parse and + // the push was dropped. + await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut); return Ok(new { id }); } @@ -3500,22 +3509,26 @@ public class AdminController( playerPresence.SetRoom(id, roomInstance); - // Two pushes back-to-back: GameSession update changes the - // watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance - // → UpdateCachedRoomInstance), and SubscriptionUpdateRoom - // refreshes the watch's RoomDetails cache so the room-info - // panel doesn't show stale data after the warp. + // The move itself comes from the presence set above: the client's + // next heartbeat sees it is out of sync and joins that instance. + // RoomInstanceUpdate only refreshes the client's cached copy of the + // instance, and RoomUpdate its cached room details, which must be + // the full room (FGCPNAACHIK) the roomserver GET returns. await notifications.NotifyAsync(id, PushNotificationId.SubscriptionUpdateGameSession, roomInstance); + var forceJoinScenes = await db.RoomScenes + .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, - new - { - Room = new { Id = room.Id, Name = room.Name }, - RoomId = room.Id, - SubRoomId = subRoomId, - Reason = "AdminForceJoin", - }); + 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}"); @@ -3532,20 +3545,46 @@ public class AdminController( // ── System ─────────────────────────────────────────────────────────── - public sealed record SetMotdRequest(string Message); + public sealed record SetMotdRequest(string? Message, int? StartsInMinutes); - /// Push a server-maintenance broadcast to every connected - /// player. The MOTD config value is changed via appsettings.json - /// (server restart required); this is for ad-hoc announcements. + /// Broadcast to every connected player. + /// + /// Message: a coach message (MessageReceived, Type 100, from + /// the system account) in each online player's notifications — + /// the client's channel for free text from the server. + /// StartsInMinutes: the client's own maintenance countdown + /// (ServerMaintenance "25", which reads only + /// StartsInMinutes; 0 cancels a running countdown). + /// + /// ServerMaintenance used to carry the text as {Message}, which the + /// client never reads: the text was never shown, and StartsInMinutes 0 + /// cancelled any countdown instead. [HttpPost("broadcast")] public async Task Broadcast([FromBody] SetMotdRequest body) { - if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message"); - await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance, - new { Message = body.Message }); - await LogAsync("broadcast", "system", 0, body.Message); + var message = body.Message?.Trim() ?? string.Empty; + if (message.Length == 0 && body.StartsInMinutes is null) + return BadRequest("empty message"); + if (body.StartsInMinutes is < 0) + return BadRequest("StartsInMinutes must be 0 or more"); + + var recipients = onlinePresence.OnlinePlayerIds().ToList(); + if (message.Length > 0) + { + foreach (var pid in recipients) + await systemNotifications.SendAsync(pid, + SystemNotificationService.MessageType.CoachMessage, + fromPlayerId: PlayerService.SystemAccountId, + data: message); + } + if (body.StartsInMinutes is int minutes) + await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance, + new { StartsInMinutes = minutes }); + + await LogAsync("broadcast", "system", 0, + body.StartsInMinutes is int m ? $"[maintenance in {m} min] {message}" : message); await db.SaveChangesAsync(); - return Ok(); + return Ok(new { recipients = recipients.Count }); } // ── Store catalog management ───────────────────────────────────────── diff --git a/DorkNet.Server/Controllers/Chat/ChatController.cs b/DorkNet.Server/Controllers/Chat/ChatController.cs index 7e1f5f3..dece901 100644 --- a/DorkNet.Server/Controllers/Chat/ChatController.cs +++ b/DorkNet.Server/Controllers/Chat/ChatController.cs @@ -460,6 +460,20 @@ public class ChatController( var removed = await db.ChatThreadMembers .Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me) .ExecuteDeleteAsync(); + if (removed > 0) + { + // "PlayerLeftChat" is an Action (DLDKCILCKNA.txt:721); + // the handler only reads ChatThreadId and SenderPlayerId (the + // player who left) and drops them from that thread's members. + // Sent to the remaining members, whose member lists otherwise + // stayed stale, and to the leaver's own other sessions. + var thread = await EnsureThreadRowAsync(chatThreadId); + var left = new ChatMessageEntity { ThreadKey = chatThreadId, SenderPlayerId = Me, Body = string.Empty }; + var recipients = await ThreadRecipientsAsync(chatThreadId); + recipients.Add(Me); + foreach (var rid in recipients.Distinct()) + await notifications.NotifyTypedAsync(rid, "PlayerLeftChat", ToWireMessage(left, thread.Id)); + } return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound); } diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index ea74468..2da8e0f 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -39,6 +39,7 @@ public class GoToController( RoomService rooms, DorkNet.Server.Data.DorkNetDbContext db, NotificationService notifications, + SystemNotificationService systemNotifications, JoinTimeoutService joinTimeouts, ServerSettingsService serverSettings, PresenceBroadcastService presenceFanout, @@ -877,11 +878,15 @@ public class GoToController( logger.LogWarning( "[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting", callerId, playerId, theirRoom.RoomInstanceId); - // Notify the OWNER that someone tried to follow them in. - // Helps surface the rejection so the owner knows there's - // an interested party — they can choose to send an invite. - await notifications.PrivateInstanceJoinRejected( - ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0); + // 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. 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 { ErrorCode = 26, // RoomInstanceIsPrivate @@ -1668,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/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index 5d5a2d3..a5a4f95 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -69,17 +69,15 @@ public class MatchPlayerController( if (replaced) { log.LogWarning( - "[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push", + "[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; logging old session(s) out", logScope, playerId, takeOverExclusiveSession); // The new session hasn't opened its SignalR socket yet - // (negotiate fires AFTER /player/login on the watch's - // boot sequence — verified in the live trace), so the - // ModerationKick push hits only the OLDER session's - // existing connections. They'll show the "you've been - // kicked" dialog and gracefully disconnect via the - // watch's LocalModerationKickSelf path. - await notifications.KickStaleSession(playerId, - "Your account was signed in on another device."); + // (negotiate fires AFTER /player/login on the boot + // sequence — verified in the live trace), so the Logout + // push (Reason SessionTakeover) reaches only the OLDER + // session, which shows the client's own "signed in + // elsewhere" message and returns to the boot scene. + await notifications.LogoutStaleSession(playerId); } } return Ok(Array.Empty()); diff --git a/DorkNet.Server/Hubs/NotifyHub.cs b/DorkNet.Server/Hubs/NotifyHub.cs index c7db3b7..301e132 100644 --- a/DorkNet.Server/Hubs/NotifyHub.cs +++ b/DorkNet.Server/Hubs/NotifyHub.cs @@ -27,15 +27,16 @@ namespace DorkNet.Server.Hubs; /// Pre-PR-2 used a local static ConcurrentDictionary which only /// reflected this replica's connections. /// -/// Hub methods invoked by the client (verified by string-literal scan -/// of metadata at addresses 0x39b2470, 0x39b24c8, 0x39b4ba8): -/// "Subscribe" - generic topic subscribe -/// "Unsubscribe" - generic topic unsubscribe -/// "SubscribeToPlayers" - subscribe to per-player events (used by -/// RecNet.NotificationManager / SubscribeToPlayers) -/// All accept JsonElement so we don't lock ourselves into a parameter -/// schema we haven't verified yet, and return Task so the client's -/// awaited Invoke completes cleanly. +/// Hub methods the 2023-03-21 client sends (fire-and-forget, RecNet.Runtime +/// OMHPBIEHOHN): +/// "SubscribeToPlayers" - {PlayerIds}: players to get presence for +/// "SubscribeTo" - {Channels}: always empty in this build +/// "UnsubscribeFrom" - {Channels}: always empty in this build +/// There is no UnsubscribeFromPlayers; SubscribeToPlayers always carries the +/// complete set. All accept JsonElement and tolerate casing differences. +/// +/// The client reconnects at most 3 times per login (its attempt counter is +/// never reset), so the server must not drop healthy connections. /// [Authorize] public class NotifyHub( @@ -65,28 +66,25 @@ public class NotifyHub( // Instead: at the moment the SECOND session's WebSocket // arrives, take a snapshot of the EXISTING connection ids // (which still belong to the original session — we haven't - // joined the group yet) and push a targeted ModerationKick - // to those ids. The watch's OnModerationKick handler at - // PlayerReporting.txt:1857 shows the standard moderation - // dialog and routes through LocalModerationKickSelf, the - // same graceful disconnect path the original Rec Room used - // for real moderator kicks. + // joined the group yet) and send those ids a Logout with + // Reason SessionTakeover. The client shows its own "signed in + // elsewhere" message and returns to the boot scene. (A + // ModerationKick only sent the old session to its dorm, still + // signed in.) // // Targeting specific connection ids (not the group) means // the new connection — about to join the group on the next - // line — never receives the kick, even with Redis backplane + // line — never receives it, even with Redis backplane // routing across replicas. var existing = presence.GetConnections(id); if (existing.Count > 0) { logger.LogWarning( - "[hub] second-session connect for player={PlayerId} (conn={NewConn}); kicking {OldCount} existing connection(s)", + "[hub] second-session connect for player={PlayerId} (conn={NewConn}); logging out {OldCount} existing connection(s)", id, Context.ConnectionId, existing.Count); try { - await notifications.KickConnectionsAsync( - existing, - "Your account was signed in on another device."); + await notifications.LogoutConnectionsAsync(existing); } catch (Exception ex) { @@ -156,36 +154,111 @@ public class NotifyHub( await base.OnDisconnectedAsync(exception); } - public Task SubscribeToPlayers(JsonElement payload) + /// SignalR group a connection joins to receive one player's + /// presence. Only presence is sent here — a subscriber must never receive + /// the subject's own pushes (DMs, gifts, …). + public static string PresenceGroupForPlayer(long playerId) => $"presence:{playerId}"; + + 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 + /// players around it, and itself. Sent on every (re)connect and whenever + /// the set changes, always as the complete set, so it REPLACES the + /// connection's subscriptions. Presence for those players is then pushed + /// to by + /// , so non-friends the client is + /// looking at (people in the room, an open profile) stay current too. + /// + /// The caller's own id is skipped: a presence frame about the local player + /// is applied as a correction and moves them. An unreadable payload + /// leaves the current subscriptions alone rather than clearing them. + /// Groups live per connection on its own replica, are routed cluster-wide + /// by the Redis backplane, and are dropped by SignalR on disconnect. + public async Task SubscribeToPlayers(JsonElement payload) + { + var me = TryGetPlayerId(); + if (!TryReadPlayerIds(payload, out var requested)) + { + logger.LogWarning( + "[hub] SubscribeToPlayers unreadable payload player={PlayerId} conn={ConnectionId} payload={Payload}", + me, Context.ConnectionId, RawPayload(payload)); + 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 + : new HashSet(); + var added = requested.Except(current).ToList(); + var removed = current.Except(requested).ToList(); + foreach (var id in added) + await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id)); + foreach (var id in removed) + await Groups.RemoveFromGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id)); + Context.Items[SubscriptionsKey] = requested; + + logger.LogInformation( + "[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} subscribed={Count} added={Added} removed={Removed}", + me, Context.ConnectionId, requested.Count, added.Count, removed.Count); + } + + /// SubscribeTo({Channels: List<string>}). The + /// 2023-03-21 client composes it on every connect but its channel set is + /// always empty, so there is nothing to subscribe to; logged only. + public Task SubscribeTo(JsonElement payload) { logger.LogInformation( - "[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} payload={Payload}", + "[hub] SubscribeTo player={PlayerId} conn={ConnectionId} payload={Payload}", TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); return Task.CompletedTask; } - public Task UnsubscribeFromPlayers(JsonElement payload) + /// Counterpart of ; logged only. + public Task UnsubscribeFrom(JsonElement payload) { logger.LogInformation( - "[hub] UnsubscribeFromPlayers player={PlayerId} conn={ConnectionId} payload={Payload}", + "[hub] UnsubscribeFrom player={PlayerId} conn={ConnectionId} payload={Payload}", TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); return Task.CompletedTask; } - public Task Subscribe(JsonElement payload) + /// Reads {PlayerIds:[…]} (any key casing), or a bare + /// array, as player ids; numbers or numeric strings. + public static bool TryReadPlayerIds(JsonElement payload, out HashSet ids) { - logger.LogInformation( - "[hub] Subscribe player={PlayerId} conn={ConnectionId} payload={Payload}", - TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); - return Task.CompletedTask; - } - - public Task Unsubscribe(JsonElement payload) - { - logger.LogInformation( - "[hub] Unsubscribe player={PlayerId} conn={ConnectionId} payload={Payload}", - TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); - return Task.CompletedTask; + ids = new HashSet(); + var list = payload; + if (payload.ValueKind == JsonValueKind.Object) + { + 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()) + { + if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var n) && n > 0) ids.Add(n); + else if (item.ValueKind == JsonValueKind.String && long.TryParse(item.GetString(), out var p) && p > 0) ids.Add(p); + } + return true; } private long? TryGetPlayerId() diff --git a/DorkNet.Server/Services/LevelService.cs b/DorkNet.Server/Services/LevelService.cs index e2ec3ca..8cf154b 100644 --- a/DorkNet.Server/Services/LevelService.cs +++ b/DorkNet.Server/Services/LevelService.cs @@ -41,6 +41,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications player.Level = LevelForXp(player.XP); await db.SaveChangesAsync(); + // Live level bar: "PlayerProgressionLevelUpdate" {PlayerId, Level, XP} + // (handler PBOGNAPEBAI.txt:263, DTO DGFGIFIHCAJ) — the same values + // api/players/v2/progression returns. Without it the watch kept the + // level and XP it loaded at login until the next relog. + await notifications.NotifyTypedAsync(playerId, "PlayerProgressionLevelUpdate", + new { PlayerId = (int)playerId, player.Level, player.XP }); + if (player.Level > oldLevel) { // Level-up grants a small currency reward. The wallet UI @@ -82,9 +89,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications row.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); + // StorefrontBalanceUpdate "61" is BalanceResponseDTO {Balance, + // CurrencyType, Platform}; the client sets that (CurrencyType, + // Platform) bucket to Balance. Platform 0 matches every balance REST + // response, so the buckets line up. await notifications.NotifyAsync(playerId, PushNotificationId.StorefrontBalanceUpdate, - new { CurrencyType = currencyType, row.Balance, Reason = reason }); + new { row.Balance, CurrencyType = currencyType, Platform = 0 }); return row.Balance; } diff --git a/DorkNet.Server/Services/NotificationService.cs b/DorkNet.Server/Services/NotificationService.cs index ad39efa..4f9572d 100644 --- a/DorkNet.Server/Services/NotificationService.cs +++ b/DorkNet.Server/Services/NotificationService.cs @@ -212,25 +212,17 @@ public class NotificationService( // ── Higher-level event helpers ───────────────────────────────────── - public Task FriendRequestReceived(long targetPlayerId, long fromPlayerId) => - NotifyAsync(targetPlayerId, PushNotificationId.RelationshipChanged, - new { Reason = "FriendRequestReceived", From = fromPlayerId }); - - public Task FriendRequestAccepted(long requesterPlayerId, long byPlayerId) => - NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged, - new { Reason = "FriendRequestAccepted", By = byPlayerId }); - - public Task FriendRequestDeclined(long requesterPlayerId, long byPlayerId) => - NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged, - new { Reason = "FriendRequestDeclined", By = byPlayerId }); - - public Task FriendRemoved(long otherPlayerId, long byPlayerId) => - NotifyAsync(otherPlayerId, PushNotificationId.RelationshipChanged, - new { Reason = "FriendRemoved", By = byPlayerId }); - - public Task FriendBlocked(long blockedPlayerId, long byPlayerId) => - NotifyAsync(blockedPlayerId, PushNotificationId.RelationshipChanged, - new { Reason = "FriendBlocked", By = byPlayerId }); + /// Push one relationship row to . + /// must be the recipient's OWN view of the + /// pair — the same {PlayerID, RelationshipType, Muted, Ignored, + /// Favorited} object api/relationships/v2/get returns. The 2023 + /// handler (EEGNOHOELBG.FOCBJAIDCBB, Relationship + /// ECGNEHMCGCN) stores it under its PlayerID and raises the + /// changed event, which refreshes the friends list and the client's + /// SubscribeToPlayers set. Any other shape parses as PlayerID 0 and the + /// real row never updates. + public Task RelationshipChanged(long recipientPlayerId, object relationship) => + NotifyAsync(recipientPlayerId, PushNotificationId.RelationshipChanged, relationship); /// Push a presence delta so subscribed friends re-render /// their friend-list row without waiting for the watch's 15-45 s @@ -270,6 +262,23 @@ public class NotificationService( await hub.Clients.Groups(groups).SendAsync(HubMethod, payload); } + /// Push one player's presence to every connection that subscribed + /// to them with SubscribeToPlayers, except + /// — the subject's own connections + /// (a frame about the local player moves them) and anyone already sent the + /// frame through their friend group. + public async Task PresenceToSubscribersAsync( + long playerId, + RoomInstanceDto? room, + bool isOnline, + int statusVisibility, + IReadOnlyList excludeConnectionIds) + { + var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility); + await hub.Clients.GroupExcept(NotifyHub.PresenceGroupForPlayer(playerId), excludeConnectionIds) + .SendAsync(HubMethod, payload); + } + /// The PresenceUpdate envelope for one player, as a JSON string. /// /// is independent of the room: the 2023 @@ -349,86 +358,85 @@ public class NotificationService( public Task PlayerEnteredRoom(IEnumerable friendIds, long playerId, RoomInstanceDto room) => PlayerPresenceChanged(friendIds, playerId, room); - /// Force every currently-connected SignalR session for - /// to disconnect via the watch's - /// ModerationKick flow. Wire path verified against decompilation: - /// PushNotificationId.ModerationKick = 22 is registered - /// by LCCEEFHOBEN.OFFLOPLJBBG via the enum overload. - /// LCCEEFHOBEN.KEIAFMLIMFA(Dictionary) reads - /// Msg.Reason as a ModerationBlockDetail object, then - /// calls PODFHPNOHGB / BootLocalPlayerToDormRoom. - /// - /// Used by - /// to enforce single-session-per-account: when a second login on - /// the same account replaces the LoginLock, the older SignalR - /// connections are kicked. The new session hasn't called - /// negotiate yet (it happens AFTER /player/login on the - /// boot sequence), so the kick only hits old sessions. - /// - public Task KickStaleSession(long playerId, string reason) => - NotifyAsync(playerId, PushNotificationId.ModerationKick, - ModerationKickPayload(reason, isBan: false, source: "session_replaced")); + /// Client logout reasons (Logout "6" payload Reason, + /// enum MPJOEALLGHE, read by formatter PBEDEFHAHID). The + /// value must go out as a NUMBER: release builds obfuscate enum names, so a + /// string like "AdminPasswordReset" fails the parse and the whole push is + /// dropped. SessionManager.JNNAOHMNGEM shows its own "signed in + /// elsewhere" text for , then returns the + /// client to the boot scene for every reason. + public enum LogoutReason + { + Unknown = 0, + UserInitiated = 1, + SessionTakeover = 2, + ForciblyLoggedOut = 3, + Banned = 4, + } - public Task KickPlayerAsync(long playerId, string reason, bool isBan = false) => - NotifyAsync(playerId, PushNotificationId.ModerationKick, - ModerationKickPayload(reason, isBan, source: "admin_kick")); + /// Log every session of out to the + /// boot scene. Use this, not a kick, when the player must not stay signed + /// in: a ModerationKick only sends them to their dorm. + public Task LogoutAsync(long playerId, LogoutReason reason) => + NotifyAsync(playerId, PushNotificationId.Logout, new { Reason = (int)reason }); - /// Push a ModerationKick to a specific set of SignalR - /// connection ids — used by - /// to evict the previous session when a new device connects on the - /// same account. Targets specific ids (not the player group) so - /// the brand-new connection joining the group right after this - /// returns can't accidentally receive its own eviction notice. - /// Works across replicas because SignalR's Redis backplane routes + /// Single-session-per-account: when a second login replaces the + /// LoginLock, log the older session out. The new session hasn't called + /// negotiate yet (it happens AFTER /player/login on the boot + /// sequence), so only the old session's connections receive it. + public Task LogoutStaleSession(long playerId) => + LogoutAsync(playerId, LogoutReason.SessionTakeover); + + /// The same takeover logout, sent to specific SignalR connection + /// ids — used by + /// to evict the previous session when a new device connects on the same + /// account. Targets ids (not the player group) so the brand-new + /// connection, which joins the group right after, never receives it. Works + /// across replicas because the Redis backplane routes /// Clients.Clients(ids) to whichever node owns each id. - public Task KickConnectionsAsync(IReadOnlyCollection connectionIds, string reason) + public Task LogoutConnectionsAsync(IReadOnlyCollection connectionIds) { if (connectionIds.Count == 0) return Task.CompletedTask; - var payload = Serialize(PushNotificationId.ModerationKick, - ModerationKickPayload(reason, isBan: false, source: "session_replaced")); + var payload = Serialize(PushNotificationId.Logout, new { Reason = (int)LogoutReason.SessionTakeover }); return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload); } - private static object ModerationKickPayload(string reason, bool isBan, string source) + /// Kick back to their dorm with + /// shown in the kick dialog. The player stays + /// signed in; use for bans and deletions. + /// Pass the room instance the player is in as + /// to have the client announce the kick + /// to that room. + public Task KickPlayerAsync(long playerId, string reason, long gameSessionId = 0) => + NotifyAsync(playerId, PushNotificationId.ModerationKick, ModerationKickPayload(reason, gameSessionId)); + + /// ModerationKick "22" payload: the FLAT moderation block + /// PIBLFGPOCIB (formatter LAIJIAIHGCO). The kick handler + /// (SessionManager HandleModerationKicked) starts from its own + /// "Kicked to Dormroom" text and only adds a reason as + /// a "Reason: " line from VoteKickReason when ReportCategory is 10 + /// (VoteKick) and IsHostKick is false; Message is never + /// shown. When GameSessionId is the kicked player's current room + /// instance it also broadcasts the kick to that room, and + /// Duration > 0 starts a timeout, so it stays 0. The old nested + /// {Reason:{…}} shape (from the 2020 client) parsed to all defaults, + /// which booted the player but dropped every reason. + private static object ModerationKickPayload(string reason, long gameSessionId) { - var message = string.IsNullOrWhiteSpace(reason) - ? "Kicked to Dormroom" - : reason.Trim(); - - var blockDetail = new - { - ReportCategory = 10, // PlayerReporting.ReportCategory.VoteKick - Duration = 0, - GameSessionId = 0L, - Message = message, - IsHostKick = true, - PlayerIdReporter = 1, - IsBan = isBan, - VoteKickReason = message, - }; - + var message = string.IsNullOrWhiteSpace(reason) ? "Kicked to Dormroom" : reason.Trim(); return new { - Reason = blockDetail, - DisplayReason = message, - BannedByPlayerId = 1, - Source = source, + ReportCategory = 10, // VoteKick: the only category that shows a reason + Duration = 0, + GameSessionId = gameSessionId, + IsHostKick = false, + Message = message, + PlayerIdReporter = (int?)null, + IsBan = false, + IsVoiceModAutoban = false, + IsWarning = false, + VoteKickReason = message, + TimeoutStartedAt = (DateTime?)null, }; } - - /// Notify a private-instance owner that someone tried to - /// follow them in via "Go to Player" but was rejected for not - /// being on the invite list. Reuses the MessageReceived channel - /// since the watch already renders that as an inbox toast — no - /// new client-side handler needed. The body wording surfaces it - /// as "Player X tried to join your private match" so the owner - /// knows to send an explicit invite if they want to let them in. - public Task PrivateInstanceJoinRejected(long ownerPlayerId, long attemptedByPlayerId) => - NotifyAsync(ownerPlayerId, PushNotificationId.MessageReceived, - new - { - Reason = "PrivateInstanceJoinRejected", - AttemptedBy = attemptedByPlayerId, - Preview = $"Player {attemptedByPlayerId} tried to join your private match", - }); } 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 e0f367f..2257216 100644 --- a/DorkNet.Server/Services/PresenceBroadcastService.cs +++ b/DorkNet.Server/Services/PresenceBroadcastService.cs @@ -36,7 +36,10 @@ public static class PresenceVisibility /// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into /// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync /// correction (:18467). A frame about the LOCAL player therefore moves that player, so -/// these frames go to friends only, never to the subject. +/// these frames never go to the subject. They go to the subject's friends and to every +/// other connection that subscribed to the subject with SubscribeToPlayers (see +/// NotifyHub.SubscribeToPlayers); non-friends only see the real presence when it is +/// Public. /// public sealed class PresenceBroadcastService( IServiceScopeFactory scopes, @@ -58,7 +61,7 @@ public sealed class PresenceBroadcastService( presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0; /// Push the player's current presence (online state + committed - /// room) to every friend. + /// room) to every friend and subscriber. public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false); /// Push an offline frame regardless of any lingering markers — @@ -105,7 +108,6 @@ public sealed class PresenceBroadcastService( .Where(id => id != playerId) .Distinct() .ToList(); - if (friendIds.Count == 0) return; var online = !forceOffline && IsOnline(playerId); var room = online ? presence.GetRoom(playerId) : null; @@ -134,6 +136,23 @@ public sealed class PresenceBroadcastService( await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility); if (hiddenFrom.Count > 0) await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility); + + // Everyone else who subscribed to this player (SubscribeToPlayers: + // people in the same room, an open profile). They are not friends, + // so they see the real presence only when it is Public, and the + // offline frame otherwise. Friends were served above and the + // subject must never get a frame about themselves, so their + // connections are excluded. + // 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; + await notifications.PresenceToSubscribersAsync( + playerId, publicToAll ? room : null, publicToAll, visibility, exclude); } catch (Exception ex) { diff --git a/DorkNet.Server/Services/SystemNotificationService.cs b/DorkNet.Server/Services/SystemNotificationService.cs index 8aaf9f1..0614038 100644 --- a/DorkNet.Server/Services/SystemNotificationService.cs +++ b/DorkNet.Server/Services/SystemNotificationService.cs @@ -28,6 +28,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS /// listed; add more as needed. public static class MessageType { + public const int FriendInvite = 4; + public const int RequestGameInvite = 10; public const int TextMessage = 30; public const int FriendRequestAccepted = 40; public const int FriendCodeUsed = 41; @@ -39,6 +41,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS public const int CreatorPublishedNewRoom = 70; public const int PlayerAttendingEvent = 80; public const int PlayerEventInvitation = 81; + public const int CoachMessage = 100; + public const int FriendIntroduction = 130; } /// Persist and push a system notification to diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index 7d6dbb6..a255b0c 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -416,15 +416,18 @@ public static class ServiceCollectionExtensions kopts.Limits.MinResponseDataRate = null; }); - // Keepalive cadence: server pings every 10s, gives the client 60s - // to ack before declaring the connection dead. The 10s ping forces - // frame traffic well below every proxy timeout we've seen in the - // path (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik - // 180s, Kestrel 130s). + // Keepalive cadence: the server pings every 10s, which keeps frame + // traffic well below every proxy timeout we've seen in the path + // (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik 180s, + // Kestrel 130s). The 2023 client pings every 15s, but from its main + // thread, so a long room-load hitch pauses its pings. It has no + // timeout of its own and reconnects at most 3 times per login, so a + // healthy socket the server drops can leave it without live + // notifications for the rest of the session. Give it 180s. var signalR = builder.Services.AddSignalR(opts => { opts.KeepAliveInterval = TimeSpan.FromSeconds(10); - opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60); + opts.ClientTimeoutInterval = TimeSpan.FromSeconds(180); opts.HandshakeTimeout = TimeSpan.FromSeconds(15); // Allow large gift / community-board payloads (default 32 KB // truncates anything bigger and disconnects the client). diff --git a/DorkNet.Server/admin-ui/src/pages/Broadcast.tsx b/DorkNet.Server/admin-ui/src/pages/Broadcast.tsx index 4ef767d..2b3db78 100644 --- a/DorkNet.Server/admin-ui/src/pages/Broadcast.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Broadcast.tsx @@ -5,18 +5,32 @@ import { useToast } from '../components/Toast'; export function Broadcast() { const [message, setMessage] = useState(''); + const [minutes, setMinutes] = useState(''); const [busy, setBusy] = useState(false); const toast = useToast(); + const startsInMinutes = minutes.trim() === '' ? null : Number(minutes); + const minutesValid = startsInMinutes === null || (Number.isInteger(startsInMinutes) && startsInMinutes >= 0); + const canSend = !busy && minutesValid && (message.trim() !== '' || startsInMinutes !== null); + const send = async (e: React.FormEvent) => { e.preventDefault(); - if (!message.trim()) return; - if (!confirm(`Broadcast this to every connected player?\n\n"${message}"`)) return; + if (!canSend) return; + const parts = [ + message.trim() && `"${message.trim()}"`, + startsInMinutes !== null && + (startsInMinutes === 0 ? 'Cancel the maintenance countdown' : `Maintenance countdown: ${startsInMinutes} min`), + ].filter(Boolean); + if (!confirm(`Broadcast this to every connected player?\n\n${parts.join('\n')}`)) return; setBusy(true); try { - await api('/broadcast', { method: 'POST', body: { Message: message } }); - toast.push('Broadcast sent', 'success'); + const res = await api<{ recipients: number }>('/broadcast', { + method: 'POST', + body: { Message: message.trim() || null, StartsInMinutes: startsInMinutes }, + }); + toast.push(`Broadcast sent to ${res.recipients} player(s)`, 'success'); setMessage(''); + setMinutes(''); } catch (e) { toast.push((e as Error).message, 'error'); } finally { @@ -28,7 +42,7 @@ export function Broadcast() { @@ -39,14 +53,33 @@ export function Broadcast() { rows={5} className="input mt-1.5 font-mono text-sm" placeholder="Server going down for maintenance in 5 minutes — log out cleanly to avoid losing your dorm save." - required /> {message.length} characters + + Maintenance countdown (minutes, optional) + setMinutes(e.target.value)} + inputMode="numeric" + className="input mt-1.5 w-40 text-sm" + placeholder="e.g. 5" + /> + + {minutesValid ? 'Leave empty for no countdown; 0 cancels a running one.' : 'Enter a whole number of minutes.'} + + - setMessage('')} className="btn-ghost text-xs" disabled={busy || !message}>Clear - + { setMessage(''); setMinutes(''); }} + className="btn-ghost text-xs" + disabled={busy || (!message && !minutes)} + > + Clear + + {busy ? 'Sending…' : 'Send broadcast'} diff --git a/README.md b/README.md index d2a7f6c..e2e9306 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,12 @@ What the 2023 client gets here that the 2020 branches don't have: `DorkNet.Server.Tests/ClientRouteCoverageTests.cs` fails the build if one regresses. Routes, verbs and wire shapes are in [docs/recroom-2023-client-api-complete.md](docs/recroom-2023-client-api-complete.md). -- **Realtime over the SignalR hub** (`hub/v1` on the notify host): numeric - push ids, presence updates to friends that honour each player's - online-status visibility, and account updates when someone edits their - profile. +- **Realtime over the SignalR hub** (`hub/v1` on the notify host): every + push matches the client's own reader — friends, messages, kicks and + logouts, gifts, balances, consumables, player events, level-ups and + account updates — and presence reaches both friends and any player who + subscribed to you, honouring your online-status visibility: + [docs/recroom-2023-notifications.md](docs/recroom-2023-notifications.md). - **Room saves and sub-rooms** through the storage host, plus cloning and private instances: [docs/recroom-2023-room-save.md](docs/recroom-2023-room-save.md). diff --git a/docs/recroom-2023-client-api-complete.md b/docs/recroom-2023-client-api-complete.md index 9ddb0c0..40c6bc7 100644 --- a/docs/recroom-2023-client-api-complete.md +++ b/docs/recroom-2023-client-api-complete.md @@ -120,17 +120,19 @@ Defect classes: ## Realtime (SignalR) surface Not every request is HTTP. The client opens a SignalR hub at `{0}hub/v1` -(`RecNet.Runtime/OMHPBIEHOHN`, negotiate + `hub/v1`). +(`RecNet.Runtime/OMHPBIEHOHN`, negotiate + `hub/v1`). The full push +contract — every handler key, its payload, and what DorkNet sends — is in +[recroom-2023-notifications.md](recroom-2023-notifications.md). - Server→client messages arrive on the hub method named **`"Notification"`** (singular) carrying a **single `string`** argument whose content is JSON `{"Id":..., "Msg":...}`. The handler is registered as `Action`. Sending `ReceiveNotification`, or passing an object instead of a string, makes every push silently vanish. -- Client→server: `SubscribeToPlayers` / `UnsubscribeFromPlayers`, plus - `"SubscribeTo"` / `"UnsubscribeFrom"` prefixes composed at runtime. -- Presence pushes are routed by the friend graph server-side, so the - subscription calls are advisory rather than load-bearing. +- Client→server: `SubscribeToPlayers` (`{PlayerIds}`), plus + `SubscribeTo` / `UnsubscribeFrom` (`{Channels}`, always empty). +- `SubscribeToPlayers` is load-bearing: it is how the client gets presence + for players who aren't friends (people in the room, an open profile). ### Push dispatch keys — the numeric-vs-name trap @@ -160,10 +162,11 @@ So **send the number**, except for these six, which are string-keyed: | 95 CommunityBoardUpdate | `CommunityBoardUpdate` | Ids the 2023 client registers NO handler for — sending them is a no-op: -4, 15 (numeric form), 21, **24 ModerationRoomBan**, 40, 85. Room bans must -therefore go out as `ModerationKick` (22) with `IsBan` set; the moderation +4, 15 (numeric form), 21, **24 ModerationRoomBan**, 40, 85. The moderation manager registers only 22, `ModerationUnkick` and 23 -(`RecNet.Runtime/FPIBGPIAOBI.txt:310,323,335`). +(`RecNet.Runtime/FPIBGPIAOBI.txt:310,323,335`), and a kick (22) only sends +the player to their dorm, so bans go out as `Logout` (6) with Reason 4 +(Banned); see recroom-2023-notifications.md. Ids the client handles that this server never sends (dead client features, available if wanted): 3, 5, 23, 32, 60, 110, 120, 121, plus a long tail of @@ -173,12 +176,12 @@ string channels — `GoToFailure`, `PhotonAccessToken`, `AppVersionUpdate`, ### Hub methods -The client invokes exactly one server method: `SubscribeToPlayers`, with a -`{PlayerIds: List}` payload, fire-and-forget. The `SubscribeTo`/ -`UnsubscribeFrom` composition exists in the binary but has no callers in -this build, and `UnsubscribeFromPlayers` does not appear at all — the -server's extra hub methods are unused but harmless. Presence is routed -server-side by the friend graph, so these calls are advisory. +The client sends `SubscribeToPlayers` with a `{PlayerIds: List}` +payload, fire-and-forget, on every (re)connect and whenever the set changes. +The `SubscribeTo`/`UnsubscribeFrom` composition runs on connect but its +channel set is always empty, and `UnsubscribeFromPlayers` does not appear at +all. The server keeps each connection's subscriptions as `presence:{id}` +SignalR groups and pushes those players' presence to them. ### Negotiate @@ -257,7 +260,8 @@ verified against the binary before the change, and the full test suite passes. | `playlists accessibility/levelvoting/restrictions/warning` | PUT 405, and values were discarded | PUT added and values persisted | | `SignalR push `Id` (notify)` | enum NAME sent for most ids; client only registered numbers — pushes vanished | numeric by default, with the six string-keyed ids listed explicitly | | `SignalR SubscriptionUpdateRoom` | sent as `SubscriptionUpdateRoom`; client listens on `RoomUpdate` | sent as `RoomUpdate` | -| `Room ban notification` | sent id 24, for which the client has no handler | sent as ModerationKick (22) with IsBan | +| `Room ban notification` | sent id 24, for which the client has no handler | sent as ModerationKick (22) with IsBan (later replaced by Logout Reason 4, below) | +| `Notification payloads (Oct 2026 audit)` | RelationshipChanged sent `{Reason, From}`; ModerationKick used the nested 2020 shape and lost every reason; bans, deletes and takeovers only kicked to the dorm; Logout sent an enum NAME and was dropped; ServerMaintenance sent `{Message}`; ConsumableMapping, PlayerEvent 80/81/83, admin RoomUpdate and level-up pushes had wrong or missing payloads; SubscribeToPlayers was ignored | every payload matches the client DTO; bans/deletes/takeovers are Logout with a numeric Reason; SubscribeToPlayers drives `presence:{id}` groups; see recroom-2023-notifications.md | | `Presence on SignalR reconnect` | disconnect pushed offline; nothing ever pushed back online, so a reconnect greyed the player out for the rest of the session | connect pushes presence (online even with no room); a drop while the player still heartbeats waits one activity window before pushing offline | | `POST matchmake/none` | re-committed the previous room (or the dorm), so the next heartbeat answered with a room while the client held its offline instance (-1): "presence out-of-sync", offline load cancelled, player dragged back | player is online with no room; any pending join is abandoned; heartbeat answers `roomInstance: null` until the next matchmake | | `Presence fan-out to friends` | only the immediate /goto commit pushed; deferred commits (matchmake/dorm, private hops), matchmake/none, logout, failed joins and statusvisibility changes were silent, and `isOnline` was derived from "has a room" | one fan-out (PresenceBroadcastService) for every transition, explicit `isOnline`, stored `statusVisibility`, never sent to the subject | diff --git a/docs/recroom-2023-notifications.md b/docs/recroom-2023-notifications.md new file mode 100644 index 0000000..da88935 --- /dev/null +++ b/docs/recroom-2023-notifications.md @@ -0,0 +1,139 @@ +# 2023 client notifications (realtime push) + +The contract between DorkNet and the 2023-03-21 client's notification hub. +Sources: the client's RecNet.Runtime ISIL (`C:\tmp\recroom-2023-03-21-isil`, +obfuscated names given as found there), cross-checked against RecFlare +(build 20230414) where useful. Code: `DorkNet.Server/Hubs/NotifyHub.cs`, +`Services/NotificationService.cs`, `Services/PresenceBroadcastService.cs`, +`Services/SystemNotificationService.cs`. Tests: +`DorkNet.Server.Tests/NotificationContractTests.cs`. + +## Connection + +- The hub is `{notify host}/hub/v1` (nameserver key `Notifications`). The + client opens it once, at login. +- **Negotiate**: `POST …/hub/v1/negotiate`, no `negotiateVersion`. The client reads + `connectionId` and `availableTransports`, and accepts only `WebSockets`. +- **WebSocket**: `…/hub/v1?id=`, JSON protocol + (`{"protocol":"json","version":1}`), no MessagePack. +- **Auth**: `Authorization: Bearer` on both negotiate and the upgrade. No + `access_token` query string. +- **Keep-alive**: the client pings every 15 s from its main thread and has + no server timeout of its own. The server pings every 10 s and allows 180 s + of client silence, because a long room-load hitch pauses the client's + pings. +- **Reconnects: at most 3 per login.** The client's attempt counter is never + reset after a successful reconnect. Once the budget is spent the hub stays + dead, with no message, until the next login. The server must never close a + healthy connection. + +## Envelope + +Every push invokes the hub method **`Notification`** with **one string +argument**, whose content is JSON: + +```json +{"Id":"","Msg":{...}} +``` + +- **`Id`** is a JSON string. It is looked up case-sensitively in the + client's handler table, and a miss drops the push silently. + - Handlers registered through the enum overload use the decimal string of + the id (`"2"`). + - The rest use a name (see the table below). + - `NotificationService.WireName` maps `PushNotificationId` to the key. +- **`Msg`** is an inline JSON object of the handler's DTO, never + JSON-in-a-string. + - It is parsed strictly. Keys may be PascalCase, camelCase or all + lowercase, and unknown keys are skipped. + - A value of the wrong type (a number where a string is expected, a + string where an enum number is expected) throws, and the whole push is + dropped. + - A key that is missing takes its default value, which usually means the + push "works" but does nothing. + +## Hub calls (client → server) + +| Method | Payload | Server | +|---|---|---| +| `SubscribeToPlayers` | `{PlayerIds: List}`: the client's relationships, the players around it, and itself. Sent on every (re)connect and whenever the set changes, always the complete set. | Replaces the connection's subscriptions. The connection is put in a `presence:{id}` group per player (own id skipped), and `PresenceBroadcastService` pushes those players' presence to those groups. Only presence, never the subject's other pushes. An unreadable payload keeps the current set. | +| `SubscribeTo` / `UnsubscribeFrom` | `{Channels: List}`, always empty in this build | logged only | + +There is no `UnsubscribeFromPlayers`. + +## Handler table + +These are the handlers the client registers. "Sent" names what DorkNet sends +today. + +| Key | Msg | Sent by | +|---|---|---| +| `1` RelationshipChanged | `{PlayerID, RelationshipType, Muted, Ignored, Favorited}`: the recipient's own view, the same row `api/relationships/v2/get` lists | friend request / accept / remove / add / add-with-code (both players); favorite / mute / ignore and their reverses (caller only) | +| `2` MessageReceived | `{Id, FromPlayerId (Int32), SentTime, Type, Data (string), RoomId, PlayerEventId}`, the same row `api/messages/v2/get` lists | DMs and game invites; `SystemNotificationService` (cheers, friend request Type 4, accepted Type 40, "wants to join" Type 10, coach message Type 100, event invite text) | +| `3` MessageDeleted | `{Id}` | not sent | +| `5` RefreshLogin | none | not sent | +| `6` Logout | `{Reason}`: a **number**, enum Unknown 0, UserInitiated 1, SessionTakeover 2, ForciblyLoggedOut 3, Banned 4 | `NotificationService.LogoutAsync`: second-session takeover (2), account deleted or password reset (3), bans (4) | +| `20` / `ModerationQuitGame` | none | not sent | +| `22` ModerationKick | **flat** `{ReportCategory, Duration, GameSessionId, IsHostKick, Message, PlayerIdReporter, IsBan, IsVoiceModAutoban, IsWarning, VoteKickReason, TimeoutStartedAt}` | `KickPlayerAsync`: admin kick, close instance, in-game instant kick, join timeout | +| `ModerationUnkick` | same as 22 | not sent | +| `23` ModerationKickAttemptFailed | `{ReportCategory, YesVotes, NoVotes, PlayerIdReported}` | not sent | +| `25` ServerMaintenance | `{StartsInMinutes}`; 0 cancels the countdown | admin Broadcast, when a countdown is given | +| `30` / `31` GiftPackageReceived(Immediate) | the full gift package, as `api/avatar/v2/gifts` lists it (`AvatarGiftsController.ToWire`) | gifts, store gift purchases, free gifts, admin gifts | +| `50` RelationshipsInvalid | none (the client refetches) | admin "everyone is friends" toggle | +| `60` / `61` / `62` StorefrontBalance Add / Update / Purchase | 61 `{Balance, CurrencyType, Platform}`; 62 `{BalanceAddType, Delta, Balance, Platform, CurrencyType}` | `LevelService.GrantCurrencyAsync` (61), store purchase (62) | +| `70` / `71` ConsumableMapping Added / Removed | `{Id, ConsumableItemDesc (string), Count, InitialCount, CreatedAt, ActiveDurationMinutes, IsActive, IsTransferable}` | transfer (70 to the recipient), consume (always 71, with the remaining Count) | +| `80` / `81` PlayerEvent Created / Updated | the full player event, as `api/playerevents/v2/{id}` returns it | invites (80 to each invitee); every edit route and the broadcast setting (81 to attendees) | +| `82` / `84` PlayerEvent Deleted / ResponseDeleted | `{PlayerEventId}` | event delete (82 to attendees), RSVP removed (84 to the creator) | +| `83` PlayerEventResponseChanged | `{PlayerEvent, PlayerEventResponse}` | RSVP (to the creator) | +| `100` InventionModerationStateChanged | none | not sent | +| `AccountUpdate` / `SelfAccountUpdate` | the public / self account DTOs the account REST routes return | every profile edit, including profile image (`NotificationService.AccountChanged`) | +| `PresenceUpdate` | `{playerId, statusVisibility, deviceClass, vrMovementMode, roomInstance, isOnline, appVersion (string)}` | `PresenceBroadcastService`, to friends and subscribers, **never to the subject** (a frame about the local player moves them) | +| `RoomInstanceUpdate` | the room instance (`RoomInstanceDto`) | admin force-join / instance pull. It only refreshes the cached instance; the move itself comes from the next heartbeat. | +| `RoomUpdate` | the **full room** (`FGCPNAACHIK`, `RoomsController.BuildRoomServerDetails`), not the 2020 `{Room, Scenes}` wrapper | admin role / owner changes (everyone in the room plus the changed player), admin force-join | +| `ChatMessageReceived` | `{chatMessageId, chatThreadId, senderPlayerId, timeSent, contents}` | chat sends, to every member including the sender | +| `PlayerLeftChat` | the same chat-message shape; only `chatThreadId` and `senderPlayerId` (the leaver) are read | leaving a group chat | +| `PlayerProgressionLevelUpdate` | `{PlayerId, Level, XP}`, as `api/players/v2/progression` returns it | every XP award (`LevelService.AwardXpAsync`) | +| `CommunityBoardUpdate` | community board | community board edits | +| `ReputationUpdate`, `RoomCurrency*`, `KeepsakeInstance*`, `PhotonAccessToken`, `GoToFailure`, `AnnouncementUpdate/Delete`, `ClubMembershipUpdate`, `GameConfig.Refresh`, `PlayerSettings.Refresh`, … | see the contract notes in `recroom-2023-client-api-complete.md` | not sent | + +**Not registered at all** (a push with these keys is dropped): numeric 4, 11, +12, 13, 15, 21, 24 (ModerationRoomBan), 40, 85, 90, 95. + +## Kicks versus logouts + +A ModerationKick only sends the player to their dorm, still signed in. + +The kick handler builds the dialog text like this: +- It always starts from its own "Kicked to Dormroom". +- It appends "Reason: …" from `VoteKickReason` only when `ReportCategory` + is 10 and `IsHostKick` is false. `Message` is never shown. +- When `GameSessionId` is the kicked player's current room instance, it also + announces the kick to that room. +- A `Duration` above 0 starts a timeout follow-up. + +Anything that must sign the player out uses Logout instead: bans, account +deletion, password resets and second-session takeover. The ban check then +refuses the banned player's next login. + +## Admin broadcast + +`POST api/admin/v1/broadcast` `{Message?, StartsInMinutes?}`: +- `Message` becomes a coach message (MessageReceived Type 100 from the + system account) in every online player's notifications. +- `StartsInMinutes` starts the client's own maintenance countdown (25). + +ServerMaintenance has no text field. The old `{Message}` payload was never +shown, and it cancelled any running countdown. + +## Not yet done + +- **No offline queue.** A push sent while the player has no connection is lost. + Messages are stored, so they still appear in `api/messages/v2/get`. + RecFlare keeps the newest 500 per player and flushes them on connect. +- The 2020 save and restore routes (`api/rooms/v4/saveData`, + `…/restoredata`, `api/rooms/v1/datahistory/restore`) still push the 2020 + `RoomUpdate` wrapper and a self PresenceUpdate. The 2023 client never + calls them; its save (`subrooms/{n}/data`) and publish routes push + nothing and return the room in the HTTP reply. +- `sendfriendintroductions` stores Type 30 text instead of FriendIntroduction + (130), and pushes nothing. The format of the 130 `Data` field is unknown.