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
@@ -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 = "" });
}