diff --git a/DorkNet.Server.Tests/RoomPublish2023Tests.cs b/DorkNet.Server.Tests/RoomPublish2023Tests.cs
new file mode 100644
index 0000000..9efc699
--- /dev/null
+++ b/DorkNet.Server.Tests/RoomPublish2023Tests.cs
@@ -0,0 +1,133 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using DorkNet.Server.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// "Publish room" in the 2023 client: RoomModel.TryPublishRoom →
+/// PUT roomserver/rooms/{id}/accessibility with form
+/// accessibility=1 (boxed Int32, NLDBPDCNNCF_NestedType_OMHIIEAKHCH),
+/// typed Task<FGCPNAACHIK>. The button only exists when
+/// get_CanPublishRoom holds: role ≥ CoOwner, not a dorm, not IsRRO,
+/// not already public. A clone of a Rec Room Original template used to be
+/// advertised as IsRRO and so could never be published.
+///
+public sealed class RoomPublish2023Tests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public RoomPublish2023Tests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public void Only_system_owned_originals_are_RRO_on_the_wire()
+ {
+ Assert.True(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = PlayerService.SystemAccountId }));
+ Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = 4242 }));
+ Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = false, CreatorPlayerId = PlayerService.SystemAccountId }));
+ }
+
+ [Fact]
+ public async Task Clone_of_an_original_is_not_RRO_and_can_be_published_by_owner_and_co_owner()
+ {
+ using var setup = Client("api");
+ var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var coOwner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var moderator = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+
+ var templateId = 9_100_000 + Random.Shared.Next(1, 99_999);
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = templateId,
+ Name = $"Tmpl{Guid.NewGuid():N}"[..18],
+ CreatorPlayerId = PlayerService.SystemAccountId,
+ IsAGRoom = true,
+ Accessibility = 1,
+ CloningAllowed = false,
+ ImageName = "image_RecCenter.png",
+ LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
+ });
+ db.RoomScenes.Add(new RoomSceneEntity { RoomId = templateId, OrderIndex = 0, Name = "Home" });
+ await db.SaveChangesAsync();
+ }
+
+ using var ownerRooms = Client("rooms", owner);
+ var template = await GetJsonAsync(ownerRooms, $"/roomserver/rooms/{templateId}");
+ Assert.True(template.GetProperty("IsRRO").GetBoolean());
+
+ using var cloneResp = await ownerRooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
+ new FormUrlEncodedContent(new Dictionary { ["name"] = $"Pub{Guid.NewGuid():N}"[..20] }));
+ var cloneText = await cloneResp.Content.ReadAsStringAsync();
+ Assert.True(cloneResp.IsSuccessStatusCode, cloneText);
+ var clone = JsonDocument.Parse(cloneText).RootElement.GetProperty("value").Clone();
+ var cloneId = clone.GetProperty("RoomId").GetInt64();
+ Assert.False(clone.GetProperty("IsRRO").GetBoolean());
+ Assert.Equal(0, clone.GetProperty("Accessibility").GetInt32());
+
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true });
+ db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = moderator.PlayerId, Role = RoomRoleRules.StoredModerator, Accepted = true });
+ await db.SaveChangesAsync();
+ }
+
+ // Publish as the owner: full room details back, now public, still not RRO.
+ var published = await PutAccessibilityAsync(ownerRooms, cloneId, "1");
+ Assert.Equal(1, published.GetProperty("Accessibility").GetInt32());
+ Assert.False(published.GetProperty("IsRRO").GetBoolean());
+ Assert.True(published.TryGetProperty("SubRooms", out _));
+
+ // A moderator may not change accessibility; a co-owner may.
+ using var modRooms = Client("rooms", moderator);
+ using (var denied = await modRooms.PutAsync($"/roomserver/rooms/{cloneId}/accessibility",
+ new FormUrlEncodedContent(new Dictionary { ["accessibility"] = "0" })))
+ {
+ // Refusals are 200 {success:false, error} so the client can show them.
+ using var doc = JsonDocument.Parse(await denied.Content.ReadAsStringAsync());
+ Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
+ }
+ using var coRooms = Client("rooms", coOwner);
+ var unpublished = await PutAccessibilityAsync(coRooms, cloneId, "0");
+ Assert.Equal(0, unpublished.GetProperty("Accessibility").GetInt32());
+ }
+
+ private static async Task PutAccessibilityAsync(HttpClient client, long roomId, string value)
+ {
+ using var response = await client.PutAsync($"/roomserver/rooms/{roomId}/accessibility",
+ new FormUrlEncodedContent(new Dictionary { ["accessibility"] = value }));
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {body}");
+ // "change room accessibility" goes through the {success, value, error}
+ // helper (NLDBPDCNNCF.BDBHCEGKGDC), same as clone.
+ using var doc = JsonDocument.Parse(body);
+ Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), body);
+ return doc.RootElement.GetProperty("value").Clone();
+ }
+
+ private static async Task GetJsonAsync(HttpClient client, string path)
+ {
+ using var response = await client.GetAsync(path);
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
+ return JsonDocument.Parse(body).RootElement.Clone();
+ }
+
+ private HttpClient Client(string subdomain, GameClientSession? session = null)
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
+ client.Timeout = TimeSpan.FromSeconds(30);
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
+ if (session is not null)
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+}
diff --git a/DorkNet.Server.Tests/RoomRoles2023Tests.cs b/DorkNet.Server.Tests/RoomRoles2023Tests.cs
new file mode 100644
index 0000000..046e207
--- /dev/null
+++ b/DorkNet.Server.Tests/RoomRoles2023Tests.cs
@@ -0,0 +1,528 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using DorkNet.Server.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// 2023 room roles, the co-owner invite flow and the sub-room role-permission
+/// matrix, against the client contract:
+///
+/// - Role enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30,
+/// TemporaryCoOwner 31, Creator 255. The creator must be listed as 255 —
+/// the client takes the local role from the Roles list and only lets a
+/// player edit roles strictly below their own, Creator excepted
+/// (OMJKHJLFOCO.DIIMLMKEJLC).
+/// - Invite: owner PUT rooms/{id}/roles/{target}/invite role=30;
+/// invitee gets message Type 62 with RoomId; accepts with
+/// PUT rooms/{id}/roles/{me} role=30, rejects with
+/// PUT rooms/{id}/roles/{me}/invite role=0.
+/// - Permissions: PUT rooms/{id}/subrooms/{sub}/permissions raw
+/// JSON array of {Permission, Role, Override, Type, Value}; read back from
+/// GET photon_access_token.
+///
+///
+public sealed class RoomRoles2023Tests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public RoomRoles2023Tests(DorkNetServerFactory factory) => _factory = factory;
+
+ // ── pure rules ──────────────────────────────────────────────────────
+
+ private static RoomEntity Room(long creator) => new() { Id = 42, CreatorPlayerId = creator };
+
+ private static RoomRoleEntity Row(long player, int stored, bool accepted = true) =>
+ new() { RoomId = 42, PlayerId = player, Role = stored, Accepted = accepted };
+
+ [Fact]
+ public void Creator_is_255_and_listed_once()
+ {
+ var room = Room(creator: 7);
+ var wires = RoomRoleRules.BuildWires(room, new[]
+ {
+ Row(7, RoomRoleRules.StoredCoOwner), // a stray row for the creator
+ Row(8, RoomRoleRules.StoredModerator),
+ Row(9, RoomRoleRules.StoredCoOwner, accepted: false),
+ });
+
+ Assert.Single(wires, w => w.AccountId == 7);
+ Assert.Equal(255, wires.Single(w => w.AccountId == 7).Role);
+ Assert.Equal(20, wires.Single(w => w.AccountId == 8).Role);
+ var invited = wires.Single(w => w.AccountId == 9);
+ Assert.Equal(0, invited.Role);
+ Assert.Equal(30, invited.InvitedRole);
+ }
+
+ [Fact]
+ public void Creator_may_edit_every_role_including_co_owner_and_creator_permissions()
+ {
+ var room = Room(creator: 7);
+ var rows = Array.Empty();
+ foreach (var role in new[] { 0, 10, 20, 30, 31, 255 })
+ Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 7, role), $"creator role {role}");
+ }
+
+ [Fact]
+ public void Co_owner_may_edit_only_roles_below_their_own()
+ {
+ var room = Room(creator: 7);
+ var rows = new[] { Row(8, RoomRoleRules.StoredCoOwner), Row(9, RoomRoleRules.StoredModerator) };
+
+ Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 20));
+ Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 0));
+ Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 30));
+ Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 31));
+ Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 255));
+ // A moderator edits nothing.
+ Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 9, 0));
+ // Nor does a stranger.
+ Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 99, 0));
+ }
+
+ [Fact]
+ public void Role_changes_follow_the_ladder()
+ {
+ var room = Room(creator: 7);
+ var rows = new List
+ {
+ Row(8, RoomRoleRules.StoredCoOwner),
+ Row(9, RoomRoleRules.StoredModerator),
+ Row(10, RoomRoleRules.StoredCoOwner, accepted: false),
+ };
+
+ // Creator: anything on anyone but themselves.
+ Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 8, 0, false, out var k));
+ Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
+ Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 11, 30, true, out k));
+ Assert.Equal(RoomRoleRules.ChangeKind.Invite, k);
+ Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 7, 7, 0, false, out _));
+ Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 8, 7, 0, false, out _));
+
+ // Co-owner: only below 30, on targets below 30.
+ Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 11, 20, false, out _));
+ Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 9, 10, false, out _));
+ Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 11, 30, true, out _));
+ Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 10, 0, true, out _)); // invited co-owner
+ // …and cannot remove a peer co-owner.
+ Assert.Equal("forbidden", RoomRoleRules.CheckChange(
+ room, new List { Row(8, 0), Row(12, 0) }, 8, 12, 0, false, out _));
+
+ // Moderator and strangers manage nobody.
+ Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 11, 10, false, out _));
+ Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 99, 11, 10, false, out _));
+
+ // Self-service: accept exactly the invited role, decline, leave.
+ Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 30, false, out k));
+ Assert.Equal(RoomRoleRules.ChangeKind.AcceptInvite, k);
+ Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 0, true, out k));
+ Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
+ Assert.Null(RoomRoleRules.CheckChange(room, rows, 9, 9, 0, false, out _));
+ Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 9, 30, false, out _)); // no invite
+ Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 10, 10, 20, false, out _)); // not the invited role
+
+ Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 255, false, out _));
+ Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 31, false, out _));
+ }
+
+ [Fact]
+ public void Permission_matrix_merges_stored_cells_over_defaults()
+ {
+ var merged = RoomPermissionMatrix.Merge(new[]
+ {
+ new RoomRolePermissionEntity { Id = 1, Role = 30, Permission = "13", Type = 0, Override = true, Value = "false" },
+ new RoomRolePermissionEntity { Id = 2, Role = 20, Permission = "13", Type = 0, Override = true, Value = "true" },
+ });
+
+ Assert.Equal("false", merged.Single(c => c.Role == 30 && c.Permission == "13").Value);
+ Assert.Equal("true", merged.Single(c => c.Role == 20 && c.Permission == "13").Value);
+ Assert.Equal("true", merged.Single(c => c.Role == 255 && c.Permission == "13").Value);
+ Assert.Equal(RoomPermissionMatrix.Defaults.Count + 1, merged.Count);
+ }
+
+ [Fact]
+ public void Permission_body_accepts_numbers_strings_and_enum_names()
+ {
+ var cells = RoomPermissionMatrix.Parse(
+ """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},""" +
+ """{"permission":"CAN_USE_MAKER_PEN","role":"Moderator","override":"true","type":"Bool","value":true},""" +
+ """{"Permission":"20","Role":"255","Override":false,"Type":1,"Value":5}]""");
+
+ Assert.NotNull(cells);
+ Assert.Equal(3, cells!.Count);
+ Assert.Equal(new RoomPermissionMatrix.Cell("13", 30, true, 0, "false"), cells[0]);
+ Assert.Equal(new RoomPermissionMatrix.Cell("CAN_USE_MAKER_PEN", 20, true, 0, "true"), cells[1]);
+ Assert.Equal(new RoomPermissionMatrix.Cell("20", 255, false, 1, "5"), cells[2]);
+ Assert.Null(RoomPermissionMatrix.Parse("role=30"));
+ }
+
+ // ── over HTTP ───────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Roles_list_reports_the_creator_as_Creator_255()
+ {
+ var (owner, _, _) = await PlayersAsync();
+ var roomId = await SeedRoomAsync(owner.PlayerId);
+
+ using var rooms = Client("rooms", owner);
+ var list = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles");
+ var me = list.EnumerateArray().Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
+ Assert.Equal(255, me.GetProperty("Role").GetInt32());
+
+ var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
+ var detailsRole = details.GetProperty("Roles").EnumerateArray()
+ .Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
+ Assert.Equal(255, detailsRole.GetProperty("Role").GetInt32());
+
+ var single = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles/{owner.PlayerId}");
+ Assert.Equal(255, single.GetProperty("Role").GetInt32());
+ }
+
+ [Fact]
+ public async Task Co_owner_invite_is_notified_then_accepted_by_the_invitee()
+ {
+ var (owner, invitee, _) = await PlayersAsync();
+ var roomId = await SeedRoomAsync(owner.PlayerId);
+
+ using var ownerRooms = Client("rooms", owner);
+ var invited = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
+ var inviteeRow = RoleOf(invited, invitee.PlayerId);
+ Assert.Equal(0, inviteeRow.GetProperty("Role").GetInt32());
+ Assert.Equal(30, inviteeRow.GetProperty("InvitedRole").GetInt32());
+
+ // The invitee's inbox carries message Type 62 (RoomCoOwnerInvited) with RoomId.
+ long messageId;
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var msg = await db.Messages.SingleAsync(m =>
+ m.RecipientPlayerId == invitee.PlayerId && m.RoomId == roomId
+ && m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited);
+ Assert.Equal(owner.PlayerId, msg.SenderPlayerId);
+ Assert.Null(msg.ReadAt);
+ messageId = msg.Id;
+ }
+
+ // Accept: the invitee PUTs their own role to 30.
+ using var inviteeRooms = Client("rooms", invitee);
+ var accepted = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
+ var acceptedRow = RoleOf(accepted, invitee.PlayerId);
+ Assert.Equal(30, acceptedRow.GetProperty("Role").GetInt32());
+ Assert.Equal(0, acceptedRow.GetProperty("InvitedRole").GetInt32());
+
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ Assert.NotNull((await db.Messages.SingleAsync(m => m.Id == messageId)).ReadAt);
+ Assert.True(await db.RoomRoles.AnyAsync(r =>
+ r.RoomId == roomId && r.PlayerId == invitee.PlayerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner));
+ }
+ }
+
+ [Fact]
+ public async Task Invitee_rejects_through_the_invite_route_and_cannot_self_promote()
+ {
+ var (owner, invitee, stranger) = await PlayersAsync();
+ var roomId = await SeedRoomAsync(owner.PlayerId);
+
+ using var ownerRooms = Client("rooms", owner);
+ await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
+
+ using var inviteeRooms = Client("rooms", invitee);
+ var rejected = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "0"));
+ Assert.DoesNotContain(rejected.GetProperty("Roles").EnumerateArray(),
+ e => e.GetProperty("AccountId").GetInt64() == invitee.PlayerId);
+
+ // With the invite gone, the same accept is refused.
+ using var again = await PutFormRawAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
+ await AssertRefusedAsync(again);
+
+ // A stranger can neither promote themselves nor invite anyone.
+ using var strangerRooms = Client("rooms", stranger);
+ using var self = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{stranger.PlayerId}", ("role", "20"));
+ await AssertRefusedAsync(self);
+ using var other = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
+ await AssertRefusedAsync(other);
+ }
+
+ [Fact]
+ public async Task Co_owner_manages_roles_below_but_not_co_owners()
+ {
+ var (owner, coOwner, target) = await PlayersAsync();
+ var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
+
+ using var coRooms = Client("rooms", coOwner);
+ var granted = await PutFormAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "20"));
+ Assert.Equal(20, RoleOf(granted, target.PlayerId).GetProperty("Role").GetInt32());
+
+ using var inviteCo = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}/invite", ("role", "30"));
+ await AssertRefusedAsync(inviteCo);
+
+ // The creator can directly add and remove a co-owner.
+ using var ownerRooms = Client("rooms", owner);
+ var promoted = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "30"));
+ Assert.Equal(30, RoleOf(promoted, target.PlayerId).GetProperty("Role").GetInt32());
+
+ // …and now the co-owner can't touch their peer.
+ using var demote = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
+ await AssertRefusedAsync(demote);
+
+ var removed = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
+ Assert.DoesNotContain(removed.GetProperty("Roles").EnumerateArray(),
+ e => e.GetProperty("AccountId").GetInt64() == target.PlayerId);
+
+ await using var scope = _factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
+ && m.Type == SystemNotificationService.MessageType.RoomCoOwnerAdded));
+ Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
+ && m.Type == SystemNotificationService.MessageType.RoomCoOwnerRemoved));
+ }
+
+ [Fact]
+ public async Task Creator_saves_co_owner_and_creator_permissions_and_reads_them_back()
+ {
+ var (owner, coOwner, _) = await PlayersAsync();
+ var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
+
+ using var ownerRooms = Client("rooms", owner);
+ var body = """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},{"Permission":"20","Role":255,"Override":true,"Type":0,"Value":"false"}]""";
+ using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", body))
+ {
+ var text = await put.Content.ReadAsStringAsync();
+ Assert.True(put.IsSuccessStatusCode, $"{(int)put.StatusCode}: {text}");
+ // {success, value: full room details, error}
+ var value = Envelope(text);
+ Assert.Equal(roomId, value.GetProperty("RoomId").GetInt64());
+ }
+
+ // The owner enters the room, then the matrix comes back with the token.
+ using var match = Client("match", owner);
+ using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary())))
+ Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
+ var token = await GetJsonAsync(ownerRooms, "/roomserver/photon_access_token");
+ var cells = token.GetProperty("Permissions").EnumerateArray().ToList();
+ Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 30
+ && c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
+ Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
+ && c.GetProperty("Permission").GetString() == "20").GetProperty("Value").GetString());
+ Assert.Equal("true", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
+ && c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
+
+ // A co-owner may not edit the Co-Owner row, but may edit Moderator.
+ using var coRooms = Client("rooms", coOwner);
+ using (var denied = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
+ """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"true"}]"""))
+ await AssertRefusedAsync(denied);
+ using (var allowed = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
+ """[{"Permission":"13","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
+ Envelope(await allowed.Content.ReadAsStringAsync());
+
+ await using var scope = _factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var stored = await db.RoomRolePermissions.Where(p => p.RoomId == roomId).ToListAsync();
+ Assert.Equal(3, stored.Count);
+ Assert.All(stored, p => Assert.Equal(0, p.SubRoomId)); // wire 1 → OrderIndex 0
+ Assert.Equal("false", stored.Single(p => p.Role == 30).Value); // the denied write changed nothing
+ }
+
+ [Fact]
+ public async Task Invite_pushes_the_message_and_room_and_permission_saves_push_the_access_token()
+ {
+ var hub = new CapturingHubContext();
+ var factory = _factory.WithWebHostBuilder(builder =>
+ builder.ConfigureServices(services =>
+ {
+ Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions
+ .RemoveAll>(services);
+ services.AddSingleton>(hub);
+ }));
+
+ HttpClient FactoryClient(string sub, GameClientSession? session = null)
+ {
+ var c = factory.CreateClient(new() { AllowAutoRedirect = false });
+ c.BaseAddress = new Uri($"http://{sub}.{_factory.ApexDomain}");
+ c.DefaultRequestHeaders.Accept.ParseAdd("application/json");
+ c.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
+ if (session is not null)
+ c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return c;
+ }
+
+ using var setup = FactoryClient("api");
+ var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var invitee = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var roomId = await SeedRoomAsync(owner.PlayerId);
+
+ using var ownerRooms = FactoryClient("rooms", owner);
+ await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
+
+ var message = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "2"));
+ Assert.Equal(62, message.Msg.GetProperty("Type").GetInt32());
+ Assert.Equal(roomId, message.Msg.GetProperty("RoomId").GetInt64());
+ Assert.Equal((int)owner.PlayerId, message.Msg.GetProperty("FromPlayerId").GetInt32());
+ var roomPush = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "RoomUpdate"));
+ Assert.Equal(30, RoleOf(roomPush.Msg, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
+
+ using var match = FactoryClient("match", owner);
+ using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary())))
+ Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
+ using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
+ """[{"Permission":"21","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
+ Envelope(await put.Content.ReadAsStringAsync());
+
+ var token = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(owner.PlayerId), "PhotonAccessToken"));
+ Assert.True(token.Msg.GetProperty("RoomInstanceId").GetInt64() > 0);
+ Assert.Contains(token.Msg.GetProperty("Permissions").EnumerateArray(),
+ c => c.GetProperty("Role").GetInt32() == 20 && c.GetProperty("Permission").GetString() == "21");
+ }
+
+ [Fact]
+ public void Envelope_filter_wraps_success_and_turns_refusals_into_messages()
+ {
+ static JsonElement Body(Microsoft.AspNetCore.Mvc.IActionResult r) =>
+ JsonDocument.Parse(JsonSerializer.Serialize(((Microsoft.AspNetCore.Mvc.ObjectResult)r).Value)).RootElement.Clone();
+
+ var ok = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.OkObjectResult(new { RoomId = 5 })));
+ Assert.True(ok.GetProperty("success").GetBoolean());
+ Assert.Equal(5, ok.GetProperty("value").GetProperty("RoomId").GetInt32());
+ Assert.Equal("", ok.GetProperty("error").GetString());
+
+ var forbid = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.ForbidResult()));
+ Assert.False(forbid.GetProperty("success").GetBoolean());
+ Assert.False(string.IsNullOrWhiteSpace(forbid.GetProperty("error").GetString()));
+
+ var bad = Body(Controllers.RecNetEnvelopeAttribute.Wrap(
+ new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(new { error = "cannot_change_creator_role" })));
+ Assert.Equal("cannot_change_creator_role", bad.GetProperty("error").GetString());
+
+ // An envelope the handler built itself is left alone.
+ var already = new Microsoft.AspNetCore.Mvc.OkObjectResult(new { success = false, value = (object?)null, error = "x" });
+ Assert.Same(already, Controllers.RecNetEnvelopeAttribute.Wrap(already));
+ }
+
+ [Fact]
+ public async Task Room_mutations_answer_the_success_value_error_envelope_on_bare_and_roomserver_paths()
+ {
+ var (owner, invitee, _) = await PlayersAsync();
+ var roomId = await SeedRoomAsync(owner.PlayerId);
+ using var rooms = Client("rooms", owner);
+
+ // The exact request the client sent in production (bare path, form role=30).
+ var invited = await PutFormAsync(rooms, $"/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
+ Assert.Equal(30, RoleOf(invited, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
+
+ foreach (var (path, key, value) in new[]
+ {
+ ($"/rooms/{roomId}/description", "description", "hello"),
+ ($"/roomserver/rooms/{roomId}/accessibility", "accessibility", "0"),
+ ($"/roomserver/rooms/{roomId}/subrooms/1/name", "name", "Lobby"),
+ ($"/roomserver/rooms/{roomId}/subrooms/1/maxplayers", "maxPlayers", "10"),
+ })
+ {
+ var details = await PutFormAsync(rooms, path, (key, value));
+ Assert.Equal(roomId, details.GetProperty("RoomId").GetInt64());
+ }
+ }
+
+ // ── helpers ─────────────────────────────────────────────────────────
+
+ private async Task<(GameClientSession A, GameClientSession B, GameClientSession C)> PlayersAsync()
+ {
+ using var setup = Client("api");
+ return (
+ await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
+ await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
+ await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain));
+ }
+
+ private async Task SeedRoomAsync(long creatorId, params (long PlayerId, int StoredRole)[] roles)
+ {
+ var roomId = 9_300_000 + Random.Shared.Next(1, 99_999);
+ await using var scope = _factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = roomId,
+ Name = $"Roles{Guid.NewGuid():N}"[..18],
+ CreatorPlayerId = creatorId,
+ ImageName = RoomService.DefaultRoomImageName,
+ Accessibility = 0,
+ LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
+ });
+ db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = 0, Name = "Home" });
+ foreach (var (pid, role) in roles)
+ db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = pid, Role = role, Accepted = true });
+ await db.SaveChangesAsync();
+ return roomId;
+ }
+
+ private static JsonElement RoleOf(JsonElement details, long playerId) =>
+ details.GetProperty("Roles").EnumerateArray()
+ .Single(e => e.GetProperty("AccountId").GetInt64() == playerId);
+
+ private static Task PutFormRawAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
+ => client.PutAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair(f.Key, f.Value))));
+
+ private static async Task PutFormAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
+ {
+ using var response = await PutFormRawAsync(client, path, fields);
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"PUT {path} -> {(int)response.StatusCode}: {body}");
+ return Envelope(body);
+ }
+
+ /// Every room mutation answers the {success, value, error}
+ /// envelope (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>): assert success and
+ /// return the room details in value.
+ private static JsonElement Envelope(string body)
+ {
+ using var doc = JsonDocument.Parse(body);
+ var root = doc.RootElement;
+ Assert.True(root.GetProperty("success").GetBoolean(), body);
+ Assert.Equal(string.Empty, root.GetProperty("error").GetString());
+ return root.GetProperty("value").Clone();
+ }
+
+ /// A refusal is HTTP 200 with success=false and a message: the
+ /// client shows the message, while a 403/404 surfaces only as the
+ /// message-less "Exception of type 'CEMNLBKJABA' was thrown".
+ private static async Task AssertRefusedAsync(HttpResponseMessage response)
+ {
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ using var doc = JsonDocument.Parse(body);
+ Assert.False(doc.RootElement.GetProperty("success").GetBoolean(), body);
+ Assert.False(string.IsNullOrWhiteSpace(doc.RootElement.GetProperty("error").GetString()), body);
+ Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("value").ValueKind);
+ }
+
+ private static Task PutJsonRawAsync(HttpClient client, string path, string json)
+ => client.PutAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
+
+ private static async Task GetJsonAsync(HttpClient client, string path)
+ {
+ using var response = await client.GetAsync(path);
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
+ return JsonDocument.Parse(body).RootElement.Clone();
+ }
+
+ private HttpClient Client(string subdomain, GameClientSession? session = null)
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
+ client.Timeout = TimeSpan.FromSeconds(30);
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
+ if (session is not null)
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+}
diff --git a/DorkNet.Server.Tests/RroRoomBlobTests.cs b/DorkNet.Server.Tests/RroRoomBlobTests.cs
index 1d34310..f2f540e 100644
--- a/DorkNet.Server.Tests/RroRoomBlobTests.cs
+++ b/DorkNet.Server.Tests/RroRoomBlobTests.cs
@@ -416,9 +416,10 @@ public sealed class RroRoomBlobTests : IClassFixture
}
}
+ // The entry sub-room (OrderIndex 0) is wire SubRoomId 1 (RoomSceneIds).
private static JsonElement SubRoomZero(JsonElement details) =>
details.GetProperty("SubRooms").EnumerateArray()
- .First(e => e.GetProperty("SubRoomId").GetInt64() == 0);
+ .First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
private static async Task GetJsonAsync(HttpClient client, string path)
{
diff --git a/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs b/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs
index e801474..194401e 100644
--- a/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs
+++ b/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs
@@ -58,7 +58,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture
await db.SaveChangesAsync();
}
- // ── in-game: the owner's slider on sub-room 1 only ──────────────────
+ // ── in-game: the owner's slider on the second sub-room only ─────────
+ // The client addresses it by its wire SubRoomId, OrderIndex + 1 = 2
+ // (RoomSceneIds — the client will not load a sub-room whose id is 0).
// Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
@@ -66,19 +68,23 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture
// real client's body went unread.
using var ownerClient = Client("rooms", owner);
using var slider = await ownerClient.PutAsJsonAsync(
- $"/rooms/{roomId}/subrooms/1/maxplayers", new { maxPlayers = 24 });
+ $"/rooms/{roomId}/subrooms/2/maxplayers", new { maxPlayers = 24 });
var sliderBody = await slider.Content.ReadAsStringAsync();
Assert.True(slider.IsSuccessStatusCode,
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
- // The client deserialises the reply as FGCPNAACHIK — the room-details
- // shape — so the slider's own response has to carry the new value.
- var echoed = JsonDocument.Parse(sliderBody).RootElement
+ // The client reads the reply through the {success, value, error}
+ // helper with value = FGCPNAACHIK, so the slider's own response has to
+ // carry the new value inside the envelope.
+ var envelope = JsonDocument.Parse(sliderBody).RootElement;
+ Assert.True(envelope.GetProperty("success").GetBoolean(), sliderBody);
+ var echoed = envelope.GetProperty("value")
.GetProperty("SubRooms").EnumerateArray()
- .First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
+ .First(e => e.GetProperty("SubRoomId").GetInt64() == 2);
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
// ── admin: reads what the owner just set, then edits sub-room 0 ─────
+ // (the admin API addresses sub-rooms by OrderIndex)
using var adminClient = Client("admin", admin);
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
var subRooms = listed.EnumerateArray()
@@ -99,8 +105,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
e => e.GetProperty("MaxPlayers").GetInt32());
- Assert.Equal(40, wire[0]);
- Assert.Equal(24, wire[1]);
+ // Wire ids are OrderIndex + 1.
+ Assert.Equal(40, wire[1]);
+ Assert.Equal(24, wire[2]);
// The room-level figure is the room's advertised capacity, NOT a copy
// of sub-room 0 — otherwise the admin edit above would have moved it.
diff --git a/DorkNet.Server.Tests/SubRoomsAndInstances2023Tests.cs b/DorkNet.Server.Tests/SubRoomsAndInstances2023Tests.cs
new file mode 100644
index 0000000..cd4e056
--- /dev/null
+++ b/DorkNet.Server.Tests/SubRoomsAndInstances2023Tests.cs
@@ -0,0 +1,277 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using DorkNet.Server.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// Sub-room ids and the room instance browser for the 2023 client.
+///
+/// - The client will not load a sub-room whose id is not positive
+/// (SubroomModel.DOKCPLJOKIE: RoomId > 0 && SubroomId > 0),
+/// so the wire SubRoomId is OrderIndex + 1 everywhere the client sees it:
+/// room details, sub-room routes, matchmaking and the instance browser.
+/// - A new room always owns a real, named entry sub-room, and a startup
+/// pass repairs rooms that lack one.
+/// - GET room/{id}/instances lists live instances with their real
+/// players, including the private instance of a private room for the people
+/// allowed into it.
+///
+///
+public sealed class SubRoomsAndInstances2023Tests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public SubRoomsAndInstances2023Tests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public void Wire_sub_room_ids_are_positive_and_round_trip()
+ {
+ Assert.Equal(1, RoomSceneIds.ToWire(0));
+ Assert.Equal(3, RoomSceneIds.ToWire(2));
+ Assert.Equal(0, RoomSceneIds.ToOrderIndex(1));
+ Assert.Equal(2, RoomSceneIds.ToOrderIndex(3));
+ // 0 (cached client state, bare matchmake) is the entry sub-room.
+ Assert.Equal(0, RoomSceneIds.ToOrderIndex(0));
+ Assert.Equal(0, RoomSceneIds.ToOrderIndex(-1));
+ }
+
+ [Fact]
+ public async Task Details_matchmaking_and_sub_room_routes_share_the_wire_id()
+ {
+ var owner = await PlayerAsync();
+ var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 1, scenes: new[] { "Home", "Arena" });
+
+ using var rooms = Client("rooms", owner);
+ var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
+ var subRooms = details.GetProperty("SubRooms").EnumerateArray().ToList();
+ Assert.Equal(new long[] { 1, 2 }, subRooms.Select(s => s.GetProperty("SubRoomId").GetInt64()));
+ Assert.Equal("Home", subRooms[0].GetProperty("Name").GetString());
+
+ // matchmake/room/{id}/{sub} takes the wire id and echoes it.
+ using var match = Client("match", owner);
+ var arena = await PostFormAsync(match, $"/matchmake/room/{roomId}/2");
+ Assert.Equal(2, arena.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
+ var lobby = await PostFormAsync(match, $"/matchmake/room/{roomId}");
+ Assert.Equal(1, lobby.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
+
+ // A sub-room route addressed by wire id 2 edits OrderIndex 1.
+ using (var rename = await rooms.PutAsync($"/roomserver/rooms/{roomId}/subrooms/2/name",
+ new FormUrlEncodedContent(new Dictionary { ["name"] = "Basement" })))
+ Assert.True(rename.IsSuccessStatusCode, await rename.Content.ReadAsStringAsync());
+
+ await using var scope = _factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ Assert.Equal("Basement", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 1)).Name);
+ Assert.Equal("Home", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 0)).Name);
+ }
+
+ [Fact]
+ public async Task Cloning_a_template_without_scene_rows_creates_a_named_home_sub_room()
+ {
+ var owner = await PlayerAsync();
+ var templateId = await SeedRoomAsync(1, accessibility: 1, scenes: Array.Empty(), isAg: true);
+
+ using var rooms = Client("rooms", owner);
+ using var resp = await rooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
+ new FormUrlEncodedContent(new Dictionary { ["name"] = $"Fresh{Guid.NewGuid():N}"[..20] }));
+ var text = await resp.Content.ReadAsStringAsync();
+ Assert.True(resp.IsSuccessStatusCode, text);
+ using var json = JsonDocument.Parse(text);
+ var value = json.RootElement.GetProperty("value");
+ var cloneId = value.GetProperty("RoomId").GetInt64();
+ var sub = value.GetProperty("SubRooms").EnumerateArray().Single();
+ Assert.Equal(1, sub.GetProperty("SubRoomId").GetInt64());
+ Assert.Equal("Home", sub.GetProperty("Name").GetString());
+
+ await using var scope = _factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == cloneId);
+ Assert.Equal(0, scene.OrderIndex);
+ Assert.Equal("Home", scene.Name);
+ Assert.Equal("a75f7547-79eb-47c6-8986-6767abcb4f92", scene.RoomSceneLocationId);
+ }
+
+ [Fact]
+ public async Task Startup_repair_gives_scene_less_rooms_a_home_and_names_blank_rows()
+ {
+ var bare = await SeedRoomAsync(1, accessibility: 0, scenes: Array.Empty());
+ var blank = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "", " " });
+
+ await using var scope = _factory.Services.CreateAsyncScope();
+ var service = scope.ServiceProvider.GetRequiredService();
+ var (added, renamed) = await service.RepairRoomScenesAsync();
+ Assert.True(added >= 1);
+ Assert.True(renamed >= 2);
+
+ var db = scope.ServiceProvider.GetRequiredService();
+ var home = await db.RoomScenes.SingleAsync(s => s.RoomId == bare);
+ Assert.Equal((0, "Home"), (home.OrderIndex, home.Name));
+ var names = await db.RoomScenes.Where(s => s.RoomId == blank).OrderBy(s => s.OrderIndex).Select(s => s.Name).ToListAsync();
+ Assert.Equal(new[] { "Home", "Subroom 2" }, names);
+
+ // Idempotent.
+ Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
+ }
+
+ [Fact]
+ public async Task Room_level_restore_also_restores_the_entry_sub_room()
+ {
+ var owner = await PlayerAsync();
+ var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home", "Back" });
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.RoomScenes.Where(s => s.RoomId == roomId)
+ .ExecuteUpdateAsync(s => s.SetProperty(x => x.DataBlobName, "blob-old"));
+ db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = roomId, BlobName = "blob-new", UploadedByPlayerId = owner.PlayerId });
+ await db.SaveChangesAsync();
+ }
+
+ using var api = Client("api", owner);
+ using var response = await api.PostAsync("/api/rooms/v1/restore", new FormUrlEncodedContent(new Dictionary
+ {
+ ["roomId"] = roomId.ToString(), ["blobName"] = "blob-new",
+ }));
+ Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
+
+ await using var check = _factory.Services.CreateAsyncScope();
+ var checkDb = check.ServiceProvider.GetRequiredService();
+ var blobs = await checkDb.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
+ .OrderBy(s => s.OrderIndex).Select(s => s.DataBlobName).ToListAsync();
+ // The entry sub-room follows the restore; other sub-rooms keep theirs.
+ Assert.Equal(new[] { "blob-new", "blob-old" }, blobs);
+ }
+
+ [Fact]
+ public async Task Startup_repair_restores_a_missing_entry_sub_room_next_to_others()
+ {
+ var roomId = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "Home", "Back" });
+ await using var scope = _factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.RoomScenes.Where(s => s.RoomId == roomId && s.OrderIndex == 0).ExecuteDeleteAsync();
+
+ var service = scope.ServiceProvider.GetRequiredService();
+ var (added, _) = await service.RepairRoomScenesAsync();
+ Assert.True(added >= 1);
+
+ var scenes = await db.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
+ .OrderBy(s => s.OrderIndex).Select(s => new { s.OrderIndex, s.Name }).ToListAsync();
+ Assert.Equal(new[] { (0, "Home"), (1, "Back") }, scenes.Select(s => (s.OrderIndex, s.Name)));
+ Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
+ }
+
+ [Fact]
+ public async Task Instance_browser_lists_the_private_instance_with_its_players_for_allowed_callers()
+ {
+ var owner = await PlayerAsync();
+ var coOwner = await PlayerAsync();
+ var stranger = await PlayerAsync();
+ var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home" });
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.RoomRoles.Add(new RoomRoleEntity
+ {
+ RoomId = roomId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true,
+ });
+ await db.SaveChangesAsync();
+ }
+
+ using var ownerMatch = Client("match", owner);
+ var joined = await PostFormAsync(ownerMatch, $"/matchmake/room/{roomId}");
+ var instanceId = joined.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64();
+
+ foreach (var viewer in new[] { owner, coOwner })
+ {
+ using var match = Client("match", viewer);
+ var list = await GetJsonAsync(match, $"/room/{roomId}/instances");
+ var row = list.EnumerateArray().Single();
+ Assert.Equal(instanceId, row.GetProperty("roomInstanceId").GetInt64());
+ Assert.Equal(roomId, row.GetProperty("roomId").GetInt64());
+ Assert.Equal(1, row.GetProperty("subRoomId").GetInt64());
+ Assert.False(row.GetProperty("isFull").GetBoolean());
+ Assert.Equal(new[] { (int)owner.PlayerId },
+ row.GetProperty("playerIds").EnumerateArray().Select(p => p.GetInt32()));
+ }
+
+ // Someone with no way into the private room is not shown it.
+ using var strangerMatch = Client("match", stranger);
+ var hidden = await GetJsonAsync(strangerMatch, $"/room/{roomId}/instances");
+ Assert.Empty(hidden.EnumerateArray());
+ }
+
+ [Fact]
+ public void Private_instance_visibility_rule()
+ {
+ var row = new PrivateInstanceEntity { Id = 5, OwnerPlayerId = 3 };
+ Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, true, Array.Empty(), row, false));
+ Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, new long[] { 9 }, row, false));
+ Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(3, false, Array.Empty(), row, false));
+ Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty(), row, true));
+ Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty(), row, false));
+ Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(0, true, Array.Empty(), row, true));
+ }
+
+ // ── helpers ─────────────────────────────────────────────────────────
+
+ private async Task PlayerAsync()
+ {
+ using var setup = Client("api");
+ return await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ }
+
+ private async Task SeedRoomAsync(long creatorId, int accessibility, string[] scenes, bool isAg = false)
+ {
+ var roomId = 9_200_000 + Random.Shared.Next(1, 99_999);
+ await using var scope = _factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = roomId,
+ Name = $"Subs{Guid.NewGuid():N}"[..18],
+ CreatorPlayerId = creatorId,
+ ImageName = RoomService.DefaultRoomImageName,
+ Accessibility = accessibility,
+ CloningAllowed = true,
+ IsAGRoom = isAg,
+ LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
+ });
+ for (var i = 0; i < scenes.Length; i++)
+ db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = i, Name = scenes[i] });
+ await db.SaveChangesAsync();
+ return roomId;
+ }
+
+ private static async Task PostFormAsync(HttpClient client, string path)
+ {
+ using var response = await client.PostAsync(path, new FormUrlEncodedContent(new Dictionary()));
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
+ return JsonDocument.Parse(body).RootElement.Clone();
+ }
+
+ private static async Task GetJsonAsync(HttpClient client, string path)
+ {
+ using var response = await client.GetAsync(path);
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
+ return JsonDocument.Parse(body).RootElement.Clone();
+ }
+
+ private HttpClient Client(string subdomain, GameClientSession? session = null)
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
+ client.Timeout = TimeSpan.FromSeconds(30);
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
+ if (session is not null)
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+}
diff --git a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs
index 2bdcd6e..13b20d4 100644
--- a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs
+++ b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs
@@ -1254,8 +1254,10 @@ public class InventionsController(
if (!canPush) return Forbid();
var subRoomId = req.SubRoomId;
+ // Wire SubRoomId (OrderIndex + 1) → the scene's OrderIndex.
+ var sceneOrderIndex = Services.RoomSceneIds.ToOrderIndex(subRoomId);
var scene = await db.RoomScenes
- .FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
+ .FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == sceneOrderIndex);
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
if (subRoomDataFilename.Length == 0)
@@ -1269,7 +1271,7 @@ public class InventionsController(
if (subRoomBlob is null)
return BadRequest(new { Error = "unknown SubRoomData.Filename" });
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
- subRoomBlob.SubRoomId ??= subRoomId;
+ subRoomBlob.SubRoomId ??= sceneOrderIndex;
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
if (roomDataFilename.Length > 0)
diff --git a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs
index 3e507be..0de0ae8 100644
--- a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs
+++ b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs
@@ -394,7 +394,11 @@ public class PlayerEventsController(
if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
- var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null;
+ // The event stores the client's wire SubRoomId; presence and private
+ // instances hold the OrderIndex (RoomSceneIds).
+ var subRoomId = extras.SubRoomId is long sub && sub > 0
+ ? RoomSceneIds.ToOrderIndex(sub)
+ : (long?)null;
// Same sourcing as the room browser: presence rows are the live truth
// (one instance per distinct PhotonRoomId), and the caller's OWN private
@@ -417,7 +421,7 @@ public class PlayerEventsController(
{
roomInstanceId = room.RoomInstanceId,
roomId = room.RoomId,
- subRoomId = room.SubRoomId,
+ subRoomId = RoomSceneIds.ToWire(room.SubRoomId),
// MaxCapacity is the per-instance cap the matchmaker handed
// out, so this is a real answer rather than a constant false.
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
@@ -438,7 +442,7 @@ public class PlayerEventsController(
{
roomInstanceId = p.Id,
roomId = p.RoomId,
- subRoomId = p.SubRoomId,
+ subRoomId = RoomSceneIds.ToWire(p.SubRoomId),
isFull = false,
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
playerIds = Array.Empty(),
diff --git a/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs b/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs
index 1e5648e..10917d2 100644
--- a/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs
+++ b/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs
@@ -48,6 +48,7 @@ public class PlaylistsController(
/// query or JSON body so JSON callers (admin UI) keep working.
[HttpPost("/playlists")]
[Authorize]
+ [RecNetEnvelope]
public async Task Create()
{
var name = await ReadFieldAsync("name", "Name");
@@ -87,6 +88,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")]
[Authorize]
+ [RecNetEnvelope]
public async Task PlaylistName(long playlistId)
{
var value = await ReadFieldAsync("name", "Name", "value", "Value");
@@ -96,6 +98,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")]
[Authorize]
+ [RecNetEnvelope]
public async Task PlaylistDescription(long playlistId)
{
var value = await ReadFieldAsync("description", "Description", "value", "Value");
@@ -105,6 +108,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")]
[Authorize]
+ [RecNetEnvelope]
public async Task PlaylistImage(long playlistId)
{
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
@@ -116,6 +120,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize]
+ [RecNetEnvelope]
public async Task PlaylistTags(long playlistId)
{
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
@@ -131,6 +136,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPut("/playlists/{playlistId:long}/visibility")]
[Authorize]
+ [RecNetEnvelope]
public async Task PlaylistAccessibility(long playlistId)
{
var value = await ReadFieldAsync(
@@ -144,6 +150,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
[Authorize]
+ [RecNetEnvelope]
public async Task PlaylistLevelVoting(long playlistId)
{
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
@@ -156,6 +163,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPut("/playlists/{playlistId:long}/restrictions")]
[Authorize]
+ [RecNetEnvelope]
public async Task PlaylistRestrictions(long playlistId)
{
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
@@ -174,6 +182,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/warning")]
[HttpPut("/playlists/{playlistId:long}/warning")]
[Authorize]
+ [RecNetEnvelope]
public async Task PlaylistWarning(long playlistId)
{
var mask = await ReadFieldAsync("warningMask", "WarningMask");
@@ -295,6 +304,7 @@ public class PlaylistsController(
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
+ [RecNetEnvelope]
public async Task AddRoom(long playlistId, long roomId)
{
try
@@ -307,6 +317,7 @@ public class PlaylistsController(
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
+ [RecNetEnvelope]
public async Task RemoveRoom(long playlistId, long roomId)
{
try
diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
index 2fc0275..052a974 100644
--- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
+++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
@@ -35,6 +35,7 @@ public class RoomsController(
DomainConfig domain,
NotificationService notifications,
ServerSettingsService serverSettings,
+ SystemNotificationService systemNotifications,
ILogger logger) : ControllerBase
{
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
@@ -574,6 +575,7 @@ public class RoomsController(
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
public async Task SubRoomDataHistory(long roomId, long subRoomId)
{
+ subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
@@ -586,7 +588,7 @@ public class RoomsController(
.Take(50)
.Select(b => new
{
- SubRoomId = subRoomId,
+ SubRoomId = RoomSceneIds.ToWire(subRoomId),
DataBlob = b.BlobName,
SavedByAccountId = b.UploadedByPlayerId,
CreatedAt = b.UploadedAt,
@@ -613,6 +615,7 @@ public class RoomsController(
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
+ subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -661,6 +664,7 @@ public class RoomsController(
[Authorize]
public async Task SubRoom(long roomId, long subRoomId)
{
+ subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -689,6 +693,7 @@ public class RoomsController(
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
+ subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -731,6 +736,7 @@ public class RoomsController(
long roomId, long subRoomId,
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
+ subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -763,7 +769,7 @@ public class RoomsController(
saves.Add(new
{
SubRoomDataSaveId = currentSaveId,
- SubRoomId = subRoomId,
+ SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty(),
@@ -809,7 +815,7 @@ public class RoomsController(
var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
return new
{
- SubRoomId = (long)orderIndex,
+ SubRoomId = RoomSceneIds.ToWire(orderIndex),
RoomId = room.Id,
Name = scene?.Name ?? "Home",
DataBlob = dataBlob,
@@ -824,7 +830,7 @@ public class RoomsController(
CurrentSave = new
{
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
- SubRoomId = (long)orderIndex,
+ SubRoomId = RoomSceneIds.ToWire(orderIndex),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty(),
@@ -871,7 +877,7 @@ public class RoomsController(
return new
{
SubRoomDataSaveId = saveId,
- SubRoomId = subRoomId,
+ SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = unityAssetId,
SubRoomUnityAssetId = unityAssetId,
ReferencedUnityAssetIds = Array.Empty(),
@@ -916,13 +922,14 @@ public class RoomsController(
public async Task SubRoomRestoreData(
long roomId, long subRoomId, [FromForm] string? filename)
{
+ subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
if (string.IsNullOrWhiteSpace(filename))
return BadRequest(new { error = "missing_filename" });
var pid = this.RequireCurrentPlayerId();
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
- if (room.CreatorPlayerId != pid) return Forbid();
+ if (!await CanEditRoomAsync(room, pid)) return Forbid();
var blob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
@@ -1399,6 +1406,7 @@ public class RoomsController(
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
+ if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(room.Id, blob.BlobName);
await db.SaveChangesAsync();
// Push the same SubscriptionUpdateRoom the save flow sends so
@@ -1472,10 +1480,27 @@ public class RoomsController(
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
+ if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(roomId, blob.BlobName);
await db.SaveChangesAsync();
return Ok(new { restored = blob.BlobName });
}
+ /// A room-level restore also restores the entry sub-room
+ /// (OrderIndex 0): room details serve the entry scene's own blob ahead
+ /// of the room's, so updating only Rooms.CurrentDataBlobName
+ /// would leave the old version in place. The per-sub-room restore keeps
+ /// the two in step the other way round. Dorms keep their blob per
+ /// player (DormState) instead.
+ private Task SyncEntrySceneBlobAsync(long roomId, string blobName)
+ {
+ var now = DateTime.UtcNow;
+ return db.RoomScenes
+ .Where(s => s.RoomId == roomId && s.OrderIndex == 0)
+ .ExecuteUpdateAsync(s => s
+ .SetProperty(x => x.DataBlobName, blobName)
+ .SetProperty(x => x.DataModifiedAt, now));
+ }
+
/// GET api/rooms/v{1,2}/personaldetails/{roomId} —
/// per-caller bookmark/cheer flags for one room. Wire shape verified
/// against RecNet.PersonalRoomDetails.Deserialize
@@ -2001,7 +2026,7 @@ public class RoomsController(
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
- SubRoomId = (long)s.OrderIndex,
+ SubRoomId = RoomSceneIds.ToWire(s.OrderIndex),
RoomId = room.Id,
Name = s.Name,
UnityAssetId = s.StudioUnityAssetId,
@@ -2027,7 +2052,7 @@ public class RoomsController(
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
- SubRoomId = 0L,
+ SubRoomId = RoomSceneIds.ToWire(0),
RoomId = room.Id,
Name = "Home",
UnityAssetId = string.Empty,
@@ -2046,11 +2071,10 @@ public class RoomsController(
},
};
- var wireRoles = new List
private async Task MutateScene(long roomId, long subRoomId, Action mutator)
{
+ subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
- if (room.CreatorPlayerId != pid) return Forbid();
+ if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
mutator(scene);
@@ -4219,12 +4323,7 @@ public class RoomsController(
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
- var list = new List(rows.Count + 1)
- {
- BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
- };
- list.AddRange(rows.Select(BuildRoomRoleGrantWire));
- return Ok(list);
+ return Ok(RoomRoleRules.BuildWires(room, rows));
}
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
@@ -4339,30 +4438,38 @@ public class RoomsController(
/// GET rooms/{id}/roles/{playerId} — one role row
/// (NLDBPDCNNCF.PCFOIPELKHN → Task<EFHPLDPNGIM>,
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
- /// above.
+ /// above. The creator answers as Creator (255).
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
public async Task RoleForPlayer(long roomId, long playerId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
- if (room.CreatorPlayerId == playerId)
- return Ok(BuildRoomAccountRoleWire(playerId, 30));
- var row = await db.RoomRoles
- .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
- if (row is null) return NotFound();
- return Ok(BuildRoomRoleGrantWire(row));
+ var rows = await db.RoomRoles
+ .Where(r => r.RoomId == roomId && r.PlayerId == playerId)
+ .ToListAsync();
+ var wire = RoomRoleRules.BuildWires(room, rows)
+ .FirstOrDefault(w => w.AccountId == RoomRoleRules.ToClientAccountId(playerId));
+ if (wire is null) return NotFound();
+ return Ok(wire);
}
- public sealed class GrantRoleRequest { public int? Role { get; set; } }
-
- /// PUT rooms/{id}/roles/{playerId} — owner grants
- /// a role to a player. Auto-accepted (no invite step).
- /// NLDBPDCNNCF.LMIEIJMCEAB ("change room account role",
- /// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key role carrying a
- /// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full
- /// FGCPNAACHIK details — which this already does. Only the
- /// roomserver/ twin was missing.
+ /// PUT rooms/{id}/roles/{playerId} — "change room account
+ /// role" (NLDBPDCNNCF.LMIEIJMCEAB, NLDBPDCNNCF.txt:7516, verb 3 =
+ /// PUT, single form key role boxed as Int32 —
+ /// NLDBPDCNNCF_NestedType_BFKNABDHMGG.txt ISIL 015-024), response the full
+ /// FGCPNAACHIK details. Three callers:
+ ///
+ /// - the owner's role button (AddRemovePlayerRoomRoleConditionalButton
+ /// .EEGJBELCBDI): Host/Moderator are set directly, and "Remove" sends
+ /// role 0;
+ /// - the invitee accepting a co-owner invite (message Type 62,
+ /// KPFKPIKDBFJ.ELKAEBMMIGL / KLNLKDJJDLE Accept): their OWN account id
+ /// with role 30;
+ /// - a co-owner rejecting a direct add (Type 60, COJCABEJDFN Reject):
+ /// their own id with role 0.
+ ///
+ /// Authorization is .
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
@@ -4372,45 +4479,20 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[Authorize]
- public async Task GrantRole(long roomId, long playerId)
- {
- var me = this.RequireCurrentPlayerId();
- var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
- if (room is null) return NotFound();
- if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
- var role = await ReadClientRoomRoleAsync();
- if (role is null) return BadRequest(new { error = "missing_role" });
- if (role.Value == 0)
- return await ClearRoomAccountRoleAsync(room, playerId);
+ [RecNetEnvelope]
+ public Task GrantRole(long roomId, long playerId)
+ => ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
- if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
- return BadRequest(new { error = "invalid_role", role });
-
- var existing = await db.RoomRoles
- .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
- if (existing is null)
- {
- await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
- db.RoomRoles.Add(new RoomRoleEntity
- {
- RoomId = roomId, PlayerId = playerId, Role = roomRole,
- Accepted = true, GrantedByPlayerId = me,
- });
- }
- else
- {
- existing.Accepted = true;
- }
- await db.SaveChangesAsync();
- return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
- }
-
- /// POST rooms/{id}/roles/{playerId}/invite — same
- /// as grant but Accepted=false; the target's accept-invite flow
- /// flips the flag (separate endpoint, not yet exposed). For now
- /// invited rows surface in RoomDetails.InvitedCoOwners etc.
- /// NLDBPDCNNCF.KIJDCLINOIC ("invite account to role",
- /// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.
+ /// PUT rooms/{id}/roles/{playerId}/invite — "invite account
+ /// to role" (NLDBPDCNNCF.KIJDCLINOIC, NLDBPDCNNCF.txt:7686, verb 3,
+ /// form key role as Int32 — NLDBPDCNNCF_NestedType_ENHMMCAOING.txt).
+ /// Only Co-Owner goes through an invite in this client: the owner's button
+ /// sends role 30 here, the invitee is told with a Type 62
+ /// (RoomCoOwnerInvited) message carrying RoomId, and either accepts with
+ /// PUT roles/{me} role 30 or rejects by sending role 0 back to THIS
+ /// route on their own id (KPFKPIKDBFJ.OGONAKHNMGN, KLNLKDJJDLE Reject),
+ /// which clears the pending invite but leaves any role they already
+ /// hold.
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
@@ -4420,62 +4502,168 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[Authorize]
- public async Task InviteRole(long roomId, long playerId)
+ [RecNetEnvelope]
+ public Task InviteRole(long roomId, long playerId)
+ => ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true);
+
+ private async Task ChangeRoomAccountRoleAsync(long roomId, long playerId, bool isInvite)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
- if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
- var role = await ReadClientRoomRoleAsync();
- if (role is null) return BadRequest(new { error = "missing_role" });
- if (role.Value == 0)
- return await ClearRoomAccountRoleAsync(room, playerId);
+ var requested = await ReadClientRoomRoleAsync();
+ if (requested is null) return BadRequest(new { error = "missing_role" });
+ if (playerId <= 0 || !await db.Players.AnyAsync(p => p.Id == playerId))
+ return NotFound(new { error = "unknown_player" });
- if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
- return BadRequest(new { error = "invalid_role", role });
-
- var existing = await db.RoomRoles
- .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
- if (existing is null)
+ var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
+ var error = RoomRoleRules.CheckChange(
+ room, rows, me, playerId, requested.Value, isInvite, out var kind);
+ if (error is not null)
{
- await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
- db.RoomRoles.Add(new RoomRoleEntity
+ logger.LogWarning(
+ "[room-roles] denied room={Room} actor={Actor} target={Target} role={Role} invite={Invite} error={Error}",
+ roomId, me, playerId, requested.Value, isInvite, error);
+ return error == "cannot_change_creator_role" || error == "invalid_role"
+ ? BadRequest(new { error, role = requested.Value })
+ : Forbid();
+ }
+
+ var targetRows = rows.Where(r => r.PlayerId == playerId).ToList();
+ var wasCoOwner = targetRows.Any(r => r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
+ var hadCoOwnerInvite = targetRows.Any(r => !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
+ RoomRoleRules.TryFromWire(requested.Value, out var storedRole);
+
+ switch (kind)
+ {
+ case RoomRoleRules.ChangeKind.Clear when isInvite:
+ // Reject / withdraw an invite: only the pending rows go.
+ db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted));
+ break;
+
+ case RoomRoleRules.ChangeKind.Clear:
+ db.RoomRoles.RemoveRange(targetRows);
+ break;
+
+ case RoomRoleRules.ChangeKind.Invite:
{
- RoomId = roomId, PlayerId = playerId, Role = roomRole,
- Accepted = false, GrantedByPlayerId = me,
- });
+ // Already holding the role: nothing to invite to. Otherwise
+ // replace any other pending invite but keep the role the
+ // player holds until they accept.
+ if (targetRows.Any(r => r.Accepted && r.Role == storedRole)) break;
+ db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted && r.Role != storedRole));
+ var pending = targetRows.FirstOrDefault(r => !r.Accepted && r.Role == storedRole);
+ if (pending is null)
+ {
+ db.RoomRoles.Add(new RoomRoleEntity
+ {
+ RoomId = roomId, PlayerId = playerId, Role = storedRole,
+ Accepted = false, GrantedByPlayerId = me, GrantedAt = DateTime.UtcNow,
+ });
+ }
+ else
+ {
+ pending.GrantedByPlayerId = me;
+ pending.GrantedAt = DateTime.UtcNow;
+ }
+ break;
+ }
+
+ case RoomRoleRules.ChangeKind.AcceptInvite:
+ case RoomRoleRules.ChangeKind.Grant:
+ {
+ // One role per account: the new one replaces the rest
+ // (an accepted invite replaces the role held meanwhile).
+ var keep = targetRows.FirstOrDefault(r => r.Role == storedRole);
+ db.RoomRoles.RemoveRange(targetRows.Where(r => !ReferenceEquals(r, keep)));
+ if (keep is null)
+ {
+ db.RoomRoles.Add(new RoomRoleEntity
+ {
+ RoomId = roomId, PlayerId = playerId, Role = storedRole,
+ Accepted = true,
+ GrantedByPlayerId = kind == RoomRoleRules.ChangeKind.AcceptInvite
+ ? targetRows.FirstOrDefault(r => !r.Accepted)?.GrantedByPlayerId ?? me
+ : me,
+ GrantedAt = DateTime.UtcNow,
+ });
+ }
+ else
+ {
+ keep.Accepted = true;
+ if (kind == RoomRoleRules.ChangeKind.Grant) keep.GrantedByPlayerId = me;
+ keep.GrantedAt = DateTime.UtcNow;
+ }
+ break;
+ }
}
- else
+
+ await db.SaveChangesAsync();
+
+ var isCoOwnerNow = await db.RoomRoles.AnyAsync(r =>
+ r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
+ var hasCoOwnerInviteNow = await db.RoomRoles.AnyAsync(r =>
+ r.RoomId == roomId && r.PlayerId == playerId && !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
+ logger.LogInformation(
+ "[room-roles] room={Room} actor={Actor} target={Target} role={Role} invite={Invite} kind={Kind} coOwner={Was}->{Now}",
+ roomId, me, playerId, requested.Value, isInvite, kind, wasCoOwner, isCoOwnerNow);
+
+ // The invitee's notifications are how the role reaches them: the
+ // 2023 client renders message Types 60/61/62 (factory
+ // OCGKOJMFLAI.ALEHDHAEFOK, enum KJECOLODAFA.MEPCALGGMJC) and reads
+ // only RoomId from them. Self-service changes (accept / reject /
+ // leave) notify nobody.
+ if (playerId != me)
{
- existing.Accepted = false;
+ if (!hadCoOwnerInvite && hasCoOwnerInviteNow)
+ await systemNotifications.SendAsync(playerId,
+ SystemNotificationService.MessageType.RoomCoOwnerInvited, me, roomId: roomId);
+ if (!wasCoOwner && isCoOwnerNow)
+ await systemNotifications.SendAsync(playerId,
+ SystemNotificationService.MessageType.RoomCoOwnerAdded, me, roomId: roomId);
+ if (wasCoOwner && !isCoOwnerNow)
+ await systemNotifications.SendAsync(playerId,
+ SystemNotificationService.MessageType.RoomCoOwnerRemoved, me, roomId: roomId);
}
- await db.SaveChangesAsync();
- return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
+
+ if (hadCoOwnerInvite && !hasCoOwnerInviteNow)
+ await MarkCoOwnerInviteMessagesReadAsync(playerId, roomId);
+
+ var details = await BuildRoomServerDetailsWithRolesAsync(roomId);
+ await PushRoomUpdateAsync(roomId, details, playerId, me);
+ return Ok(details);
}
- private async Task ClearRoomAccountRoleAsync(RoomEntity room, long playerId)
+ /// Once an invite is answered (or withdrawn) its Type 62 message
+ /// must stop offering Accept/Reject in the inbox.
+ private async Task MarkCoOwnerInviteMessagesReadAsync(long playerId, long roomId)
{
- if (room.CreatorPlayerId == playerId)
- return BadRequest(new { error = "cannot_change_creator_role" });
-
- var rows = await db.RoomRoles
- .Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
+ var stale = await db.Messages
+ .Where(m => m.RecipientPlayerId == playerId
+ && m.RoomId == roomId
+ && m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited
+ && m.ReadAt == null)
.ToListAsync();
- if (rows.Count > 0)
- db.RoomRoles.RemoveRange(rows);
-
+ if (stale.Count == 0) return;
+ foreach (var m in stale) m.ReadAt = DateTime.UtcNow;
await db.SaveChangesAsync();
- return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
}
- private async Task CanManageRoomRolesAsync(RoomEntity room, long playerId)
+ /// Push the fresh room details as RoomUpdate (handler
+ /// IBEOONPEELF.txt:507, Action<FGCPNAACHIK>) to everyone currently
+ /// in the room plus the players whose view changed, so the role list,
+ /// the role-gated buttons and the local player's own role refresh
+ /// without leaving the room.
+ private async Task PushRoomUpdateAsync(long roomId, object details, params long[] alsoNotify)
{
- if (room.CreatorPlayerId == playerId) return true;
- return await db.RoomRoles.AnyAsync(r =>
- r.RoomId == room.Id
- && r.PlayerId == playerId
- && r.Accepted
- && r.Role == 0);
+ var recipients = presence.EnumerateActiveInstances(roomId, includePrivate: true)
+ .SelectMany(i => i.PlayerIds)
+ .Concat(alsoNotify)
+ .Where(pid => pid > 0)
+ .Distinct()
+ .ToList();
+ foreach (var pid in recipients)
+ await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, details);
}
private async Task ReadClientRoomRoleAsync()
@@ -4486,6 +4674,8 @@ public class RoomsController(
var form = await Request.ReadFormAsync();
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
+ if (TryParseRoleName(form["role"].FirstOrDefault() ?? form["Role"].FirstOrDefault(), out parsed))
+ return parsed;
}
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
@@ -4495,10 +4685,20 @@ public class RoomsController(
{
try
{
- var body = await JsonSerializer.DeserializeAsync(
- Request.Body,
- new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
- if (body?.Role is int bodyRole) return bodyRole;
+ using var doc = await JsonDocument.ParseAsync(Request.Body);
+ if (doc.RootElement.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var prop in doc.RootElement.EnumerateObject())
+ {
+ if (!prop.Name.Equals("role", StringComparison.OrdinalIgnoreCase)) continue;
+ if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out parsed))
+ return parsed;
+ if (prop.Value.ValueKind == JsonValueKind.String
+ && (int.TryParse(prop.Value.GetString(), out parsed)
+ || TryParseRoleName(prop.Value.GetString(), out parsed)))
+ return parsed;
+ }
+ }
}
catch (JsonException)
{
@@ -4509,64 +4709,34 @@ public class RoomsController(
return null;
}
- private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole)
+ /// OMMBGJMJJPN member names, for callers that box the enum
+ /// itself (Enum.ToString) instead of an Int32.
+ internal static bool TryParseRoleName(string? raw, out int role)
{
- var rows = await db.RoomRoles
- .Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole)
- .ToListAsync();
- if (rows.Count > 0)
- db.RoomRoles.RemoveRange(rows);
- }
-
- private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole)
- {
- roomRole = clientRole switch
+ role = (raw ?? string.Empty).Trim().ToLowerInvariant() switch
{
- 30 => 0, // CoOwner
- 20 => 1, // Moderator
- 10 => 2, // Host
+ "none" => RoomRoleRules.WireNone,
+ "banned" => RoomRoleRules.WireBanned,
+ "host" => RoomRoleRules.WireHost,
+ "moderator" => RoomRoleRules.WireModerator,
+ "coowner" => RoomRoleRules.WireCoOwner,
+ "temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
+ "creator" => RoomRoleRules.WireCreator,
_ => -1,
};
- return roomRole >= 0;
+ return role >= 0;
}
- private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
- => BuildRoomAccountRoleWire(
- role.PlayerId,
- ToClientRoomRole(role.Role),
- role.Accepted,
- role.GrantedByPlayerId);
-
- private static object BuildRoomAccountRoleWire(
- long accountId,
- int role,
- bool accepted = true,
- long? lastChangedByAccountId = null)
- => new
- {
- AccountId = ToClientAccountId(accountId),
- Role = accepted ? role : 0,
- LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
- InvitedRole = accepted ? 0 : role,
- };
-
- private static int ToClientRoomRole(int roomRole) => roomRole switch
+ /// Creator or accepted co-owner (see
+ /// ).
+ private async Task CanEditRoomAsync(RoomEntity room, long playerId)
{
- 0 => 30, // CoOwner
- 1 => 20, // Moderator
- 2 => 10, // Host
- _ => 0,
- };
-
- private static int ToClientAccountId(long accountId)
- => accountId > int.MaxValue
- ? int.MaxValue
- : accountId < int.MinValue
- ? int.MinValue
- : (int)accountId;
-
- private static int? ToClientAccountId(long? accountId)
- => accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
+ if (room.CreatorPlayerId == playerId) return true;
+ var rows = await db.RoomRoles
+ .Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
+ .ToListAsync();
+ return RoomRoleRules.CanEditRoom(room, rows, playerId);
+ }
private async Task BuildRoomServerDetailsWithRolesAsync(long roomId)
{
diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs
index 34baf7f..93688d5 100644
--- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs
+++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs
@@ -501,6 +501,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/description")]
[HttpPut("roomserver/rooms/{roomId:long}/description")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareDescription(long roomId)
{
// RawValue, not the whitespace-skipping reader: clearing the
@@ -520,6 +521,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/image")]
[HttpPut("roomserver/rooms/{roomId:long}/image")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareImage(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -541,6 +543,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/tags")]
[HttpPut("roomserver/rooms/{roomId:long}/tags")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareTags(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -567,6 +570,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareAccessibility(long roomId)
{
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
@@ -899,6 +903,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/cloning")]
[HttpPut("roomserver/rooms/{roomId:long}/cloning")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareCloning(long roomId)
{
var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value");
@@ -918,6 +923,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/automute")]
[HttpPut("roomserver/rooms/{roomId:long}/automute")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareAutomute(long roomId)
{
var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value");
@@ -939,6 +945,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareRestrictions(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -965,6 +972,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/warning")]
[HttpPut("roomserver/rooms/{roomId:long}/warning")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareWarning(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -983,6 +991,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareCreator(long roomId)
{
var creatorId = await ReadBareLongAsync(
@@ -1001,6 +1010,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareAllowNewUsers(long roomId)
{
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
@@ -1015,6 +1025,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareMinLevel(long roomId)
{
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
@@ -1029,6 +1040,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareMaxPlayerCalculationMode(long roomId)
{
var value = await ReadBareIntAsync(
@@ -1054,6 +1066,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareLoadScreen(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1085,6 +1098,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/loadscreen")]
[HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareLoadScreenRemove(long roomId)
{
var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value")
@@ -1121,6 +1135,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BarePromoImagesAdd(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
@@ -1137,6 +1152,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/promo_images")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BarePromoImagesRemove(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
@@ -1167,6 +1183,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BarePromoExternalAdd(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1197,6 +1214,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/promo_external")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BarePromoExternalRemove(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1275,6 +1293,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
[HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareAck(long roomId) =>
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
@@ -1305,6 +1324,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
+ [RecNetEnvelope]
public async Task BareModify(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1432,6 +1452,13 @@ public class RoomsModerationController(
// it editable. Safe for admin: the purge gate and "RR Original"
// badge both key on CreatorPlayerId == SystemAccountId, so a
// user-owned clone is still shown as Custom and stays purgeable.
+ //
+ // The WIRE IsRRO is a different thing: it is false for every
+ // player-owned room (RoomService.WireIsRRO). The 2023 client's
+ // room-load pipeline never reads IsRRO (the baked scene comes
+ // from the sub-room's UnitySceneId, the blob step from DataBlob),
+ // while its owner UI refuses publish and every room setting on
+ // an RRO, which is what kept cloned rooms from being published.
IsAGRoom = source.IsAGRoom,
IsDormRoom = false,
CloningAllowed = source.CloningAllowed,
@@ -1509,6 +1536,18 @@ public class RoomsModerationController(
CanMatchmakeInto = s.CanMatchmakeInto,
DataModifiedAt = DateTime.UtcNow,
}).ToList();
+ // A template with no scene rows still has to give the new room a
+ // real entry sub-room (named "Home", from the clone's own location,
+ // blob and capacity). Without it the details served a synthesised
+ // placeholder and Room Settings > Subrooms showed an empty row.
+ if (clonedScenes.Count == 0)
+ {
+ var home = Services.RoomService.NewHomeScene(clone);
+ home.Id = ++nextSceneId;
+ clonedScenes.Add(home);
+ }
+ foreach (var scene in clonedScenes.Where(s => string.IsNullOrWhiteSpace(s.Name)))
+ scene.Name = Services.RoomService.DefaultSubRoomName(scene.OrderIndex);
if (clonedScenes.Count > 0)
{
db.RoomScenes.AddRange(clonedScenes);
@@ -1580,8 +1619,11 @@ public class RoomsModerationController(
// The scene was created as a shallow copy, so its DataBlobName is
// currently the SOURCE's. Either a copy replaces it or it gets
// cleared — it must never survive as-is.
+ // A synthesised Home scene (template had no rows) has no source
+ // scene; it starts from the room-level blob it was built with.
+ var sourceBlob = i < sourceScenes.Count ? sourceScenes[i].DataBlobName : target.DataBlobName;
var copied = await CopyBlobForCloneAsync(
- sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId);
+ sourceBlob, clone.Id, target.OrderIndex, callerId);
target.DataBlobName = copied ?? string.Empty;
target.DataModifiedAt = DateTime.UtcNow;
@@ -1892,7 +1934,13 @@ public class RoomsModerationController(
/// ). The slim RoomService.ToWireRoom has no
/// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict
/// reader's dispose-walk NRE'd on the missing nested lists and every
- /// mutation reported failure even though it had persisted.
+ /// mutation reported failure even though it had persisted.
+ ///
+ /// That helper is NLDBPDCNNCF.BDBHCEGKGDC<TResult>, the
+ /// {success, value, error} envelope reader, so the details are the
+ /// envelope's value: every caller carries
+ /// . A bare details body
+ /// fails with "Exception of type 'CEMNLBKJABA' was thrown."
private async Task BuildRoomDetailsAsync(RoomEntity room)
{
var scenes = await db.RoomScenes
diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs
index 2da8e0f..6467650 100644
--- a/DorkNet.Server/Controllers/Match/GoToController.cs
+++ b/DorkNet.Server/Controllers/Match/GoToController.cs
@@ -317,6 +317,10 @@ public class GoToController(
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null)
{
+ // The client sends the wire SubRoomId it read from the room details
+ // (OrderIndex + 1, see RoomSceneIds); everything below works in
+ // OrderIndex.
+ subRoomId = RoomSceneIds.ToOrderIndex(subRoomId);
var room = await rooms.GetByIdAsync(roomId);
if (room is null)
{
@@ -1552,9 +1556,25 @@ public class RoomInstanceDto
[JsonPropertyName("roomId")]
public long RoomId { get; set; }
- [JsonPropertyName("subRoomId")]
+ /// The sub-room's
+ /// (0 = the room's first sub-room). Server code reads and writes this;
+ /// the wire carries .
+ [JsonIgnore]
public long SubRoomId { get; set; }
+ /// The 2023 client's SubRoomId: OrderIndex + 1 (see
+ /// ). It must equal the
+ /// SubRooms[].SubRoomId the room details advertise — the client
+ /// compares the two (SubroomModel.get_SubroomIsLocal) and looks the
+ /// current sub-room up by it. Presence round-trips through this property
+ /// too, so stored JSON stays consistent.
+ [JsonPropertyName("subRoomId")]
+ public long WireSubRoomId
+ {
+ get => Services.RoomSceneIds.ToWire(SubRoomId);
+ set => SubRoomId = Services.RoomSceneIds.ToOrderIndex(value);
+ }
+
// STRING RoomSceneLocationId GUID — foreign-keys to
// AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via
// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default
diff --git a/DorkNet.Server/Controllers/Match/MatchController.cs b/DorkNet.Server/Controllers/Match/MatchController.cs
index 2d23d33..c874eef 100644
--- a/DorkNet.Server/Controllers/Match/MatchController.cs
+++ b/DorkNet.Server/Controllers/Match/MatchController.cs
@@ -140,66 +140,48 @@ public class MatchController(
});
}
+ /// GET photon_access_token — read by the room-permissions
+ /// runtime as ALIIIAGAPND {RoomInstanceId, PhotonAccessToken,
+ /// Permissions} (NLDBPDCNNCF.CEOPCANHPPH, verb 0, issued from the
+ /// rooms client, hence the roomserver/ twin).
+ /// Permissions is the role-permission matrix of the sub-room the
+ /// player is in: the defaults plus whatever the room's editors saved with
+ /// PUT rooms/{id}/subrooms/{sub}/permissions
+ /// (). It must not be empty: with no
+ /// Permissions the client falls into its persisted-role migration path,
+ /// which NREs during first spawn.
[HttpGet("/photon_access_token")]
- public IActionResult PhotonAccessToken()
+ [HttpGet("/roomserver/photon_access_token")]
+ public async Task PhotonAccessToken()
{
var pid = this.RequireCurrentPlayerId();
var appId = config["Photon:AppId"] ?? string.Empty;
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
var currentRoom = presence.GetRoom(pid);
- var permissions = BuildPhotonPermissionSeed();
+ var permissions = currentRoom is null
+ ? RoomPermissionMatrix.Merge(Array.Empty())
+ : await RoomPermissionMatrix.LoadAsync(db, currentRoom.RoomId, currentRoom.SubRoomId);
logger.LogInformation(
- "[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
- pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length);
- return Ok(new Dictionary
- {
- // 2023 deserializes this endpoint as ALIIIAGAPND before room
- // permissions initialization. If Permissions is missing/empty,
- // OMJKHJLFOCO falls into the persisted-role migration path that
- // currently NREs during first spawn.
- ["RoomInstanceId"] = currentRoom?.RoomInstanceId ?? 0L,
- ["PhotonAccessToken"] = string.Empty,
- ["Permissions"] = permissions,
+ "[photon-token] player={PlayerId} room={RoomId} sub={SubRoomId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
+ pid, currentRoom?.RoomId ?? 0L, currentRoom?.SubRoomId ?? 0L, currentRoom?.RoomInstanceId ?? 0L, permissions.Count);
- // Existing custom-auth/app-id shape kept for Photon auth callers.
- ["ResultCode"] = 1,
- ["Message"] = string.Empty,
- ["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture),
- ["PhotonAppId"] = appId,
- ["PhotonVoiceAppId"] = voiceAppId,
- ["AppId"] = appId,
- ["VoiceAppId"] = voiceAppId,
- ["CloudRegion"] = region,
- ["Region"] = region,
- ["Token"] = string.Empty,
- });
+ var body = RoomPermissionMatrix.AccessTokenBody(currentRoom?.RoomInstanceId ?? 0L, permissions);
+ // Existing custom-auth/app-id shape kept for Photon auth callers; the
+ // ALIIIAGAPND reader ignores keys it does not register.
+ body["ResultCode"] = 1;
+ body["Message"] = string.Empty;
+ body["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ body["PhotonAppId"] = appId;
+ body["PhotonVoiceAppId"] = voiceAppId;
+ body["AppId"] = appId;
+ body["VoiceAppId"] = voiceAppId;
+ body["CloudRegion"] = region;
+ body["Region"] = region;
+ body["Token"] = string.Empty;
+ return Ok(body);
}
- private static object[] BuildPhotonPermissionSeed()
- =>
- [
- BuildPhotonPermission(13, 30), // Can Use Maker Pen
- BuildPhotonPermission(20, 30), // Can Spawn Inventions
- BuildPhotonPermission(21, 30), // Can Spawn Consumables
- BuildPhotonPermission(22, 30), // Can Use Room Reset Button
- BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle
- BuildPhotonPermission(13, 255), // Creator alias
- BuildPhotonPermission(20, 255),
- BuildPhotonPermission(21, 255),
- BuildPhotonPermission(22, 255),
- BuildPhotonPermission(23, 255),
- ];
-
- private static object BuildPhotonPermission(int permission, int role) => new
- {
- Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture),
- Role = role,
- Override = true,
- Type = 0,
- Value = "true",
- };
-
[HttpPost("/matchmake/dorm")]
public async Task MatchmakeDorm()
{
@@ -490,97 +472,125 @@ public class MatchController(
return Ok(session);
}
- /// GET match.*/room/{id}/instances — the
- /// "RoomInstanceBrowser" the watch shows when you tap a room tile.
- /// Returns the currently-active instances so the player can see
- /// which one their friends are in (and join it) instead of being
- /// matchmade into a fresh empty room. Verified URL at
- /// Matchmaking.txt:6286; response is a
- /// List<SimpleRoomInstance> per
- /// SimpleRoomInstance.cs: RoomInstanceId/RoomId/SubRoomId,
- /// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount,
- /// HasModPresent, HashedInstanceId.
+ /// GET match.*/room/{id}/instances — the room instance
+ /// browser.
///
- /// We derive instances from PlayerPresenceService's per-player
- /// RoomInstanceDto: every distinct PhotonRoomId in the same RoomId
- /// is one instance, with the players currently presenced there.
- /// PrivateInstances are surfaced as well so the inviter and
- /// invitee see the same room in the browser.
+ /// Client contract. RecNet.Matchmaking.KAIPGDGACGG(long)
+ /// (RecNet.Runtime/RecNet/Matchmaking.txt:13966, route at :14237, verb 0 =
+ /// GET on the matchmaking host) returns List<PNDCMIMEJLD>, a bare
+ /// array. The reader (JPNMBICAMEE.txt) takes exactly RoomInstanceId,
+ /// RoomId, SubRoomId, IsFull, CreatedAt and PlayerIds; the post-parse hook
+ /// (PNDCMIMEJLD.FKDDCNLJOLF) derives PlayerCount from PlayerIds.Count,
+ /// SubroomName from the cached room details' SubRooms by SubRoomId, and
+ /// HasModPresent from the room's Roles. The screen
+ /// (RoomInstanceBrowserScreen / RoomInstancePagedGrid) shows every row it
+ /// is given, current instance first then oldest CreatedAt first, and
+ /// renders the age as UtcNow - CreatedAt, so CreatedAt must be UTC.
+ ///
+ /// What is listed. Every live instance of the room: each
+ /// distinct Photon room in which at least one player is presenced with a
+ /// live activity marker ().
+ /// Private instances are included only for callers allowed into them —
+ /// the room creator, accepted role holders, the instance owner, its
+ /// invitees, and players already inside — so another room's invite-only
+ /// match never leaks. Empty instances are not listed: there is nothing to
+ /// join and they are what made the old list stale.
+ ///
+ /// Fixed defects. The Redis presence path returned nothing at
+ /// all, so the browser was empty on every multi-replica deployment;
+ /// private instances (every user room is born private and has one
+ /// instance per sub-room) were dropped from presence and re-added only
+ /// for their owner with an empty PlayerIds, so they showed 0 players or
+ /// nothing; expired or crashed presences still counted; IsFull was always
+ /// false; and SubRoomId used the OrderIndex, which does not match the
+ /// room details' wire ids.
[HttpGet("/room/{roomId:long}/instances")]
[AllowAnonymous]
public async Task RoomInstances(long roomId)
{
- var presenced = presence.EnumerateActiveInstances(roomId).ToList();
- // Only surface the caller's OWN private instances — we never
- // leak someone else's PhotonRoomId into the browser. The Photon
- // join check still requires CanJoinAsync, but redacting at the
- // list level matches the existing RoomInstanceDto.Redact policy.
var pid = this.CurrentPlayerId() ?? 0;
- var privates = pid == 0
- ? new List()
- : await db.PrivateInstances
- .Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid)
- .ToListAsync();
+ var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
+ if (room is null) return Ok(Array.Empty());
- // Merge: presence rows are authoritative (player currently
- // there); add any owned private instance the inviter created
- // but nobody's joined yet so the inviter can still see it.
- var byKey = new Dictionary(StringComparer.Ordinal);
- foreach (var (room, playerIds) in presenced)
+ var live = presence.EnumerateActiveInstances(roomId, includePrivate: true).ToList();
+
+ var privateIds = live.Where(l => l.Room.IsPrivate).Select(l => l.Room.RoomInstanceId).Distinct().ToList();
+ var privateRows = privateIds.Count == 0
+ ? new Dictionary()
+ : await db.PrivateInstances.AsNoTracking()
+ .Where(p => privateIds.Contains(p.Id))
+ .ToDictionaryAsync(p => p.Id);
+
+ var callerHasRole = pid != 0 && (room.CreatorPlayerId == pid
+ || await db.RoomRoles.AsNoTracking().AnyAsync(r =>
+ r.RoomId == roomId && r.PlayerId == pid && r.Accepted));
+ var invitedTo = pid == 0 || privateIds.Count == 0
+ ? new HashSet()
+ : (await db.PrivateInstanceInvitees.AsNoTracking()
+ .Where(i => i.PlayerId == pid && privateIds.Contains(i.PrivateInstanceId))
+ .Select(i => i.PrivateInstanceId)
+ .ToListAsync()).ToHashSet();
+
+ var rows = new List();
+ foreach (var (inst, playerIds) in live)
{
- if (string.IsNullOrEmpty(room.PhotonRoomId)) continue;
- byKey[room.PhotonRoomId] = new SimpleInstance(
- RoomInstanceId: room.RoomInstanceId,
- RoomId: room.RoomId,
- SubRoomId: room.SubRoomId,
- IsFull: false,
- CreatedAt: DateTime.UtcNow,
- PlayerIds: playerIds.Select(p => (int)p).ToList(),
- SubroomName: room.Name ?? string.Empty,
- PlayerCount: playerIds.Count,
- HasModPresent: false,
- HashedInstanceId: room.PhotonRoomId);
- }
- foreach (var p in privates)
- {
- if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
- byKey[p.PhotonRoomId] = new SimpleInstance(
- RoomInstanceId: p.Id,
- RoomId: p.RoomId,
- SubRoomId: p.SubRoomId,
- IsFull: false,
- CreatedAt: DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
- PlayerIds: new List(),
- SubroomName: p.Name,
- PlayerCount: 0,
- HasModPresent: false,
- HashedInstanceId: p.PhotonRoomId);
+ privateRows.TryGetValue(inst.RoomInstanceId, out var privateRow);
+ if (inst.IsPrivate && !CanSeePrivateInstance(
+ pid, callerHasRole, playerIds, privateRow, invitedTo.Contains(inst.RoomInstanceId)))
+ continue;
+
+ var capacity = inst.MaxCapacity > 0 ? inst.MaxCapacity : privateRow?.MaxCapacity ?? 0;
+ rows.Add(new RoomInstanceRow(
+ RoomInstanceId: inst.RoomInstanceId,
+ RoomId: inst.RoomId,
+ SubRoomId: inst.SubRoomId,
+ IsFull: capacity > 0 && playerIds.Count >= capacity,
+ CreatedAt: privateRow is not null
+ ? DateTime.SpecifyKind(privateRow.CreatedAt, DateTimeKind.Utc)
+ : DateTime.UtcNow,
+ PlayerIds: playerIds.Distinct().OrderBy(p => p).ToList()));
}
- // Wire shape: SimpleRoomInstance.Deserialize reads CAMELCASE
- // keys — verified at Cpp2IL_ISIL/.../RecNet/SimpleRoomInstance.txt
- // lines 765-808 ({roomInstanceId, roomId, subRoomId, isFull,
- // createdAt, playerIds}). SubroomName / PlayerCount /
- // HasModPresent / HashedInstanceId are populated locally on the
- // watch (PlayerIds.Count, local hash, etc.) — not read from
- // JSON, so we don't send them. Earlier PascalCase response made
- // every field default to 0/null on the watch, breaking the
- // browser.
- return Ok(byKey.Values.Select(i => new
- {
- roomInstanceId = i.RoomInstanceId,
- roomId = i.RoomId,
- subRoomId = i.SubRoomId,
- isFull = i.IsFull,
- createdAt = i.CreatedAt,
- playerIds = i.PlayerIds.ToArray(),
- }));
+ logger.LogInformation(
+ "[room-instances] room={Room} caller={Caller} live={Live} listed={Listed}",
+ roomId, pid, live.Count, rows.Count);
+ return Ok(rows.OrderBy(r => r.CreatedAt).Select(ToWire));
}
- private sealed record SimpleInstance(
+ /// Who may see (and so be offered a join into) a private
+ /// instance in the browser.
+ public static bool CanSeePrivateInstance(
+ long callerId,
+ bool callerHasRoomRole,
+ IReadOnlyCollection playersInside,
+ PrivateInstanceEntity? privateRow,
+ bool callerInvited)
+ {
+ if (callerId == 0) return false;
+ if (callerHasRoomRole) return true;
+ if (playersInside.Contains(callerId)) return true;
+ if (privateRow is not null && privateRow.OwnerPlayerId == callerId) return true;
+ return callerInvited;
+ }
+
+ internal sealed record RoomInstanceRow(
long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull,
- DateTime CreatedAt, List PlayerIds, string SubroomName,
- int PlayerCount, bool HasModPresent, string HashedInstanceId);
+ DateTime CreatedAt, List PlayerIds);
+
+ /// PNDCMIMEJLD wire row. Account ids are Int32 on this DTO;
+ /// SubRoomId is the wire id the room details advertise
+ /// (), which the client uses to name the row.
+ /// The reader matches PascalCase, camelCase and lowercase keys; camelCase
+ /// is what this route has always sent.
+ internal static object ToWire(RoomInstanceRow r) => new
+ {
+ roomInstanceId = r.RoomInstanceId,
+ roomId = r.RoomId,
+ subRoomId = RoomSceneIds.ToWire(r.SubRoomId),
+ isFull = r.IsFull,
+ createdAt = r.CreatedAt,
+ playerIds = r.PlayerIds.Select(p => (int)p).ToArray(),
+ };
/// DELETE match.*/invite/{id} — Matchmaking.cs
/// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method
diff --git a/DorkNet.Server/Controllers/RecNetEnvelopeAttribute.cs b/DorkNet.Server/Controllers/RecNetEnvelopeAttribute.cs
new file mode 100644
index 0000000..eaf6577
--- /dev/null
+++ b/DorkNet.Server/Controllers/RecNetEnvelopeAttribute.cs
@@ -0,0 +1,115 @@
+using System.Text.Json;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace DorkNet.Server.Controllers;
+
+///
+/// Wraps an action's reply in the legacy {success, value, error}
+/// envelope the 2023 rooms client reads for every room and playlist mutation.
+///
+/// Client contract. All of these calls go through one generic
+/// helper, NLDBPDCNNCF.BDBHCEGKGDC<TResult>(string opName, HTTPMethods,
+/// string route, …) (RVA 0x35E50A0, recnet-runtime-decomp/NLDBPDCNNCF.cs:2310;
+/// called as Call 0x1835E50A0 47 times in
+/// IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt). It is the same helper, with the
+/// same BDBHCEGKGDC<FGCPNAACHIK> instantiation token
+/// [0x1884C1948], that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and
+/// clone is proven in game to need {success, value, error}
+/// (docs/recroom-2023-room-save.md). A bare room-details body has no
+/// success, so the helper rejects it with no message and the player sees
+/// Exception of type 'CEMNLBKJABA' was thrown. even though the server
+/// applied the change. The routes are: rooms/{0}/{accessibility,
+/// allow_new_users, automute, cloning, comments, creator, description, image,
+/// loadscreen, max_player_calculation_mode, min_level, modify, name,
+/// promo_external, promo_images, restrictions, tags, voice_chat_encryption,
+/// warning}, rooms/{0}/roles/{1}[/invite], rooms/{0}/subrooms[/{1}[/accessibility,
+/// clone, maxplayers, modify, move, name, permissions, publish_save]],
+/// playlists and playlists/{0}/{accessibility, description, image, levelvoting,
+/// name, restrictions, rooms/{1}, tags, warning}.
+///
+/// A 2xx object result becomes {success:true, value, error:""}.
+/// Anything else becomes HTTP 200 {success:false, value:null, error}
+/// with a message the client can show, because a non-2xx reply also surfaces
+/// only as the message-less CEMNLBKJABA.
+///
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
+public sealed class RecNetEnvelopeAttribute : ResultFilterAttribute
+{
+ public override void OnResultExecuting(ResultExecutingContext context)
+ {
+ context.Result = Wrap(context.Result);
+ base.OnResultExecuting(context);
+ }
+
+ public static IActionResult Wrap(IActionResult result)
+ {
+ switch (result)
+ {
+ case ObjectResult obj when obj.StatusCode is null or (>= 200 and < 300):
+ return IsEnvelope(obj.Value)
+ ? result
+ : Success(obj.Value);
+ case ObjectResult obj:
+ return Failure(ErrorText(obj.Value) ?? DefaultError(obj.StatusCode ?? 500));
+ case ForbidResult or ChallengeResult or UnauthorizedResult:
+ return Failure(DefaultError(403));
+ case StatusCodeResult code when code.StatusCode is >= 200 and < 300:
+ return Success(null);
+ case StatusCodeResult code:
+ return Failure(DefaultError(code.StatusCode));
+ case EmptyResult:
+ return Success(null);
+ default:
+ return result;
+ }
+ }
+
+ public static OkObjectResult Success(object? value) =>
+ new(new { success = true, value, error = string.Empty });
+
+ public static OkObjectResult Failure(string error) =>
+ new(new { success = false, value = (object?)null, error });
+
+ private static string DefaultError(int status) => status switch
+ {
+ 400 => "That request was not valid.",
+ 401 or 403 => "You don't have permission to do that.",
+ 404 => "That room doesn't exist.",
+ 409 => "That name is already taken.",
+ _ => "Something went wrong.",
+ };
+
+ /// An error/Error string on the failure body, if any.
+ private static string? ErrorText(object? value)
+ {
+ if (value is null) return null;
+ if (value is string s) return string.IsNullOrWhiteSpace(s) ? null : s;
+ try
+ {
+ using var doc = JsonDocument.Parse(JsonSerializer.Serialize(value));
+ if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
+ foreach (var prop in doc.RootElement.EnumerateObject())
+ {
+ if (!prop.Name.Equals("error", StringComparison.OrdinalIgnoreCase)) continue;
+ if (prop.Value.ValueKind == JsonValueKind.String)
+ {
+ var text = prop.Value.GetString();
+ return string.IsNullOrWhiteSpace(text) ? null : text;
+ }
+ }
+ }
+ catch (NotSupportedException)
+ {
+ }
+ return null;
+ }
+
+ private static bool IsEnvelope(object? value)
+ {
+ if (value is null) return false;
+ var props = value.GetType().GetProperties();
+ bool Has(string name) => props.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ return Has("success") && Has("value") && Has("error");
+ }
+}
diff --git a/DorkNet.Server/Data/DorkNetDbContext.cs b/DorkNet.Server/Data/DorkNetDbContext.cs
index 7b92952..f6461d2 100644
--- a/DorkNet.Server/Data/DorkNetDbContext.cs
+++ b/DorkNet.Server/Data/DorkNetDbContext.cs
@@ -11,6 +11,7 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo
public DbSet Relationships => Set();
public DbSet Rooms => Set();
public DbSet RoomRoles => Set();
+ public DbSet RoomRolePermissions => Set();
public DbSet LeaderboardChannelMeta => Set();
public DbSet RoomBookmarks => Set();
public DbSet RoomDataBlobs => Set();
@@ -134,6 +135,14 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo
e.HasIndex(p => new { p.ClubId, p.MembershipType }).IsUnique();
});
+ // One cell per (room, sub-room, role, permission): the sub-room
+ // permissions PUT upserts the cells it was sent.
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(p => p.Id);
+ e.HasIndex(p => new { p.RoomId, p.SubRoomId, p.Role, p.Permission }).IsUnique();
+ });
+
modelBuilder.Entity(e =>
{
e.HasKey(i => i.Id);
diff --git a/DorkNet.Server/Data/Entities/RoomRolePermissionEntity.cs b/DorkNet.Server/Data/Entities/RoomRolePermissionEntity.cs
new file mode 100644
index 0000000..75d2731
--- /dev/null
+++ b/DorkNet.Server/Data/Entities/RoomRolePermissionEntity.cs
@@ -0,0 +1,53 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace DorkNet.Server.Data.Entities;
+
+///
+/// One cell of a sub-room's role-permission matrix, as written by
+/// PUT rooms/{roomId}/subrooms/{subRoomId}/permissions and served back
+/// in the Permissions list of GET photon_access_token.
+///
+/// The 2023-03-21 client models a cell as BNLPBMJJOMM
+/// (formatter RecNet.Runtime/HDOJMEBCAGP.txt, keys
+/// Permission / Role / Override / Type /
+/// Value): Permission and Value are strings, Role is the room-role
+/// enum OMMBGJMJJPN (None 0, Host 10, Moderator 20, CoOwner 30,
+/// TemporaryCoOwner 31, Creator 255), Type is FEOJFGECKGH
+/// (Bool 0, Int 1, StringList 2) and Override is a bool.
+///
+/// The editor sends only the cells it changed (the room-permissions runtime
+/// builds a one-element BNLPBMJJOMM[] per edit,
+/// OMJKHJLFOCO.AFHJLBBFAGP.<LocalRequestModifyRolePermission>g__ChangeAndRefresh),
+/// so the write is an upsert per (room, sub-room, role, permission) and never
+/// a replace of the whole matrix.
+///
+public class RoomRolePermissionEntity
+{
+ public long Id { get; set; }
+
+ public long RoomId { get; set; }
+
+ /// The wire sub-room id from the route (the same value the room
+ /// details advertise as SubRooms[].SubRoomId).
+ public long SubRoomId { get; set; }
+
+ /// Wire room-role value (OMMBGJMJJPN), e.g. 30 = CoOwner, 255 = Creator.
+ public int Role { get; set; }
+
+ /// The permission id exactly as the client sent it.
+ [MaxLength(128)]
+ public string Permission { get; set; } = string.Empty;
+
+ /// FEOJFGECKGH: 0 = Bool, 1 = Int, 2 = StringList.
+ public int Type { get; set; }
+
+ public bool Override { get; set; }
+
+ /// The value exactly as the client sent it (a string on the wire).
+ [MaxLength(4096)]
+ public string Value { get; set; } = string.Empty;
+
+ public long UpdatedByPlayerId { get; set; }
+
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/DorkNet.Server/Migrations/20261024201104_RoomRolePermissions.Designer.cs b/DorkNet.Server/Migrations/20261024201104_RoomRolePermissions.Designer.cs
new file mode 100644
index 0000000..c9ef971
--- /dev/null
+++ b/DorkNet.Server/Migrations/20261024201104_RoomRolePermissions.Designer.cs
@@ -0,0 +1,3718 @@
+//
+using System;
+using DorkNet.Server.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace DorkNet.Server.Migrations
+{
+ [DbContext(typeof(DorkNetDbContext))]
+ [Migration("20261024201104_RoomRolePermissions")]
+ partial class RoomRolePermissions
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("AdminPlayerId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("TargetId")
+ .HasColumnType("INTEGER");
+
+ b.Property("TargetType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Timestamp")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AdminPlayerId");
+
+ b.HasIndex("Timestamp");
+
+ b.HasIndex("TargetType", "TargetId");
+
+ b.ToTable("AdminActions");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CustomAvatarItemsJson")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("EquippedItemsJson")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("FaceFeatures")
+ .IsRequired()
+ .HasMaxLength(8192)
+ .HasColumnType("TEXT");
+
+ b.Property("HairColor")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("InventoryJson")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("OutfitSelections")
+ .IsRequired()
+ .HasMaxLength(2048)
+ .HasColumnType("TEXT");
+
+ b.Property("OutfitSelectionsV2")
+ .IsRequired()
+ .HasMaxLength(8192)
+ .HasColumnType("TEXT");
+
+ b.Property("PlayerId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SavedOutfitsJson")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("SkinColor")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PlayerId")
+ .IsUnique();
+
+ b.ToTable("Avatars");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasMaxLength(4000)
+ .HasColumnType("TEXT");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("ClientVersion")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("GameSessionId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Platform")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("ReadAt")
+ .HasColumnType("TEXT");
+
+ b.Property("ReporterPlayerId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ReadAt");
+
+ b.HasIndex("ReporterPlayerId");
+
+ b.ToTable("BugReports");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ActionUrl")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("TEXT");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("TEXT");
+
+ b.Property("ImageName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("PlayerId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Priority")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExpiresAt");
+
+ b.HasIndex("PlayerId");
+
+ b.ToTable("Cards");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("IsBuiltIn")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("WordsJson")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("CharadesWordLists");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("TEXT");
+
+ b.Property("ModerationState")
+ .HasColumnType("INTEGER");
+
+ b.Property("SenderPlayerId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SentAt")
+ .HasColumnType("TEXT");
+
+ b.Property("ThreadKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ThreadKey", "SentAt");
+
+ b.ToTable("ChatMessages");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatorPlayerId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("ThreadKey")
+ .IsRequired()
+ .HasMaxLength(96)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ThreadKey")
+ .IsUnique();
+
+ b.ToTable("ChatThreads");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("JoinedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("LastReadMessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlayerId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SnoozeUntil")
+ .HasColumnType("TEXT");
+
+ b.Property("ThreadKey")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PlayerId");
+
+ b.HasIndex("ThreadKey", "PlayerId")
+ .IsUnique();
+
+ b.ToTable("ChatThreadMembers");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CheeredAt")
+ .HasColumnType("TEXT");
+
+ b.Property("FromPlayerId")
+ .HasColumnType("INTEGER");
+
+ b.Property("TargetInventionId")
+ .HasColumnType("INTEGER");
+
+ b.Property("TargetPhotoId")
+ .HasColumnType("INTEGER");
+
+ b.Property("TargetPlayerId")
+ .HasColumnType("INTEGER");
+
+ b.Property("TargetRoomId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TargetPhotoId");
+
+ b.HasIndex("TargetPlayerId");
+
+ b.HasIndex("TargetRoomId");
+
+ b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
+ .IsUnique();
+
+ b.ToTable("Cheers");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClubId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ImageName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("Slot")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClubId", "Slot")
+ .IsUnique();
+
+ b.ToTable("ClubAdditionalImages");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AuthorPlayerId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasMaxLength(4000)
+ .HasColumnType("TEXT");
+
+ b.Property("ClubId")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("ImageName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClubId", "CreatedAt");
+
+ b.ToTable("ClubAnnouncements");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AnnouncementId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlayerId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PlayerId");
+
+ b.HasIndex("AnnouncementId", "PlayerId")
+ .IsUnique();
+
+ b.ToTable("ClubAnnouncementReads");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CategoryTagId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ClubId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CategoryTagId");
+
+ b.HasIndex("ClubId", "CategoryTagId")
+ .IsUnique();
+
+ b.ToTable("ClubCategoryAssignments");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Active")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("OrderIndex")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.ToTable("ClubCategoryTags");
+ });
+
+ modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AllowJuniors")
+ .HasColumnType("INTEGER");
+
+ b.Property("BanStatus")
+ .HasColumnType("INTEGER");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("ClubChatEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("ClubType")
+ .HasColumnType("INTEGER");
+
+ b.Property("ClubhouseRoomId")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatorPlayerId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("TEXT");
+
+ b.Property