From e33145307f9296984c472cef3d52a422ca76319d Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:50:05 +0300 Subject: [PATCH] fix: notification pushes now match what the 2023 client reads Most pushes were still shaped for the 2020 watch, so the 2023 client parsed them as empty or dropped them without any error. - RelationshipChanged carries the relationship row, to both players, on friend request / accept / remove / add and on favorite / mute / ignore. Friend requests and accepts also land in the notifications inbox. - ModerationKick uses the client's flat moderation block, with the reason in VoteKickReason (the only field the dialog shows). - Bans, account deletion, password resets and second-session takeover are a Logout with a numeric reason instead of a kick to the dorm; the old string reason failed the client's parse. - SubscribeToPlayers is honoured: each connection joins a presence group per subscribed player, and presence (only presence) reaches subscribers as well as friends. The hub allows 180 s of client silence, since the client reconnects at most 3 times per login. - Admin broadcast sends its text as a coach message and can start the client's own maintenance countdown; ServerMaintenance only reads StartsInMinutes. - ConsumableMapping, StorefrontBalance, gift, player event, level-up, RoomUpdate, profile-image and PlayerLeftChat pushes carry the client's DTOs. "Join rejected" is the client's RequestGameInvite message. Contract in docs/recroom-2023-notifications.md; push frames are checked by NotificationContractTests through a capturing hub context. --- DorkNet.Server.Tests/CapturingHubContext.cs | 78 +++++++ .../NotificationContractTests.cs | 198 ++++++++++++++++++ .../API/FreeGifts/FreeGiftsController.cs | 5 +- .../API/Images/ImagesController.cs | 41 +--- .../API/Inventions/InventionsController.cs | 8 +- .../API/Inventory/InventoryController.cs | 29 ++- .../Moderation/InGameModerationController.cs | 25 +-- .../PlayerEvents/PlayerEventsController.cs | 111 +++++++--- .../V2/RelationshipsController.cs | 51 ++++- .../API/Store/StorefrontsBuyController.cs | 33 ++- .../Accounts/AccountsController.cs | 2 +- .../Controllers/Admin/AdminController.cs | 101 ++++++--- .../Controllers/Chat/ChatController.cs | 14 ++ .../Controllers/Match/GoToController.cs | 12 +- .../Match/MatchPlayerController.cs | 16 +- DorkNet.Server/Hubs/NotifyHub.cs | 137 ++++++++---- DorkNet.Server/Services/LevelService.cs | 13 +- .../Services/NotificationService.cs | 184 ++++++++-------- .../Services/PresenceBroadcastService.cs | 22 +- .../Services/SystemNotificationService.cs | 4 + .../Startup/ServiceCollectionExtensions.cs | 15 +- .../admin-ui/src/pages/Broadcast.tsx | 49 ++++- README.md | 10 +- docs/recroom-2023-client-api-complete.md | 34 +-- docs/recroom-2023-notifications.md | 139 ++++++++++++ 25 files changed, 1011 insertions(+), 320 deletions(-) create mode 100644 DorkNet.Server.Tests/CapturingHubContext.cs create mode 100644 DorkNet.Server.Tests/NotificationContractTests.cs create mode 100644 docs/recroom-2023-notifications.md 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..7db6530 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,24 @@ 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, + IsTransferable = true, + }; + 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..59e967e 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,20 @@ 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(); 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)); await LogAsync("force_join", "player", id, $"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}"); @@ -3532,20 +3539,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..fb9ea87 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,12 @@ 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. + if (callerId is long requester) + await systemNotifications.SendAsync(playerId, + SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester); return Ok(new MatchmakingResponseDto { ErrorCode = 26, // RoomInstanceIsPrivate 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..04e01fc 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,101 @@ 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"; + + /// 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); + + 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 found = false; + foreach (var prop in payload.EnumerateObject()) + { + if (!string.Equals(prop.Name, "PlayerIds", StringComparison.OrdinalIgnoreCase)) continue; + list = prop.Value; + found = true; + break; + } + if (!found) return false; + } + 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/PresenceBroadcastService.cs b/DorkNet.Server/Services/PresenceBroadcastService.cs index e0f367f..6288c8a 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,20 @@ 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. + var exclude = connections.GetConnections(playerId) + .Concat(friendIds.SelectMany(connections.GetConnections)) + .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
+ + + 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.'} +
+
- - +
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.