diff --git a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs index 180db22..ebba4d5 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs @@ -332,17 +332,13 @@ public class AvatarGiftsController( gift.ConsumedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - // Profile-update push so the watch's wallet readout refreshes - // without waiting for the next /balance poll. Mirrors what - // /api/admin/v1/players/{id}/gift does after the inbox push so - // the toast number animates up the moment the box closes. - if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0) - { - await notifications.NotifyAsync(pid, - PushNotificationId.SubscriptionUpdateProfile, - new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level }); - } - + // The wallet readout refreshes off the StorefrontBalanceUpdate that + // GrantCurrencyAsync (above) already pushes for currency gifts. Do + // NOT also push SubscriptionUpdateProfile ("AccountUpdate") with a + // {Reason,Currency,…} blob: the 2023 client deserialises that channel + // as its Account DTO, so a non-account payload decodes to a blank + // account (accountId 0) → "Orphan player account (0) found" spam + // (audited 2026-07-12). return Ok(ToWire(gift)); } diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 99584c5..2a7ac1b 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -1054,11 +1054,12 @@ public class RoomsController( EncryptVoiceChat = currentPresence.EncryptVoiceChat, }, isOnline = true, - // 2023.03.21 client build version (baked "20230317"). MUST match - // the joining client's own version or it reports a version - // mismatch and refuses to join. Was a stale December-2020 value - // (20201210) carried over when this branch forked. - appVersion = 20230317, + // 2023.03.21 client build version. MUST be the STRING + // "20230317" — the client's presence DTO reads appVersion + // with a string reader, so an int crashes the SignalR + // message ("expected:'String Begin Token'") and aborts the + // update. Must also match the joining client's version. + appVersion = "20230317", }; await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload); } diff --git a/DorkNet.Server/Services/LevelService.cs b/DorkNet.Server/Services/LevelService.cs index 091b1bb..e2ec3ca 100644 --- a/DorkNet.Server/Services/LevelService.cs +++ b/DorkNet.Server/Services/LevelService.cs @@ -43,19 +43,17 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications if (player.Level > oldLevel) { - // Level-up grants a small currency reward and a push so - // the watch can play its level-up animation. + // Level-up grants a small currency reward. The wallet UI + // refreshes off the StorefrontBalanceUpdate that + // GrantCurrencyAsync already pushes — do NOT also push + // SubscriptionUpdateProfile ("AccountUpdate") with a + // {Reason,Level} blob: the 2023 client deserialises that + // channel as its Account DTO, so a non-account payload + // decodes to a blank account (accountId 0) and logs + // "Orphan player account (0) found" (audited 2026-07-12). await GrantCurrencyAsync(playerId, currencyType: 2, amount: 100 * (player.Level - oldLevel), reason: $"levelup:{oldLevel}->{player.Level}"); - await notifications.NotifyAsync(playerId, - PushNotificationId.SubscriptionUpdateProfile, - new - { - Reason = "LevelUp", - Level = player.Level, - PreviousLevel = oldLevel, - }); } return (player.Level, player.XP); diff --git a/DorkNet.Server/Services/NotificationService.cs b/DorkNet.Server/Services/NotificationService.cs index 842c2c3..56a8799 100644 --- a/DorkNet.Server/Services/NotificationService.cs +++ b/DorkNet.Server/Services/NotificationService.cs @@ -298,10 +298,14 @@ public class NotificationService( }, // 2020.12 PlayerPresence.Deserialize requires these. ["isOnline"] = redacted is not null, - // 2023.03.21 client build version ("20230317"); must match the - // joining client's own version or it reports a version mismatch. - // Stale 2020 value (20201210) carried over from the December branch. - ["appVersion"] = 20230317, + // 2023.03.21 client build version; must match the joining + // client's own version or it reports a version mismatch. MUST be + // a STRING ("20230317"): the client's presence DTO reads + // appVersion with a string reader, so an int crashes the whole + // SignalR message with "expected:'String Begin Token', + // actual:'20230317'" — which aborts the presence/room update + // (e.g. surfaced as "failed to create a new room"). + ["appVersion"] = "20230317", }; var envelope = new System.Text.Json.Nodes.JsonObject diff --git a/DorkNet.Server/Services/SystemNotificationService.cs b/DorkNet.Server/Services/SystemNotificationService.cs new file mode 100644 index 0000000..8aaf9f1 --- /dev/null +++ b/DorkNet.Server/Services/SystemNotificationService.cs @@ -0,0 +1,84 @@ +using DorkNet.Models.Notification; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Services; + +/// +/// The REAL channel for event notifications (cheers, friend events, room +/// co-owner changes, …). A notification is a +/// with a discriminator: it persists to the +/// recipient's message/notification feed (so GET api/messages +/// returns it) and is pushed live via MessageReceived with the +/// client's Message DTO shape (RecNet.Runtime KJECOLODAFA: +/// Id/FromPlayerId/SentTime/Type/Data/RoomId/PlayerEventId). +/// +/// This replaces the old anti-pattern of pushing {Reason:"…"} blobs +/// on the SubscriptionUpdateProfile ("AccountUpdate") channel — the +/// 2023 client decoded those as its Account DTO, producing a blank account +/// (accountId 0) and "Orphan player account (0) found" log spam, and never +/// rendered the notification. Every field type here matches the client +/// reader (FromPlayerId is Int32; SentTime is an ISO DateTime; Type/RoomId/ +/// PlayerEventId are number/nullable-number). +/// +public sealed class SystemNotificationService(DorkNetDbContext db, NotificationService notifications) +{ + /// RecNet message-type enum (KJECOLODAFA+MEPCALGGMJC), + /// verified from the 2023.03.21 client. Only the values we send are + /// listed; add more as needed. + public static class MessageType + { + public const int TextMessage = 30; + public const int FriendRequestAccepted = 40; + public const int FriendCodeUsed = 41; + public const int PlayerCheer = 50; + public const int PlayerCheerAnonymous = 51; + public const int RoomCoOwnerAdded = 60; + public const int RoomCoOwnerRemoved = 61; + public const int RoomCoOwnerInvited = 62; + public const int CreatorPublishedNewRoom = 70; + public const int PlayerAttendingEvent = 80; + public const int PlayerEventInvitation = 81; + } + + /// Persist and push a system notification to + /// . + /// is the actor the client resolves the name/avatar from (0 for a + /// system/anonymous notification). is the + /// type-specific payload string (empty when the Type + FromPlayerId are + /// sufficient, e.g. a cheer). Returns the created message id. + public async Task SendAsync( + long recipientPlayerId, + int type, + long fromPlayerId = 0, + string data = "", + long? roomId = null) + { + if (recipientPlayerId <= 0) return 0; + + var msg = new MessageEntity + { + SenderPlayerId = fromPlayerId, + RecipientPlayerId = recipientPlayerId, + Body = data ?? string.Empty, + Type = type, + RoomId = roomId, + SentAt = DateTime.UtcNow, + }; + db.Messages.Add(msg); + await db.SaveChangesAsync(); + + await notifications.NotifyAsync(recipientPlayerId, PushNotificationId.MessageReceived, new + { + Id = msg.Id, + // Client Message DTO reads FromPlayerId as Int32. + FromPlayerId = unchecked((int)fromPlayerId), + SentTime = msg.SentAt, + Type = type, + Data = msg.Body, + RoomId = roomId, + PlayerEventId = (long?)null, + }); + return msg.Id; + } +} diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index aa9c9f7..793d230 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -201,6 +201,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); // Singleton — the protobuf blob is identical for every room and built