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