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 - { - BuildRoomAccountRoleWire(room.CreatorPlayerId, 30), - }; - wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire)); + // The creator is Creator (255), never CoOwner (30): the client reads + // the local player's role from this list and only lets a player edit + // roles strictly below their own (see RoomRoleRules). + var wireRoles = RoomRoleRules.BuildWires(room, roleList); var tags = string.IsNullOrEmpty(room.TagsCsv) ? Array.Empty() @@ -2075,7 +2099,9 @@ public class RoomsController( State = room.State, Accessibility = room.Accessibility, SupportsLevelVoting = room.SupportsLevelVoting, - IsRRO = room.IsAGRoom, + // Player rooms are never RRO on the wire (see RoomService.WireIsRRO): + // the client refuses publish and room settings for an RRO. + IsRRO = RoomService.WireIsRRO(room), SupportsScreens = room.SupportsScreens, SupportsWalkVR = room.SupportsWalkVR, SupportsTeleportVR = room.SupportsTeleportVR, @@ -2904,6 +2930,7 @@ public class RoomsController( [HttpPost("roomserver/rooms/{roomId:long}/name")] [HttpPut("roomserver/rooms/{roomId:long}/name")] [Authorize] + [RecNetEnvelope] public async Task RenameRoom( long roomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body) @@ -2922,9 +2949,9 @@ public class RoomsController( { // RoomRoles co-owner check — same authorisation as the // existing modify endpoint, lets co-owners rename too. - var coOwner = await db.RoomRoles.AnyAsync(rr => - rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1)); - if (!coOwner) return Forbid(); + // Room settings are creator / co-owner only; moderators and + // hosts moderate players, they don't edit the room. + if (!await CanEditRoomAsync(room, me)) return Forbid(); } // Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118). @@ -2959,11 +2986,13 @@ public class RoomsController( [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] [Authorize] + [RecNetEnvelope] public async Task RenameSubroom( long roomId, long subRoomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body) { + subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex var newName = body?.Name; if (string.IsNullOrWhiteSpace(newName)) return BadRequest(new { Result = 1, error = "empty_name" }); @@ -2976,9 +3005,9 @@ public class RoomsController( if (room is null) return NotFound(new { Result = 4, error = "room_not_found" }); if (room.CreatorPlayerId != me) { - var coOwner = await db.RoomRoles.AnyAsync(rr => - rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1)); - if (!coOwner) return Forbid(); + // Room settings are creator / co-owner only; moderators and + // hosts moderate players, they don't edit the room. + if (!await CanEditRoomAsync(room, me)) return Forbid(); } var scene = await db.RoomScenes.FirstOrDefaultAsync(s => @@ -3607,6 +3636,7 @@ public class RoomsController( [HttpPost("rooms/{roomId:long}/subrooms")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms")] [Authorize] + [RecNetEnvelope] public async Task AddSubRoom( long roomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req) @@ -3614,7 +3644,7 @@ public class RoomsController( 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 name = (req.Name ?? string.Empty).Trim(); if (name.Length == 0) name = "NewScene"; @@ -3649,12 +3679,14 @@ public class RoomsController( [HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")] [HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")] [Authorize] + [RecNetEnvelope] public async Task DeleteSubRoom(long roomId, long subRoomId) { + 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 db.RoomScenes .FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); if (scene is null) return NotFound(); @@ -3710,20 +3742,6 @@ public class RoomsController( /// One row of the per-subroom role-permission matrix the 2023 /// client PUTs to subrooms/{sub}/permissions. Keys verified in the /// writer HDOJMEBCAGP.txt ISIL 050/069/085/101/117. - public sealed class SubRoomPermissionEntry - { - // Field order + CLR types come from the BNLPBMJJOMM constructor - // (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role, - // Boolean Override, FEOJFGECKGH Type, String Value). The two enum - // members serialize as numbers (the role writer boxes them - // System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here. - public string? Permission { get; set; } - public int? Role { get; set; } - public bool? Override { get; set; } - public int? Type { get; set; } - public string? Value { get; set; } - } - public sealed class PublishSubRoomSaveRequest { public long? SubRoomDataSaveId { get; set; } @@ -3746,6 +3764,7 @@ public class RoomsController( [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [Authorize] + [RecNetEnvelope] public Task SubRoomMaxPlayers( long roomId, long subRoomId, @@ -3763,6 +3782,7 @@ public class RoomsController( [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [Authorize] + [RecNetEnvelope] public Task SubRoomAccessibility( long roomId, long subRoomId, @@ -3773,45 +3793,120 @@ public class RoomsController( s.CanMatchmakeInto = a != AccessibilityPrivate; }); - /// PUT rooms/{id}/subrooms/{sub}/permissions — the - /// role-permission matrix. Client: + /// PUT rooms/{id}/subrooms/{sub}/permissions — "change + /// subroom permissions", the role-permission matrix. Client: /// NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList<BNLPBMJJOMM>) - /// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT - /// form-encode: the closure calls BNDIAONDFFF.FJLLPHFOOJJ(String) - /// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string - /// (BNDIAONDFFF.txt:275, mov dword ptr [rbx+38h],4) — i.e. a raw - /// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON - /// deserialize into the List). + /// (NLDBPDCNNCF.txt:9198, verb 3, same typed helper as every other room + /// mutation, so the response is the full FGCPNAACHIK details). + /// Unlike its siblings the body is NOT form-encoded: the closure calls + /// BNDIAONDFFF.FJLLPHFOOJJ(String) (CCNOMNNHNNG.txt ISIL 009), a raw + /// JSON array of cells. The editor sends one changed cell per call + /// (OMJKHJLFOCO.AFHJLBBFAGP.<LocalRequestModifyRolePermission>g__ChangeAndRefresh + /// builds a one-element BNLPBMJJOMM[]), so each cell is upserted; cells not + /// sent are left alone. /// - /// Scope note — deliberately not persisted. DorkNet has no - /// per-subroom permission storage: has no - /// column for it and there is no permissions table. Adding one means an - /// entity + DbContext + migration, which is outside this file. Until then - /// the matrix is accepted, logged, and the room details echoed back so the - /// client's promise resolves instead of 415-ing; the values do not survive - /// a round-trip. The old handler silently bound an unrelated DTO whose keys - /// the client never sends, which was strictly worse. + /// Read back through GET photon_access_token (its + /// Permissions list), and pushed to everyone in that sub-room as + /// the PhotonAccessToken hub message so the change takes effect + /// without a rejoin. + /// + /// Authorization mirrors the client's own rule + /// (OMJKHJLFOCO.DIIMLMKEJLC): the creator may edit every role, including + /// Co-Owner and Creator; a co-owner only the roles below their own; + /// nobody else may edit (). [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [Authorize] - public async Task SubRoomPermissions( - long roomId, - long subRoomId, - [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List req) + [RecNetEnvelope] + public async Task SubRoomPermissions(long roomId, long subRoomId) { + 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(); + var roleRows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync(); + if (!RoomRoleRules.CanEditRoom(room, roleRows, pid)) return Forbid(); + + string body; + using (var reader = new StreamReader(Request.Body)) + body = await reader.ReadToEndAsync(); + var cells = RoomPermissionMatrix.Parse(body); + if (cells is null) return BadRequest(new { error = "invalid_permissions_body" }); + + var denied = cells.Where(c => !RoomRoleRules.CanEditRolePermissions(room, roleRows, pid, c.Role)).ToList(); + if (denied.Count > 0) + { + logger.LogWarning( + "[subroom-permissions] denied room={Room} sub={Sub} by={By} roles={Roles}", + roomId, subRoomId, pid, string.Join(",", denied.Select(d => d.Role).Distinct())); + return Forbid(); + } + var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId); if (scene is null) return NotFound(); - await db.SaveChangesAsync(); - logger.LogWarning( - "[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)", - roomId, subRoomId, pid, req?.Count ?? 0); + var now = DateTime.UtcNow; + scene.DataModifiedAt = now; + for (var attempt = 1; ; attempt++) + { + var existing = await db.RoomRolePermissions + .Where(p => p.RoomId == roomId && p.SubRoomId == subRoomId) + .ToListAsync(); + foreach (var cell in cells) + { + // Exact match, like the unique index on + // (RoomId, SubRoomId, Role, Permission). + var row = existing.FirstOrDefault(p => + p.Role == cell.Role && string.Equals(p.Permission, cell.Permission, StringComparison.Ordinal)); + if (row is null) + { + row = new RoomRolePermissionEntity + { + RoomId = roomId, + SubRoomId = subRoomId, + Role = cell.Role, + Permission = cell.Permission, + }; + db.RoomRolePermissions.Add(row); + existing.Add(row); + } + row.Type = cell.Type; + row.Override = cell.Override; + row.Value = cell.Value; + row.UpdatedByPlayerId = pid; + row.UpdatedAt = now; + } + try + { + await db.SaveChangesAsync(); + break; + } + catch (DbUpdateException) when (attempt < 3) + { + // Another editor inserted the same new cell between our read + // and our insert (unique index). Forget our pending rows and + // apply the cells again, now as updates of theirs. + foreach (var entry in db.ChangeTracker.Entries().ToList()) + entry.State = EntityState.Detached; + } + } + + logger.LogInformation( + "[subroom-permissions] room={Room} sub={Sub} by={By} cells={Count}", + roomId, subRoomId, pid, cells.Count); + + // Everyone in that sub-room gets the new matrix; the client's + // PhotonAccessToken handler re-initialises its permissions from it. + var matrix = await RoomPermissionMatrix.LoadAsync(db, roomId, subRoomId); + foreach (var (instance, players) in presence.EnumerateActiveInstances(roomId, includePrivate: true)) + { + if (instance.SubRoomId != subRoomId) continue; + var tokenBody = RoomPermissionMatrix.AccessTokenBody(instance.RoomInstanceId, matrix); + foreach (var other in players) + await notifications.NotifyTypedAsync(other, "PhotonAccessToken", tokenBody); + } return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } @@ -3840,12 +3935,14 @@ public class RoomsController( [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] [Authorize] + [RecNetEnvelope] public async Task PublishSubRoomSave(long roomId, long subRoomId) { + 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(); @@ -3919,12 +4016,14 @@ public class RoomsController( [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")] [Authorize] + [RecNetEnvelope] public async Task CloneSubRoom(long roomId, long subRoomId) { + 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 src = await db.RoomScenes .FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); if (src is null) return NotFound(); @@ -3971,6 +4070,7 @@ public class RoomsController( [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [Authorize] + [RecNetEnvelope] public Task ModifySubRoom( long roomId, long subRoomId, @@ -4010,11 +4110,13 @@ public class RoomsController( [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] [Authorize] + [RecNetEnvelope] public async Task MoveSubRoom( long roomId, long subRoomId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req) { + 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(); @@ -4105,6 +4207,7 @@ public class RoomsController( [Authorize] public async Task SubRoomData(long roomId, long subRoomId) { + subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex var body = await ReadSaveRoomSceneRequestAsync(); body.RoomSceneId = subRoomId; return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true); @@ -4165,10 +4268,11 @@ public class RoomsController( /// successful mutation surface as a client-side parse failure. 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("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsRRO") + .HasColumnType("INTEGER"); + + b.Property("Joinability") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApproveMember") + .HasColumnType("INTEGER"); + + b.Property("BanUnban") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreateEvent") + .HasColumnType("INTEGER"); + + b.Property("EditDetails") + .HasColumnType("INTEGER"); + + b.Property("EditPermissionSettings") + .HasColumnType("INTEGER"); + + b.Property("MembershipType") + .HasColumnType("INTEGER"); + + b.Property("PostAnnouncement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "MembershipType") + .IsUnique(); + + b.ToTable("ClubRolePermissions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SubscribedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("PlayerId", "ClubId") + .IsUnique(); + + b.ToTable("ClubSubscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssetName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BaseAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("PurchaseCount") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsFeatured"); + + b.HasIndex("IsPublic"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("CustomAvatarItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("CustomAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CustomAvatarItemId"); + + b.HasIndex("PlayerId", "CustomAvatarItemId") + .IsUnique(); + + b.ToTable("CustomAvatarItemOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GrantedItemId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Offer1ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer2ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer3ItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.Property("SelectedAt") + .HasColumnType("TEXT"); + + b.Property("SelectedGiftDropId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SelectedAt"); + + b.ToTable("GameRewardSelections"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SourceStoreItemId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "ItemKey", "ItemType") + .IsUnique(); + + b.ToTable("ItemWishlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("EarnedAt") + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "EventKey"); + + b.ToTable("Keepsakes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b => + { + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LowerIsBetter") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueFormat") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RoomId", "Channel"); + + b.HasIndex("RoomId"); + + b.ToTable("LeaderboardChannelMeta"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("RoomId", "StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PlatformMask") + .HasColumnType("INTEGER"); + + b.Property("RoomNamesCsv") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LoadingScreenTips"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastIp") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PendingDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("BannerImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayEmoji") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IdentityFlags") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCommunityTeam") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PersonalPronouns") + .HasColumnType("INTEGER"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CustomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsCurated") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("SupportsJuniors") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.Property("WarningMask") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsCurated"); + + b.HasIndex("Name"); + + b.ToTable("Playlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cheered") + .HasColumnType("INTEGER"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlaylistId", "PlayerId") + .IsUnique(); + + b.ToTable("PlaylistInteractions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlaylistId"); + + b.HasIndex("RoomId"); + + b.HasIndex("PlaylistId", "OrderIndex"); + + b.ToTable("PlaylistRooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.Property("LatestInviteMessageId") + .HasColumnType("INTEGER"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyId") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomConsumables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyCode") + .HasColumnType("TEXT"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomConsumableId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomConsumableId"); + + b.HasIndex("PlayerId", "RoomConsumableId") + .IsUnique(); + + b.ToTable("RoomConsumableOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomCurrencyId"); + + b.HasIndex("PlayerId", "RoomCurrencyId") + .IsUnique(); + + b.ToTable("RoomCurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("DailyLimit") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomCurrencies"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomCurrencyId"); + + b.ToTable("RoomCurrencyPurchaseOffers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowNewUsers") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HiddenFromBrowse") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("IsRoomLinkedToRecRoomStudio") + .HasColumnType("INTEGER"); + + b.Property("IsStudioRoom") + .HasColumnType("INTEGER"); + + b.Property("LoadScreensJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayerCalculationMode") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("PromoExternalContentJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PromoImagesJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("StudioSessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(174) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "Name"); + + b.ToTable("RoomKeys"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PaidPrice") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PurchasedAt") + .HasColumnType("TEXT"); + + b.Property("RoomKeyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomKeyId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomKeyPurchases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accepted") + .HasColumnType("INTEGER"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "PlayerId", "Role") + .IsUnique(); + + b.ToTable("RoomRoles"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Override") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "SubRoomId", "Role", "Permission") + .IsUnique(); + + b.ToTable("RoomRolePermissions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StudioAssetBundleNamesCsv") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("StudioSubRoomDataSaveId") + .HasColumnType("INTEGER"); + + b.Property("StudioUnityAssetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("AllAvatarItemsOwned") + .HasColumnType("INTEGER"); + + b.Property("BaseRoomNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CharadesSlotBindingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CircuitsV2LimitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscoveredGameConfigsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("GlobalFriendsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PlayMenuTagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ProfanityFilterDisabled") + .HasColumnType("INTEGER"); + + b.Property("RecCenterDoorsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("RoomBlobVersionClampDisabled") + .HasColumnType("INTEGER"); + + b.Property("SignupsDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WeeklyChallengeRewardJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WeeklyChallengesCompletedRequired") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("WeeklyChallengesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Descriptor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SignupCodes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CommentsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("UgcPurchasables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20261024201104_RoomRolePermissions.cs b/DorkNet.Server/Migrations/20261024201104_RoomRolePermissions.cs new file mode 100644 index 0000000..4bdb54e --- /dev/null +++ b/DorkNet.Server/Migrations/20261024201104_RoomRolePermissions.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class RoomRolePermissions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RoomRolePermissions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoomId = table.Column(type: "INTEGER", nullable: false), + SubRoomId = table.Column(type: "INTEGER", nullable: false), + Role = table.Column(type: "INTEGER", nullable: false), + Permission = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + Override = table.Column(type: "INTEGER", nullable: false), + Value = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + UpdatedByPlayerId = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoomRolePermissions", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission", + table: "RoomRolePermissions", + columns: new[] { "RoomId", "SubRoomId", "Role", "Permission" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RoomRolePermissions"); + } + } +} diff --git a/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs b/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs index 8677b2c..23e6c14 100644 --- a/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs +++ b/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs @@ -3021,6 +3021,51 @@ namespace DorkNet.Server.Migrations b.ToTable("RoomRoles"); }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Override") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "SubRoomId", "Role", "Permission") + .IsUnique(); + + b.ToTable("RoomRolePermissions"); + }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => { b.Property("Id") diff --git a/DorkNet.Server/Services/NotificationService.cs b/DorkNet.Server/Services/NotificationService.cs index 4f9572d..19f4887 100644 --- a/DorkNet.Server/Services/NotificationService.cs +++ b/DorkNet.Server/Services/NotificationService.cs @@ -311,7 +311,7 @@ public class NotificationService( { ["roomInstanceId"] = redacted.RoomInstanceId, ["roomId"] = redacted.RoomId, - ["subRoomId"] = redacted.SubRoomId, + ["subRoomId"] = redacted.WireSubRoomId, ["roomInstanceType"] = redacted.RoomInstanceType, ["location"] = redacted.Location, ["dataBlob"] = redacted.DataBlob, diff --git a/DorkNet.Server/Services/PlayerPresenceService.cs b/DorkNet.Server/Services/PlayerPresenceService.cs index 1611611..c0e11f9 100644 --- a/DorkNet.Server/Services/PlayerPresenceService.cs +++ b/DorkNet.Server/Services/PlayerPresenceService.cs @@ -105,6 +105,15 @@ public class PlayerPresenceService var db = mux.GetDatabase(); db.StringSet(Key(playerId), json, PresenceTtl); db.KeyDelete(NoRoomKey(playerId)); + // Per-room membership index for the instance browser + // (EnumerateActiveInstances). Members are validated against + // the player's live presence on read, so a player who moved + // on is pruned lazily instead of needing the old room here. + if (room is not null && room.RoomId > 0) + { + db.SetAdd(RoomIndexKey(room.RoomId), playerId); + db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl); + } _log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}", playerId, room?.Name ?? "", json.Length); } @@ -132,7 +141,19 @@ public class PlayerPresenceService { try { - mux.GetDatabase().KeyExpire(Key(playerId), PresenceTtl); + var db = mux.GetDatabase(); + db.KeyExpire(Key(playerId), PresenceTtl); + // Keep the room's membership index alive as long as its + // members are: a player refreshed only through here would + // otherwise outlive the set (SetRoom's 2 × TTL) and drop out + // of the instance browser and room pushes. + var json = db.StringGet(Key(playerId)); + if (json.HasValue && + JsonSerializer.Deserialize(json.ToString(), JsonOpts) is { RoomId: > 0 } room) + { + db.SetAdd(RoomIndexKey(room.RoomId), playerId); + db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl); + } } catch (Exception ex) { @@ -459,45 +480,97 @@ public class PlayerPresenceService _localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl); } - /// List the currently-active instances of a given room, - /// grouped by PhotonRoomId, with the player ids currently presenced - /// in each. Drives the watch's RoomInstanceBrowser - /// (GET match.*/room/{id}/instances) so players can see - /// which instance their friends are in. + /// List the live instances of a given room, grouped by + /// PhotonRoomId (or RoomInstanceId when a row has none), with the player + /// ids currently presenced in each. Drives the room instance browser + /// (GET match.*/room/{id}/instances) and the event instance list. /// - /// Redis caveat: without a secondary index per room we'd need a - /// SCAN over every presence key — too expensive to do on each - /// request. For now the Redis path returns empty and the local - /// in-process dictionary is the source of truth. Single-instance - /// deployments work; multi-replica Redis deployments need a - /// per-room set index to be added before the browser is reliable - /// there. - public IEnumerable<(RoomInstanceDto Room, List PlayerIds)> EnumerateActiveInstances(long roomId) + /// Only players whose presence is still live count: an expired + /// room presence () is dropped, and so is a + /// player whose activity marker (, refreshed by + /// every heartbeat and join) has lapsed. A client that crashed keeps its + /// room presence for up to ten minutes, but it is not in the instance and + /// must not inflate the player count or keep an empty instance listed. + /// + /// Redis. records each player in a + /// per-room set (presence-room:{roomId}); this reads that set and + /// validates every member against their current presence, pruning the + /// ones that have moved on or expired. Without the index the Redis path + /// had nothing to enumerate and returned an empty list on every + /// multi-replica deployment. + /// + /// Private instances are skipped unless + /// is set; callers that include them + /// must apply their own visibility rule. + public IEnumerable<(RoomInstanceDto Room, List PlayerIds)> EnumerateActiveInstances( + long roomId, bool includePrivate = false) { - if (_redis is not null) return Array.Empty<(RoomInstanceDto, List)>(); - var groups = new Dictionary PlayerIds)>(StringComparer.Ordinal); - foreach (var kv in _local) + var members = new List<(long PlayerId, RoomInstanceDto Room)>(); + if (_redis is { } mux) { - var room = kv.Value; - if (room is null || room.RoomId != roomId) continue; - // Skip private instances — those are surfaced separately - // in the controller via PrivateInstanceService so we never - // leak a photonRoomId for an invite-only match into the - // public browser. - if (room.IsPrivate) continue; - var key = string.IsNullOrEmpty(room.PhotonRoomId) - ? $"<{room.RoomInstanceId}>" - : room.PhotonRoomId; + try + { + var db = mux.GetDatabase(); + var stale = new List(); + foreach (var member in db.SetMembers(RoomIndexKey(roomId))) + { + if (!long.TryParse(member.ToString(), out var playerId)) + { + stale.Add(member); + continue; + } + var room = GetRoom(playerId); + if (room is null || room.RoomId != roomId) + { + stale.Add(member); + continue; + } + members.Add((playerId, room)); + } + if (stale.Count > 0) + db.SetRemove(RoomIndexKey(roomId), stale.ToArray()); + } + catch (Exception ex) + { + _log?.LogWarning(ex, "[presence] redis room index scan failed room={RoomId}", roomId); + return Array.Empty<(RoomInstanceDto, List)>(); + } + } + else + { + foreach (var playerId in _local.Keys.ToList()) + { + var room = GetRoom(playerId); // honours the room TTL + if (room is not null && room.RoomId == roomId) + members.Add((playerId, room)); + } + } + + var groups = new Dictionary PlayerIds)>(StringComparer.Ordinal); + foreach (var (playerId, room) in members) + { + if (room.IsPrivate && !includePrivate) continue; + if (!IsRecentlyActive(playerId)) continue; + var key = InstanceKey(room); if (!groups.TryGetValue(key, out var entry)) { entry = (room, new List()); groups[key] = entry; } - entry.PlayerIds.Add(kv.Key); + entry.PlayerIds.Add(playerId); } return groups.Values; } + /// The identity two presence rows share when they are in the + /// same live instance: the Photon room, else the instance id. + public static string InstanceKey(RoomInstanceDto room) + => string.IsNullOrEmpty(room.PhotonRoomId) + ? "<" + room.RoomInstanceId + ">" + : room.PhotonRoomId; + + private static string RoomIndexKey(long roomId) => "presence-room:" + roomId; + private static string Key(long playerId) => $"presence:{playerId}"; private static string ActiveKey(long playerId) => $"presence-active:{playerId}"; private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}"; diff --git a/DorkNet.Server/Services/RoomPermissionMatrix.cs b/DorkNet.Server/Services/RoomPermissionMatrix.cs new file mode 100644 index 0000000..44b0a72 --- /dev/null +++ b/DorkNet.Server/Services/RoomPermissionMatrix.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace DorkNet.Server.Services; + +/// +/// A sub-room's role-permission matrix: the cells the 2023 client reads from +/// GET photon_access_token (ALIIIAGAPND.Permissions, a list of +/// BNLPBMJJOMM) and writes with +/// PUT rooms/{id}/subrooms/{sub}/permissions. +/// +/// The served list is the built-in defaults with every stored cell for +/// the room's sub-room applied on top: a stored cell replaces the default +/// with the same (Role, Permission), and cells with no default are appended. +/// The list must never be empty — with no Permissions the room-permissions +/// runtime (OMJKHJLFOCO) falls into its persisted-role migration path, which +/// NREs during first spawn. +/// +/// Cell keys (formatter RecNet.Runtime/HDOJMEBCAGP.txt): +/// Permission (string), Role (OMMBGJMJJPN), Override +/// (bool), Type (FEOJFGECKGH: Bool 0 / Int 1 / StringList 2), +/// Value (string). Role and Type go out as numbers; requests are +/// accepted with numbers, numeric strings or enum names. +/// +public static class RoomPermissionMatrix +{ + /// One matrix cell. + public sealed record Cell(string Permission, int Role, bool Override, int Type, string Value); + + /// The defaults every sub-room starts from: Co-Owner and Creator + /// may use the maker pen, spawn inventions and consumables, reset the + /// room and toggle play gizmos. + public static IReadOnlyList Defaults { get; } = BuildDefaults(); + + private static IReadOnlyList BuildDefaults() + { + var permissions = new[] + { + "13", // Can Use Maker Pen + "20", // Can Spawn Inventions + "21", // Can Spawn Consumables + "22", // Can Use Room Reset Button + "23", // Can Use Play Gizmos Toggle + }; + var list = new List(); + foreach (var role in new[] { RoomRoleRules.WireCoOwner, RoomRoleRules.WireCreator }) + foreach (var permission in permissions) + list.Add(new Cell(permission, role, true, 0, "true")); + return list; + } + + /// Defaults merged with the stored cells. + public static List Merge(IEnumerable stored) + { + var cells = Defaults.ToList(); + foreach (var row in stored.OrderBy(r => r.Id)) + { + var cell = new Cell(row.Permission, row.Role, row.Override, row.Type, row.Value); + var at = cells.FindIndex(c => + c.Role == row.Role + && string.Equals(c.Permission, row.Permission, StringComparison.Ordinal)); + if (at >= 0) cells[at] = cell; + else cells.Add(cell); + } + return cells; + } + + /// The served matrix for one sub-room (by OrderIndex). + public static async Task> LoadAsync(DorkNetDbContext db, long roomId, long subRoomOrderIndex) + { + var stored = await db.RoomRolePermissions.AsNoTracking() + .Where(p => p.RoomId == roomId && p.SubRoomId == subRoomOrderIndex) + .ToListAsync(); + return Merge(stored); + } + + /// The ALIIIAGAPND body: {RoomInstanceId, + /// PhotonAccessToken, Permissions}. Used both by + /// GET photon_access_token and the PhotonAccessToken hub + /// push (IBEOONPEELF.txt:519 registers it as Action<ALIIIAGAPND>). + public static Dictionary AccessTokenBody(long roomInstanceId, IEnumerable cells) + => new() + { + ["RoomInstanceId"] = roomInstanceId, + ["PhotonAccessToken"] = string.Empty, + ["Permissions"] = cells.Select(ToWire).ToList(), + }; + + public static object ToWire(Cell c) => new + { + c.Permission, + c.Role, + c.Override, + c.Type, + c.Value, + }; + + /// Parse the PUT body: a JSON array of cells (an object wrapping + /// a Permissions array is accepted too). Returns null when the body + /// is not JSON or holds no array. + public static List? Parse(string body) + { + if (string.IsNullOrWhiteSpace(body)) return null; + JsonDocument doc; + try { doc = JsonDocument.Parse(body); } + catch (JsonException) { return null; } + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object) + { + root = root.EnumerateObject() + .Where(p => p.Name.Equals("Permissions", StringComparison.OrdinalIgnoreCase)) + .Select(p => p.Value) + .FirstOrDefault(); + } + if (root.ValueKind != JsonValueKind.Array) return null; + + var cells = new List(); + foreach (var item in root.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) continue; + string? permission = null; + int? role = null; + bool @override = false; + int type = 0; + string value = string.Empty; + foreach (var prop in item.EnumerateObject()) + { + switch (prop.Name.ToLowerInvariant()) + { + case "permission": + permission = Scalar(prop.Value)?.Trim(); + break; + case "role": + role = EnumValue(prop.Value, RoleName); + break; + case "type": + type = EnumValue(prop.Value, TypeName) ?? 0; + break; + case "override": + @override = prop.Value.ValueKind == JsonValueKind.True + || (prop.Value.ValueKind == JsonValueKind.String + && bool.TryParse(prop.Value.GetString(), out var b) && b); + break; + case "value": + value = Scalar(prop.Value) ?? string.Empty; + break; + } + } + if (string.IsNullOrEmpty(permission) || role is null) continue; + if (permission.Length > 128 || value.Length > 4096) continue; + cells.Add(new Cell(permission, role.Value, @override, type, value)); + } + return cells; + } + } + + private static string? Scalar(JsonElement e) => e.ValueKind switch + { + JsonValueKind.String => e.GetString(), + JsonValueKind.Number => e.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null or JsonValueKind.Undefined => null, + _ => e.GetRawText(), + }; + + private static int? EnumValue(JsonElement e, Func byName) + { + if (e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var n)) return n; + if (e.ValueKind != JsonValueKind.String) return null; + var s = e.GetString() ?? string.Empty; + return int.TryParse(s, out n) ? n : byName(s); + } + + private static int? RoleName(string s) => s.Trim().ToLowerInvariant() switch + { + "none" => RoomRoleRules.WireNone, + "banned" => RoomRoleRules.WireBanned, + "host" => RoomRoleRules.WireHost, + "moderator" => RoomRoleRules.WireModerator, + "coowner" => RoomRoleRules.WireCoOwner, + "temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner, + "creator" => RoomRoleRules.WireCreator, + _ => null, + }; + + private static int? TypeName(string s) => s.Trim().ToLowerInvariant() switch + { + "bool" => 0, + "int" => 1, + "stringlist" => 2, + _ => null, + }; +} diff --git a/DorkNet.Server/Services/RoomRoleRules.cs b/DorkNet.Server/Services/RoomRoleRules.cs new file mode 100644 index 0000000..edbff51 --- /dev/null +++ b/DorkNet.Server/Services/RoomRoleRules.cs @@ -0,0 +1,222 @@ +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Services; + +/// +/// The 2023 room-role ladder and who may change whose role. +/// +/// Wire enum. The client's room role is OMMBGJMJJPN : byte +/// (recnet-runtime-decomp/OMMBGJMJJPN.cs): None 0, Banned 1, Host 10, +/// Moderator 20, CoOwner 30, TemporaryCoOwner 31, Creator 255. It is +/// gap-skipping, like the club MembershipType enum, so never treat it as an +/// index. stores a compact kind instead +/// (0 = CoOwner, 1 = Moderator, 2 = Host); and +/// convert. +/// +/// The creator is 255, not 30. The client reads the local +/// player's role straight out of the room details Roles list +/// (FGCPNAACHIK.ONECLLALJEO: first row whose AccountId matches), and +/// the room-permissions runtime only lets a player edit a role strictly +/// below their own, with Creator (255) the one unconditional pass +/// (OMJKHJLFOCO.DIIMLMKEJLC, +/// IsilDump/RecRoom.RoomPermissions.Runtime/OMJKHJLFOCO.txt:2367: +/// if role == 255 return true; if target >= role return false). +/// Advertising the creator as CoOwner (30) therefore locked the room's own +/// creator out of the Co-Owner and Creator permission rows. +/// +/// Server rule (): the creator may set +/// any role on anyone but themselves. Otherwise the actor must hold an +/// accepted CoOwner role, and both the target's current role (accepted or +/// still invited) and the requested role must be strictly below the +/// actor's. A player may always drop their own role or decline their own +/// invite (role 0), and may accept exactly the role they were invited to. +/// +public static class RoomRoleRules +{ + public const int WireNone = 0; + public const int WireBanned = 1; + public const int WireHost = 10; + public const int WireModerator = 20; + public const int WireCoOwner = 30; + public const int WireTemporaryCoOwner = 31; + public const int WireCreator = 255; + + public const int StoredCoOwner = 0; + public const int StoredModerator = 1; + public const int StoredHost = 2; + + /// Stored kind → wire role. + public static int ToWire(int storedRole) => storedRole switch + { + StoredCoOwner => WireCoOwner, + StoredModerator => WireModerator, + StoredHost => WireHost, + _ => WireNone, + }; + + /// Wire role → stored kind. Only the three persistable roles + /// map; None, Banned, TemporaryCoOwner (an in-instance grant) and Creator + /// (ownership, changed through rooms/{id}/creator) do not. + public static bool TryFromWire(int wireRole, out int storedRole) + { + storedRole = wireRole switch + { + WireCoOwner => StoredCoOwner, + WireModerator => StoredModerator, + WireHost => StoredHost, + _ => -1, + }; + return storedRole >= 0; + } + + /// The player's accepted wire role in the room: 255 for the + /// creator, the highest accepted grant otherwise, 0 for none. + public static int AcceptedRank(RoomEntity room, IEnumerable rows, long playerId) + { + if (room.CreatorPlayerId == playerId) return WireCreator; + return rows + .Where(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Accepted) + .Select(r => ToWire(r.Role)) + .DefaultIfEmpty(WireNone) + .Max(); + } + + /// The highest role the player has been invited to and not yet + /// accepted, 0 for none. + public static int InvitedRank(RoomEntity room, IEnumerable rows, long playerId) + => rows + .Where(r => r.RoomId == room.Id && r.PlayerId == playerId && !r.Accepted) + .Select(r => ToWire(r.Role)) + .DefaultIfEmpty(WireNone) + .Max(); + + /// True when the player may change room-level settings and the + /// sub-room settings that hang off them: the creator or an accepted + /// co-owner. This is the same gate as the bare room mutations + /// (RoomsModerationController.RequireOwnedRoomAsync) and room saves. + public static bool CanEditRoom(RoomEntity room, IEnumerable rows, long playerId) + => AcceptedRank(room, rows, playerId) >= WireCoOwner; + + /// What a role write will do once allows it. + public enum ChangeKind + { + /// Grant or replace the target's role (accepted at once). + Grant, + /// Record a pending invite the target must accept. + Invite, + /// The target accepts their own pending invite. + AcceptInvite, + /// Remove the target's role and any pending invite. + Clear, + } + + /// Decide whether may set + /// 's role to . + /// Returns null when allowed, otherwise an error code for the response. + /// is what the caller should then do. + public static string? CheckChange( + RoomEntity room, + IReadOnlyCollection rows, + long actorId, + long targetId, + int requestedWireRole, + bool isInvite, + out ChangeKind kind) + { + kind = requestedWireRole == WireNone + ? ChangeKind.Clear + : isInvite ? ChangeKind.Invite : ChangeKind.Grant; + + if (targetId == room.CreatorPlayerId) + return "cannot_change_creator_role"; + + if (requestedWireRole != WireNone && !TryFromWire(requestedWireRole, out _)) + return "invalid_role"; + + var actorRank = AcceptedRank(room, rows, actorId); + + // Self-service: anyone may give up their own role or decline their + // own invite, and accept the exact role they were invited to. + if (actorId == targetId && actorRank != WireCreator) + { + if (requestedWireRole == WireNone) return null; + if (!isInvite && requestedWireRole == InvitedRank(room, rows, targetId)) + { + kind = ChangeKind.AcceptInvite; + return null; + } + return "forbidden"; + } + + if (actorRank == WireCreator) return null; + if (actorRank < WireCoOwner) return "forbidden"; + + var targetRank = Math.Max( + AcceptedRank(room, rows, targetId), + InvitedRank(room, rows, targetId)); + if (targetRank >= actorRank) return "forbidden"; + if (requestedWireRole >= actorRank) return "forbidden"; + return null; + } + + /// True when may write the + /// permission row for : the creator may edit + /// every role (including CoOwner and Creator); a co-owner only the roles + /// below their own. Mirrors the client's OMJKHJLFOCO.DIIMLMKEJLC. + public static bool CanEditRolePermissions( + RoomEntity room, IEnumerable rows, long playerId, int wireRole) + { + var rank = AcceptedRank(room, rows, playerId); + if (rank == WireCreator) return true; + if (rank < WireCoOwner) return false; + // TemporaryCoOwner collapses to CoOwner on the client (ONECLLALJEO). + var target = wireRole == WireTemporaryCoOwner ? WireCoOwner : wireRole; + return target < rank; + } + + /// The room details / roles-list rows: one per account, with + /// the creator first as 255 and every other account's accepted role in + /// Role and pending invite in InvitedRole. Rows the + /// creator happens to own in RoomRoles are ignored so the creator is + /// never listed twice (the client takes the first match). + public static List BuildWires(RoomEntity room, IEnumerable rows) + { + var list = new List + { + new(ToClientAccountId(room.CreatorPlayerId), WireCreator, WireNone, null), + }; + foreach (var group in rows + .Where(r => r.RoomId == room.Id && r.PlayerId != room.CreatorPlayerId) + .GroupBy(r => r.PlayerId) + .OrderBy(g => g.Key)) + { + var accepted = group.Where(r => r.Accepted).ToList(); + var invited = group.Where(r => !r.Accepted).ToList(); + var role = accepted.Select(r => ToWire(r.Role)).DefaultIfEmpty(WireNone).Max(); + var invitedRole = invited.Select(r => ToWire(r.Role)).DefaultIfEmpty(WireNone).Max(); + var lastChangedBy = group + .OrderByDescending(r => r.GrantedAt) + .Select(r => r.GrantedByPlayerId) + .FirstOrDefault(); + list.Add(new RoomRoleWire( + ToClientAccountId(group.Key), + role, + invitedRole, + lastChangedBy is long by ? ToClientAccountId(by) : null)); + } + return list; + } + + /// One EFHPLDPNGIM row: {AccountId: Int32, Role, + /// InvitedRole, LastChangedByAccountId: Int32?}. + public sealed record RoomRoleWire(int AccountId, int Role, int InvitedRole, int? LastChangedByAccountId); + + /// Account ids are Int32 in the 2023 role DTO. Clamp rather than + /// wrap so an out-of-range id is obviously wrong. + public static int ToClientAccountId(long accountId) + => accountId > int.MaxValue + ? int.MaxValue + : accountId < int.MinValue + ? int.MinValue + : (int)accountId; +} diff --git a/DorkNet.Server/Services/RoomSceneIds.cs b/DorkNet.Server/Services/RoomSceneIds.cs new file mode 100644 index 0000000..899aaa9 --- /dev/null +++ b/DorkNet.Server/Services/RoomSceneIds.cs @@ -0,0 +1,44 @@ +namespace DorkNet.Server.Services; + +/// +/// Converts between a sub-room's position in the room +/// (, 0-based, what the +/// server stores and indexes on) and the SubRoomId the 2023 client +/// sees on the wire. +/// +/// Why they differ. The client refuses to load a sub-room whose +/// id is not positive: SubroomModel.DOKCPLJOKIE +/// (IsilDump/Assembly-CSharp/RRUI/Data/SubroomModel.txt:3) is +/// RoomId > 0 && SubroomId > 0, and +/// Model`1.LNPEMMMGCPC skips the load when it is false. With the +/// first sub-room advertised as id 0, its Room Settings > Subrooms row +/// never fetched its details (SubroomModel.KBOJBJNCOGK → +/// FGCPNAACHIK.OBFPEGLLELP(long?)), so the row showed no name, and +/// every per-row action (rename, max players, permissions, duplicate) ran +/// against a null sub-room. Rec Room's own ids were large unique numbers; +/// the client needs only "positive and stable per room". +/// +/// Mapping. Wire id = OrderIndex + 1, so a room's first +/// sub-room is 1. Incoming ids go back through ; +/// 0 (and anything negative) is accepted as an alias of the first sub-room, +/// which keeps cached client state, old presence rows and bare +/// matchmake/room/{id} joins working. +/// +/// Where the conversion happens. Only at the wire: the room +/// details SubRooms[], the sub-room routes +/// (rooms/{id}/subrooms/{sub}/*), matchmake/room/{id}/{sub}, +/// the room-instance JSON (RoomInstanceDto.subRoomId), the instance +/// browser, sub-room save DTOs and permission rows. Everything stored +/// (RoomScenes, PrivateInstances, RoomDataBlobs, presence) keeps the +/// OrderIndex. +/// +public static class RoomSceneIds +{ + /// OrderIndex → wire SubRoomId (always ≥ 1). + public static long ToWire(long orderIndex) => Math.Max(0, orderIndex) + 1; + + /// Wire SubRoomId → OrderIndex. 0 and negatives map to the first + /// sub-room. + public static int ToOrderIndex(long wireSubRoomId) + => wireSubRoomId <= 1 ? 0 : (int)Math.Min(int.MaxValue, wireSubRoomId - 1); +} diff --git a/DorkNet.Server/Services/RoomService.cs b/DorkNet.Server/Services/RoomService.cs index a11c1d7..6596323 100644 --- a/DorkNet.Server/Services/RoomService.cs +++ b/DorkNet.Server/Services/RoomService.cs @@ -546,6 +546,94 @@ public class RoomService(DorkNetDbContext db) return existing; } + /// The name a sub-room gets when it has none: "Home" for the + /// entry sub-room, "Subroom N" (1-based, matching its wire id) for the + /// rest. + public static string DefaultSubRoomName(int orderIndex) + => orderIndex <= 0 ? "Home" : $"Subroom {orderIndex + 1}"; + + /// A real entry sub-room row for a room that has none, built + /// from the room's own location, data blob and capacity. Callers add it + /// to the context. + public static RoomSceneEntity NewHomeScene(RoomEntity room) => new() + { + RoomId = room.Id, + OrderIndex = 0, + Name = DefaultSubRoomName(0), + RoomSceneLocationId = string.IsNullOrWhiteSpace(room.LocationReplicationId) + ? new RoomSceneEntity().RoomSceneLocationId + : room.LocationReplicationId, + DataBlobName = room.CurrentDataBlobName ?? string.Empty, + MaxPlayers = room.MaxCapacity > 0 ? room.MaxCapacity : 8, + IsSandbox = false, + CanMatchmakeInto = true, + DataModifiedAt = DateTime.UtcNow, + }; + + /// Postgres only: moves the RoomScenes id sequence past the + /// highest existing id. Rows written with explicit ids (seeds, older + /// builds of the clone and repair code) leave it behind, and the next + /// insert that relies on it (add / clone sub-room) then collides on the + /// primary key. A no-op on SQLite, which assigns max + 1 itself. + public async Task SyncRoomSceneIdSequenceAsync() + { + if (!db.Database.IsNpgsql()) return; + await db.Database.ExecuteSqlRawAsync( + """ + SELECT setval(pg_get_serial_sequence('"RoomScenes"', 'Id'), + GREATEST(COALESCE((SELECT MAX("Id") FROM "RoomScenes"), 0), 1), + EXISTS (SELECT 1 FROM "RoomScenes")); + """); + } + + /// Startup self-heal for sub-rooms (idempotent). + /// + /// Every non-dorm room must own at least one real RoomScenes row, + /// and every row must have a name. A room created by cloning a template + /// that had no scene rows (the 2023 "create room" flow clones a base room + /// such as MakerRoom) ended up with none, so the details builder served a + /// synthesised fallback and every sub-room mutation had to create the row + /// on the fly; a blank Name renders as an empty row in Room Settings > + /// Subrooms. This inserts the missing entry sub-room (OrderIndex 0, the + /// room's own location / blob / capacity) and names unnamed rows. Dorms + /// are left alone: their details are shaped per player and their single + /// scene is synthesised on purpose. + /// + /// It first moves the Postgres id sequence of RoomScenes past the + /// highest id (), then lets the + /// database assign ids like every other scene insert. Every service that + /// runs the database bootstrap calls this at startup, so on Postgres the + /// whole repair holds the bootstrap's advisory lock and replicas booting + /// together take turns. + /// Returns (rooms given an entry scene, scenes renamed). + public async Task<(int ScenesAdded, int ScenesRenamed)> RepairRoomScenesAsync() + { + await using var tx = await db.Database.BeginTransactionAsync(); + if (db.Database.IsNpgsql()) + await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock(1146246987);"); + + // The entry sub-room is OrderIndex 0 (its blob is the room's own front + // door), so a room missing it is repaired even when it has others. + await SyncRoomSceneIdSequenceAsync(); + + var roomsWithoutEntry = await db.Rooms + .Where(r => !r.IsDormRoom && !db.RoomScenes.Any(s => s.RoomId == r.Id && s.OrderIndex == 0)) + .ToListAsync(); + foreach (var room in roomsWithoutEntry) + db.RoomScenes.Add(NewHomeScene(room)); + + var unnamed = await db.RoomScenes + .Where(s => s.Name == null || s.Name.Trim() == string.Empty) + .ToListAsync(); + foreach (var scene in unnamed) + scene.Name = DefaultSubRoomName(scene.OrderIndex); + + if (roomsWithoutEntry.Count > 0 || unnamed.Count > 0) + await db.SaveChangesAsync(); + await tx.CommitAsync(); + return (roomsWithoutEntry.Count, unnamed.Count); + } + /// Run once at startup to backfill /// + rows for every account that /// pre-dates the auto-create-on-signup work. Idempotent — calls @@ -1068,6 +1156,8 @@ public class RoomService(DorkNetDbContext db) }; db.Rooms.Add(clone); + // Every room owns a real entry sub-room (see RepairRoomScenesAsync). + db.RoomScenes.Add(NewHomeScene(clone)); await db.SaveChangesAsync(); return new CloneResult(0 /* Success */, clone); } @@ -1081,6 +1171,25 @@ public class RoomService(DorkNetDbContext db) /// AllowsJuniors, RoomWarningMask, CustomRoomWarning /// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute. /// + /// The IsRRO the 2023 client is told: only a first-party + /// room (baked/AG and still owned by the system account) is a Rec Room + /// Original. A player's room is never one, even when it was cloned from an + /// RRO template and keeps for the baked + /// blob handling. + /// + /// The client gates the whole owner UI on it: RoomModel + /// get_CanPublishRoom, CanUnpublishRoom, CanDeleteRoom, + /// CanChangeRoomCreator, CanChangeRoomSetting, CanChangeCustomWarning, + /// CanChangeLoadingScreen and CanCreateNewSubroom all require + /// !IsRRO (IsilDump/Assembly-CSharp/RRUI/Data/RoomModel.txt:275, + /// 429, 560, 682, 1002, 1473, 1680, 1816), so a clone advertised as RRO + /// could never be published ("Cannot publish room") or have its settings + /// changed. The room-load pipeline (RecRoom.RoomLoading.Runtime) never + /// reads the flag: the scene comes from the sub-room's UnitySceneId and + /// the blob step from whether DataBlob is set. + public static bool WireIsRRO(RoomEntity r) + => r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId; + public static object ToWireRoom(RoomEntity r) => new { RoomId = r.Id, @@ -1093,7 +1202,7 @@ public class RoomService(DorkNetDbContext db) Accessibility = r.Accessibility, SupportsLevelVoting = r.SupportsLevelVoting, IsAGRoom = r.IsAGRoom, - IsRRO = r.IsAGRoom, + IsRRO = WireIsRRO(r), // 2020.12 Room.Deserialize (KLCOGEIGEBJ.PPGFHEDFBEA, the Room base) // reads required keys: RoomId, IsDorm, CloningAllowed, DisableMicAutoMute, // DisableRoomComments, EncryptVoiceChat. The 2020.03 key was IsDormRoom; diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 5ac885c..64ed35a 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -213,6 +213,11 @@ public static class DatabaseBootstrap await roomService.ApplyCanonicalOverridesAsync(http, imagesDir); } + // Every non-dorm room needs a real, named entry sub-room (see + // RoomService.RepairRoomScenesAsync). Runs after the seed and the + // canonical overrides so it only fills what they left missing. + await roomService.RepairRoomScenesAsync(); + // Seed default curated playlists, club category tags, store catalog. // Must run after SeedAsync so they can reference RR-Originals. var playlistService = scope.ServiceProvider.GetRequiredService(); @@ -948,6 +953,23 @@ public static class DatabaseBootstrap ); """, @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");", + // 2023 sub-room role-permission matrix + // (PUT rooms/{id}/subrooms/{sub}/permissions → photon_access_token). + """ + CREATE TABLE IF NOT EXISTS "RoomRolePermissions" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "RoomId" bigint NOT NULL, + "SubRoomId" bigint NOT NULL, + "Role" integer NOT NULL, + "Permission" character varying(128) NOT NULL DEFAULT '', + "Type" integer NOT NULL DEFAULT 0, + "Override" boolean NOT NULL DEFAULT false, + "Value" character varying(4096) NOT NULL DEFAULT '', + "UpdatedByPlayerId" bigint NOT NULL DEFAULT 0, + "UpdatedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission"" ON ""RoomRolePermissions"" (""RoomId"", ""SubRoomId"", ""Role"", ""Permission"");", }; foreach (var sql in statements) await db.Database.ExecuteSqlRawAsync(sql); @@ -1025,6 +1047,21 @@ public static class DatabaseBootstrap ); """, @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");", + """ + CREATE TABLE IF NOT EXISTS "RoomRolePermissions" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_RoomRolePermissions" PRIMARY KEY AUTOINCREMENT, + "RoomId" INTEGER NOT NULL, + "SubRoomId" INTEGER NOT NULL, + "Role" INTEGER NOT NULL, + "Permission" TEXT NOT NULL DEFAULT '', + "Type" INTEGER NOT NULL DEFAULT 0, + "Override" INTEGER NOT NULL DEFAULT 0, + "Value" TEXT NOT NULL DEFAULT '', + "UpdatedByPlayerId" INTEGER NOT NULL DEFAULT 0, + "UpdatedAt" TEXT NOT NULL DEFAULT '' + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission"" ON ""RoomRolePermissions"" (""RoomId"", ""SubRoomId"", ""Role"", ""Permission"");", }; // CREATE ... IF NOT EXISTS is already idempotent; a failure here is a // real problem and must fail startup. diff --git a/docs/recroom-2023-client-api-complete.md b/docs/recroom-2023-client-api-complete.md index 40c6bc7..06b9bfd 100644 --- a/docs/recroom-2023-client-api-complete.md +++ b/docs/recroom-2023-client-api-complete.md @@ -170,7 +170,7 @@ the player to their dorm, so bans go out as `Logout` (6) with Reason 4 Ids the client handles that this server never sends (dead client features, available if wanted): 3, 5, 23, 32, 60, 110, 120, 121, plus a long tail of -string channels — `GoToFailure`, `PhotonAccessToken`, `AppVersionUpdate`, +string channels — `GoToFailure`, `AppVersionUpdate`, `GameConfig.Refresh`, `ReputationUpdate`, `KeepsakeInstanceAdded/Removed`, `RoomCurrencyCreated/Modified/Deleted`, `RoomCommentDeleted` and others. @@ -622,6 +622,83 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains 8. UNKNOWNS: rooms/requiring/{mode} response element type could not be resolved from the ISIL metadata token (stored internally as a restricted-rooms list; server already implements rooms/requiring/{restriction}). The exact wire type of BNLPBMJJOMM.Value (subroom permissions) is a variant read through the shared value reader — keys are certain, scalar type is not. +#### Sub-room ids, IsRRO and the instance browser + +These cut across the whole rooms surface. + +- **Every room and playlist mutation answers `{success, value, error}`.** + They all go through one generic helper, + `NLDBPDCNNCF.BDBHCEGKGDC(opName, verb, route, …)` (RVA 0x35E50A0, + `Call 0x1835E50A0` at 47 call sites in NLDBPDCNNCF.txt). It is the same + helper and the same `BDBHCEGKGDC` instantiation token + (`[0x1884C1948]`) that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and + clone is proven in game to need the envelope. A bare room-details body has + no `success`; the helper rejects it with no message, and the player sees + "Exception of type 'CEMNLBKJABA' was thrown." (CEMNLBKJABA is the RecNet + request exception, base BCPHBJHOIEM : Exception, built with a null + message) although the server applied the change. This is what broke the + co-owner invite in game after the role fixes. Routes: 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} and /invite, rooms/{0}/subrooms (POST), + rooms/{0}/subrooms/{1} (DELETE) and its accessibility, clone, maxplayers, + modify, move, name, permissions, publish_save, plus playlists (POST) and + playlists/{0}/{accessibility, description, image, levelvoting, name, + restrictions, rooms/{1}, tags, warning}. `value` is the full FGCPNAACHIK + (playlist DTO for playlists). Refusals are HTTP 200 with `success:false` + and a message in `error`, since a 403/404 also shows only the message-less + exception. Server: `Controllers/RecNetEnvelopeAttribute.cs` on each handler. + Wherever a row below says "FGCPNAACHIK full room details" for one of these + routes, read it as the envelope's `value`. + +- **Sub-room ids are OrderIndex + 1 on the wire.** The client refuses to load + a sub-room whose id is not positive: `SubroomModel.DOKCPLJOKIE` + (Assembly-CSharp/RRUI/Data/SubroomModel.txt:3) is + `RoomId > 0 && SubroomId > 0`, and `Model`1.LNPEMMMGCPC` skips the load when + it fails. With the first sub-room sent as 0, its Room Settings > Subrooms row + never fetched its details and showed no name, and every row action ran + against a null sub-room. The wire id is now `RoomSceneEntity.OrderIndex + 1` + in the room details `SubRooms[]`, the sub-room routes + (`rooms/{0}/subrooms/{1}/*`), `matchmake/room/{0}/{1}`, the room-instance + JSON (`RoomInstanceDto.subRoomId`), the instance browser and the sub-room + save DTOs; incoming 0 is accepted as the first sub-room. Storage (RoomScenes, + PrivateInstances, RoomDataBlobs, presence) keeps the OrderIndex. + See `DorkNet.Server/Services/RoomSceneIds.cs`. +- **Every room owns a real, named entry sub-room.** Cloning a template that + has no RoomScenes rows now creates a "Home" row from the clone's own + location, blob and capacity, and a startup pass + (`RoomService.RepairRoomScenesAsync`) adds the row to existing rooms that + lack one and names blank rows ("Home", "Subroom N"). +- **`IsRRO` is false for every player-owned room.** RoomModel's owner UI — + get_CanPublishRoom, CanUnpublishRoom, CanDeleteRoom, CanChangeRoomCreator, + CanChangeRoomSetting, CanChangeCustomWarning, CanChangeLoadingScreen, + CanCreateNewSubroom (RoomModel.txt:275, 429, 560, 682, 1002, 1473, 1680, + 1816) — requires `!IsRRO`, so a clone of a Rec Room Original template + advertised as RRO could not be published ("Cannot publish room") or have its + settings changed. The load pipeline (RecRoom.RoomLoading.Runtime) never + reads the flag. `RoomService.WireIsRRO` = AG room still owned by the system + account; `RoomEntity.IsAGRoom` keeps its server-side meaning. +- **Publish preconditions are client-side.** Besides `!IsRRO`, the client + requires role ≥ CoOwner, not a dorm, a valid name (2–128 chars) and a room + image other than `DefaultRoomImage.jpg` ("Cannot publish room: Missing room + photo") before it sends `PUT rooms/{0}/accessibility accessibility=1`. +- **Room settings are creator or accepted co-owner.** Sub-room mutations + (name, max players, accessibility, modify, add, delete, duplicate, restore, + permissions) used to be creator-only and the room and sub-room renames also + let moderators in; all now use the same creator-or-co-owner gate as the + bare room mutations. +- **`room/{0}/instances`** (Matchmaking.KAIPGDGACGG, Matchmaking.txt:13966, + verb 0) is shown unfiltered by the client (RoomInstancePagedGrid, current + instance first, then oldest CreatedAt), which derives PlayerCount from + PlayerIds and SubroomName from the room's SubRooms by SubRoomId. The server + used to return nothing at all on Redis deployments (no per-room index), + dropped private instances from presence and re-added only the caller's own + with no players, counted crashed clients, and always sent IsFull false. It + now lists live instances from a per-room presence index, with the real + players (live activity marker only), IsFull from capacity, and private + instances for the people allowed into them. + #### Endpoints | Verb | Route | Request | Response | @@ -630,7 +707,7 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains | POST | `api/rooms/v1/verifyRole` | x-www-form-urlencoded fields: roomId (Int64, current room from Matchmaking ObscuredLong), role (Int32 enum), context (String). Keys are camelCase. | none parsed — client ignores the body; return 200 | | POST | `api/rooms/v2/report` | x-www-form-urlencoded fields (PascalCase!): RoomId (Int64), RoomKeyId (Nullable), Details (String), ReportCategory (Int32 enum FPIBGPIAOBI/BDOGOIGCKMK) | none parsed — logged as 'Failed to report room' on error; return 200 | | GET | `featuredrooms/current` | none | JBHJKCCFCIO: {"FeaturedRoomGroupId": Int64, "Name": String, "Rooms": [COGJIOGPNGD {"ImageName": String, "RoomId": Int64, "RoomName": String}]} (casing-tolerant) | -| GET | `room/{0}/instances` | none | JSON array: [PNDCMIMEJLD {"RoomInstanceId": Int64, "RoomId": Int64, "SubRoomId": Int64, "PlayerIds": [Int32], "IsFull": Boolean, "CreatedAt": DateTime string}] (casing-tolerant) | +| GET | `room/{0}/instances` | none | JSON array: [PNDCMIMEJLD {"RoomInstanceId": Int64, "RoomId": Int64, "SubRoomId": Int64 (wire id, see "Sub-room ids" below), "PlayerIds": [Int32], "IsFull": Boolean, "CreatedAt": UTC DateTime string}] (casing-tolerant). Live instances only (at least one player with a live activity marker); private instances only for the creator, accepted role holders, the instance owner, its invitees and players inside | | PUT | `roominstance/{0}/inprogress` | x-www-form-urlencoded field: inProgress (Boolean) | none parsed ('Failed to modify current room's game-in-progress state' on error); return 200 | | PUT | `roominstance/{0}/markprivate` | none (empty body) | none parsed ('Failed to mark room instance {0} as private'); return 200 | | POST | `roominstance/{0}/reportjoinresult` | x-www-form-urlencoded field: result (Int32 enum MEIJCIADDHJ) | none parsed ('Failed to report room join result'); return 200 | @@ -657,7 +734,7 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains | GET | `rooms/visitedby/me` | query params: skip (Int32), take (Int32) | [NEMINAEBALC] | | GET | `rooms/visitedby/{0}` | query params: skip (Int32), take (Int32) | [NEMINAEBALC] | | GET (2 overloads) + DELETE | `rooms/{0}` | GET summary: none. GET details: query params include (Byte bitmask), unityAssetTarget (String), unityAssetVersion (Int32) from closure CAEBODLFAMA. DELETE: none. | GET summary → NEMINAEBALC. GET details → FGCPNAACHIK = NEMINAEBALC keys PLUS: "DataBlob" String, "DataBlobHash" String, "LoadScreens": [{"ImageName","Title","Subtitle" all String}], "PromoImages": [String], "PromoExterna | -| PUT | `rooms/{0}/accessibility` | x-www-form-urlencoded field: accessibility (Int32 enum JHAOFLCJNAL) | FGCPNAACHIK full room details (see rooms/{0}) — client re-parses the whole object; missing keys NRE | +| PUT | `rooms/{0}/accessibility` | x-www-form-urlencoded field: accessibility (Int32 enum JHAOFLCJNAL: Private 0, Public 1, Unlisted 2). This is the "Publish room" button (RoomModel.TryPublishRoom → DBKKLDIPCHP(1)); unpublish sends 0 | FGCPNAACHIK full room details (see rooms/{0}) — client re-parses the whole object; missing keys NRE | | PUT | `rooms/{0}/allow_new_users` | form field: allowNewUsers (Boolean) | FGCPNAACHIK full room details | | PUT | `rooms/{0}/automute` | form field: disable (Boolean) — NOTE key is 'disable', maps to DisableMicAutoMute | FGCPNAACHIK full room details | | GET + POST | `rooms/{0}/bans` | GET: none. POST 'ban players': form fields id (repeated Int32 account ids) + banMask (Int32 enum DCDPJPHBHOA) | GET → [IBHAKOOKEEE {"AccountId": Int32, "BannedByAccountId": Int32, "BanStartTime": DateTime string}]. POST → none parsed. | @@ -681,9 +758,9 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains | PUT + DELETE | `rooms/{0}/promo_external` | form fields: type (Int32 enum OJGHPAELGBO), reference (String) — same keys on both verbs | FGCPNAACHIK full room details | | PUT + DELETE | `rooms/{0}/promo_images` | form field: imageName (String) on both verbs | FGCPNAACHIK full room details | | PUT | `rooms/{0}/restrictions` | form fields: supportsJuniors, supportsScreens, supportsTeleportVR, supportsWalkVR (Booleans) | FGCPNAACHIK full room details | -| GET | `rooms/{0}/roles` | none | [EFHPLDPNGIM {"AccountId": Int32, "Role": Int32 enum, "InvitedRole": Int32 enum, "LastChangedByAccountId": Int32}] (casing-tolerant) | -| GET + PUT | `rooms/{0}/roles/{1}` | GET: none. PUT: form field role (Int32 enum OMMBGJMJJPN). | GET → EFHPLDPNGIM single object. PUT → FGCPNAACHIK full room details. | -| PUT | `rooms/{0}/roles/{1}/invite` | form field: role (Int32 enum) | FGCPNAACHIK full room details | +| GET | `rooms/{0}/roles` | none | [EFHPLDPNGIM {"AccountId": Int32, "Role": Int32 enum, "InvitedRole": Int32 enum, "LastChangedByAccountId": Int32}] (casing-tolerant). One row per account; the creator is Role 255 (Creator) | +| GET + PUT | `rooms/{0}/roles/{1}` | GET: none. PUT: form field role (Int32 enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30, TemporaryCoOwner 31, Creator 255). Also how an invitee ACCEPTS (own id, role 30) and how anyone leaves their role (own id, role 0) | GET → EFHPLDPNGIM single object. PUT → FGCPNAACHIK full room details. | +| PUT | `rooms/{0}/roles/{1}/invite` | form field: role (Int32 enum). Owner invites with 30; the invitee REJECTS by sending role 0 here on their own id | FGCPNAACHIK full room details | | GET | `rooms/{0}/similar` | none | single OPGDCAELKMJ: {"SeedRoom": NEMINAEBALC, "Rooms": [NEMINAEBALC]} | | POST | `rooms/{0}/subrooms` | form field: name (String) — new subroom name | FGCPNAACHIK full room details (with the new SubRooms entry) | | DELETE | `rooms/{0}/subrooms/{1}` | none | FGCPNAACHIK full room details | @@ -694,7 +771,7 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains | PUT | `rooms/{0}/subrooms/{1}/modify` | form fields: name (String), accessibility (Int32 enum), maxPlayers (Int32) | FGCPNAACHIK full room details | | POST | `rooms/{0}/subrooms/{1}/move` | form field: newRoomId (Nullable — target room) | FGCPNAACHIK full room details | | PUT | `rooms/{0}/subrooms/{1}/name` | form field: name (String) | FGCPNAACHIK full room details | -| PUT | `rooms/{0}/subrooms/{1}/permissions` | serialized list of BNLPBMJJOMM permission entries; per-entry keys (from its serializer): Role (Int32), Type (Int32), Permission (Int32), Override (Boolean), Value (variant) | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/subrooms/{1}/permissions` | raw JSON array (BNDIAONDFFF.FJLLPHFOOJJ, not form) of BNLPBMJJOMM cells, keys from formatter HDOJMEBCAGP: Permission (String), Role (OMMBGJMJJPN), Override (Boolean), Type (FEOJFGECKGH: Bool 0, Int 1, StringList 2), Value (String). The editor sends only the changed cell | FGCPNAACHIK full room details | | POST | `rooms/{0}/subrooms/{1}/publish_save` | form field: subRoomDataSaveId (Int64) | FGCPNAACHIK full room details | | GET | `rooms/{0}/subrooms/{1}/saves` | query params: skip (Int32), take (Int32) | paged object GJDIKIMLCHA: {"Results": [JKIFFPPAJNK {"SubRoomDataSaveId" Int64, "SubRoomId" Int64, "UnityAssetId" String, "DataBlob" String, "DataBlobHash" String, "Description" String, "SavedByAccountId" Int64, "SavedOnP | | PUT | `rooms/{0}/tags` | form fields: tag (repeated String), autoTag (repeated String) | FGCPNAACHIK full room details | @@ -948,6 +1025,8 @@ Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:4 **Fix.** Change ApplyAndReturn to return RoomsController.BuildRoomServerDetails (load scenes+roles like BuildRoomServerDetailsWithRolesAsync) instead of ToWireRoom — this one fix repairs the response of every bare-path room mutation. +**Fixed.** ApplyAndReturn returns the full details. This route is also the "Publish room" button; publishing a cloned room still failed because the clone was advertised as `IsRRO`, which hides and refuses Publish client-side. Player-owned rooms are now never RRO on the wire (see "Sub-room ids, IsRRO and the instance browser" above). + ##### `PUT rooms/{0}/allow_new_users` — SHAPE_MISMATCH (degraded) Twins + form key allowNewUsers OK; response is ToWireRoom (same ApplyAndReturn defect as accessibility). @@ -1036,29 +1115,33 @@ Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:8 **Fix.** Same ApplyAndReturn fix. -##### `GET rooms/{0}/roles` — MISSING (degraded) +##### `GET rooms/{0}/roles`, `GET + PUT rooms/{0}/roles/{1}`, `PUT rooms/{0}/roles/{1}/invite` — FIXED -Handler + wire shape are correct ([{AccountId,Role,InvitedRole,LastChangedByAccountId}], BuildRoomAccountRoleWire :4003-4014) but there is no roomserver twin → the permissions screen's list fetch 404s under the 2023 prefix. +Was: the roomserver/ twins were added earlier, but three defects remained. +(1) The creator was listed as Role 30 (CoOwner). The client takes the local +role from the Roles list (`FGCPNAACHIK.ONECLLALJEO`, first AccountId match) +and the permission editor only allows roles strictly below your own, with +Creator (255) the one unconditional pass (`OMJKHJLFOCO.DIIMLMKEJLC`, +RecRoom.RoomPermissions.Runtime/OMJKHJLFOCO.txt:2367), so the creator could +not edit Co-Owner or Creator permissions. (2) Only the creator or a co-owner +could call either route, so an invitee could never accept (own id, role 30) +or reject (`/invite` on own id, role 0), and re-inviting an accepted co-owner +demoted them to pending. (3) Nobody was told: the invitee's prompt is a +message, Type 62 RoomCoOwnerInvited with RoomId (KPFKPIKDBFJ, factory +OCGKOJMFLAI.ALEHDHAEFOK), and none was sent. -Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3695` +**Fixed.** Creator is 255 and listed once (`RoomRoleRules.BuildWires`); one +row per account with accepted Role and pending InvitedRole side by side. +`RoomRoleRules.CheckChange` enforces the ladder: the creator may set any role +on anyone but themselves; otherwise the actor must be an accepted co-owner and +both the target's current or invited role and the requested role must be +strictly below theirs; anyone may drop their own role or decline their invite; +an invitee may accept exactly the invited role. Messages 62 (invite), 60 +(added) and 61 (removed) go to the target, answered invites are marked read, +and the full room goes out as `RoomUpdate` to everyone in the room plus the +changed player. -**Fix.** Add [HttpGet("roomserver/rooms/{roomId:long}/roles")] (and twins for the two routes below). - -##### `GET + PUT rooms/{0}/roles/{1}` — MISSING (degraded) - -Handlers are contract-correct: GET returns a single role wire object; PUT reads form 'role' (ReadClientRoomRoleAsync :3944-3973) and returns full BuildRoomServerDetailsWithRolesAsync. But neither has a roomserver twin → 404 under the 2023 prefix. - -Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3818 (GET), 3835 (PUT)` - -**Fix.** Add roomserver twins for GET and PUT/POST. - -##### `PUT rooms/{0}/roles/{1}/invite` — MISSING (degraded) - -Handler correct (form role, Accepted=false row, full-details response); no roomserver twin → 404 under prefix. - -Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3879` - -**Fix.** Add roomserver twin. +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs` (`GrantRole`, `InviteRole`, `ChangeRoomAccountRoleAsync`), rules in `DorkNet.Server/Services/RoomRoleRules.cs` ##### `POST rooms/{0}/subrooms/{1}/clone` — SHAPE_MISMATCH (degraded) @@ -1084,13 +1167,29 @@ Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2656` **Fix.** Drop the [FromBody] parameter; return BuildRoomServerDetailsWithRolesAsync. -##### `PUT rooms/{0}/subrooms/{1}/permissions` — STUB (degraded) +##### `PUT rooms/{0}/subrooms/{1}/permissions` + `GET photon_access_token` — FIXED -Twins exist but the handler accepts SubRoomModifyRequest (Name/MaxPlayers/IsSandbox/CanMatchmakeInto) — the client actually sends a serialized LIST of BNLPBMJJOMM permission entries {Role,Type,Permission,Override,Value}. No permission matrix is ever stored (there is no storage for it), and the client body would 415/no-op. Response SceneWire. The role-permission matrix silently never saves. +Was: the PUT accepted the matrix and dropped it (no storage), and +`photon_access_token` always answered a hardcoded seed, so a permission +change never survived a reload and nobody else in the room saw it. -Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3391` +**Fixed.** Cells persist in `RoomRolePermissions` (one row per room, sub-room +OrderIndex, role, permission; upsert, cells not sent are untouched). +`photon_access_token` (`ALIIIAGAPND {RoomInstanceId, PhotonAccessToken, +Permissions}`, now also under `roomserver/`) serves the defaults with the +caller's current sub-room cells applied on top, and every save pushes the +same body as the `PhotonAccessToken` hub message (registered as +`Action`, IBEOONPEELF.txt:519) to everyone in that sub-room. Writes +follow the client's own rule: the creator may edit every role row, a co-owner +only rows below 30, nobody else may write. Role and Type are accepted as +numbers, numeric strings or enum names. -**Fix.** Parse the request as a list of {Role,Type,Permission,Override,Value}; persist per-subroom permission rows (new table); echo full room details. Scope explicitly if deferred — currently it is a silent stub. +Unproven: the client's permission-id spelling. The defaults keep the numeric +ids the server has always sent ("13" maker pen, "20"–"23"); RecFlare sends +names such as `CAN_USE_MAKER_PEN`. Stored cells are echoed exactly as the +client wrote them, so the client's own edits round-trip either way. + +Handler: `RoomsController.SubRoomPermissions`, `MatchController.PhotonAccessToken`, `DorkNet.Server/Services/RoomPermissionMatrix.cs` ##### `POST rooms/{0}/subrooms/{1}/publish_save` — SHAPE_MISMATCH (degraded) diff --git a/docs/recroom-2023-notifications.md b/docs/recroom-2023-notifications.md index da88935..1f13b0f 100644 --- a/docs/recroom-2023-notifications.md +++ b/docs/recroom-2023-notifications.md @@ -69,7 +69,7 @@ today. | Key | Msg | Sent by | |---|---|---| | `1` RelationshipChanged | `{PlayerID, RelationshipType, Muted, Ignored, Favorited}`: the recipient's own view, the same row `api/relationships/v2/get` lists | friend request / accept / remove / add / add-with-code (both players); favorite / mute / ignore and their reverses (caller only) | -| `2` MessageReceived | `{Id, FromPlayerId (Int32), SentTime, Type, Data (string), RoomId, PlayerEventId}`, the same row `api/messages/v2/get` lists | DMs and game invites; `SystemNotificationService` (cheers, friend request Type 4, accepted Type 40, "wants to join" Type 10, coach message Type 100, event invite text) | +| `2` MessageReceived | `{Id, FromPlayerId (Int32), SentTime, Type, Data (string), RoomId, PlayerEventId}`, the same row `api/messages/v2/get` lists | DMs and game invites; `SystemNotificationService` (cheers, friend request Type 4, accepted Type 40, "wants to join" Type 10, coach message Type 100, event invite text, room co-owner Type 62 invited / 60 added / 61 removed with `RoomId` set — the client's co-owner slots read only RoomId) | | `3` MessageDeleted | `{Id}` | not sent | | `5` RefreshLogin | none | not sent | | `6` Logout | `{Reason}`: a **number**, enum Unknown 0, UserInitiated 1, SessionTakeover 2, ForciblyLoggedOut 3, Banned 4 | `NotificationService.LogoutAsync`: second-session takeover (2), account deleted or password reset (3), bans (4) | @@ -89,12 +89,13 @@ today. | `AccountUpdate` / `SelfAccountUpdate` | the public / self account DTOs the account REST routes return | every profile edit, including profile image (`NotificationService.AccountChanged`) | | `PresenceUpdate` | `{playerId, statusVisibility, deviceClass, vrMovementMode, roomInstance, isOnline, appVersion (string)}` | `PresenceBroadcastService`, to friends and subscribers, **never to the subject** (a frame about the local player moves them) | | `RoomInstanceUpdate` | the room instance (`RoomInstanceDto`) | admin force-join / instance pull. It only refreshes the cached instance; the move itself comes from the next heartbeat. | -| `RoomUpdate` | the **full room** (`FGCPNAACHIK`, `RoomsController.BuildRoomServerDetails`), not the 2020 `{Room, Scenes}` wrapper | admin role / owner changes (everyone in the room plus the changed player), admin force-join | +| `RoomUpdate` | the **full room** (`FGCPNAACHIK`, `RoomsController.BuildRoomServerDetails`), not the 2020 `{Room, Scenes}` wrapper | admin role / owner changes and every in-game role change, invite, accept, reject or removal (everyone in the room plus the changed player and the actor), admin force-join | +| `PhotonAccessToken` | `ALIIIAGAPND {RoomInstanceId, PhotonAccessToken (""), Permissions: [{Permission, Role, Override, Type, Value}]}`, the same body `GET photon_access_token` returns (handler IBEOONPEELF.txt:519, `Action`) | every `PUT rooms/{id}/subrooms/{sub}/permissions`, to each player in that sub-room with their own RoomInstanceId | | `ChatMessageReceived` | `{chatMessageId, chatThreadId, senderPlayerId, timeSent, contents}` | chat sends, to every member including the sender | | `PlayerLeftChat` | the same chat-message shape; only `chatThreadId` and `senderPlayerId` (the leaver) are read | leaving a group chat | | `PlayerProgressionLevelUpdate` | `{PlayerId, Level, XP}`, as `api/players/v2/progression` returns it | every XP award (`LevelService.AwardXpAsync`) | | `CommunityBoardUpdate` | community board | community board edits | -| `ReputationUpdate`, `RoomCurrency*`, `KeepsakeInstance*`, `PhotonAccessToken`, `GoToFailure`, `AnnouncementUpdate/Delete`, `ClubMembershipUpdate`, `GameConfig.Refresh`, `PlayerSettings.Refresh`, … | see the contract notes in `recroom-2023-client-api-complete.md` | not sent | +| `ReputationUpdate`, `RoomCurrency*`, `KeepsakeInstance*`, `GoToFailure`, `AnnouncementUpdate/Delete`, `ClubMembershipUpdate`, `GameConfig.Refresh`, `PlayerSettings.Refresh`, … | see the contract notes in `recroom-2023-client-api-complete.md` | not sent | **Not registered at all** (a push with these keys is dropped): numeric 4, 11, 12, 13, 15, 21, 24 (ModerationRoomBan), 40, 85, 90, 95.