mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
Merge pull request #61 from DorkSquadRR/fix/notify-2023-contract
Notification pushes now match what the 2023 client reads
This commit is contained in:
@@ -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":"<key>","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,26 @@ 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,
|
||||||
|
// Same as GET api/consumables/v2/getUnlocked, so a push never flips
|
||||||
|
// what the inventory list says.
|
||||||
|
IsTransferable = false,
|
||||||
|
};
|
||||||
|
|
||||||
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,26 @@ 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();
|
||||||
|
// With every role grant: the client replaces its cached room with
|
||||||
|
// this, so a payload without them would drop everyone's roles.
|
||||||
|
var forceJoinRoles = await db.RoomRoles
|
||||||
|
.Where(r => r.RoomId == room.Id)
|
||||||
|
.ToListAsync();
|
||||||
await notifications.NotifyAsync(id,
|
await notifications.NotifyAsync(id,
|
||||||
PushNotificationId.SubscriptionUpdateRoom,
|
PushNotificationId.SubscriptionUpdateRoom,
|
||||||
new
|
DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(
|
||||||
{
|
room, forceJoinScenes, roles: forceJoinRoles));
|
||||||
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 +3545,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;
|
||||||
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
|
if (message.Length == 0 && body.StartsInMinutes is null)
|
||||||
new { Message = body.Message });
|
return BadRequest("empty message");
|
||||||
await LogAsync("broadcast", "system", 0, body.Message);
|
if (body.StartsInMinutes is < 0)
|
||||||
|
return BadRequest("StartsInMinutes must be 0 or more");
|
||||||
|
|
||||||
|
var recipients = onlinePresence.OnlinePlayerIds().ToList();
|
||||||
|
if (message.Length > 0)
|
||||||
|
{
|
||||||
|
foreach (var pid in recipients)
|
||||||
|
await systemNotifications.SendAsync(pid,
|
||||||
|
SystemNotificationService.MessageType.CoachMessage,
|
||||||
|
fromPlayerId: PlayerService.SystemAccountId,
|
||||||
|
data: message);
|
||||||
|
}
|
||||||
|
if (body.StartsInMinutes is int minutes)
|
||||||
|
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
|
||||||
|
new { StartsInMinutes = minutes });
|
||||||
|
|
||||||
|
await LogAsync("broadcast", "system", 0,
|
||||||
|
body.StartsInMinutes is int m ? $"[maintenance in {m} min] {message}" : message);
|
||||||
await db.SaveChangesAsync();
|
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,15 @@ 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. At most once
|
||||||
await notifications.PrivateInstanceJoinRejected(
|
// per requester, owner and instance per cooldown, so retrying
|
||||||
ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0);
|
// doesn't fill the owner's inbox.
|
||||||
|
if (callerId is long requester
|
||||||
|
&& JoinRequestCooldown.TryStart(requester, playerId, theirRoom.RoomInstanceId))
|
||||||
|
await systemNotifications.SendAsync(playerId,
|
||||||
|
SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester);
|
||||||
return Ok(new MatchmakingResponseDto
|
return Ok(new MatchmakingResponseDto
|
||||||
{
|
{
|
||||||
ErrorCode = 26, // RoomInstanceIsPrivate
|
ErrorCode = 26, // RoomInstanceIsPrivate
|
||||||
@@ -1668,3 +1673,35 @@ public class RoomInstanceDto
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Rate limit for "wants to join" messages: one per (requester,
|
||||||
|
/// owner, room instance) per <see cref="Window"/>. Later retries and other
|
||||||
|
/// instances still go through. Per replica, which is enough to stop a
|
||||||
|
/// retry loop from flooding an inbox.</summary>
|
||||||
|
internal static class JoinRequestCooldown
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan Window = TimeSpan.FromMinutes(5);
|
||||||
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<(long, long, long), DateTime> Last = new();
|
||||||
|
|
||||||
|
/// <summary>True when no request for this key was sent within the window
|
||||||
|
/// (and records this one); false while it is cooling down.</summary>
|
||||||
|
public static bool TryStart(long requester, long owner, long roomInstanceId)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (Last.Count > 10_000)
|
||||||
|
foreach (var (key, at) in Last)
|
||||||
|
if (now - at > Window) Last.TryRemove(key, out _);
|
||||||
|
|
||||||
|
var k = (requester, owner, roomInstanceId);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (!Last.TryGetValue(k, out var prev))
|
||||||
|
{
|
||||||
|
if (Last.TryAdd(k, now)) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (now - prev < Window) return false;
|
||||||
|
if (Last.TryUpdate(k, now, prev)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>());
|
||||||
|
|||||||
@@ -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,111 @@ 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>Most players one connection may subscribe to. The client sends
|
||||||
|
/// its relationships plus the players around it (dozens); anything far
|
||||||
|
/// past that is not a real client, and each id costs a group membership
|
||||||
|
/// on the backplane.</summary>
|
||||||
|
public const int MaxPresenceSubscriptions = 5000;
|
||||||
|
|
||||||
|
/// <summary><c>SubscribeToPlayers(PlayerSubscriptionListDTO {PlayerIds:
|
||||||
|
/// List<int>})</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);
|
||||||
|
if (requested.Count > MaxPresenceSubscriptions)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"[hub] SubscribeToPlayers over the {Max} limit player={PlayerId} conn={ConnectionId} count={Count}; kept the current set",
|
||||||
|
MaxPresenceSubscriptions, me, Context.ConnectionId, requested.Count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>})</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 match = payload.EnumerateObject()
|
||||||
|
.Where(prop => string.Equals(prop.Name, "PlayerIds", StringComparison.OrdinalIgnoreCase))
|
||||||
public Task Unsubscribe(JsonElement payload)
|
.Select(prop => (JsonElement?)prop.Value)
|
||||||
{
|
.FirstOrDefault();
|
||||||
logger.LogInformation(
|
if (match is not JsonElement value) return false;
|
||||||
"[hub] Unsubscribe player={PlayerId} conn={ConnectionId} payload={Payload}",
|
list = value;
|
||||||
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
}
|
||||||
return Task.CompletedTask;
|
if (list.ValueKind != JsonValueKind.Array) return false;
|
||||||
|
foreach (var item in list.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var n) && n > 0) ids.Add(n);
|
||||||
|
else if (item.ValueKind == JsonValueKind.String && long.TryParse(item.GetString(), out var p) && p > 0) ids.Add(p);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private long? TryGetPlayerId()
|
private long? TryGetPlayerId()
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
/// signed in; use <see cref="LogoutAsync"/> for bans and deletions.
|
||||||
|
/// Pass the room instance the player is in as
|
||||||
|
/// <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));
|
||||||
|
|
||||||
|
/// <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> > 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)
|
||||||
{
|
{
|
||||||
var message = string.IsNullOrWhiteSpace(reason)
|
var message = string.IsNullOrWhiteSpace(reason) ? "Kicked to Dormroom" : reason.Trim();
|
||||||
? "Kicked to Dormroom"
|
|
||||||
: reason.Trim();
|
|
||||||
|
|
||||||
var blockDetail = new
|
|
||||||
{
|
|
||||||
ReportCategory = 10, // PlayerReporting.ReportCategory.VoteKick
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,34 @@ public class OnlinePresenceService
|
|||||||
lock (set) return set.ToArray();
|
lock (set) return set.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Connection ids of every player in <paramref name="playerIds"/>,
|
||||||
|
/// in one go: the Redis lookups are issued together, so the multiplexer
|
||||||
|
/// pipelines them into one round trip instead of one per player.</summary>
|
||||||
|
public async Task<IReadOnlyCollection<string>> GetConnectionsAsync(IEnumerable<long> playerIds)
|
||||||
|
{
|
||||||
|
var ids = playerIds.Distinct().ToArray();
|
||||||
|
if (ids.Length == 0) return Array.Empty<string>();
|
||||||
|
if (_redis is { } mux)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var db = mux.GetDatabase();
|
||||||
|
var sets = await Task.WhenAll(ids.Select(id => db.SetMembersAsync(ConnsKey(id))));
|
||||||
|
return sets.SelectMany(s => s).Select(v => v.ToString()).ToArray();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Redis sick → fall through to the local fallback, as
|
||||||
|
// GetConnections does.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var result = new List<string>();
|
||||||
|
foreach (var id in ids)
|
||||||
|
if (_localConns.TryGetValue(id, out var set))
|
||||||
|
lock (set) result.AddRange(set);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Snapshot of currently-online player ids across the
|
/// <summary>Snapshot of currently-online player ids across the
|
||||||
/// cluster. Returns an empty array when nobody's online; never null.
|
/// cluster. Returns an empty array when nobody's online; never null.
|
||||||
/// Callers typically <c>.ToHashSet()</c> the result for membership
|
/// Callers typically <c>.ToHashSet()</c> the result for membership
|
||||||
|
|||||||
@@ -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,23 @@ 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.
|
||||||
|
// Only friends who are online have connections; one call for the
|
||||||
|
// online set, then one pipelined lookup for all of them.
|
||||||
|
var onlineIds = connections.OnlinePlayerIds().ToHashSet();
|
||||||
|
var exclude = (await connections.GetConnectionsAsync(
|
||||||
|
friendIds.Where(onlineIds.Contains).Append(playerId)))
|
||||||
|
.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>
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user