mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
@@ -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
|
||||
|
||||
@@ -154,7 +154,7 @@ public class AccountsController(
|
||||
playerId, username, summary);
|
||||
}
|
||||
|
||||
await notifications.KickPlayerAsync(playerId, "This account has been deleted.");
|
||||
await notifications.LogoutAsync(playerId, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||
presence.Clear(playerId);
|
||||
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
|
||||
@@ -29,6 +29,7 @@ namespace DorkNet.Server.Controllers.Admin;
|
||||
public class AdminController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
SystemNotificationService systemNotifications,
|
||||
LevelService level,
|
||||
CommunityBoardService communityBoard,
|
||||
RoomBlobNormalizerService roomBlobNormalizer,
|
||||
@@ -160,7 +161,9 @@ public class AdminController(
|
||||
await LogAsync("ban_player", "player", id, body.Reason ?? "");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned.");
|
||||
// A ban must sign the player out (a kick only sends them to the dorm);
|
||||
// the next login is refused by the ban check.
|
||||
await notifications.LogoutAsync(id, NotificationService.LogoutReason.Banned);
|
||||
playerPresence.Clear(id);
|
||||
|
||||
return Ok(new { player.Id, player.BannedUntil });
|
||||
@@ -243,7 +246,7 @@ public class AdminController(
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin.");
|
||||
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||
playerPresence.Clear(id);
|
||||
|
||||
return Ok(new
|
||||
@@ -1570,10 +1573,13 @@ public class AdminController(
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
/// <summary>Push fresh RoomDetails after role/owner mutations. The
|
||||
/// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts,
|
||||
/// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom
|
||||
/// stores a replacement.</summary>
|
||||
/// <summary>Push fresh room details after role/owner mutations, to everyone
|
||||
/// in the room plus the player whose role changed. The client caches the
|
||||
/// room's Roles and role-gated UI reads that cache. The 2023 "RoomUpdate"
|
||||
/// handler (IBEOONPEELF.txt:507) reads the FULL room (FGCPNAACHIK) — the
|
||||
/// shape GET roomserver/rooms/{id} returns. The 2020 {Room, Scenes}
|
||||
/// wrapper parsed as RoomId 0 with no roles, so role changes never
|
||||
/// reached anyone until they re-entered the room.</summary>
|
||||
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
@@ -1587,7 +1593,7 @@ public class AdminController(
|
||||
.ToListAsync();
|
||||
|
||||
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
|
||||
.BuildRoomDetails(room, scenes, roles: roles);
|
||||
.BuildRoomServerDetails(room, scenes, roles: roles);
|
||||
|
||||
var recipients = onlinePresence.OnlinePlayerIds()
|
||||
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
|
||||
@@ -1739,7 +1745,7 @@ public class AdminController(
|
||||
{
|
||||
var r = playerPresence.GetRoom(pid);
|
||||
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
|
||||
await notifications.KickPlayerAsync(pid, reason);
|
||||
await notifications.KickPlayerAsync(pid, reason, gameSessionId: instanceId);
|
||||
playerPresence.Clear(pid);
|
||||
kicked.Add(pid);
|
||||
}
|
||||
@@ -2396,7 +2402,9 @@ public class AdminController(
|
||||
var reason = string.IsNullOrWhiteSpace(body?.Reason)
|
||||
? "Kicked by an admin."
|
||||
: body!.Reason!.Trim();
|
||||
await notifications.KickPlayerAsync(id, reason);
|
||||
// The room instance lets the client announce the kick to that room.
|
||||
await notifications.KickPlayerAsync(id, reason,
|
||||
gameSessionId: playerPresence.GetRoom(id)?.RoomInstanceId ?? 0);
|
||||
playerPresence.Clear(id);
|
||||
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
|
||||
await db.SaveChangesAsync();
|
||||
@@ -3328,10 +3336,11 @@ public class AdminController(
|
||||
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
|
||||
await LogAsync("set_password", "player", id, "");
|
||||
await db.SaveChangesAsync();
|
||||
// Push a logout so any active sessions on the old password get
|
||||
// dropped — the watch re-authenticates with the new password.
|
||||
await notifications.NotifyAsync(id, PushNotificationId.Logout,
|
||||
new { Reason = "AdminPasswordReset" });
|
||||
// Log out any session still on the old password; the client has to
|
||||
// sign in again with the new one. The reason must be the numeric
|
||||
// enum: the string "AdminPasswordReset" failed the client's parse and
|
||||
// the push was dropped.
|
||||
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||
return Ok(new { id });
|
||||
}
|
||||
|
||||
@@ -3500,22 +3509,20 @@ public class AdminController(
|
||||
|
||||
playerPresence.SetRoom(id, roomInstance);
|
||||
|
||||
// Two pushes back-to-back: GameSession update changes the
|
||||
// watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance
|
||||
// → UpdateCachedRoomInstance), and SubscriptionUpdateRoom
|
||||
// refreshes the watch's RoomDetails cache so the room-info
|
||||
// panel doesn't show stale data after the warp.
|
||||
// The move itself comes from the presence set above: the client's
|
||||
// next heartbeat sees it is out of sync and joins that instance.
|
||||
// RoomInstanceUpdate only refreshes the client's cached copy of the
|
||||
// instance, and RoomUpdate its cached room details, which must be
|
||||
// the full room (FGCPNAACHIK) the roomserver GET returns.
|
||||
await notifications.NotifyAsync(id,
|
||||
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
|
||||
var forceJoinScenes = await db.RoomScenes
|
||||
.Where(sc => sc.RoomId == room.Id)
|
||||
.OrderBy(sc => sc.OrderIndex)
|
||||
.ToListAsync();
|
||||
await notifications.NotifyAsync(id,
|
||||
PushNotificationId.SubscriptionUpdateRoom,
|
||||
new
|
||||
{
|
||||
Room = new { Id = room.Id, Name = room.Name },
|
||||
RoomId = room.Id,
|
||||
SubRoomId = subRoomId,
|
||||
Reason = "AdminForceJoin",
|
||||
});
|
||||
DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(room, forceJoinScenes));
|
||||
|
||||
await LogAsync("force_join", "player", id,
|
||||
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
|
||||
@@ -3532,20 +3539,46 @@ public class AdminController(
|
||||
|
||||
// ── System ───────────────────────────────────────────────────────────
|
||||
|
||||
public sealed record SetMotdRequest(string Message);
|
||||
public sealed record SetMotdRequest(string? Message, int? StartsInMinutes);
|
||||
|
||||
/// <summary>Push a server-maintenance broadcast to every connected
|
||||
/// player. The MOTD config value is changed via appsettings.json
|
||||
/// (server restart required); this is for ad-hoc announcements.</summary>
|
||||
/// <summary>Broadcast to every connected player.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Message</c>: a coach message (MessageReceived, Type 100, from
|
||||
/// the system account) in each online player's notifications —
|
||||
/// the client's channel for free text from the server.</item>
|
||||
/// <item><c>StartsInMinutes</c>: the client's own maintenance countdown
|
||||
/// (ServerMaintenance "25", which reads only
|
||||
/// <c>StartsInMinutes</c>; 0 cancels a running countdown).</item>
|
||||
/// </list>
|
||||
/// ServerMaintenance used to carry the text as <c>{Message}</c>, which the
|
||||
/// client never reads: the text was never shown, and StartsInMinutes 0
|
||||
/// cancelled any countdown instead.</summary>
|
||||
[HttpPost("broadcast")]
|
||||
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message");
|
||||
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
|
||||
new { Message = body.Message });
|
||||
await LogAsync("broadcast", "system", 0, body.Message);
|
||||
var message = body.Message?.Trim() ?? string.Empty;
|
||||
if (message.Length == 0 && body.StartsInMinutes is null)
|
||||
return BadRequest("empty message");
|
||||
if (body.StartsInMinutes is < 0)
|
||||
return BadRequest("StartsInMinutes must be 0 or more");
|
||||
|
||||
var recipients = onlinePresence.OnlinePlayerIds().ToList();
|
||||
if (message.Length > 0)
|
||||
{
|
||||
foreach (var pid in recipients)
|
||||
await systemNotifications.SendAsync(pid,
|
||||
SystemNotificationService.MessageType.CoachMessage,
|
||||
fromPlayerId: PlayerService.SystemAccountId,
|
||||
data: message);
|
||||
}
|
||||
if (body.StartsInMinutes is int minutes)
|
||||
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
|
||||
new { StartsInMinutes = minutes });
|
||||
|
||||
await LogAsync("broadcast", "system", 0,
|
||||
body.StartsInMinutes is int m ? $"[maintenance in {m} min] {message}" : message);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
return Ok(new { recipients = recipients.Count });
|
||||
}
|
||||
|
||||
// ── Store catalog management ─────────────────────────────────────────
|
||||
|
||||
@@ -460,6 +460,20 @@ public class ChatController(
|
||||
var removed = await db.ChatThreadMembers
|
||||
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
|
||||
.ExecuteDeleteAsync();
|
||||
if (removed > 0)
|
||||
{
|
||||
// "PlayerLeftChat" is an Action<ChatMessage> (DLDKCILCKNA.txt:721);
|
||||
// the handler only reads ChatThreadId and SenderPlayerId (the
|
||||
// player who left) and drops them from that thread's members.
|
||||
// Sent to the remaining members, whose member lists otherwise
|
||||
// stayed stale, and to the leaver's own other sessions.
|
||||
var thread = await EnsureThreadRowAsync(chatThreadId);
|
||||
var left = new ChatMessageEntity { ThreadKey = chatThreadId, SenderPlayerId = Me, Body = string.Empty };
|
||||
var recipients = await ThreadRecipientsAsync(chatThreadId);
|
||||
recipients.Add(Me);
|
||||
foreach (var rid in recipients.Distinct())
|
||||
await notifications.NotifyTypedAsync(rid, "PlayerLeftChat", ToWireMessage(left, thread.Id));
|
||||
}
|
||||
return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ public class GoToController(
|
||||
RoomService rooms,
|
||||
DorkNet.Server.Data.DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
SystemNotificationService systemNotifications,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
ServerSettingsService serverSettings,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
@@ -877,11 +878,12 @@ public class GoToController(
|
||||
logger.LogWarning(
|
||||
"[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting",
|
||||
callerId, playerId, theirRoom.RoomInstanceId);
|
||||
// Notify the OWNER that someone tried to follow them in.
|
||||
// Helps surface the rejection so the owner knows there's
|
||||
// an interested party — they can choose to send an invite.
|
||||
await notifications.PrivateInstanceJoinRejected(
|
||||
ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0);
|
||||
// Tell the OWNER someone tried to follow them in, as the
|
||||
// client's own "wants to join" message (RequestGameInvite,
|
||||
// from the caller), so they can send an invite.
|
||||
if (callerId is long requester)
|
||||
await systemNotifications.SendAsync(playerId,
|
||||
SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester);
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 26, // RoomInstanceIsPrivate
|
||||
|
||||
@@ -69,17 +69,15 @@ public class MatchPlayerController(
|
||||
if (replaced)
|
||||
{
|
||||
log.LogWarning(
|
||||
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push",
|
||||
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; logging old session(s) out",
|
||||
logScope, playerId, takeOverExclusiveSession);
|
||||
// The new session hasn't opened its SignalR socket yet
|
||||
// (negotiate fires AFTER /player/login on the watch's
|
||||
// boot sequence — verified in the live trace), so the
|
||||
// ModerationKick push hits only the OLDER session's
|
||||
// existing connections. They'll show the "you've been
|
||||
// kicked" dialog and gracefully disconnect via the
|
||||
// watch's LocalModerationKickSelf path.
|
||||
await notifications.KickStaleSession(playerId,
|
||||
"Your account was signed in on another device.");
|
||||
// (negotiate fires AFTER /player/login on the boot
|
||||
// sequence — verified in the live trace), so the Logout
|
||||
// push (Reason SessionTakeover) reaches only the OLDER
|
||||
// session, which shows the client's own "signed in
|
||||
// elsewhere" message and returns to the boot scene.
|
||||
await notifications.LogoutStaleSession(playerId);
|
||||
}
|
||||
}
|
||||
return Ok(Array.Empty<object>());
|
||||
|
||||
Reference in New Issue
Block a user