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
@@ -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 });
@@ -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));
}
/// <summary>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.</summary>
private Task NotifyAccountImageChangedAsync(PlayerEntity player) =>
notifications.AccountChanged(player);
private static string NormaliseTaggedIds(string? csv, long exceptId)
{
@@ -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)
{
@@ -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(
};
}
/// <summary>ConsumableMapping push payload ("70" Added / "71" Removed):
/// exactly the client's mapping DTO <c>CGCALDBHLGD</c> (formatter
/// <c>JICIOMDBPDO</c>) — Id (Int64), ConsumableItemDesc (STRING, not an
/// object), Count, InitialCount, CreatedAt, ActiveDurationMinutes (Int32?),
/// IsActive, IsTransferable. The old <c>{Id, ItemSlug, Quantity}</c> parsed
/// as a mapping with no item and a count of 0.</summary>
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)
@@ -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 = "" });
}
@@ -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<object>();
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
/// <summary>Everyone who answered the event with anything but NotGoing,
/// except its creator.</summary>
private async Task<List<long>> 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();
/// <summary>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 <c>{PlayerEventId, Title}</c> overwrote the
/// cached event with an empty name and default times, and the
/// PlayerEventStateChanged (85) used for broadcasts has no client handler
/// at all.</summary>
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);
}
/// <summary>PlayerEventDeleted "82" ({PlayerEventId}) to the players who
/// were attending, collected before the rows were removed.</summary>
private async Task NotifyEventDeletedAsync(long eventId, IEnumerable<long> recipients)
{
foreach (var playerId in recipients)
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventDeleted,
new { PlayerEventId = eventId });
}
/// <summary>PlayerEventResponseChanged "83" to the event's creator:
/// <c>{PlayerEvent, PlayerEventResponse}</c> (COAGAJPELCG), each the same
/// object the event and response REST routes return.</summary>
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 ───────────────────────────────────────────────────────────
@@ -48,7 +48,11 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2;
/// </summary>
[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 ──────────────────────────────────────────────────────
/// <summary>Push RelationshipChanged for the pair (<paramref name="me"/>,
/// <paramref name="other"/>) 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.</summary>
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));
}
/// <summary>The relationship from <paramref name="viewer"/>'s side, the way
/// <c>v2/get</c> would list it.</summary>
private static Dictionary<string, object> ViewOf(
long viewer, long other, List<RelationshipEntity> 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<HashSet<long>> FriendIdsAsync(long playerId)
{
var rows = await db.Relationships
@@ -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