From fd2153d82b2cb04a8b6c9c1d51be26fc951e3917 Mon Sep 17 00:00:00 2001
From: Alexa <53471801+Alexa-RR@users.noreply.github.com>
Date: Sat, 24 Oct 2026 23:21:46 +0300
Subject: [PATCH 1/5] fix: 2023 room instances, publish, role permissions and
co-owner invites
- Room roles: list the creator as Creator (255), not CoOwner (30). The
client takes the local role from the Roles list and only lets a player
edit roles below their own (Creator excepted), so the creator could not
edit Co-Owner and Creator permissions. Enforce the role ladder on the
server (RoomRoleRules), let invitees accept (PUT roles/{me} role=30) and
reject (PUT roles/{me}/invite role=0), send message Types 62/60/61, and
push RoomUpdate after every role change.
- Permissions: persist PUT subrooms/{sub}/permissions cells in a new
RoomRolePermissions table, serve them from photon_access_token (plus a
roomserver/ twin) and push PhotonAccessToken to the sub-room.
- Publish: player-owned rooms are never IsRRO on the wire; the client
hides Publish and every room setting for an RRO.
- Sub-rooms: wire SubRoomId = OrderIndex + 1 (the client will not load a
sub-room with id 0), new rooms always get a named Home sub-room, and a
startup pass repairs rooms without one.
- Instance browser: per-room Redis presence index, live players only,
private instances for the people allowed in, real IsFull.
- Room/sub-room settings: creator or accepted co-owner everywhere.
---
DorkNet.Server.Tests/RoomPublish2023Tests.cs | 125 +
DorkNet.Server.Tests/RoomRoles2023Tests.cs | 454 ++
DorkNet.Server.Tests/RroRoomBlobTests.cs | 3 +-
.../SubRoomMaxPlayersTests.cs | 14 +-
.../SubRoomsAndInstances2023Tests.cs | 230 +
.../API/Inventions/InventionsController.cs | 6 +-
.../PlayerEvents/PlayerEventsController.cs | 10 +-
.../API/Rooms/V2/RoomsController.cs | 555 ++-
.../API/Rooms/V2/RoomsModerationController.cs | 24 +-
.../Controllers/Match/GoToController.cs | 22 +-
.../Controllers/Match/MatchController.cs | 264 +-
DorkNet.Server/Data/DorkNetDbContext.cs | 9 +
.../Data/Entities/RoomRolePermissionEntity.cs | 53 +
...1024201104_RoomRolePermissions.Designer.cs | 3718 +++++++++++++++++
.../20261024201104_RoomRolePermissions.cs | 49 +
.../DorkNetDbContextModelSnapshot.cs | 45 +
.../Services/NotificationService.cs | 2 +-
.../Services/PlayerPresenceService.cs | 115 +-
.../Services/RoomPermissionMatrix.cs | 198 +
DorkNet.Server/Services/RoomRoleRules.cs | 222 +
DorkNet.Server/Services/RoomSceneIds.cs | 44 +
DorkNet.Server/Services/RoomService.cs | 90 +-
DorkNet.Server/Startup/DatabaseBootstrap.cs | 37 +
docs/recroom-2023-client-api-complete.md | 135 +-
docs/recroom-2023-notifications.md | 7 +-
25 files changed, 6012 insertions(+), 419 deletions(-)
create mode 100644 DorkNet.Server.Tests/RoomPublish2023Tests.cs
create mode 100644 DorkNet.Server.Tests/RoomRoles2023Tests.cs
create mode 100644 DorkNet.Server.Tests/SubRoomsAndInstances2023Tests.cs
create mode 100644 DorkNet.Server/Data/Entities/RoomRolePermissionEntity.cs
create mode 100644 DorkNet.Server/Migrations/20261024201104_RoomRolePermissions.Designer.cs
create mode 100644 DorkNet.Server/Migrations/20261024201104_RoomRolePermissions.cs
create mode 100644 DorkNet.Server/Services/RoomPermissionMatrix.cs
create mode 100644 DorkNet.Server/Services/RoomRoleRules.cs
create mode 100644 DorkNet.Server/Services/RoomSceneIds.cs
diff --git a/DorkNet.Server.Tests/RoomPublish2023Tests.cs b/DorkNet.Server.Tests/RoomPublish2023Tests.cs
new file mode 100644
index 0000000..69c75e9
--- /dev/null
+++ b/DorkNet.Server.Tests/RoomPublish2023Tests.cs
@@ -0,0 +1,125 @@
+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" })))
+ Assert.False(denied.IsSuccessStatusCode);
+ 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}");
+ 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.Tests/RoomRoles2023Tests.cs b/DorkNet.Server.Tests/RoomRoles2023Tests.cs
new file mode 100644
index 0000000..0ffefe7
--- /dev/null
+++ b/DorkNet.Server.Tests/RoomRoles2023Tests.cs
@@ -0,0 +1,454 @@
+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"));
+ Assert.Equal(HttpStatusCode.Forbidden, again.StatusCode);
+
+ // 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"));
+ Assert.Equal(HttpStatusCode.Forbidden, self.StatusCode);
+ using var other = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
+ Assert.Equal(HttpStatusCode.Forbidden, other.StatusCode);
+ }
+
+ [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"));
+ Assert.Equal(HttpStatusCode.Forbidden, inviteCo.StatusCode);
+
+ // 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"));
+ Assert.Equal(HttpStatusCode.Forbidden, demote.StatusCode);
+
+ 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}");
+ using var json = JsonDocument.Parse(text);
+ Assert.Equal(roomId, json.RootElement.GetProperty("RoomId").GetInt64()); // full room details
+ }
+
+ // 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"}]"""))
+ Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
+ using (var allowed = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
+ """[{"Permission":"13","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
+ Assert.True(allowed.IsSuccessStatusCode, 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"}]"""))
+ Assert.True(put.IsSuccessStatusCode, 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");
+ }
+
+ // ── 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 JsonDocument.Parse(body).RootElement.Clone();
+ }
+
+ 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..5f3c316 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,7 +68,7 @@ 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}");
@@ -75,10 +77,11 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture
// shape — so the slider's own response has to carry the new value.
var echoed = JsonDocument.Parse(sliderBody).RootElement
.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 +102,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..c1f83be
--- /dev/null
+++ b/DorkNet.Server.Tests/SubRoomsAndInstances2023Tests.cs
@@ -0,0 +1,230 @@
+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 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/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
index 2fc0275..006d755 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);
@@ -2001,7 +2008,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 +2034,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 +2053,10 @@ public class RoomsController(
},
};
- var wireRoles = new List