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.
This commit is contained in:
Alexa
2026-10-24 19:50:05 +03:00
parent 38335c06a9
commit e33145307f
25 changed files with 1011 additions and 320 deletions
+19 -15
View File
@@ -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<string>`.
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<int>}` 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<int>}`
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 |
+139
View File
@@ -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=<connectionId>`, 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":"<key>","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<int>}`: 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<string>}`, 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.