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
@@ -0,0 +1,78 @@
using System.Collections.Concurrent;
using System.Text.Json;
using DorkNet.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
namespace DorkNet.Server.Tests;
/// <summary>
/// Stand-in for the notify hub that records every push instead of sending it,
/// so tests can assert on the exact frames the game client would receive.
/// A frame is the hub method plus its single string argument, parsed back into
/// the <c>{Id, Msg}</c> envelope.
/// </summary>
public sealed class CapturingHubContext : IHubContext<NotifyHub>
{
private readonly ConcurrentQueue<SentFrame> _sent = new();
public IHubClients Clients => new CapturingClients(this);
public IGroupManager Groups { get; } = new NoopGroups();
public IReadOnlyList<SentFrame> Sent => _sent.ToArray();
/// <summary>Frames sent to a target such as <c>group:player:12</c>, in
/// order, optionally filtered by envelope <c>Id</c>.</summary>
public IReadOnlyList<SentFrame> To(string target, string? id = null) =>
_sent.Where(f => f.Target == target && (id is null || f.Id == id)).ToList();
public static string PlayerGroup(long playerId) => $"group:{NotifyHub.GroupForPlayer(playerId)}";
internal void Record(string target, IReadOnlyList<string>? excluded, string method, object?[] args) =>
_sent.Enqueue(new SentFrame(target, excluded ?? Array.Empty<string>(), method,
args.Length == 1 && args[0] is string s ? s : JsonSerializer.Serialize(args)));
public sealed record SentFrame(string Target, IReadOnlyList<string> Excluded, string Method, string Payload)
{
public JsonElement Envelope => JsonDocument.Parse(Payload).RootElement;
public string? Id => Envelope.TryGetProperty("Id", out var id) ? id.GetString() : null;
public JsonElement Msg => Envelope.GetProperty("Msg");
}
private sealed class CapturingClients(CapturingHubContext owner) : IHubClients
{
private IClientProxy Proxy(string target, IReadOnlyList<string>? excluded = null) =>
new CapturingProxy(owner, target, excluded);
public IClientProxy All => Proxy("all");
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy("all", excludedConnectionIds);
public IClientProxy Client(string connectionId) => Proxy($"conn:{connectionId}");
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy($"conns:{string.Join(",", connectionIds)}");
public IClientProxy Group(string groupName) => Proxy($"group:{groupName}");
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) =>
Proxy($"group:{groupName}", excludedConnectionIds);
public IClientProxy Groups(IReadOnlyList<string> groupNames) => new FanOutProxy(groupNames.Select(g => Proxy($"group:{g}")).ToList());
public IClientProxy User(string userId) => Proxy($"user:{userId}");
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy($"users:{string.Join(",", userIds)}");
}
private sealed class CapturingProxy(CapturingHubContext owner, string target, IReadOnlyList<string>? excluded) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
owner.Record(target, excluded, method, args);
return Task.CompletedTask;
}
}
private sealed class FanOutProxy(IReadOnlyList<IClientProxy> proxies) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) =>
Task.WhenAll(proxies.Select(p => p.SendCoreAsync(method, args, cancellationToken)));
}
private sealed class NoopGroups : IGroupManager
{
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}
@@ -0,0 +1,198 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Controllers.API.Inventory;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace DorkNet.Server.Tests;
/// <summary>
/// Push frames checked against the 2023-03-21 client's notification readers
/// (RecNet.Runtime). Every push is the hub method "Notification" with one
/// JSON string <c>{"Id":"&lt;key&gt;","Msg":{…}}</c>; the client looks the Id
/// up case-sensitively and parses Msg strictly into the handler's DTO, so a
/// wrong key or shape drops the push without any error.
/// </summary>
public sealed class NotificationContractTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public NotificationContractTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Friend_request_pushes_each_side_its_own_relationship_and_an_inbox_message()
{
var (factory, hub) = WithCapturingHub();
using var setup = ApiClient(factory);
var sender = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(factory, sender);
(await client.PostAsync($"/api/relationships/v2/sendfriendrequest?id={target.PlayerId}", null))
.EnsureSuccessStatusCode();
// RelationshipChanged "1" = the Relationship row from each side's view.
var toTarget = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1"));
Assert.Equal("Notification", toTarget.Method);
Assert.Equal(sender.PlayerId, toTarget.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(2, toTarget.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestReceived
Assert.False(toTarget.Msg.TryGetProperty("Reason", out _));
var toSender = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1"));
Assert.Equal(target.PlayerId, toSender.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(1, toSender.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestSent
// MessageReceived "2" — the "X sent you a friend request" inbox row.
var invite = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "2"));
Assert.Equal(4, invite.Msg.GetProperty("Type").GetInt32()); // FriendInvite
Assert.Equal(sender.PlayerId, invite.Msg.GetProperty("FromPlayerId").GetInt64());
using var targetClient = ApiClient(factory, target);
(await targetClient.PostAsync($"/api/relationships/v2/acceptfriendrequest?id={sender.PlayerId}", null))
.EnsureSuccessStatusCode();
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1")[^1]
.Msg.GetProperty("RelationshipType").GetInt32());
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1")[^1]
.Msg.GetProperty("RelationshipType").GetInt32());
var accepted = hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "2")[^1];
Assert.Equal(40, accepted.Msg.GetProperty("Type").GetInt32()); // FriendRequestAccepted
}
[Fact]
public async Task Favorite_pushes_the_callers_updated_row_to_the_caller_only()
{
var (factory, hub) = WithCapturingHub();
using var setup = ApiClient(factory);
var me = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var other = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(factory, me);
(await client.GetAsync($"/api/relationships/v1/favorite?id={other.PlayerId}")).EnsureSuccessStatusCode();
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(me.PlayerId), "1"));
Assert.Equal(other.PlayerId, frame.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(1, frame.Msg.GetProperty("Favorited").GetInt32());
Assert.Empty(hub.To(CapturingHubContext.PlayerGroup(other.PlayerId), "1"));
}
[Fact]
public async Task Kick_is_the_flat_moderation_block_with_the_reason_in_VoteKickReason()
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.KickPlayerAsync(42, "Too loud", gameSessionId: 777);
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(42), "22"));
var msg = frame.Msg;
// The handler only shows a reason for ReportCategory 10 + !IsHostKick.
Assert.Equal(10, msg.GetProperty("ReportCategory").GetInt32());
Assert.False(msg.GetProperty("IsHostKick").GetBoolean());
Assert.Equal("Too loud", msg.GetProperty("VoteKickReason").GetString());
Assert.Equal(777, msg.GetProperty("GameSessionId").GetInt64());
Assert.Equal(0, msg.GetProperty("Duration").GetInt32());
Assert.False(msg.TryGetProperty("Reason", out _)); // the old nested 2020 shape
}
[Theory]
[InlineData(NotificationService.LogoutReason.SessionTakeover, 2)]
[InlineData(NotificationService.LogoutReason.ForciblyLoggedOut, 3)]
[InlineData(NotificationService.LogoutReason.Banned, 4)]
public async Task Logout_reason_is_the_numeric_enum(NotificationService.LogoutReason reason, int wire)
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.LogoutAsync(43, reason);
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(43), "6"));
var value = frame.Msg.GetProperty("Reason");
Assert.Equal(JsonValueKind.Number, value.ValueKind);
Assert.Equal(wire, value.GetInt32());
}
[Fact]
public async Task Presence_for_subscribers_goes_to_the_subjects_presence_group_minus_excluded_connections()
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.PresenceToSubscribersAsync(55, room: null, isOnline: true, statusVisibility: 0,
excludeConnectionIds: ["self-conn", "friend-conn"]);
var frame = Assert.Single(hub.To($"group:{NotifyHub.PresenceGroupForPlayer(55)}", "PresenceUpdate"));
Assert.Equal(["self-conn", "friend-conn"], frame.Excluded);
Assert.Equal(55, frame.Msg.GetProperty("playerId").GetInt32());
Assert.True(frame.Msg.GetProperty("isOnline").GetBoolean());
Assert.Equal(JsonValueKind.String, frame.Msg.GetProperty("appVersion").ValueKind);
}
[Theory]
[InlineData("""{"PlayerIds":[1,2,3]}""", new long[] { 1, 2, 3 })]
[InlineData("""{"playerIds":["4",5]}""", new long[] { 4, 5 })]
[InlineData("""[6,7]""", new long[] { 6, 7 })]
[InlineData("""{"PlayerIds":[]}""", new long[0])]
public void SubscribeToPlayers_payload_is_read_in_any_casing(string json, long[] expected)
{
Assert.True(NotifyHub.TryReadPlayerIds(JsonDocument.Parse(json).RootElement, out var ids));
Assert.Equal(expected.OrderBy(x => x), ids.OrderBy(x => x));
}
[Fact]
public void Unreadable_SubscribeToPlayers_payload_is_rejected_not_read_as_empty()
{
Assert.False(NotifyHub.TryReadPlayerIds(JsonDocument.Parse("""{"Channels":[]}""").RootElement, out _));
}
[Fact]
public void Consumable_mapping_push_has_exactly_the_clients_keys()
{
var wire = JsonSerializer.SerializeToElement(InventoryController.ConsumableMappingWire(new PlayerInventoryEntity
{
Id = 9,
ItemSlug = "some-consumable",
Quantity = 3,
}));
var keys = wire.EnumerateObject().Select(p => p.Name).ToHashSet();
Assert.Equal(
new HashSet<string> { "Id", "ConsumableItemDesc", "Count", "InitialCount", "CreatedAt", "ActiveDurationMinutes", "IsActive", "IsTransferable" },
keys);
Assert.Equal(JsonValueKind.String, wire.GetProperty("ConsumableItemDesc").ValueKind);
Assert.Equal(3, wire.GetProperty("Count").GetInt32());
}
private (WebApplicationFactory<Program> Factory, CapturingHubContext Hub) WithCapturingHub()
{
var hub = new CapturingHubContext();
var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IHubContext<NotifyHub>>();
services.AddSingleton<IHubContext<NotifyHub>>(hub);
}));
return (factory, hub);
}
private HttpClient ApiClient(WebApplicationFactory<Program> factory, GameClientSession? session = null)
{
var client = factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -56,9 +56,12 @@ public class FreeGiftsController(DorkNetDbContext db, NotificationService notifi
await db.SaveChangesAsync(); await db.SaveChangesAsync();
foreach (var row in created) 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, await notifications.NotifyAsync(row.RecipientPlayerId,
PushNotificationId.GiftPackageReceived, 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 }); 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) .Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0); .Where(v => v > 0);
private Task NotifyAccountImageChangedAsync(PlayerEntity player) /// <summary>Push the new profile image through the same account pushes
{ /// as every other profile edit: the owner's SelfAccountUpdate and an
var account = new DorkNet.Models.Auth.RecNetAccount /// AccountUpdate to everyone. A hand-built account here left out the
{ /// emoji, banner, pronouns, identity flags and creation date, so an image
AccountId = (int)player.Id, /// change wiped them from the owner's cached account, and nobody else saw
RawUsername = player.Username, /// the new picture.</summary>
Username = player.Username, private Task NotifyAccountImageChangedAsync(PlayerEntity player) =>
DisplayName = player.DisplayName ?? player.Username, notifications.AccountChanged(player);
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));
}
private static string NormaliseTaggedIds(string? csv, long exceptId) private static string NormaliseTaggedIds(string? csv, long exceptId)
{ {
@@ -1095,11 +1095,9 @@ public class InventionsController(
}); });
inv.CheerCount += 1; inv.CheerCount += 1;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// No push: InventionModerationStateChanged ("100") means "your
if (inv.CreatorPlayerId != pid) // inventions' moderation state changed" and takes no payload; sent
await notifications.NotifyAsync(inv.CreatorPlayerId, // on every cheer it only made the creator refetch for nothing.
PushNotificationId.InventionModerationStateChanged,
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
} }
else if (!req.Cheer && existing is not null) else if (!req.Cheer && existing is not null)
{ {
@@ -279,11 +279,11 @@ public class InventoryController(
if (row.Quantity == 0) db.PlayerInventory.Remove(row); if (row.Quantity == 0) db.PlayerInventory.Remove(row);
await db.SaveChangesAsync(); 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, await notifications.NotifyAsync(pid,
row.Quantity > 0 PushNotificationId.ConsumableMappingRemoved, ConsumableMappingWire(row));
? PushNotificationId.ConsumableMappingAdded
: PushNotificationId.ConsumableMappingRemoved,
new { row.Id, row.ItemSlug, row.Quantity });
return Ok(new { row.Id, Count = row.Quantity }); return Ok(new { row.Id, Count = row.Quantity });
} }
@@ -332,8 +332,7 @@ public class InventoryController(
if (source.Quantity == 0) db.PlayerInventory.Remove(source); if (source.Quantity == 0) db.PlayerInventory.Remove(source);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.NotifyAsync(recipient, await notifications.NotifyAsync(recipient,
PushNotificationId.ConsumableMappingAdded, PushNotificationId.ConsumableMappingAdded, ConsumableMappingWire(target));
new { target.Id, target.ItemSlug, target.Quantity });
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity }); 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) private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
{ {
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc) var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
@@ -152,8 +152,6 @@ public class InGameModerationController(
foreach (var target in ids) foreach (var target in ids)
{ {
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target); 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."); await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
} }
return Ok(new { success = true, error = "" }); 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}", "[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
caller, target, reason, p.BannedUntil, displayReason); caller, target, reason, p.BannedUntil, displayReason);
// Push the ban as a ModerationKick (22) with IsBan set, NOT as // Sign the banned player out with Logout "6" (Reason Banned). Not
// ModerationRoomBan (24): the 2023-03-21 client's moderation manager // ModerationRoomBan (24): the 2023-03-21 client registers no handler
// registers only 22, "ModerationUnkick" and 23 // for it (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335 register only
// (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler // 22, "ModerationUnkick" and 23), so that push landed nowhere. Not a
// for 24, so that push landed nowhere and the banned player kept playing // ModerationKick either: a kick only sends the player to their dorm
// until their next login. The 22 handler reads Msg.Reason as a // and they kept playing until their next login. The ban check refuses
// ModerationBlockDetail, which ModerationKickPayload already produces. // that next login.
await notifications.KickPlayerAsync( await notifications.LogoutAsync(target, NotificationService.LogoutReason.Banned);
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);
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
} }
@@ -34,6 +34,7 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents;
public class PlayerEventsController( public class PlayerEventsController(
DorkNetDbContext db, DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
SystemNotificationService systemNotifications,
PlayerPresenceService presence) : ControllerBase PlayerPresenceService presence) : ControllerBase
{ {
private long Me => this.RequireCurrentPlayerId(); private long Me => this.RequireCurrentPlayerId();
@@ -817,7 +818,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
await ApplyBodyToExtrasAsync(extras); await ApplyBodyToExtrasAsync(extras);
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated); await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -901,10 +902,7 @@ public class PlayerEventsController(
// Tell the creator someone RSVP'd. They can ignore but the // Tell the creator someone RSVP'd. They can ignore but the
// watch will refresh their event detail card. // watch will refresh their event detail card.
if (ev.CreatorPlayerId != Me) await NotifyResponseChangedAsync(ev, row);
await notifications.NotifyAsync(ev.CreatorPlayerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, From = Me, body.Response });
// Left as the historical shape on purpose: this route is 2020-only // Left as the historical shape on purpose: this route is 2020-only
// (the 2023 client GETs .../responses and POSTs v1/respond instead), // (the 2023 client GETs .../responses and POSTs v1/respond instead),
// so there is no 2023 DTO to match here. // so there is no 2023 DTO to match here.
@@ -947,10 +945,15 @@ public class PlayerEventsController(
var existing = await db.PlayerEventResponses var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt); .FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null) 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 else
existing.Response = response; existing.Response = response;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var responded = await db.PlayerEvents.FirstAsync(e => e.Id == evt);
await NotifyResponseChangedAsync(responded, existing);
return RespondResult(legacyForm, 0 /*Success*/); return RespondResult(legacyForm, 0 /*Success*/);
} }
@@ -968,9 +971,18 @@ public class PlayerEventsController(
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId"); var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/); 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) .Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync(); .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*/); return RespondResult(legacyForm, 0 /*Success*/);
} }
@@ -993,6 +1005,7 @@ public class PlayerEventsController(
// has to echo the event that was deleted. // has to echo the event that was deleted.
var extras = (await LoadExtrasAsync(new[] { evt }))[id]; var extras = (await LoadExtrasAsync(new[] { evt }))[id];
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id); var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
var notify = await AttendeeIdsAsync(evt);
db.PlayerEvents.Remove(evt); db.PlayerEvents.Remove(evt);
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync(); 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)) .Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyEventDeletedAsync(id, notify);
return Ok(Wrapper(evt, extras, attendees)); return Ok(Wrapper(evt, extras, attendees));
} }
@@ -1021,6 +1035,7 @@ public class PlayerEventsController(
evt.Title = Clamp(value.Trim(), 128); evt.Title = Clamp(value.Trim(), 128);
evt.UpdatedAt = DateTime.UtcNow; evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1034,6 +1049,7 @@ public class PlayerEventsController(
evt.Description = Clamp(value.Trim(), 2000); evt.Description = Clamp(value.Trim(), 2000);
evt.UpdatedAt = DateTime.UtcNow; evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1056,6 +1072,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId"); extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1072,6 +1089,7 @@ public class PlayerEventsController(
if (start is null && end is null) return BadRequest("missing_time"); if (start is null && end is null) return BadRequest("missing_time");
evt.UpdatedAt = DateTime.UtcNow; evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1085,6 +1103,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.ImageName = Clamp(value.Trim(), 128); extras.ImageName = Clamp(value.Trim(), 128);
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1099,6 +1118,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Accessibility = accessibility; extras.Accessibility = accessibility;
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1120,6 +1140,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Tags = tags; extras.Tags = tags;
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1158,6 +1179,7 @@ public class PlayerEventsController(
?? extras.CanRequestBroadcastPermissions; ?? extras.CanRequestBroadcastPermissions;
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1188,6 +1210,7 @@ public class PlayerEventsController(
} }
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1197,6 +1220,7 @@ public class PlayerEventsController(
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound(); if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid(); if (ev.CreatorPlayerId != Me) return Forbid();
var notify = await AttendeeIdsAsync(ev);
db.PlayerEvents.Remove(ev); db.PlayerEvents.Remove(ev);
// Cascade-clean the responses; SQLite has no FK cascade // Cascade-clean the responses; SQLite has no FK cascade
// configured here so do it manually. // configured here so do it manually.
@@ -1206,6 +1230,7 @@ public class PlayerEventsController(
await db.PlayerSettings await db.PlayerSettings
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId)) .Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
await NotifyEventDeletedAsync(eventId, notify);
return Ok(); return Ok();
} }
@@ -1244,6 +1269,7 @@ public class PlayerEventsController(
var failed = new List<object>(); var failed = new List<object>();
var sender = Me; var sender = Me;
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}."; var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
var eventWire = await ToWireOneAsync(ev);
foreach (var rid in req.InvitedPlayerIds.Distinct()) foreach (var rid in req.InvitedPlayerIds.Distinct())
{ {
var exists = await db.Players.AnyAsync(p => p.Id == rid); var exists = await db.Players.AnyAsync(p => p.Id == rid);
@@ -1252,17 +1278,15 @@ public class PlayerEventsController(
failed.Add(new { InvitedPlayerId = rid, Result = 1 }); failed.Add(new { InvitedPlayerId = rid, Result = 1 });
continue; continue;
} }
db.Messages.Add(new MessageEntity // 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).
SenderPlayerId = sender, await systemNotifications.SendAsync(rid,
RecipientPlayerId = rid, SystemNotificationService.MessageType.TextMessage, fromPlayerId: sender, data: inviteText);
Body = inviteText, // And the event itself, so it shows in the invitee's event list:
}); // PlayerEventCreated "80" carries the full event (MDCBEPJCJPO) —
await notifications.NotifyAsync(rid, // the same object GET api/playerevents/v2/{id} returns.
PushNotificationId.PlayerEventCreated, await notifications.NotifyAsync(rid, PushNotificationId.PlayerEventCreated, eventWire);
new { ev.Id, ev.Title, ev.StartsAt, From = sender });
} }
await db.SaveChangesAsync();
return Ok(new { FailedInvites = failed, Result = 0 }); return Ok(new { FailedInvites = failed, Result = 0 });
} }
@@ -1289,21 +1313,56 @@ public class PlayerEventsController(
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId"); "BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge // Attendees' event cards key off BroadcastingRoomInstanceId, so send
// them to re-fetch instead of sending a chat message. // them the updated event.
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged); await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id) /// <summary>Everyone who answered the event with anything but NotGoing,
{ /// except its creator.</summary>
var recipients = await db.PlayerEventResponses private async Task<List<long>> AttendeeIdsAsync(PlayerEventEntity evt) =>
(await db.PlayerEventResponses
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse) .Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
.Select(r => r.PlayerId) .Select(r => r.PlayerId)
.Distinct() .Distinct()
.ToListAsync(); .ToListAsync())
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId)) .Where(p => p != evt.CreatorPlayerId)
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title }); .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 ─────────────────────────────────────────────────────────── // ── Report ───────────────────────────────────────────────────────────
@@ -48,7 +48,11 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2;
/// </summary> /// </summary>
[ApiController] [ApiController]
[Authorize] [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 // RelationshipType — int values match Relationship.RelationshipType
// in the patched dump.cs. // in the patched dump.cs.
@@ -226,7 +230,9 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
} }
await db.SaveChangesAsync(); 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)); return Ok(BuildRelationship(row, me));
} }
@@ -251,7 +257,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
rel.UpdatedAt = DateTime.UtcNow; rel.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); 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)); return Ok(BuildRelationship(rel, me));
} }
@@ -285,7 +292,7 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
db.Relationships.RemoveRange(removable); db.Relationships.RemoveRange(removable);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.FriendRemoved(id, me); await PushPairAsync(me, id);
var remaining = pair.Except(removable).ToList(); var remaining = pair.Except(removable).ToList();
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining)); 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; existing.UpdatedAt = DateTime.UtcNow;
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PushPairAsync(me, id);
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
return Ok(BuildRelationship(existing, 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; if (ignored is bool i) row.Ignored = i;
row.UpdatedAt = DateTime.UtcNow; row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); 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 }); return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
} }
@@ -511,6 +524,36 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
// ── helpers ────────────────────────────────────────────────────── // ── 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) private async Task<HashSet<long>> FriendIdsAsync(long playerId)
{ {
var rows = await db.Relationships var rows = await db.Relationships
@@ -156,9 +156,19 @@ public class StorefrontsBuyController(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Push the buyer's balance update so their wallet UI refreshes. // 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, await notifications.NotifyAsync(pid,
PushNotificationId.StorefrontBalancePurchase, 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) if (isGift)
{ {
@@ -166,24 +176,13 @@ public class StorefrontsBuyController(
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts // GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
// and pop the "you got a gift" toast; the gift box itself // and pop the "you got a gift" toast; the gift box itself
// appears next time the recipient opens their inventory. // 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, await notifications.NotifyAsync(recipient,
PushNotificationId.GiftPackageReceived, PushNotificationId.GiftPackageReceived,
new DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
{
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,
});
} }
// For gift purchases the buyer doesn't get a box — their flow // For gift purchases the buyer doesn't get a box — their flow
@@ -154,7 +154,7 @@ public class AccountsController(
playerId, username, summary); playerId, username, summary);
} }
await notifications.KickPlayerAsync(playerId, "This account has been deleted."); await notifications.LogoutAsync(playerId, NotificationService.LogoutReason.ForciblyLoggedOut);
presence.Clear(playerId); presence.Clear(playerId);
return Ok(new RecNetResult { Success = true, Error = string.Empty }); return Ok(new RecNetResult { Success = true, Error = string.Empty });
@@ -29,6 +29,7 @@ namespace DorkNet.Server.Controllers.Admin;
public class AdminController( public class AdminController(
DorkNetDbContext db, DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
SystemNotificationService systemNotifications,
LevelService level, LevelService level,
CommunityBoardService communityBoard, CommunityBoardService communityBoard,
RoomBlobNormalizerService roomBlobNormalizer, RoomBlobNormalizerService roomBlobNormalizer,
@@ -160,7 +161,9 @@ public class AdminController(
await LogAsync("ban_player", "player", id, body.Reason ?? ""); await LogAsync("ban_player", "player", id, body.Reason ?? "");
await db.SaveChangesAsync(); 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); playerPresence.Clear(id);
return Ok(new { player.Id, player.BannedUntil }); return Ok(new { player.Id, player.BannedUntil });
@@ -243,7 +246,7 @@ public class AdminController(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await tx.CommitAsync(); 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); playerPresence.Clear(id);
return Ok(new return Ok(new
@@ -1570,10 +1573,13 @@ public class AdminController(
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
/// <summary>Push fresh RoomDetails after role/owner mutations. The /// <summary>Push fresh room details after role/owner mutations, to everyone
/// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts, /// in the room plus the player whose role changed. The client caches the
/// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom /// room's Roles and role-gated UI reads that cache. The 2023 "RoomUpdate"
/// stores a replacement.</summary> /// 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) private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
{ {
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -1587,7 +1593,7 @@ public class AdminController(
.ToListAsync(); .ToListAsync();
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
.BuildRoomDetails(room, scenes, roles: roles); .BuildRoomServerDetails(room, scenes, roles: roles);
var recipients = onlinePresence.OnlinePlayerIds() var recipients = onlinePresence.OnlinePlayerIds()
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId) .Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
@@ -1739,7 +1745,7 @@ public class AdminController(
{ {
var r = playerPresence.GetRoom(pid); var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue; 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); playerPresence.Clear(pid);
kicked.Add(pid); kicked.Add(pid);
} }
@@ -2396,7 +2402,9 @@ public class AdminController(
var reason = string.IsNullOrWhiteSpace(body?.Reason) var reason = string.IsNullOrWhiteSpace(body?.Reason)
? "Kicked by an admin." ? "Kicked by an admin."
: body!.Reason!.Trim(); : 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); playerPresence.Clear(id);
await LogAsync("kick_player", "player", id, body?.Reason ?? ""); await LogAsync("kick_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -3328,10 +3336,11 @@ public class AdminController(
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword); player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
await LogAsync("set_password", "player", id, ""); await LogAsync("set_password", "player", id, "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Push a logout so any active sessions on the old password get // Log out any session still on the old password; the client has to
// dropped — the watch re-authenticates with the new password. // sign in again with the new one. The reason must be the numeric
await notifications.NotifyAsync(id, PushNotificationId.Logout, // enum: the string "AdminPasswordReset" failed the client's parse and
new { Reason = "AdminPasswordReset" }); // the push was dropped.
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
return Ok(new { id }); return Ok(new { id });
} }
@@ -3500,22 +3509,20 @@ public class AdminController(
playerPresence.SetRoom(id, roomInstance); playerPresence.SetRoom(id, roomInstance);
// Two pushes back-to-back: GameSession update changes the // The move itself comes from the presence set above: the client's
// watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance // next heartbeat sees it is out of sync and joins that instance.
// → UpdateCachedRoomInstance), and SubscriptionUpdateRoom // RoomInstanceUpdate only refreshes the client's cached copy of the
// refreshes the watch's RoomDetails cache so the room-info // instance, and RoomUpdate its cached room details, which must be
// panel doesn't show stale data after the warp. // the full room (FGCPNAACHIK) the roomserver GET returns.
await notifications.NotifyAsync(id, await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateGameSession, roomInstance); PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
var forceJoinScenes = await db.RoomScenes
.Where(sc => sc.RoomId == room.Id)
.OrderBy(sc => sc.OrderIndex)
.ToListAsync();
await notifications.NotifyAsync(id, await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateRoom, PushNotificationId.SubscriptionUpdateRoom,
new DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(room, forceJoinScenes));
{
Room = new { Id = room.Id, Name = room.Name },
RoomId = room.Id,
SubRoomId = subRoomId,
Reason = "AdminForceJoin",
});
await LogAsync("force_join", "player", id, await LogAsync("force_join", "player", id,
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}"); $"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
@@ -3532,20 +3539,46 @@ public class AdminController(
// ── System ─────────────────────────────────────────────────────────── // ── System ───────────────────────────────────────────────────────────
public sealed record SetMotdRequest(string Message); public sealed record SetMotdRequest(string? Message, int? StartsInMinutes);
/// <summary>Push a server-maintenance broadcast to every connected /// <summary>Broadcast to every connected player.
/// player. The MOTD config value is changed via appsettings.json /// <list type="bullet">
/// (server restart required); this is for ad-hoc announcements.</summary> /// <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")] [HttpPost("broadcast")]
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body) public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
{ {
if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty 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, await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
new { Message = body.Message }); new { StartsInMinutes = minutes });
await LogAsync("broadcast", "system", 0, body.Message);
await LogAsync("broadcast", "system", 0,
body.StartsInMinutes is int m ? $"[maintenance in {m} min] {message}" : message);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(); return Ok(new { recipients = recipients.Count });
} }
// ── Store catalog management ───────────────────────────────────────── // ── Store catalog management ─────────────────────────────────────────
@@ -460,6 +460,20 @@ public class ChatController(
var removed = await db.ChatThreadMembers var removed = await db.ChatThreadMembers
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me) .Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
.ExecuteDeleteAsync(); .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); return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
} }
@@ -39,6 +39,7 @@ public class GoToController(
RoomService rooms, RoomService rooms,
DorkNet.Server.Data.DorkNetDbContext db, DorkNet.Server.Data.DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
SystemNotificationService systemNotifications,
JoinTimeoutService joinTimeouts, JoinTimeoutService joinTimeouts,
ServerSettingsService serverSettings, ServerSettingsService serverSettings,
PresenceBroadcastService presenceFanout, PresenceBroadcastService presenceFanout,
@@ -877,11 +878,12 @@ public class GoToController(
logger.LogWarning( logger.LogWarning(
"[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting", "[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting",
callerId, playerId, theirRoom.RoomInstanceId); callerId, playerId, theirRoom.RoomInstanceId);
// Notify the OWNER that someone tried to follow them in. // Tell the OWNER someone tried to follow them in, as the
// Helps surface the rejection so the owner knows there's // client's own "wants to join" message (RequestGameInvite,
// an interested party — they can choose to send an invite. // from the caller), so they can send an invite.
await notifications.PrivateInstanceJoinRejected( if (callerId is long requester)
ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0); await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester);
return Ok(new MatchmakingResponseDto return Ok(new MatchmakingResponseDto
{ {
ErrorCode = 26, // RoomInstanceIsPrivate ErrorCode = 26, // RoomInstanceIsPrivate
@@ -69,17 +69,15 @@ public class MatchPlayerController(
if (replaced) if (replaced)
{ {
log.LogWarning( 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); logScope, playerId, takeOverExclusiveSession);
// The new session hasn't opened its SignalR socket yet // The new session hasn't opened its SignalR socket yet
// (negotiate fires AFTER /player/login on the watch's // (negotiate fires AFTER /player/login on the boot
// boot sequence — verified in the live trace), so the // sequence — verified in the live trace), so the Logout
// ModerationKick push hits only the OLDER session's // push (Reason SessionTakeover) reaches only the OLDER
// existing connections. They'll show the "you've been // session, which shows the client's own "signed in
// kicked" dialog and gracefully disconnect via the // elsewhere" message and returns to the boot scene.
// watch's LocalModerationKickSelf path. await notifications.LogoutStaleSession(playerId);
await notifications.KickStaleSession(playerId,
"Your account was signed in on another device.");
} }
} }
return Ok(Array.Empty<object>()); return Ok(Array.Empty<object>());
+98 -35
View File
@@ -27,15 +27,16 @@ namespace DorkNet.Server.Hubs;
/// Pre-PR-2 used a local <c>static ConcurrentDictionary</c> which only /// Pre-PR-2 used a local <c>static ConcurrentDictionary</c> which only
/// reflected this replica's connections. /// reflected this replica's connections.
/// ///
/// Hub methods invoked by the client (verified by string-literal scan /// Hub methods the 2023-03-21 client sends (fire-and-forget, RecNet.Runtime
/// of metadata at addresses 0x39b2470, 0x39b24c8, 0x39b4ba8): /// OMHPBIEHOHN):
/// "Subscribe" - generic topic subscribe /// "SubscribeToPlayers" - {PlayerIds}: players to get presence for
/// "Unsubscribe" - generic topic unsubscribe /// "SubscribeTo" - {Channels}: always empty in this build
/// "SubscribeToPlayers" - subscribe to per-player events (used by /// "UnsubscribeFrom" - {Channels}: always empty in this build
/// RecNet.NotificationManager / SubscribeToPlayers) /// There is no UnsubscribeFromPlayers; SubscribeToPlayers always carries the
/// All accept JsonElement so we don't lock ourselves into a parameter /// complete set. All accept JsonElement and tolerate casing differences.
/// schema we haven't verified yet, and return Task so the client's ///
/// awaited Invoke completes cleanly. /// The client reconnects at most 3 times per login (its attempt counter is
/// never reset), so the server must not drop healthy connections.
/// </summary> /// </summary>
[Authorize] [Authorize]
public class NotifyHub( public class NotifyHub(
@@ -65,28 +66,25 @@ public class NotifyHub(
// Instead: at the moment the SECOND session's WebSocket // Instead: at the moment the SECOND session's WebSocket
// arrives, take a snapshot of the EXISTING connection ids // arrives, take a snapshot of the EXISTING connection ids
// (which still belong to the original session — we haven't // (which still belong to the original session — we haven't
// joined the group yet) and push a targeted ModerationKick // joined the group yet) and send those ids a Logout with
// to those ids. The watch's OnModerationKick handler at // Reason SessionTakeover. The client shows its own "signed in
// PlayerReporting.txt:1857 shows the standard moderation // elsewhere" message and returns to the boot scene. (A
// dialog and routes through LocalModerationKickSelf, the // ModerationKick only sent the old session to its dorm, still
// same graceful disconnect path the original Rec Room used // signed in.)
// for real moderator kicks.
// //
// Targeting specific connection ids (not the group) means // Targeting specific connection ids (not the group) means
// the new connection — about to join the group on the next // the new connection — about to join the group on the next
// line — never receives the kick, even with Redis backplane // line — never receives it, even with Redis backplane
// routing across replicas. // routing across replicas.
var existing = presence.GetConnections(id); var existing = presence.GetConnections(id);
if (existing.Count > 0) if (existing.Count > 0)
{ {
logger.LogWarning( logger.LogWarning(
"[hub] second-session connect for player={PlayerId} (conn={NewConn}); kicking {OldCount} existing connection(s)", "[hub] second-session connect for player={PlayerId} (conn={NewConn}); logging out {OldCount} existing connection(s)",
id, Context.ConnectionId, existing.Count); id, Context.ConnectionId, existing.Count);
try try
{ {
await notifications.KickConnectionsAsync( await notifications.LogoutConnectionsAsync(existing);
existing,
"Your account was signed in on another device.");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -156,36 +154,101 @@ public class NotifyHub(
await base.OnDisconnectedAsync(exception); await base.OnDisconnectedAsync(exception);
} }
public Task SubscribeToPlayers(JsonElement payload) /// <summary>SignalR group a connection joins to receive one player's
/// presence. Only presence is sent here — a subscriber must never receive
/// the subject's own pushes (DMs, gifts, …).</summary>
public static string PresenceGroupForPlayer(long playerId) => $"presence:{playerId}";
private const string SubscriptionsKey = "presence-subscriptions";
/// <summary><c>SubscribeToPlayers(PlayerSubscriptionListDTO {PlayerIds:
/// List&lt;int&gt;})</c> (RecNet.Runtime OMHPBIEHOHN.txt:3430): the full set
/// of players this client wants presence for — its relationships, the
/// players around it, and itself. Sent on every (re)connect and whenever
/// the set changes, always as the complete set, so it REPLACES the
/// connection's subscriptions. Presence for those players is then pushed
/// to <see cref="PresenceGroupForPlayer"/> by
/// <see cref="PresenceBroadcastService"/>, so non-friends the client is
/// looking at (people in the room, an open profile) stay current too.
///
/// The caller's own id is skipped: a presence frame about the local player
/// is applied as a correction and moves them. An unreadable payload
/// leaves the current subscriptions alone rather than clearing them.
/// Groups live per connection on its own replica, are routed cluster-wide
/// by the Redis backplane, and are dropped by SignalR on disconnect.</summary>
public async Task SubscribeToPlayers(JsonElement payload)
{
var me = TryGetPlayerId();
if (!TryReadPlayerIds(payload, out var requested))
{
logger.LogWarning(
"[hub] SubscribeToPlayers unreadable payload player={PlayerId} conn={ConnectionId} payload={Payload}",
me, Context.ConnectionId, RawPayload(payload));
return;
}
if (me is long self) requested.Remove(self);
var current = Context.Items.TryGetValue(SubscriptionsKey, out var existing) && existing is HashSet<long> set
? set
: new HashSet<long>();
var added = requested.Except(current).ToList();
var removed = current.Except(requested).ToList();
foreach (var id in added)
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
foreach (var id in removed)
await Groups.RemoveFromGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
Context.Items[SubscriptionsKey] = requested;
logger.LogInformation(
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} subscribed={Count} added={Added} removed={Removed}",
me, Context.ConnectionId, requested.Count, added.Count, removed.Count);
}
/// <summary><c>SubscribeTo({Channels: List&lt;string&gt;})</c>. The
/// 2023-03-21 client composes it on every connect but its channel set is
/// always empty, so there is nothing to subscribe to; logged only.</summary>
public Task SubscribeTo(JsonElement payload)
{ {
logger.LogInformation( logger.LogInformation(
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} payload={Payload}", "[hub] SubscribeTo player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task UnsubscribeFromPlayers(JsonElement payload) /// <summary>Counterpart of <see cref="SubscribeTo"/>; logged only.</summary>
public Task UnsubscribeFrom(JsonElement payload)
{ {
logger.LogInformation( logger.LogInformation(
"[hub] UnsubscribeFromPlayers player={PlayerId} conn={ConnectionId} payload={Payload}", "[hub] UnsubscribeFrom player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task Subscribe(JsonElement payload) /// <summary>Reads <c>{PlayerIds:[…]}</c> (any key casing), or a bare
/// array, as player ids; numbers or numeric strings.</summary>
public static bool TryReadPlayerIds(JsonElement payload, out HashSet<long> ids)
{ {
logger.LogInformation( ids = new HashSet<long>();
"[hub] Subscribe player={PlayerId} conn={ConnectionId} payload={Payload}", var list = payload;
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); if (payload.ValueKind == JsonValueKind.Object)
return Task.CompletedTask; {
var found = false;
foreach (var prop in payload.EnumerateObject())
{
if (!string.Equals(prop.Name, "PlayerIds", StringComparison.OrdinalIgnoreCase)) continue;
list = prop.Value;
found = true;
break;
} }
if (!found) return false;
public Task Unsubscribe(JsonElement payload) }
if (list.ValueKind != JsonValueKind.Array) return false;
foreach (var item in list.EnumerateArray())
{ {
logger.LogInformation( if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var n) && n > 0) ids.Add(n);
"[hub] Unsubscribe player={PlayerId} conn={ConnectionId} payload={Payload}", else if (item.ValueKind == JsonValueKind.String && long.TryParse(item.GetString(), out var p) && p > 0) ids.Add(p);
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); }
return Task.CompletedTask; return true;
} }
private long? TryGetPlayerId() private long? TryGetPlayerId()
+12 -1
View File
@@ -41,6 +41,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications
player.Level = LevelForXp(player.XP); player.Level = LevelForXp(player.XP);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Live level bar: "PlayerProgressionLevelUpdate" {PlayerId, Level, XP}
// (handler PBOGNAPEBAI.txt:263, DTO DGFGIFIHCAJ) — the same values
// api/players/v2/progression returns. Without it the watch kept the
// level and XP it loaded at login until the next relog.
await notifications.NotifyTypedAsync(playerId, "PlayerProgressionLevelUpdate",
new { PlayerId = (int)playerId, player.Level, player.XP });
if (player.Level > oldLevel) if (player.Level > oldLevel)
{ {
// Level-up grants a small currency reward. The wallet UI // Level-up grants a small currency reward. The wallet UI
@@ -82,9 +89,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications
row.UpdatedAt = DateTime.UtcNow; row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// StorefrontBalanceUpdate "61" is BalanceResponseDTO {Balance,
// CurrencyType, Platform}; the client sets that (CurrencyType,
// Platform) bucket to Balance. Platform 0 matches every balance REST
// response, so the buckets line up.
await notifications.NotifyAsync(playerId, await notifications.NotifyAsync(playerId,
PushNotificationId.StorefrontBalanceUpdate, PushNotificationId.StorefrontBalanceUpdate,
new { CurrencyType = currencyType, row.Balance, Reason = reason }); new { row.Balance, CurrencyType = currencyType, Platform = 0 });
return row.Balance; return row.Balance;
} }
+95 -87
View File
@@ -212,25 +212,17 @@ public class NotificationService(
// ── Higher-level event helpers ───────────────────────────────────── // ── Higher-level event helpers ─────────────────────────────────────
public Task FriendRequestReceived(long targetPlayerId, long fromPlayerId) => /// <summary>Push one relationship row to <paramref name="recipientPlayerId"/>.
NotifyAsync(targetPlayerId, PushNotificationId.RelationshipChanged, /// <paramref name="relationship"/> must be the recipient's OWN view of the
new { Reason = "FriendRequestReceived", From = fromPlayerId }); /// pair — the same <c>{PlayerID, RelationshipType, Muted, Ignored,
/// Favorited}</c> object <c>api/relationships/v2/get</c> returns. The 2023
public Task FriendRequestAccepted(long requesterPlayerId, long byPlayerId) => /// handler (<c>EEGNOHOELBG.FOCBJAIDCBB</c>, Relationship
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged, /// <c>ECGNEHMCGCN</c>) stores it under its <c>PlayerID</c> and raises the
new { Reason = "FriendRequestAccepted", By = byPlayerId }); /// changed event, which refreshes the friends list and the client's
/// SubscribeToPlayers set. Any other shape parses as PlayerID 0 and the
public Task FriendRequestDeclined(long requesterPlayerId, long byPlayerId) => /// real row never updates.</summary>
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged, public Task RelationshipChanged(long recipientPlayerId, object relationship) =>
new { Reason = "FriendRequestDeclined", By = byPlayerId }); NotifyAsync(recipientPlayerId, PushNotificationId.RelationshipChanged, relationship);
public Task FriendRemoved(long otherPlayerId, long byPlayerId) =>
NotifyAsync(otherPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRemoved", By = byPlayerId });
public Task FriendBlocked(long blockedPlayerId, long byPlayerId) =>
NotifyAsync(blockedPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendBlocked", By = byPlayerId });
/// <summary>Push a presence delta so subscribed friends re-render /// <summary>Push a presence delta so subscribed friends re-render
/// their friend-list row without waiting for the watch's 15-45 s /// their friend-list row without waiting for the watch's 15-45 s
@@ -270,6 +262,23 @@ public class NotificationService(
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload); await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
} }
/// <summary>Push one player's presence to every connection that subscribed
/// to them with <c>SubscribeToPlayers</c>, except
/// <paramref name="excludeConnectionIds"/> — the subject's own connections
/// (a frame about the local player moves them) and anyone already sent the
/// frame through their friend group.</summary>
public async Task PresenceToSubscribersAsync(
long playerId,
RoomInstanceDto? room,
bool isOnline,
int statusVisibility,
IReadOnlyList<string> excludeConnectionIds)
{
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
await hub.Clients.GroupExcept(NotifyHub.PresenceGroupForPlayer(playerId), excludeConnectionIds)
.SendAsync(HubMethod, payload);
}
/// <summary>The PresenceUpdate envelope for one player, as a JSON string. /// <summary>The PresenceUpdate envelope for one player, as a JSON string.
/// ///
/// <paramref name="isOnline"/> is independent of the room: the 2023 /// <paramref name="isOnline"/> is independent of the room: the 2023
@@ -349,86 +358,85 @@ public class NotificationService(
public Task PlayerEnteredRoom(IEnumerable<long> friendIds, long playerId, RoomInstanceDto room) => public Task PlayerEnteredRoom(IEnumerable<long> friendIds, long playerId, RoomInstanceDto room) =>
PlayerPresenceChanged(friendIds, playerId, room); PlayerPresenceChanged(friendIds, playerId, room);
/// <summary>Force every currently-connected SignalR session for /// <summary>Client logout reasons (<c>Logout</c> "6" payload <c>Reason</c>,
/// <paramref name="playerId"/> to disconnect via the watch's /// enum <c>MPJOEALLGHE</c>, read by formatter <c>PBEDEFHAHID</c>). The
/// ModerationKick flow. Wire path verified against decompilation: /// value must go out as a NUMBER: release builds obfuscate enum names, so a
/// <c>PushNotificationId.ModerationKick = 22</c> is registered /// string like "AdminPasswordReset" fails the parse and the whole push is
/// by <c>LCCEEFHOBEN.OFFLOPLJBBG</c> via the enum overload. /// dropped. <c>SessionManager.JNNAOHMNGEM</c> shows its own "signed in
/// <c>LCCEEFHOBEN.KEIAFMLIMFA(Dictionary)</c> reads /// elsewhere" text for <see cref="SessionTakeover"/>, then returns the
/// <c>Msg.Reason</c> as a ModerationBlockDetail object, then /// client to the boot scene for every reason.</summary>
/// calls <c>PODFHPNOHGB</c> / <c>BootLocalPlayerToDormRoom</c>. public enum LogoutReason
/// {
/// Used by <see cref="Controllers.Match.MatchPlayerController.PlayerLogin"/> Unknown = 0,
/// to enforce single-session-per-account: when a second login on UserInitiated = 1,
/// the same account replaces the LoginLock, the older SignalR SessionTakeover = 2,
/// connections are kicked. The new session hasn't called ForciblyLoggedOut = 3,
/// <c>negotiate</c> yet (it happens AFTER /player/login on the Banned = 4,
/// boot sequence), so the kick only hits old sessions. }
/// </summary>
public Task KickStaleSession(long playerId, string reason) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick,
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
public Task KickPlayerAsync(long playerId, string reason, bool isBan = false) => /// <summary>Log every session of <paramref name="playerId"/> out to the
NotifyAsync(playerId, PushNotificationId.ModerationKick, /// boot scene. Use this, not a kick, when the player must not stay signed
ModerationKickPayload(reason, isBan, source: "admin_kick")); /// in: a ModerationKick only sends them to their dorm.</summary>
public Task LogoutAsync(long playerId, LogoutReason reason) =>
NotifyAsync(playerId, PushNotificationId.Logout, new { Reason = (int)reason });
/// <summary>Push a ModerationKick to a specific set of SignalR /// <summary>Single-session-per-account: when a second login replaces the
/// connection ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/> /// LoginLock, log the older session out. The new session hasn't called
/// to evict the previous session when a new device connects on the /// <c>negotiate</c> yet (it happens AFTER /player/login on the boot
/// same account. Targets specific ids (not the player group) so /// sequence), so only the old session's connections receive it.</summary>
/// the brand-new connection joining the group right after this public Task LogoutStaleSession(long playerId) =>
/// returns can't accidentally receive its own eviction notice. LogoutAsync(playerId, LogoutReason.SessionTakeover);
/// Works across replicas because SignalR's Redis backplane routes
/// <summary>The same takeover logout, sent to specific SignalR connection
/// ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
/// to evict the previous session when a new device connects on the same
/// account. Targets ids (not the player group) so the brand-new
/// connection, which joins the group right after, never receives it. Works
/// across replicas because the Redis backplane routes
/// <c>Clients.Clients(ids)</c> to whichever node owns each id.</summary> /// <c>Clients.Clients(ids)</c> to whichever node owns each id.</summary>
public Task KickConnectionsAsync(IReadOnlyCollection<string> connectionIds, string reason) public Task LogoutConnectionsAsync(IReadOnlyCollection<string> connectionIds)
{ {
if (connectionIds.Count == 0) return Task.CompletedTask; if (connectionIds.Count == 0) return Task.CompletedTask;
var payload = Serialize(PushNotificationId.ModerationKick, var payload = Serialize(PushNotificationId.Logout, new { Reason = (int)LogoutReason.SessionTakeover });
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload); return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload);
} }
private static object ModerationKickPayload(string reason, bool isBan, string source) /// <summary>Kick <paramref name="playerId"/> back to their dorm with
{ /// <paramref name="reason"/> shown in the kick dialog. The player stays
var message = string.IsNullOrWhiteSpace(reason) /// signed in; use <see cref="LogoutAsync"/> for bans and deletions.
? "Kicked to Dormroom" /// Pass the room instance the player is in as
: reason.Trim(); /// <paramref name="gameSessionId"/> to have the client announce the kick
/// to that room.</summary>
public Task KickPlayerAsync(long playerId, string reason, long gameSessionId = 0) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick, ModerationKickPayload(reason, gameSessionId));
var blockDetail = new /// <summary>ModerationKick "22" payload: the FLAT moderation block
/// <c>PIBLFGPOCIB</c> (formatter <c>LAIJIAIHGCO</c>). The kick handler
/// (<c>SessionManager</c> HandleModerationKicked) starts from its own
/// "Kicked to Dormroom" text and only adds a reason as
/// a "Reason: " line from <c>VoteKickReason</c> when <c>ReportCategory</c> is 10
/// (VoteKick) and <c>IsHostKick</c> is false; <c>Message</c> is never
/// shown. When <c>GameSessionId</c> is the kicked player's current room
/// instance it also broadcasts the kick to that room, and
/// <c>Duration</c> &gt; 0 starts a timeout, so it stays 0. The old nested
/// <c>{Reason:{…}}</c> shape (from the 2020 client) parsed to all defaults,
/// which booted the player but dropped every reason.</summary>
private static object ModerationKickPayload(string reason, long gameSessionId)
{ {
ReportCategory = 10, // PlayerReporting.ReportCategory.VoteKick var message = string.IsNullOrWhiteSpace(reason) ? "Kicked to Dormroom" : reason.Trim();
Duration = 0,
GameSessionId = 0L,
Message = message,
IsHostKick = true,
PlayerIdReporter = 1,
IsBan = isBan,
VoteKickReason = message,
};
return new return new
{ {
Reason = blockDetail, ReportCategory = 10, // VoteKick: the only category that shows a reason
DisplayReason = message, Duration = 0,
BannedByPlayerId = 1, GameSessionId = gameSessionId,
Source = source, IsHostKick = false,
Message = message,
PlayerIdReporter = (int?)null,
IsBan = false,
IsVoiceModAutoban = false,
IsWarning = false,
VoteKickReason = message,
TimeoutStartedAt = (DateTime?)null,
}; };
} }
/// <summary>Notify a private-instance owner that someone tried to
/// follow them in via "Go to Player" but was rejected for not
/// being on the invite list. Reuses the MessageReceived channel
/// since the watch already renders that as an inbox toast — no
/// new client-side handler needed. The body wording surfaces it
/// as "Player X tried to join your private match" so the owner
/// knows to send an explicit invite if they want to let them in.</summary>
public Task PrivateInstanceJoinRejected(long ownerPlayerId, long attemptedByPlayerId) =>
NotifyAsync(ownerPlayerId, PushNotificationId.MessageReceived,
new
{
Reason = "PrivateInstanceJoinRejected",
AttemptedBy = attemptedByPlayerId,
Preview = $"Player {attemptedByPlayerId} tried to join your private match",
});
} }
@@ -36,7 +36,10 @@ public static class PresenceVisibility
/// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into /// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into
/// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync /// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync
/// correction (:18467). A frame about the LOCAL player therefore moves that player, so /// correction (:18467). A frame about the LOCAL player therefore moves that player, so
/// these frames go to friends only, never to the subject. /// these frames never go to the subject. They go to the subject's friends and to every
/// other connection that subscribed to the subject with SubscribeToPlayers (see
/// NotifyHub.SubscribeToPlayers); non-friends only see the real presence when it is
/// Public.
/// </summary> /// </summary>
public sealed class PresenceBroadcastService( public sealed class PresenceBroadcastService(
IServiceScopeFactory scopes, IServiceScopeFactory scopes,
@@ -58,7 +61,7 @@ public sealed class PresenceBroadcastService(
presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0; presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0;
/// <summary>Push the player's current presence (online state + committed /// <summary>Push the player's current presence (online state + committed
/// room) to every friend.</summary> /// room) to every friend and subscriber.</summary>
public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false); public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false);
/// <summary>Push an offline frame regardless of any lingering markers — /// <summary>Push an offline frame regardless of any lingering markers —
@@ -105,7 +108,6 @@ public sealed class PresenceBroadcastService(
.Where(id => id != playerId) .Where(id => id != playerId)
.Distinct() .Distinct()
.ToList(); .ToList();
if (friendIds.Count == 0) return;
var online = !forceOffline && IsOnline(playerId); var online = !forceOffline && IsOnline(playerId);
var room = online ? presence.GetRoom(playerId) : null; var room = online ? presence.GetRoom(playerId) : null;
@@ -134,6 +136,20 @@ public sealed class PresenceBroadcastService(
await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility); await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility);
if (hiddenFrom.Count > 0) if (hiddenFrom.Count > 0)
await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility); await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility);
// Everyone else who subscribed to this player (SubscribeToPlayers:
// people in the same room, an open profile). They are not friends,
// so they see the real presence only when it is Public, and the
// offline frame otherwise. Friends were served above and the
// subject must never get a frame about themselves, so their
// connections are excluded.
var exclude = connections.GetConnections(playerId)
.Concat(friendIds.SelectMany(connections.GetConnections))
.Distinct()
.ToList();
var publicToAll = visibility == PresenceVisibility.Public && online;
await notifications.PresenceToSubscribersAsync(
playerId, publicToAll ? room : null, publicToAll, visibility, exclude);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -28,6 +28,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS
/// listed; add more as needed.</summary> /// listed; add more as needed.</summary>
public static class MessageType public static class MessageType
{ {
public const int FriendInvite = 4;
public const int RequestGameInvite = 10;
public const int TextMessage = 30; public const int TextMessage = 30;
public const int FriendRequestAccepted = 40; public const int FriendRequestAccepted = 40;
public const int FriendCodeUsed = 41; public const int FriendCodeUsed = 41;
@@ -39,6 +41,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS
public const int CreatorPublishedNewRoom = 70; public const int CreatorPublishedNewRoom = 70;
public const int PlayerAttendingEvent = 80; public const int PlayerAttendingEvent = 80;
public const int PlayerEventInvitation = 81; public const int PlayerEventInvitation = 81;
public const int CoachMessage = 100;
public const int FriendIntroduction = 130;
} }
/// <summary>Persist and push a system notification to /// <summary>Persist and push a system notification to
@@ -416,15 +416,18 @@ public static class ServiceCollectionExtensions
kopts.Limits.MinResponseDataRate = null; kopts.Limits.MinResponseDataRate = null;
}); });
// Keepalive cadence: server pings every 10s, gives the client 60s // Keepalive cadence: the server pings every 10s, which keeps frame
// to ack before declaring the connection dead. The 10s ping forces // traffic well below every proxy timeout we've seen in the path
// frame traffic well below every proxy timeout we've seen in the // (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik 180s,
// path (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik // Kestrel 130s). The 2023 client pings every 15s, but from its main
// 180s, Kestrel 130s). // thread, so a long room-load hitch pauses its pings. It has no
// timeout of its own and reconnects at most 3 times per login, so a
// healthy socket the server drops can leave it without live
// notifications for the rest of the session. Give it 180s.
var signalR = builder.Services.AddSignalR(opts => var signalR = builder.Services.AddSignalR(opts =>
{ {
opts.KeepAliveInterval = TimeSpan.FromSeconds(10); opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60); opts.ClientTimeoutInterval = TimeSpan.FromSeconds(180);
opts.HandshakeTimeout = TimeSpan.FromSeconds(15); opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
// Allow large gift / community-board payloads (default 32 KB // Allow large gift / community-board payloads (default 32 KB
// truncates anything bigger and disconnects the client). // truncates anything bigger and disconnects the client).
@@ -5,18 +5,32 @@ import { useToast } from '../components/Toast';
export function Broadcast() { export function Broadcast() {
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [minutes, setMinutes] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const toast = useToast(); const toast = useToast();
const startsInMinutes = minutes.trim() === '' ? null : Number(minutes);
const minutesValid = startsInMinutes === null || (Number.isInteger(startsInMinutes) && startsInMinutes >= 0);
const canSend = !busy && minutesValid && (message.trim() !== '' || startsInMinutes !== null);
const send = async (e: React.FormEvent) => { const send = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!message.trim()) return; if (!canSend) return;
if (!confirm(`Broadcast this to every connected player?\n\n"${message}"`)) return; const parts = [
message.trim() && `"${message.trim()}"`,
startsInMinutes !== null &&
(startsInMinutes === 0 ? 'Cancel the maintenance countdown' : `Maintenance countdown: ${startsInMinutes} min`),
].filter(Boolean);
if (!confirm(`Broadcast this to every connected player?\n\n${parts.join('\n')}`)) return;
setBusy(true); setBusy(true);
try { try {
await api('/broadcast', { method: 'POST', body: { Message: message } }); const res = await api<{ recipients: number }>('/broadcast', {
toast.push('Broadcast sent', 'success'); method: 'POST',
body: { Message: message.trim() || null, StartsInMinutes: startsInMinutes },
});
toast.push(`Broadcast sent to ${res.recipients} player(s)`, 'success');
setMessage(''); setMessage('');
setMinutes('');
} catch (e) { } catch (e) {
toast.push((e as Error).message, 'error'); toast.push((e as Error).message, 'error');
} finally { } finally {
@@ -28,7 +42,7 @@ export function Broadcast() {
<div> <div>
<PageHeader <PageHeader
title="Broadcast" title="Broadcast"
blurb="Push a server-maintenance notification to every connected player. The watch shows it as a system toast." blurb="Message every connected player. The message lands in their in-game notifications from the system account; the optional countdown starts the game's own maintenance timer."
/> />
<form onSubmit={send} className="card !p-5 max-w-2xl"> <form onSubmit={send} className="card !p-5 max-w-2xl">
@@ -39,14 +53,33 @@ export function Broadcast() {
rows={5} rows={5}
className="input mt-1.5 font-mono text-sm" className="input mt-1.5 font-mono text-sm"
placeholder="Server going down for maintenance in 5 minutes — log out cleanly to avoid losing your dorm save." placeholder="Server going down for maintenance in 5 minutes — log out cleanly to avoid losing your dorm save."
required
/> />
<div className="mt-2 text-xs text-ink-400"> <div className="mt-2 text-xs text-ink-400">
{message.length} characters {message.length} characters
</div> </div>
<label className="label mt-4">Maintenance countdown (minutes, optional)</label>
<input
value={minutes}
onChange={e => setMinutes(e.target.value)}
inputMode="numeric"
className="input mt-1.5 w-40 text-sm"
placeholder="e.g. 5"
/>
<div className="mt-2 text-xs text-ink-400">
{minutesValid ? 'Leave empty for no countdown; 0 cancels a running one.' : 'Enter a whole number of minutes.'}
</div>
<div className="mt-4 flex gap-2 justify-end"> <div className="mt-4 flex gap-2 justify-end">
<button type="button" onClick={() => setMessage('')} className="btn-ghost text-xs" disabled={busy || !message}>Clear</button> <button
<button className="btn-primary text-xs" disabled={busy || !message.trim()}> type="button"
onClick={() => { setMessage(''); setMinutes(''); }}
className="btn-ghost text-xs"
disabled={busy || (!message && !minutes)}
>
Clear
</button>
<button className="btn-primary text-xs" disabled={!canSend}>
{busy ? 'Sending…' : 'Send broadcast'} {busy ? 'Sending…' : 'Send broadcast'}
</button> </button>
</div> </div>
+6 -4
View File
@@ -38,10 +38,12 @@ What the 2023 client gets here that the 2020 branches don't have:
`DorkNet.Server.Tests/ClientRouteCoverageTests.cs` fails the build if one `DorkNet.Server.Tests/ClientRouteCoverageTests.cs` fails the build if one
regresses. Routes, verbs and wire shapes are in regresses. Routes, verbs and wire shapes are in
[docs/recroom-2023-client-api-complete.md](docs/recroom-2023-client-api-complete.md). [docs/recroom-2023-client-api-complete.md](docs/recroom-2023-client-api-complete.md).
- **Realtime over the SignalR hub** (`hub/v1` on the notify host): numeric - **Realtime over the SignalR hub** (`hub/v1` on the notify host): every
push ids, presence updates to friends that honour each player's push matches the client's own reader — friends, messages, kicks and
online-status visibility, and account updates when someone edits their logouts, gifts, balances, consumables, player events, level-ups and
profile. account updates — and presence reaches both friends and any player who
subscribed to you, honouring your online-status visibility:
[docs/recroom-2023-notifications.md](docs/recroom-2023-notifications.md).
- **Room saves and sub-rooms** through the storage host, plus cloning and - **Room saves and sub-rooms** through the storage host, plus cloning and
private instances: private instances:
[docs/recroom-2023-room-save.md](docs/recroom-2023-room-save.md). [docs/recroom-2023-room-save.md](docs/recroom-2023-room-save.md).
+19 -15
View File
@@ -120,17 +120,19 @@ Defect classes:
## Realtime (SignalR) surface ## Realtime (SignalR) surface
Not every request is HTTP. The client opens a SignalR hub at `{0}hub/v1` 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"`** - Server→client messages arrive on the hub method named **`"Notification"`**
(singular) carrying a **single `string`** argument whose content is JSON (singular) carrying a **single `string`** argument whose content is JSON
`{"Id":..., "Msg":...}`. The handler is registered as `Action<string>`. `{"Id":..., "Msg":...}`. The handler is registered as `Action<string>`.
Sending `ReceiveNotification`, or passing an object instead of a string, Sending `ReceiveNotification`, or passing an object instead of a string,
makes every push silently vanish. makes every push silently vanish.
- Client→server: `SubscribeToPlayers` / `UnsubscribeFromPlayers`, plus - Client→server: `SubscribeToPlayers` (`{PlayerIds}`), plus
`"SubscribeTo"` / `"UnsubscribeFrom"` prefixes composed at runtime. `SubscribeTo` / `UnsubscribeFrom` (`{Channels}`, always empty).
- Presence pushes are routed by the friend graph server-side, so the - `SubscribeToPlayers` is load-bearing: it is how the client gets presence
subscription calls are advisory rather than load-bearing. for players who aren't friends (people in the room, an open profile).
### Push dispatch keys — the numeric-vs-name trap ### 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` | | 95 CommunityBoardUpdate | `CommunityBoardUpdate` |
Ids the 2023 client registers NO handler for — sending them is a no-op: 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 4, 15 (numeric form), 21, **24 ModerationRoomBan**, 40, 85. The moderation
therefore go out as `ModerationKick` (22) with `IsBan` set; the moderation
manager registers only 22, `ModerationUnkick` and 23 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, 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 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 ### Hub methods
The client invokes exactly one server method: `SubscribeToPlayers`, with a The client sends `SubscribeToPlayers` with a `{PlayerIds: List<int>}`
`{PlayerIds: List<int>}` payload, fire-and-forget. The `SubscribeTo`/ payload, fire-and-forget, on every (re)connect and whenever the set changes.
`UnsubscribeFrom` composition exists in the binary but has no callers in The `SubscribeTo`/`UnsubscribeFrom` composition runs on connect but its
this build, and `UnsubscribeFromPlayers` does not appear at all — the channel set is always empty, and `UnsubscribeFromPlayers` does not appear at
server's extra hub methods are unused but harmless. Presence is routed all. The server keeps each connection's subscriptions as `presence:{id}`
server-side by the friend graph, so these calls are advisory. SignalR groups and pushes those players' presence to them.
### Negotiate ### 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 | | `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 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` | | `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 | | `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 | | `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 | | `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.