fix: 2023 room instances, publish, role permissions and co-owner invites

- Room roles: list the creator as Creator (255), not CoOwner (30). The
  client takes the local role from the Roles list and only lets a player
  edit roles below their own (Creator excepted), so the creator could not
  edit Co-Owner and Creator permissions. Enforce the role ladder on the
  server (RoomRoleRules), let invitees accept (PUT roles/{me} role=30) and
  reject (PUT roles/{me}/invite role=0), send message Types 62/60/61, and
  push RoomUpdate after every role change.
- Permissions: persist PUT subrooms/{sub}/permissions cells in a new
  RoomRolePermissions table, serve them from photon_access_token (plus a
  roomserver/ twin) and push PhotonAccessToken to the sub-room.
- Publish: player-owned rooms are never IsRRO on the wire; the client
  hides Publish and every room setting for an RRO.
- Sub-rooms: wire SubRoomId = OrderIndex + 1 (the client will not load a
  sub-room with id 0), new rooms always get a named Home sub-room, and a
  startup pass repairs rooms without one.
- Instance browser: per-room Redis presence index, live players only,
  private instances for the people allowed in, real IsFull.
- Room/sub-room settings: creator or accepted co-owner everywhere.
This commit is contained in:
Alexa
2026-10-24 23:21:46 +03:00
parent 0839d932ce
commit fd2153d82b
25 changed files with 6012 additions and 419 deletions
@@ -0,0 +1,125 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// "Publish room" in the 2023 client: RoomModel.TryPublishRoom →
/// <c>PUT roomserver/rooms/{id}/accessibility</c> with form
/// <c>accessibility=1</c> (boxed Int32, NLDBPDCNNCF_NestedType_OMHIIEAKHCH),
/// typed <c>Task&lt;FGCPNAACHIK&gt;</c>. The button only exists when
/// get_CanPublishRoom holds: role ≥ CoOwner, not a dorm, <b>not IsRRO</b>,
/// not already public. A clone of a Rec Room Original template used to be
/// advertised as IsRRO and so could never be published.
/// </summary>
public sealed class RoomPublish2023Tests : IClassFixture<DorkNetServerFactory>
{
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<DorkNetDbContext>();
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<string, string> { ["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<DorkNetDbContext>();
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<string, string> { ["accessibility"] = "0" })))
Assert.False(denied.IsSuccessStatusCode);
using var coRooms = Client("rooms", coOwner);
var unpublished = await PutAccessibilityAsync(coRooms, cloneId, "0");
Assert.Equal(0, unpublished.GetProperty("Accessibility").GetInt32());
}
private static async Task<JsonElement> PutAccessibilityAsync(HttpClient client, long roomId, string value)
{
using var response = await client.PutAsync($"/roomserver/rooms/{roomId}/accessibility",
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = value }));
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private static async Task<JsonElement> 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;
}
}
+454
View File
@@ -0,0 +1,454 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// 2023 room roles, the co-owner invite flow and the sub-room role-permission
/// matrix, against the client contract:
/// <list type="bullet">
/// <item>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).</item>
/// <item>Invite: owner <c>PUT rooms/{id}/roles/{target}/invite</c> role=30;
/// invitee gets message Type 62 with RoomId; accepts with
/// <c>PUT rooms/{id}/roles/{me}</c> role=30, rejects with
/// <c>PUT rooms/{id}/roles/{me}/invite</c> role=0.</item>
/// <item>Permissions: <c>PUT rooms/{id}/subrooms/{sub}/permissions</c> raw
/// JSON array of {Permission, Role, Override, Type, Value}; read back from
/// <c>GET photon_access_token</c>.</item>
/// </list>
/// </summary>
public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
{
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<RoomRoleEntity>();
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<RoomRoleEntity>
{
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<RoomRoleEntity> { 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<DorkNetDbContext>();
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<DorkNetDbContext>();
Assert.NotNull((await db.Messages.SingleAsync(m => m.Id == messageId)).ReadAt);
Assert.True(await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == invitee.PlayerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner));
}
}
[Fact]
public async Task Invitee_rejects_through_the_invite_route_and_cannot_self_promote()
{
var (owner, invitee, stranger) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var ownerRooms = Client("rooms", owner);
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
using var inviteeRooms = Client("rooms", invitee);
var rejected = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "0"));
Assert.DoesNotContain(rejected.GetProperty("Roles").EnumerateArray(),
e => e.GetProperty("AccountId").GetInt64() == invitee.PlayerId);
// With the invite gone, the same accept is refused.
using var again = await PutFormRawAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
Assert.Equal(HttpStatusCode.Forbidden, again.StatusCode);
// A stranger can neither promote themselves nor invite anyone.
using var strangerRooms = Client("rooms", stranger);
using var self = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{stranger.PlayerId}", ("role", "20"));
Assert.Equal(HttpStatusCode.Forbidden, self.StatusCode);
using var other = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
Assert.Equal(HttpStatusCode.Forbidden, other.StatusCode);
}
[Fact]
public async Task Co_owner_manages_roles_below_but_not_co_owners()
{
var (owner, coOwner, target) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
using var coRooms = Client("rooms", coOwner);
var granted = await PutFormAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "20"));
Assert.Equal(20, RoleOf(granted, target.PlayerId).GetProperty("Role").GetInt32());
using var inviteCo = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}/invite", ("role", "30"));
Assert.Equal(HttpStatusCode.Forbidden, inviteCo.StatusCode);
// The creator can directly add and remove a co-owner.
using var ownerRooms = Client("rooms", owner);
var promoted = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "30"));
Assert.Equal(30, RoleOf(promoted, target.PlayerId).GetProperty("Role").GetInt32());
// …and now the co-owner can't touch their peer.
using var demote = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
Assert.Equal(HttpStatusCode.Forbidden, demote.StatusCode);
var removed = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
Assert.DoesNotContain(removed.GetProperty("Roles").EnumerateArray(),
e => e.GetProperty("AccountId").GetInt64() == target.PlayerId);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerAdded));
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerRemoved));
}
[Fact]
public async Task Creator_saves_co_owner_and_creator_permissions_and_reads_them_back()
{
var (owner, coOwner, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
using var ownerRooms = Client("rooms", owner);
var body = """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},{"Permission":"20","Role":255,"Override":true,"Type":0,"Value":"false"}]""";
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", body))
{
var text = await put.Content.ReadAsStringAsync();
Assert.True(put.IsSuccessStatusCode, $"{(int)put.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
Assert.Equal(roomId, json.RootElement.GetProperty("RoomId").GetInt64()); // full room details
}
// The owner enters the room, then the matrix comes back with the token.
using var match = Client("match", owner);
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
var token = await GetJsonAsync(ownerRooms, "/roomserver/photon_access_token");
var cells = token.GetProperty("Permissions").EnumerateArray().ToList();
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 30
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
&& c.GetProperty("Permission").GetString() == "20").GetProperty("Value").GetString());
Assert.Equal("true", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
// A co-owner may not edit the Co-Owner row, but may edit Moderator.
using var coRooms = Client("rooms", coOwner);
using (var denied = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"true"}]"""))
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
using (var allowed = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"13","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
Assert.True(allowed.IsSuccessStatusCode, await allowed.Content.ReadAsStringAsync());
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
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<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(services);
services.AddSingleton<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(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<string, string>())))
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"21","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
Assert.True(put.IsSuccessStatusCode, await put.Content.ReadAsStringAsync());
var token = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(owner.PlayerId), "PhotonAccessToken"));
Assert.True(token.Msg.GetProperty("RoomInstanceId").GetInt64() > 0);
Assert.Contains(token.Msg.GetProperty("Permissions").EnumerateArray(),
c => c.GetProperty("Role").GetInt32() == 20 && c.GetProperty("Permission").GetString() == "21");
}
// ── helpers ─────────────────────────────────────────────────────────
private async Task<(GameClientSession A, GameClientSession B, GameClientSession C)> PlayersAsync()
{
using var setup = Client("api");
return (
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain));
}
private async Task<long> 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<DorkNetDbContext>();
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<HttpResponseMessage> PutFormRawAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
=> client.PutAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
private static async Task<JsonElement> PutFormAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
{
using var response = await PutFormRawAsync(client, path, fields);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"PUT {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private static Task<HttpResponseMessage> PutJsonRawAsync(HttpClient client, string path, string json)
=> client.PutAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
private static async Task<JsonElement> 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;
}
}
+2 -1
View File
@@ -416,9 +416,10 @@ public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
} }
} }
// The entry sub-room (OrderIndex 0) is wire SubRoomId 1 (RoomSceneIds).
private static JsonElement SubRoomZero(JsonElement details) => private static JsonElement SubRoomZero(JsonElement details) =>
details.GetProperty("SubRooms").EnumerateArray() details.GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 0); .First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path) private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{ {
@@ -58,7 +58,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
await db.SaveChangesAsync(); 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, // Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is // Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding // "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
@@ -66,7 +68,7 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
// real client's body went unread. // real client's body went unread.
using var ownerClient = Client("rooms", owner); using var ownerClient = Client("rooms", owner);
using var slider = await ownerClient.PutAsJsonAsync( 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(); var sliderBody = await slider.Content.ReadAsStringAsync();
Assert.True(slider.IsSuccessStatusCode, Assert.True(slider.IsSuccessStatusCode,
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}"); $"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
@@ -75,10 +77,11 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
// shape — so the slider's own response has to carry the new value. // shape — so the slider's own response has to carry the new value.
var echoed = JsonDocument.Parse(sliderBody).RootElement var echoed = JsonDocument.Parse(sliderBody).RootElement
.GetProperty("SubRooms").EnumerateArray() .GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1); .First(e => e.GetProperty("SubRoomId").GetInt64() == 2);
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32()); Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
// ── admin: reads what the owner just set, then edits sub-room 0 ───── // ── 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); using var adminClient = Client("admin", admin);
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms"); var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
var subRooms = listed.EnumerateArray() var subRooms = listed.EnumerateArray()
@@ -99,8 +102,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(), .ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
e => e.GetProperty("MaxPlayers").GetInt32()); e => e.GetProperty("MaxPlayers").GetInt32());
Assert.Equal(40, wire[0]); // Wire ids are OrderIndex + 1.
Assert.Equal(24, wire[1]); Assert.Equal(40, wire[1]);
Assert.Equal(24, wire[2]);
// The room-level figure is the room's advertised capacity, NOT a copy // 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. // of sub-room 0 — otherwise the admin edit above would have moved it.
@@ -0,0 +1,230 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Sub-room ids and the room instance browser for the 2023 client.
/// <list type="bullet">
/// <item>The client will not load a sub-room whose id is not positive
/// (SubroomModel.DOKCPLJOKIE: <c>RoomId &gt; 0 &amp;&amp; SubroomId &gt; 0</c>),
/// so the wire SubRoomId is OrderIndex + 1 everywhere the client sees it:
/// room details, sub-room routes, matchmaking and the instance browser.</item>
/// <item>A new room always owns a real, named entry sub-room, and a startup
/// pass repairs rooms that lack one.</item>
/// <item><c>GET room/{id}/instances</c> lists live instances with their real
/// players, including the private instance of a private room for the people
/// allowed into it.</item>
/// </list>
/// </summary>
public sealed class SubRoomsAndInstances2023Tests : IClassFixture<DorkNetServerFactory>
{
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<string, string> { ["name"] = "Basement" })))
Assert.True(rename.IsSuccessStatusCode, await rename.Content.ReadAsStringAsync());
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
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<string>(), isAg: true);
using var rooms = Client("rooms", owner);
using var resp = await rooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
new FormUrlEncodedContent(new Dictionary<string, string> { ["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<DorkNetDbContext>();
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<string>());
var blank = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "", " " });
await using var scope = _factory.Services.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
var (added, renamed) = await service.RepairRoomScenesAsync();
Assert.True(added >= 1);
Assert.True(renamed >= 2);
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var home = await db.RoomScenes.SingleAsync(s => s.RoomId == bare);
Assert.Equal((0, "Home"), (home.OrderIndex, home.Name));
var names = await db.RoomScenes.Where(s => s.RoomId == blank).OrderBy(s => s.OrderIndex).Select(s => s.Name).ToListAsync();
Assert.Equal(new[] { "Home", "Subroom 2" }, names);
// Idempotent.
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
}
[Fact]
public async Task Instance_browser_lists_the_private_instance_with_its_players_for_allowed_callers()
{
var owner = await PlayerAsync();
var coOwner = await PlayerAsync();
var stranger = await PlayerAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home" });
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
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<long>(), 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<long>(), row, false));
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, true));
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, false));
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(0, true, Array.Empty<long>(), row, true));
}
// ── helpers ─────────────────────────────────────────────────────────
private async Task<GameClientSession> PlayerAsync()
{
using var setup = Client("api");
return await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
}
private async Task<long> 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<DorkNetDbContext>();
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<JsonElement> PostFormAsync(HttpClient client, string path)
{
using var response = await client.PostAsync(path, new FormUrlEncodedContent(new Dictionary<string, string>()));
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<JsonElement> 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;
}
}
@@ -1254,8 +1254,10 @@ public class InventionsController(
if (!canPush) return Forbid(); if (!canPush) return Forbid();
var subRoomId = req.SubRoomId; var subRoomId = req.SubRoomId;
// Wire SubRoomId (OrderIndex + 1) → the scene's OrderIndex.
var sceneOrderIndex = Services.RoomSceneIds.ToOrderIndex(subRoomId);
var scene = await db.RoomScenes 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); var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
if (subRoomDataFilename.Length == 0) if (subRoomDataFilename.Length == 0)
@@ -1269,7 +1271,7 @@ public class InventionsController(
if (subRoomBlob is null) if (subRoomBlob is null)
return BadRequest(new { Error = "unknown SubRoomData.Filename" }); return BadRequest(new { Error = "unknown SubRoomData.Filename" });
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id; if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
subRoomBlob.SubRoomId ??= subRoomId; subRoomBlob.SubRoomId ??= sceneOrderIndex;
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128); var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
if (roomDataFilename.Length > 0) if (roomDataFilename.Length > 0)
@@ -394,7 +394,11 @@ public class PlayerEventsController(
if (evt is null) return NotFound(); if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; 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 // Same sourcing as the room browser: presence rows are the live truth
// (one instance per distinct PhotonRoomId), and the caller's OWN private // (one instance per distinct PhotonRoomId), and the caller's OWN private
@@ -417,7 +421,7 @@ public class PlayerEventsController(
{ {
roomInstanceId = room.RoomInstanceId, roomInstanceId = room.RoomInstanceId,
roomId = room.RoomId, roomId = room.RoomId,
subRoomId = room.SubRoomId, subRoomId = RoomSceneIds.ToWire(room.SubRoomId),
// MaxCapacity is the per-instance cap the matchmaker handed // MaxCapacity is the per-instance cap the matchmaker handed
// out, so this is a real answer rather than a constant false. // out, so this is a real answer rather than a constant false.
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity, isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
@@ -438,7 +442,7 @@ public class PlayerEventsController(
{ {
roomInstanceId = p.Id, roomInstanceId = p.Id,
roomId = p.RoomId, roomId = p.RoomId,
subRoomId = p.SubRoomId, subRoomId = RoomSceneIds.ToWire(p.SubRoomId),
isFull = false, isFull = false,
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc), createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
playerIds = Array.Empty<int>(), playerIds = Array.Empty<int>(),
@@ -35,6 +35,7 @@ public class RoomsController(
DomainConfig domain, DomainConfig domain,
NotificationService notifications, NotificationService notifications,
ServerSettingsService serverSettings, ServerSettingsService serverSettings,
SystemNotificationService systemNotifications,
ILogger<RoomsController> logger) : ControllerBase ILogger<RoomsController> logger) : ControllerBase
{ {
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this); private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
@@ -574,6 +575,7 @@ public class RoomsController(
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")] [HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId) public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var blobs = await db.RoomDataBlobs var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId .Where(b => b.RoomId == roomId
&& (b.SubRoomId == subRoomId || b.SubRoomId == null)) && (b.SubRoomId == subRoomId || b.SubRoomId == null))
@@ -586,7 +588,7 @@ public class RoomsController(
.Take(50) .Take(50)
.Select(b => new .Select(b => new
{ {
SubRoomId = subRoomId, SubRoomId = RoomSceneIds.ToWire(subRoomId),
DataBlob = b.BlobName, DataBlob = b.BlobName,
SavedByAccountId = b.UploadedByPlayerId, SavedByAccountId = b.UploadedByPlayerId,
CreatedAt = b.UploadedAt, CreatedAt = b.UploadedAt,
@@ -613,6 +615,7 @@ public class RoomsController(
[FromQuery] int? unityAssetTarget, [FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion) [FromQuery] int? unityAssetVersion)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId }); if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -661,6 +664,7 @@ public class RoomsController(
[Authorize] [Authorize]
public async Task<IActionResult> SubRoom(long roomId, long subRoomId) public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", 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? unityAssetTarget,
[FromQuery] int? unityAssetVersion) [FromQuery] int? unityAssetVersion)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId }); if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -731,6 +736,7 @@ public class RoomsController(
long roomId, long subRoomId, long roomId, long subRoomId,
[FromQuery] int skip = 0, [FromQuery] int take = 50) [FromQuery] int skip = 0, [FromQuery] int take = 50)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId }); if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -763,7 +769,7 @@ public class RoomsController(
saves.Add(new saves.Add(new
{ {
SubRoomDataSaveId = currentSaveId, SubRoomDataSaveId = currentSaveId,
SubRoomId = subRoomId, SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(), ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -809,7 +815,7 @@ public class RoomsController(
var modified = scene?.DataModifiedAt ?? room.UpdatedAt; var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
return new return new
{ {
SubRoomId = (long)orderIndex, SubRoomId = RoomSceneIds.ToWire(orderIndex),
RoomId = room.Id, RoomId = room.Id,
Name = scene?.Name ?? "Home", Name = scene?.Name ?? "Home",
DataBlob = dataBlob, DataBlob = dataBlob,
@@ -824,7 +830,7 @@ public class RoomsController(
CurrentSave = new CurrentSave = new
{ {
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L, SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
SubRoomId = (long)orderIndex, SubRoomId = RoomSceneIds.ToWire(orderIndex),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(), ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -871,7 +877,7 @@ public class RoomsController(
return new return new
{ {
SubRoomDataSaveId = saveId, SubRoomDataSaveId = saveId,
SubRoomId = subRoomId, SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = unityAssetId, UnityAssetId = unityAssetId,
SubRoomUnityAssetId = unityAssetId, SubRoomUnityAssetId = unityAssetId,
ReferencedUnityAssetIds = Array.Empty<string>(), ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -916,13 +922,14 @@ public class RoomsController(
public async Task<IActionResult> SubRoomRestoreData( public async Task<IActionResult> SubRoomRestoreData(
long roomId, long subRoomId, [FromForm] string? filename) long roomId, long subRoomId, [FromForm] string? filename)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
if (string.IsNullOrWhiteSpace(filename)) if (string.IsNullOrWhiteSpace(filename))
return BadRequest(new { error = "missing_filename" }); return BadRequest(new { error = "missing_filename" });
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" }); 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 var blob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId); .FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
@@ -2001,7 +2008,7 @@ public class RoomsController(
UseLevelBasedMatchmaking = false, UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false, UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false, UseRecRoyaleMatchmaking = false,
SubRoomId = (long)s.OrderIndex, SubRoomId = RoomSceneIds.ToWire(s.OrderIndex),
RoomId = room.Id, RoomId = room.Id,
Name = s.Name, Name = s.Name,
UnityAssetId = s.StudioUnityAssetId, UnityAssetId = s.StudioUnityAssetId,
@@ -2027,7 +2034,7 @@ public class RoomsController(
UseLevelBasedMatchmaking = false, UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false, UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false, UseRecRoyaleMatchmaking = false,
SubRoomId = 0L, SubRoomId = RoomSceneIds.ToWire(0),
RoomId = room.Id, RoomId = room.Id,
Name = "Home", Name = "Home",
UnityAssetId = string.Empty, UnityAssetId = string.Empty,
@@ -2046,11 +2053,10 @@ public class RoomsController(
}, },
}; };
var wireRoles = new List<object> // 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
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30), // roles strictly below their own (see RoomRoleRules).
}; var wireRoles = RoomRoleRules.BuildWires(room, roleList);
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
var tags = string.IsNullOrEmpty(room.TagsCsv) var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>() ? Array.Empty<object>()
@@ -2075,7 +2081,9 @@ public class RoomsController(
State = room.State, State = room.State,
Accessibility = room.Accessibility, Accessibility = room.Accessibility,
SupportsLevelVoting = room.SupportsLevelVoting, 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, SupportsScreens = room.SupportsScreens,
SupportsWalkVR = room.SupportsWalkVR, SupportsWalkVR = room.SupportsWalkVR,
SupportsTeleportVR = room.SupportsTeleportVR, SupportsTeleportVR = room.SupportsTeleportVR,
@@ -2922,9 +2930,9 @@ public class RoomsController(
{ {
// RoomRoles co-owner check — same authorisation as the // RoomRoles co-owner check — same authorisation as the
// existing modify endpoint, lets co-owners rename too. // existing modify endpoint, lets co-owners rename too.
var coOwner = await db.RoomRoles.AnyAsync(rr => // Room settings are creator / co-owner only; moderators and
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1)); // hosts moderate players, they don't edit the room.
if (!coOwner) return Forbid(); if (!await CanEditRoomAsync(room, me)) return Forbid();
} }
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118). // Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
@@ -2964,6 +2972,7 @@ public class RoomsController(
long subRoomId, long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body) [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var newName = body?.Name; var newName = body?.Name;
if (string.IsNullOrWhiteSpace(newName)) if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" }); return BadRequest(new { Result = 1, error = "empty_name" });
@@ -2976,9 +2985,9 @@ public class RoomsController(
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" }); if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
if (room.CreatorPlayerId != me) if (room.CreatorPlayerId != me)
{ {
var coOwner = await db.RoomRoles.AnyAsync(rr => // Room settings are creator / co-owner only; moderators and
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1)); // hosts moderate players, they don't edit the room.
if (!coOwner) return Forbid(); if (!await CanEditRoomAsync(room, me)) return Forbid();
} }
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
@@ -3614,7 +3623,7 @@ public class RoomsController(
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); 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(); var name = (req.Name ?? string.Empty).Trim();
if (name.Length == 0) name = "NewScene"; if (name.Length == 0) name = "NewScene";
@@ -3651,10 +3660,11 @@ public class RoomsController(
[Authorize] [Authorize]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId) public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid(); if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await db.RoomScenes var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); .FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound(); if (scene is null) return NotFound();
@@ -3710,20 +3720,6 @@ public class RoomsController(
/// <summary>One row of the per-subroom role-permission matrix the 2023 /// <summary>One row of the per-subroom role-permission matrix the 2023
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the /// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
/// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary> /// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary>
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 sealed class PublishSubRoomSaveRequest
{ {
public long? SubRoomDataSaveId { get; set; } public long? SubRoomDataSaveId { get; set; }
@@ -3773,45 +3769,103 @@ public class RoomsController(
s.CanMatchmakeInto = a != AccessibilityPrivate; s.CanMatchmakeInto = a != AccessibilityPrivate;
}); });
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — the /// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — "change
/// role-permission matrix. Client: /// subroom permissions", the role-permission matrix. Client:
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList&lt;BNLPBMJJOMM&gt;)</c> /// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList&lt;BNLPBMJJOMM&gt;)</c>
/// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT /// (NLDBPDCNNCF.txt:9198, verb 3, same typed helper as every other room
/// form-encode: the closure calls <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c> /// mutation, so the response is the full <c>FGCPNAACHIK</c> details).
/// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string /// Unlike its siblings the body is NOT form-encoded: the closure calls
/// (BNDIAONDFFF.txt:275, <c>mov dword ptr [rbx+38h],4</c>) — i.e. a raw /// <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c> (CCNOMNNHNNG.txt ISIL 009), a raw
/// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON /// JSON array of cells. The editor sends one changed cell per call
/// deserialize into the List). /// (OMJKHJLFOCO.AFHJLBBFAGP.&lt;LocalRequestModifyRolePermission&gt;g__ChangeAndRefresh
/// builds a one-element BNLPBMJJOMM[]), so each cell is upserted; cells not
/// sent are left alone.
/// ///
/// <para><b>Scope note — deliberately not persisted.</b> DorkNet has no /// <para>Read back through <c>GET photon_access_token</c> (its
/// per-subroom permission storage: <see cref="RoomSceneEntity"/> has no /// <c>Permissions</c> list), and pushed to everyone in that sub-room as
/// column for it and there is no permissions table. Adding one means an /// the <c>PhotonAccessToken</c> hub message so the change takes effect
/// entity + DbContext + migration, which is outside this file. Until then /// without a rejoin.</para>
/// 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 /// <para>Authorization mirrors the client's own rule
/// a round-trip. The old handler silently bound an unrelated DTO whose keys /// (OMJKHJLFOCO.DIIMLMKEJLC): the creator may edit every role, including
/// the client never sends, which was strictly worse.</para></summary> /// Co-Owner and Creator; a co-owner only the roles below their own;
/// nobody else may edit (<see cref="RoomRoleRules.CanEditRolePermissions"/>).</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPost("roomserver/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")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize] [Authorize]
public async Task<IActionResult> SubRoomPermissions( public async Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId)
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); 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); var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound(); if (scene is null) return NotFound();
var existing = await db.RoomRolePermissions
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomId)
.ToListAsync();
var now = DateTime.UtcNow;
foreach (var cell in cells)
{
var row = existing.FirstOrDefault(p =>
p.Role == cell.Role
&& string.Equals(p.Permission, cell.Permission, StringComparison.OrdinalIgnoreCase));
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;
}
scene.DataModifiedAt = now;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.LogWarning( logger.LogInformation(
"[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)", "[subroom-permissions] room={Room} sub={Sub} by={By} cells={Count}",
roomId, subRoomId, pid, req?.Count ?? 0); 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)); return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
} }
@@ -3842,10 +3896,11 @@ public class RoomsController(
[Authorize] [Authorize]
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId) public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); 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); var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound(); if (scene is null) return NotFound();
@@ -3921,10 +3976,11 @@ public class RoomsController(
[Authorize] [Authorize]
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId) public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid(); if (!await CanEditRoomAsync(room, pid)) return Forbid();
var src = await db.RoomScenes var src = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); .FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (src is null) return NotFound(); if (src is null) return NotFound();
@@ -4015,6 +4071,7 @@ public class RoomsController(
long subRoomId, long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req) [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
@@ -4105,6 +4162,7 @@ public class RoomsController(
[Authorize] [Authorize]
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId) public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var body = await ReadSaveRoomSceneRequestAsync(); var body = await ReadSaveRoomSceneRequestAsync();
body.RoomSceneId = subRoomId; body.RoomSceneId = subRoomId;
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true); return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
@@ -4165,10 +4223,11 @@ public class RoomsController(
/// successful mutation surface as a client-side parse failure.</summary> /// successful mutation surface as a client-side parse failure.</summary>
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator) private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); 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); var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound(); if (scene is null) return NotFound();
mutator(scene); mutator(scene);
@@ -4219,12 +4278,7 @@ public class RoomsController(
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync(); var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var list = new List<object>(rows.Count + 1) return Ok(RoomRoleRules.BuildWires(room, rows));
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
};
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
return Ok(list);
} }
private static string CurrentOrSyntheticDataBlobName(RoomEntity room) private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
@@ -4339,30 +4393,38 @@ public class RoomsController(
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row /// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task&lt;EFHPLDPNGIM&gt;</c>, /// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task&lt;EFHPLDPNGIM&gt;</c>,
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list /// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
/// above.</summary> /// above. The creator answers as Creator (255).</summary>
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")] [HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")] [HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId) public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
{ {
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (room.CreatorPlayerId == playerId) var rows = await db.RoomRoles
return Ok(BuildRoomAccountRoleWire(playerId, 30)); .Where(r => r.RoomId == roomId && r.PlayerId == playerId)
var row = await db.RoomRoles .ToListAsync();
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId); var wire = RoomRoleRules.BuildWires(room, rows)
if (row is null) return NotFound(); .FirstOrDefault(w => w.AccountId == RoomRoleRules.ToClientAccountId(playerId));
return Ok(BuildRoomRoleGrantWire(row)); if (wire is null) return NotFound();
return Ok(wire);
} }
public sealed class GrantRoleRequest { public int? Role { get; set; } } /// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — "change room account
/// role" (<c>NLDBPDCNNCF.LMIEIJMCEAB</c>, NLDBPDCNNCF.txt:7516, verb 3 =
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — owner grants /// PUT, single form key <c>role</c> boxed as Int32 —
/// a role to a player. Auto-accepted (no invite step). /// NLDBPDCNNCF_NestedType_BFKNABDHMGG.txt ISIL 015-024), response the full
/// <c>NLDBPDCNNCF.LMIEIJMCEAB</c> ("change room account role", /// <c>FGCPNAACHIK</c> details. Three callers:
/// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key <c>role</c> carrying a /// <list type="bullet">
/// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full /// <item>the owner's role button (AddRemovePlayerRoomRoleConditionalButton
/// <c>FGCPNAACHIK</c> details — which this already does. Only the /// .EEGJBELCBDI): Host/Moderator are set directly, and "Remove" sends
/// roomserver/ twin was missing.</summary> /// role 0;</item>
/// <item>the invitee accepting a co-owner invite (message Type 62,
/// KPFKPIKDBFJ.ELKAEBMMIGL / KLNLKDJJDLE Accept): their OWN account id
/// with role 30;</item>
/// <item>a co-owner rejecting a direct add (Type 60, COJCABEJDFN Reject):
/// their own id with role 0.</item>
/// </list>
/// Authorization is <see cref="RoomRoleRules.CheckChange"/>.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
@@ -4372,45 +4434,19 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[Authorize] [Authorize]
public async Task<IActionResult> GrantRole(long roomId, long playerId) public Task<IActionResult> GrantRole(long roomId, long playerId)
{ => ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
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);
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole)) /// <summary>PUT <c>rooms/{id}/roles/{playerId}/invite</c> — "invite account
return BadRequest(new { error = "invalid_role", role }); /// to role" (<c>NLDBPDCNNCF.KIJDCLINOIC</c>, NLDBPDCNNCF.txt:7686, verb 3,
/// form key <c>role</c> as Int32 — NLDBPDCNNCF_NestedType_ENHMMCAOING.txt).
var existing = await db.RoomRoles /// Only Co-Owner goes through an invite in this client: the owner's button
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole); /// sends role 30 here, the invitee is told with a Type 62
if (existing is null) /// (RoomCoOwnerInvited) message carrying RoomId, and either accepts with
{ /// <c>PUT roles/{me}</c> role 30 or rejects by sending role 0 back to THIS
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole); /// route on their own id (KPFKPIKDBFJ.OGONAKHNMGN, KLNLKDJJDLE Reject),
db.RoomRoles.Add(new RoomRoleEntity /// which clears the pending invite but leaves any role they already
{ /// hold.</summary>
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = true, GrantedByPlayerId = me,
});
}
else
{
existing.Accepted = true;
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — 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.
/// <c>NLDBPDCNNCF.KIJDCLINOIC</c> ("invite account to role",
/// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
@@ -4420,62 +4456,167 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[Authorize] [Authorize]
public async Task<IActionResult> InviteRole(long roomId, long playerId) public Task<IActionResult> InviteRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true);
private async Task<IActionResult> ChangeRoomAccountRoleAsync(long roomId, long playerId, bool isInvite)
{ {
var me = this.RequireCurrentPlayerId(); var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid(); var requested = await ReadClientRoomRoleAsync();
var role = await ReadClientRoomRoleAsync(); if (requested is null) return BadRequest(new { error = "missing_role" });
if (role is null) return BadRequest(new { error = "missing_role" }); if (playerId <= 0 || !await db.Players.AnyAsync(p => p.Id == playerId))
if (role.Value == 0) return NotFound(new { error = "unknown_player" });
return await ClearRoomAccountRoleAsync(room, playerId);
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole)) var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
return BadRequest(new { error = "invalid_role", role }); var error = RoomRoleRules.CheckChange(
room, rows, me, playerId, requested.Value, isInvite, out var kind);
if (error is not null)
{
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 existing = await db.RoomRoles var targetRows = rows.Where(r => r.PlayerId == playerId).ToList();
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole); var wasCoOwner = targetRows.Any(r => r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
if (existing is null) 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:
{
// 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)
{ {
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity db.RoomRoles.Add(new RoomRoleEntity
{ {
RoomId = roomId, PlayerId = playerId, Role = roomRole, RoomId = roomId, PlayerId = playerId, Role = storedRole,
Accepted = false, GrantedByPlayerId = me, Accepted = false, GrantedByPlayerId = me, GrantedAt = DateTime.UtcNow,
}); });
} }
else else
{ {
existing.Accepted = false; pending.GrantedByPlayerId = me;
pending.GrantedAt = DateTime.UtcNow;
} }
await db.SaveChangesAsync(); break;
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
} }
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId) case RoomRoleRules.ChangeKind.AcceptInvite:
case RoomRoleRules.ChangeKind.Grant:
{ {
if (room.CreatorPlayerId == playerId) // One role per account: the new one replaces the rest
return BadRequest(new { error = "cannot_change_creator_role" }); // (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;
}
}
var rows = await db.RoomRoles await db.SaveChangesAsync();
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
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)
{
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);
}
if (hadCoOwnerInvite && !hasCoOwnerInviteNow)
await MarkCoOwnerInviteMessagesReadAsync(playerId, roomId);
var details = await BuildRoomServerDetailsWithRolesAsync(roomId);
await PushRoomUpdateAsync(roomId, details, playerId, me);
return Ok(details);
}
/// <summary>Once an invite is answered (or withdrawn) its Type 62 message
/// must stop offering Accept/Reject in the inbox.</summary>
private async Task MarkCoOwnerInviteMessagesReadAsync(long playerId, long roomId)
{
var stale = await db.Messages
.Where(m => m.RecipientPlayerId == playerId
&& m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited
&& m.ReadAt == null)
.ToListAsync(); .ToListAsync();
if (rows.Count > 0) if (stale.Count == 0) return;
db.RoomRoles.RemoveRange(rows); foreach (var m in stale) m.ReadAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
} }
private async Task<bool> CanManageRoomRolesAsync(RoomEntity room, long playerId) /// <summary>Push the fresh room details as <c>RoomUpdate</c> (handler
/// IBEOONPEELF.txt:507, Action&lt;FGCPNAACHIK&gt;) 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.</summary>
private async Task PushRoomUpdateAsync(long roomId, object details, params long[] alsoNotify)
{ {
if (room.CreatorPlayerId == playerId) return true; var recipients = presence.EnumerateActiveInstances(roomId, includePrivate: true)
return await db.RoomRoles.AnyAsync(r => .SelectMany(i => i.PlayerIds)
r.RoomId == room.Id .Concat(alsoNotify)
&& r.PlayerId == playerId .Where(pid => pid > 0)
&& r.Accepted .Distinct()
&& r.Role == 0); .ToList();
foreach (var pid in recipients)
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, details);
} }
private async Task<int?> ReadClientRoomRoleAsync() private async Task<int?> ReadClientRoomRoleAsync()
@@ -4486,6 +4627,8 @@ public class RoomsController(
var form = await Request.ReadFormAsync(); 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 (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; if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
@@ -4495,10 +4638,20 @@ public class RoomsController(
{ {
try try
{ {
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>( using var doc = await JsonDocument.ParseAsync(Request.Body);
Request.Body, if (doc.RootElement.ValueKind == JsonValueKind.Object)
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); {
if (body?.Role is int bodyRole) return bodyRole; 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) catch (JsonException)
{ {
@@ -4509,64 +4662,34 @@ public class RoomsController(
return null; return null;
} }
private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole) /// <summary>OMMBGJMJJPN member names, for callers that box the enum
/// itself (Enum.ToString) instead of an Int32.</summary>
internal static bool TryParseRoleName(string? raw, out int role)
{ {
var rows = await db.RoomRoles role = (raw ?? string.Empty).Trim().ToLowerInvariant() switch
.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 "none" => RoomRoleRules.WireNone,
{ "banned" => RoomRoleRules.WireBanned,
30 => 0, // CoOwner "host" => RoomRoleRules.WireHost,
20 => 1, // Moderator "moderator" => RoomRoleRules.WireModerator,
10 => 2, // Host "coowner" => RoomRoleRules.WireCoOwner,
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
"creator" => RoomRoleRules.WireCreator,
_ => -1, _ => -1,
}; };
return roomRole >= 0; return role >= 0;
} }
private static object BuildRoomRoleGrantWire(RoomRoleEntity role) /// <summary>Creator or accepted co-owner (see
=> BuildRoomAccountRoleWire( /// <see cref="RoomRoleRules.CanEditRoom"/>).</summary>
role.PlayerId, private async Task<bool> CanEditRoomAsync(RoomEntity room, long 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), if (room.CreatorPlayerId == playerId) return true;
Role = accepted ? role : 0, var rows = await db.RoomRoles
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId), .Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
InvitedRole = accepted ? 0 : role, .ToListAsync();
}; return RoomRoleRules.CanEditRoom(room, rows, playerId);
}
private static int ToClientRoomRole(int roomRole) => roomRole switch
{
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;
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId) private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
{ {
@@ -1432,6 +1432,13 @@ public class RoomsModerationController(
// it editable. Safe for admin: the purge gate and "RR Original" // it editable. Safe for admin: the purge gate and "RR Original"
// badge both key on CreatorPlayerId == SystemAccountId, so a // badge both key on CreatorPlayerId == SystemAccountId, so a
// user-owned clone is still shown as Custom and stays purgeable. // 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, IsAGRoom = source.IsAGRoom,
IsDormRoom = false, IsDormRoom = false,
CloningAllowed = source.CloningAllowed, CloningAllowed = source.CloningAllowed,
@@ -1509,6 +1516,18 @@ public class RoomsModerationController(
CanMatchmakeInto = s.CanMatchmakeInto, CanMatchmakeInto = s.CanMatchmakeInto,
DataModifiedAt = DateTime.UtcNow, DataModifiedAt = DateTime.UtcNow,
}).ToList(); }).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) if (clonedScenes.Count > 0)
{ {
db.RoomScenes.AddRange(clonedScenes); db.RoomScenes.AddRange(clonedScenes);
@@ -1580,8 +1599,11 @@ public class RoomsModerationController(
// The scene was created as a shallow copy, so its DataBlobName is // The scene was created as a shallow copy, so its DataBlobName is
// currently the SOURCE's. Either a copy replaces it or it gets // currently the SOURCE's. Either a copy replaces it or it gets
// cleared — it must never survive as-is. // 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( var copied = await CopyBlobForCloneAsync(
sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId); sourceBlob, clone.Id, target.OrderIndex, callerId);
target.DataBlobName = copied ?? string.Empty; target.DataBlobName = copied ?? string.Empty;
target.DataModifiedAt = DateTime.UtcNow; target.DataModifiedAt = DateTime.UtcNow;
@@ -317,6 +317,10 @@ public class GoToController(
[FromForm(Name = "JoinMode")] int joinMode = 0, [FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null) [FromForm(Name = "AdditionalPlayerIds")] List<long>? 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); var room = await rooms.GetByIdAsync(roomId);
if (room is null) if (room is null)
{ {
@@ -1552,9 +1556,25 @@ public class RoomInstanceDto
[JsonPropertyName("roomId")] [JsonPropertyName("roomId")]
public long RoomId { get; set; } public long RoomId { get; set; }
[JsonPropertyName("subRoomId")] /// <summary>The sub-room's <see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>
/// (0 = the room's first sub-room). Server code reads and writes this;
/// the wire carries <see cref="WireSubRoomId"/>.</summary>
[JsonIgnore]
public long SubRoomId { get; set; } public long SubRoomId { get; set; }
/// <summary>The 2023 client's SubRoomId: OrderIndex + 1 (see
/// <see cref="Services.RoomSceneIds"/>). It must equal the
/// <c>SubRooms[].SubRoomId</c> the room details advertise — the client
/// compares the two (<c>SubroomModel.get_SubroomIsLocal</c>) and looks the
/// current sub-room up by it. Presence round-trips through this property
/// too, so stored JSON stays consistent.</summary>
[JsonPropertyName("subRoomId")]
public long WireSubRoomId
{
get => Services.RoomSceneIds.ToWire(SubRoomId);
set => SubRoomId = Services.RoomSceneIds.ToOrderIndex(value);
}
// STRING RoomSceneLocationId GUID — foreign-keys to // STRING RoomSceneLocationId GUID — foreign-keys to
// AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via // AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via
// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default // AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default
@@ -140,66 +140,48 @@ public class MatchController(
}); });
} }
/// <summary>GET <c>photon_access_token</c> — read by the room-permissions
/// runtime as <c>ALIIIAGAPND {RoomInstanceId, PhotonAccessToken,
/// Permissions}</c> (NLDBPDCNNCF.CEOPCANHPPH, verb 0, issued from the
/// rooms client, hence the <c>roomserver/</c> twin).
/// <c>Permissions</c> is the role-permission matrix of the sub-room the
/// player is in: the defaults plus whatever the room's editors saved with
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>
/// (<see cref="RoomPermissionMatrix"/>). It must not be empty: with no
/// Permissions the client falls into its persisted-role migration path,
/// which NREs during first spawn.</summary>
[HttpGet("/photon_access_token")] [HttpGet("/photon_access_token")]
public IActionResult PhotonAccessToken() [HttpGet("/roomserver/photon_access_token")]
public async Task<IActionResult> PhotonAccessToken()
{ {
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var appId = config["Photon:AppId"] ?? string.Empty; var appId = config["Photon:AppId"] ?? string.Empty;
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty; var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant(); var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
var currentRoom = presence.GetRoom(pid); var currentRoom = presence.GetRoom(pid);
var permissions = BuildPhotonPermissionSeed(); var permissions = currentRoom is null
? RoomPermissionMatrix.Merge(Array.Empty<RoomRolePermissionEntity>())
: await RoomPermissionMatrix.LoadAsync(db, currentRoom.RoomId, currentRoom.SubRoomId);
logger.LogInformation( logger.LogInformation(
"[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}", "[photon-token] player={PlayerId} room={RoomId} sub={SubRoomId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length); pid, currentRoom?.RoomId ?? 0L, currentRoom?.SubRoomId ?? 0L, currentRoom?.RoomInstanceId ?? 0L, permissions.Count);
return Ok(new Dictionary<string, object>
{
// 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,
// Existing custom-auth/app-id shape kept for Photon auth callers. var body = RoomPermissionMatrix.AccessTokenBody(currentRoom?.RoomInstanceId ?? 0L, permissions);
["ResultCode"] = 1, // Existing custom-auth/app-id shape kept for Photon auth callers; the
["Message"] = string.Empty, // ALIIIAGAPND reader ignores keys it does not register.
["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture), body["ResultCode"] = 1;
["PhotonAppId"] = appId, body["Message"] = string.Empty;
["PhotonVoiceAppId"] = voiceAppId, body["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture);
["AppId"] = appId, body["PhotonAppId"] = appId;
["VoiceAppId"] = voiceAppId, body["PhotonVoiceAppId"] = voiceAppId;
["CloudRegion"] = region, body["AppId"] = appId;
["Region"] = region, body["VoiceAppId"] = voiceAppId;
["Token"] = string.Empty, 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")] [HttpPost("/matchmake/dorm")]
public async Task<IActionResult> MatchmakeDorm() public async Task<IActionResult> MatchmakeDorm()
{ {
@@ -490,97 +472,125 @@ public class MatchController(
return Ok(session); return Ok(session);
} }
/// <summary>GET <c>match.*/room/{id}/instances</c> — the /// <summary>GET <c>match.*/room/{id}/instances</c> — the room instance
/// "RoomInstanceBrowser" the watch shows when you tap a room tile. /// browser.
/// 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
/// <c>List&lt;SimpleRoomInstance&gt;</c> per
/// <c>SimpleRoomInstance.cs</c>: RoomInstanceId/RoomId/SubRoomId,
/// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount,
/// HasModPresent, HashedInstanceId.
/// ///
/// We derive instances from PlayerPresenceService's per-player /// <para><b>Client contract.</b> <c>RecNet.Matchmaking.KAIPGDGACGG(long)</c>
/// RoomInstanceDto: every distinct PhotonRoomId in the same RoomId /// (RecNet.Runtime/RecNet/Matchmaking.txt:13966, route at :14237, verb 0 =
/// is one instance, with the players currently presenced there. /// GET on the matchmaking host) returns <c>List&lt;PNDCMIMEJLD&gt;</c>, a bare
/// PrivateInstances are surfaced as well so the inviter and /// array. The reader (JPNMBICAMEE.txt) takes exactly RoomInstanceId,
/// invitee see the same room in the browser.</summary> /// 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.</para>
///
/// <para><b>What is listed.</b> Every live instance of the room: each
/// distinct Photon room in which at least one player is presenced with a
/// live activity marker (<see cref="PlayerPresenceService.EnumerateActiveInstances"/>).
/// 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.</para>
///
/// <para><b>Fixed defects.</b> 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.</para></summary>
[HttpGet("/room/{roomId:long}/instances")] [HttpGet("/room/{roomId:long}/instances")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> RoomInstances(long roomId) public async Task<IActionResult> 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 pid = this.CurrentPlayerId() ?? 0;
var privates = pid == 0 var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
? new List<Data.Entities.PrivateInstanceEntity>() if (room is null) return Ok(Array.Empty<object>());
: await db.PrivateInstances
.Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid)
.ToListAsync();
// Merge: presence rows are authoritative (player currently var live = presence.EnumerateActiveInstances(roomId, includePrivate: true).ToList();
// there); add any owned private instance the inviter created
// but nobody's joined yet so the inviter can still see it. var privateIds = live.Where(l => l.Room.IsPrivate).Select(l => l.Room.RoomInstanceId).Distinct().ToList();
var byKey = new Dictionary<string, SimpleInstance>(StringComparer.Ordinal); var privateRows = privateIds.Count == 0
foreach (var (room, playerIds) in presenced) ? new Dictionary<long, PrivateInstanceEntity>()
: 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<long>()
: (await db.PrivateInstanceInvitees.AsNoTracking()
.Where(i => i.PlayerId == pid && privateIds.Contains(i.PrivateInstanceId))
.Select(i => i.PrivateInstanceId)
.ToListAsync()).ToHashSet();
var rows = new List<RoomInstanceRow>();
foreach (var (inst, playerIds) in live)
{ {
if (string.IsNullOrEmpty(room.PhotonRoomId)) continue; privateRows.TryGetValue(inst.RoomInstanceId, out var privateRow);
byKey[room.PhotonRoomId] = new SimpleInstance( if (inst.IsPrivate && !CanSeePrivateInstance(
RoomInstanceId: room.RoomInstanceId, pid, callerHasRole, playerIds, privateRow, invitedTo.Contains(inst.RoomInstanceId)))
RoomId: room.RoomId, continue;
SubRoomId: room.SubRoomId,
IsFull: false, var capacity = inst.MaxCapacity > 0 ? inst.MaxCapacity : privateRow?.MaxCapacity ?? 0;
CreatedAt: DateTime.UtcNow, rows.Add(new RoomInstanceRow(
PlayerIds: playerIds.Select(p => (int)p).ToList(), RoomInstanceId: inst.RoomInstanceId,
SubroomName: room.Name ?? string.Empty, RoomId: inst.RoomId,
PlayerCount: playerIds.Count, SubRoomId: inst.SubRoomId,
HasModPresent: false, IsFull: capacity > 0 && playerIds.Count >= capacity,
HashedInstanceId: room.PhotonRoomId); CreatedAt: privateRow is not null
} ? DateTime.SpecifyKind(privateRow.CreatedAt, DateTimeKind.Utc)
foreach (var p in privates) : DateTime.UtcNow,
{ PlayerIds: playerIds.Distinct().OrderBy(p => p).ToList()));
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<int>(),
SubroomName: p.Name,
PlayerCount: 0,
HasModPresent: false,
HashedInstanceId: p.PhotonRoomId);
} }
// Wire shape: SimpleRoomInstance.Deserialize reads CAMELCASE logger.LogInformation(
// keys — verified at Cpp2IL_ISIL/.../RecNet/SimpleRoomInstance.txt "[room-instances] room={Room} caller={Caller} live={Live} listed={Listed}",
// lines 765-808 ({roomInstanceId, roomId, subRoomId, isFull, roomId, pid, live.Count, rows.Count);
// createdAt, playerIds}). SubroomName / PlayerCount / return Ok(rows.OrderBy(r => r.CreatedAt).Select(ToWire));
// 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(),
}));
} }
private sealed record SimpleInstance( /// <summary>Who may see (and so be offered a join into) a private
/// instance in the browser.</summary>
public static bool CanSeePrivateInstance(
long callerId,
bool callerHasRoomRole,
IReadOnlyCollection<long> 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, long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull,
DateTime CreatedAt, List<int> PlayerIds, string SubroomName, DateTime CreatedAt, List<long> PlayerIds);
int PlayerCount, bool HasModPresent, string HashedInstanceId);
/// <summary>PNDCMIMEJLD wire row. Account ids are Int32 on this DTO;
/// SubRoomId is the wire id the room details advertise
/// (<see cref="RoomSceneIds"/>), which the client uses to name the row.
/// The reader matches PascalCase, camelCase and lowercase keys; camelCase
/// is what this route has always sent.</summary>
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(),
};
/// <summary>DELETE <c>match.*/invite/{id}</c> — Matchmaking.cs /// <summary>DELETE <c>match.*/invite/{id}</c> — Matchmaking.cs
/// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method /// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method
+9
View File
@@ -11,6 +11,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
public DbSet<RelationshipEntity> Relationships => Set<RelationshipEntity>(); public DbSet<RelationshipEntity> Relationships => Set<RelationshipEntity>();
public DbSet<RoomEntity> Rooms => Set<RoomEntity>(); public DbSet<RoomEntity> Rooms => Set<RoomEntity>();
public DbSet<RoomRoleEntity> RoomRoles => Set<RoomRoleEntity>(); public DbSet<RoomRoleEntity> RoomRoles => Set<RoomRoleEntity>();
public DbSet<RoomRolePermissionEntity> RoomRolePermissions => Set<RoomRolePermissionEntity>();
public DbSet<LeaderboardChannelMetaEntity> LeaderboardChannelMeta => Set<LeaderboardChannelMetaEntity>(); public DbSet<LeaderboardChannelMetaEntity> LeaderboardChannelMeta => Set<LeaderboardChannelMetaEntity>();
public DbSet<RoomBookmarkEntity> RoomBookmarks => Set<RoomBookmarkEntity>(); public DbSet<RoomBookmarkEntity> RoomBookmarks => Set<RoomBookmarkEntity>();
public DbSet<RoomDataBlobEntity> RoomDataBlobs => Set<RoomDataBlobEntity>(); public DbSet<RoomDataBlobEntity> RoomDataBlobs => Set<RoomDataBlobEntity>();
@@ -134,6 +135,14 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
e.HasIndex(p => new { p.ClubId, p.MembershipType }).IsUnique(); 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<RoomRolePermissionEntity>(e =>
{
e.HasKey(p => p.Id);
e.HasIndex(p => new { p.RoomId, p.SubRoomId, p.Role, p.Permission }).IsUnique();
});
modelBuilder.Entity<ClubAdditionalImageEntity>(e => modelBuilder.Entity<ClubAdditionalImageEntity>(e =>
{ {
e.HasKey(i => i.Id); e.HasKey(i => i.Id);
@@ -0,0 +1,53 @@
using System.ComponentModel.DataAnnotations;
namespace DorkNet.Server.Data.Entities;
/// <summary>
/// One cell of a sub-room's role-permission matrix, as written by
/// <c>PUT rooms/{roomId}/subrooms/{subRoomId}/permissions</c> and served back
/// in the <c>Permissions</c> list of <c>GET photon_access_token</c>.
///
/// The 2023-03-21 client models a cell as <c>BNLPBMJJOMM</c>
/// (formatter <c>RecNet.Runtime/HDOJMEBCAGP.txt</c>, keys
/// <c>Permission</c> / <c>Role</c> / <c>Override</c> / <c>Type</c> /
/// <c>Value</c>): Permission and Value are strings, Role is the room-role
/// enum <c>OMMBGJMJJPN</c> (None 0, Host 10, Moderator 20, CoOwner 30,
/// TemporaryCoOwner 31, Creator 255), Type is <c>FEOJFGECKGH</c>
/// (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 <c>BNLPBMJJOMM[]</c> per edit,
/// <c>OMJKHJLFOCO.AFHJLBBFAGP.&lt;LocalRequestModifyRolePermission&gt;g__ChangeAndRefresh</c>),
/// so the write is an upsert per (room, sub-room, role, permission) and never
/// a replace of the whole matrix.
/// </summary>
public class RoomRolePermissionEntity
{
public long Id { get; set; }
public long RoomId { get; set; }
/// <summary>The wire sub-room id from the route (the same value the room
/// details advertise as <c>SubRooms[].SubRoomId</c>).</summary>
public long SubRoomId { get; set; }
/// <summary>Wire room-role value (OMMBGJMJJPN), e.g. 30 = CoOwner, 255 = Creator.</summary>
public int Role { get; set; }
/// <summary>The permission id exactly as the client sent it.</summary>
[MaxLength(128)]
public string Permission { get; set; } = string.Empty;
/// <summary>FEOJFGECKGH: 0 = Bool, 1 = Int, 2 = StringList.</summary>
public int Type { get; set; }
public bool Override { get; set; }
/// <summary>The value exactly as the client sent it (a string on the wire).</summary>
[MaxLength(4096)]
public string Value { get; set; } = string.Empty;
public long UpdatedByPlayerId { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,3718 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("AdminPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CustomAvatarItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EquippedItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FaceFeatures")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<string>("HairColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("InventoryJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OutfitSelections")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("OutfitSelectionsV2")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("SavedOutfitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SkinColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("Avatars");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ClientVersion")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActionUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long?>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBuiltIn")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WordsJson")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CharadesWordLists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("ModerationState")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey")
.IsUnique();
b.ToTable("ChatThreads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<long?>("LastReadMessageId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SnoozeUntil")
.HasColumnType("TEXT");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CheeredAt")
.HasColumnType("TEXT");
b.Property<long>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPhotoId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Slot")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "Slot")
.IsUnique();
b.ToTable("ClubAdditionalImages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AuthorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "CreatedAt");
b.ToTable("ClubAnnouncements");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AnnouncementId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CategoryTagId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Active")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("ClubCategoryTags");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowJuniors")
.HasColumnType("INTEGER");
b.Property<int>("BanStatus")
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("ClubChatEnabled")
.HasColumnType("INTEGER");
b.Property<int>("ClubType")
.HasColumnType("INTEGER");
b.Property<long?>("ClubhouseRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsRRO")
.HasColumnType("INTEGER");
b.Property<int>("Joinability")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<int>("Permissions")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ApproveMember")
.HasColumnType("INTEGER");
b.Property<bool>("BanUnban")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<bool>("CreateEvent")
.HasColumnType("INTEGER");
b.Property<bool>("EditDetails")
.HasColumnType("INTEGER");
b.Property<bool>("EditPermissionSettings")
.HasColumnType("INTEGER");
b.Property<int>("MembershipType")
.HasColumnType("INTEGER");
b.Property<bool>("PostAnnouncement")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "MembershipType")
.IsUnique();
b.ToTable("ClubRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AssignedAt")
.HasColumnType("TEXT");
b.Property<string>("CohortKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CommunityBoardRows");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int>("MaxRedemptions")
.HasColumnType("INTEGER");
b.Property<int>("RedemptionCount")
.HasColumnType("INTEGER");
b.Property<long>("RewardAmount")
.HasColumnType("INTEGER");
b.Property<int>("RewardCurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("RewardItemSlug")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Coupons");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CouponId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CouponId", "PlayerId")
.IsUnique();
b.ToTable("CouponRedemptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CurrencyType")
.IsUnique();
b.ToTable("CurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssetName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("BaseAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("CheerCount")
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("PurchaseCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<long>("CustomAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("PlayerId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("PlayerId");
b.ToTable("DormStates");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<long>("GrantedItemId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("Offer1ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer2ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer3ItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SelectedAt")
.HasColumnType("TEXT");
b.Property<int?>("SelectedGiftDropId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "SelectedAt");
b.ToTable("GameRewardSelections");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActivityLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PlayerCount")
.HasColumnType("INTEGER");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AvatarItemDescOrHairDyeDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("AvatarItemType")
.HasColumnType("INTEGER");
b.Property<string>("ConsumableItemDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("Consumed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Currency")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("EquipmentModificationGuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("EquipmentPrefabName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<int>("GiftRarity")
.HasColumnType("INTEGER");
b.Property<bool>("IsGifted")
.HasColumnType("INTEGER");
b.Property<bool>("IsValid")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("PackageMaterial")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PackageVariant")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("SourceStoreItemId")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsCurrentPlatform")
.HasColumnType("INTEGER");
b.Property<int>("Xp")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "Consumed");
b.ToTable("GiftPackages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long?>("CreationRoomId")
.HasColumnType("INTEGER");
b.Property<int>("CreatorPermission")
.HasColumnType("INTEGER");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("CurrentVersionNumber")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("FirstPublishedAt")
.HasColumnType("TEXT");
b.Property<int>("GeneralPermission")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAgInvention")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublished")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("NumPlayersHaveUsedInRoom")
.HasColumnType("INTEGER");
b.Property<int>("Permission")
.HasColumnType("INTEGER");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("SpawnCount")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("InstantiationCost")
.HasColumnType("INTEGER");
b.Property<long>("InventionId")
.HasColumnType("INTEGER");
b.Property<int>("LightsCost")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("VersionNumber")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InventionId", "VersionNumber")
.IsUnique();
b.ToTable("InventionVersions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("BannedAt")
.HasColumnType("TEXT");
b.Property<long>("BannedByAdminId")
.HasColumnType("INTEGER");
b.Property<string>("Cidr")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Cidr");
b.ToTable("IpBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("EarnedAt")
.HasColumnType("TEXT");
b.Property<string>("EventKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("Channel")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("LowerIsBetter")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ValueFormat")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("RoomId", "Channel");
b.HasIndex("RoomId");
b.ToTable("LeaderboardChannelMeta");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("StatChannel")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Context")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("PlatformMask")
.HasColumnType("INTEGER");
b.Property<string>("RoomNamesCsv")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("LoadingScreenTips");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long?>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowAll")
.HasColumnType("INTEGER");
b.Property<bool>("AllowAnnouncements")
.HasColumnType("INTEGER");
b.Property<bool>("AllowEventInvite")
.HasColumnType("INTEGER");
b.Property<bool>("AllowFriendRequest")
.HasColumnType("INTEGER");
b.Property<bool>("AllowMessage")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("NotificationPrefs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ClearedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("ObjectiveProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("TEXT");
b.Property<string>("LastIp")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PendingDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Caption")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("TaggedPlayerIdsCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("UploaderPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeen")
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastSeen")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Elo")
.HasColumnType("INTEGER");
b.Property<int>("GameMode")
.HasColumnType("INTEGER");
b.Property<int>("Losses")
.HasColumnType("INTEGER");
b.Property<int>("MatchesPlayed")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("TEXT");
b.Property<string>("BannerImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Birthday")
.HasColumnType("TEXT");
b.Property<bool>("CanReceiveInvites")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedFromDeviceId")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DisplayEmoji")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("IdentityFlags")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCommunityTeam")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeveloper")
.HasColumnType("INTEGER");
b.Property<bool>("IsJunior")
.HasColumnType("INTEGER");
b.Property<bool>("IsVerified")
.HasColumnType("INTEGER");
b.Property<string>("LastIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("LastPlatform")
.HasColumnType("INTEGER");
b.Property<string>("LastPlatformId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PersonalPronouns")
.HasColumnType("INTEGER");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ProfileImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Reputation")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("XP")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Capacity")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("EndsAt")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartsAt")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("EventId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("ItemSlug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CustomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsCurated")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsJuniors")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Cheered")
.HasColumnType("INTEGER");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DataBlob")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("OwnerPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRegion")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PhotonRoomId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("RoomCode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("PrivateInstanceId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("InvitedAt")
.HasColumnType("TEXT");
b.Property<long?>("LatestInviteMessageId")
.HasColumnType("INTEGER");
b.HasKey("PrivateInstanceId", "PlayerId");
b.HasIndex("PlayerId");
b.ToTable("PrivateInstanceInvitees");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<bool>("Ignored")
.HasColumnType("INTEGER");
b.Property<bool>("Muted")
.HasColumnType("INTEGER");
b.Property<long>("RequesterId")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RequesterId", "TargetId")
.IsUnique();
b.ToTable("Relationships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Category")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("ResolutionNote")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("TEXT");
b.Property<long?>("ResolverAdminId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("TargetEventId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BanType")
.HasColumnType("INTEGER");
b.Property<long>("BannedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("BannedPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<Guid?>("CurrencyId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<Guid>("ConcurrencyCode")
.HasColumnType("TEXT");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("DailyLimit")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Amount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<byte[]>("Bytes")
.HasColumnType("BLOB");
b.Property<string>("ReferencedFilenamesCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long?>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UploadedAt")
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<bool>("AllowNewUsers")
.HasColumnType("INTEGER");
b.Property<bool>("AllowsJuniors")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<bool>("CloningAllowed")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("CustomRoomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool>("DisableMicAutoMute")
.HasColumnType("INTEGER");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<bool>("HiddenFromBrowse")
.HasColumnType("INTEGER");
b.Property<double>("HotScore")
.HasColumnType("REAL");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAGRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsDormRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
.HasColumnType("INTEGER");
b.Property<bool>("IsStudioRoom")
.HasColumnType("INTEGER");
b.Property<string>("LoadScreensJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("LocationReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayerCalculationMode")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<string>("PromoExternalContentJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("PromoImagesJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<int>("RoomWarningMask")
.HasColumnType("INTEGER");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<string>("StudioSessionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsMobile")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsVRLow")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(174)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("PaidPrice")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Accepted")
.HasColumnType("INTEGER");
b.Property<DateTime>("GrantedAt")
.HasColumnType("TEXT");
b.Property<long?>("GrantedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Override")
.HasColumnType("INTEGER");
b.Property<string>("Permission")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("UpdatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("CanMatchmakeInto")
.HasColumnType("INTEGER");
b.Property<string>("DataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("DataModifiedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsSandbox")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("RoomSceneLocationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("StudioAssetBundleNamesCsv")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<long?>("StudioSubRoomDataSaveId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("FirstVisitAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastVisitAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChestsOpened")
.HasColumnType("INTEGER");
b.Property<DateTime>("CompletedAt")
.HasColumnType("TEXT");
b.Property<bool>("CustomGame")
.HasColumnType("INTEGER");
b.Property<int>("HealthPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.Property<int>("SecondsInAir")
.HasColumnType("INTEGER");
b.Property<int>("ShieldPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<bool>("WalkGame")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CompletedAt");
b.ToTable("RoyaleMatches");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("MatchId")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CurrentLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<int>("NextLevelAcornReward")
.HasColumnType("INTEGER");
b.Property<long>("NextLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RankIdx")
.HasColumnType("INTEGER");
b.Property<string>("RankName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("TotalXP")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("RoyalePlayerProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("AllAvatarItemsOwned")
.HasColumnType("INTEGER");
b.Property<string>("BaseRoomNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CharadesSlotBindingsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CircuitsV2LimitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DiscoveredGameConfigsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("GlobalFriendsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("PlayMenuTagsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("ProfanityFilterDisabled")
.HasColumnType("INTEGER");
b.Property<string>("RecCenterDoorsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("RoomBlobVersionClampDisabled")
.HasColumnType("INTEGER");
b.Property<bool>("SignupsDisabled")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WeeklyChallengeRewardJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("WeeklyChallengesCompletedRequired")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("WeeklyChallengesJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.HasKey("Id");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Descriptor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("RedeemedAt")
.HasColumnType("TEXT");
b.Property<long?>("RedeemedByPlayerId")
.HasColumnType("INTEGER");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SignupCodes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("AvailableUntil")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<bool>("IsLimitedTime")
.HasColumnType("INTEGER");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Storefront")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("SubscriberPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Pk")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssignedPlayerIdsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AssignedPlayerNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CommentsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("JiraBugUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("JiraUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MinNumAssignedPlayers")
.HasColumnType("INTEGER");
b.Property<string>("RoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint?>("TestPassId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("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<uint>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndDate")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("WasManuallyClosed")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TestPasses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<DateTime>("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
}
}
}
@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DorkNet.Server.Migrations
{
/// <inheritdoc />
public partial class RoomRolePermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RoomRolePermissions",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RoomId = table.Column<long>(type: "INTEGER", nullable: false),
SubRoomId = table.Column<long>(type: "INTEGER", nullable: false),
Role = table.Column<int>(type: "INTEGER", nullable: false),
Permission = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
Override = table.Column<bool>(type: "INTEGER", nullable: false),
Value = table.Column<string>(type: "TEXT", maxLength: 4096, nullable: false),
UpdatedByPlayerId = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<DateTime>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RoomRolePermissions");
}
}
}
@@ -3021,6 +3021,51 @@ namespace DorkNet.Server.Migrations
b.ToTable("RoomRoles"); b.ToTable("RoomRoles");
}); });
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Override")
.HasColumnType("INTEGER");
b.Property<string>("Permission")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("UpdatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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 => modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -311,7 +311,7 @@ public class NotificationService(
{ {
["roomInstanceId"] = redacted.RoomInstanceId, ["roomInstanceId"] = redacted.RoomInstanceId,
["roomId"] = redacted.RoomId, ["roomId"] = redacted.RoomId,
["subRoomId"] = redacted.SubRoomId, ["subRoomId"] = redacted.WireSubRoomId,
["roomInstanceType"] = redacted.RoomInstanceType, ["roomInstanceType"] = redacted.RoomInstanceType,
["location"] = redacted.Location, ["location"] = redacted.Location,
["dataBlob"] = redacted.DataBlob, ["dataBlob"] = redacted.DataBlob,
@@ -105,6 +105,15 @@ public class PlayerPresenceService
var db = mux.GetDatabase(); var db = mux.GetDatabase();
db.StringSet(Key(playerId), json, PresenceTtl); db.StringSet(Key(playerId), json, PresenceTtl);
db.KeyDelete(NoRoomKey(playerId)); 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}", _log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
playerId, room?.Name ?? "<null>", json.Length); playerId, room?.Name ?? "<null>", json.Length);
} }
@@ -459,45 +468,97 @@ public class PlayerPresenceService
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl); _localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
} }
/// <summary>List the currently-active instances of a given room, /// <summary>List the live instances of a given room, grouped by
/// grouped by PhotonRoomId, with the player ids currently presenced /// PhotonRoomId (or RoomInstanceId when a row has none), with the player
/// in each. Drives the watch's RoomInstanceBrowser /// ids currently presenced in each. Drives the room instance browser
/// (<c>GET match.*/room/{id}/instances</c>) so players can see /// (<c>GET match.*/room/{id}/instances</c>) and the event instance list.
/// which instance their friends are in.
/// ///
/// Redis caveat: without a secondary index per room we'd need a /// <para>Only players whose presence is still live count: an expired
/// SCAN over every presence key — too expensive to do on each /// room presence (<see cref="PresenceTtl"/>) is dropped, and so is a
/// request. For now the Redis path returns empty and the local /// player whose activity marker (<see cref="ActivityTtl"/>, refreshed by
/// in-process dictionary is the source of truth. Single-instance /// every heartbeat and join) has lapsed. A client that crashed keeps its
/// deployments work; multi-replica Redis deployments need a /// room presence for up to ten minutes, but it is not in the instance and
/// per-room set index to be added before the browser is reliable /// must not inflate the player count or keep an empty instance listed.</para>
/// there.</summary> ///
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(long roomId) /// <para><b>Redis.</b> <see cref="SetRoom"/> records each player in a
/// per-room set (<c>presence-room:{roomId}</c>); 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.</para>
///
/// <para>Private instances are skipped unless
/// <paramref name="includePrivate"/> is set; callers that include them
/// must apply their own visibility rule.</para></summary>
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(
long roomId, bool includePrivate = false)
{ {
if (_redis is not null) return Array.Empty<(RoomInstanceDto, List<long>)>(); var members = new List<(long PlayerId, RoomInstanceDto Room)>();
if (_redis is { } mux)
{
try
{
var db = mux.GetDatabase();
var stale = new List<RedisValue>();
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<long>)>();
}
}
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<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal); var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
foreach (var kv in _local) foreach (var (playerId, room) in members)
{ {
var room = kv.Value; if (room.IsPrivate && !includePrivate) continue;
if (room is null || room.RoomId != roomId) continue; if (!IsRecentlyActive(playerId)) continue;
// Skip private instances — those are surfaced separately var key = InstanceKey(room);
// 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;
if (!groups.TryGetValue(key, out var entry)) if (!groups.TryGetValue(key, out var entry))
{ {
entry = (room, new List<long>()); entry = (room, new List<long>());
groups[key] = entry; groups[key] = entry;
} }
entry.PlayerIds.Add(kv.Key); entry.PlayerIds.Add(playerId);
} }
return groups.Values; return groups.Values;
} }
/// <summary>The identity two presence rows share when they are in the
/// same live instance: the Photon room, else the instance id.</summary>
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 Key(long playerId) => $"presence:{playerId}";
private static string ActiveKey(long playerId) => $"presence-active:{playerId}"; private static string ActiveKey(long playerId) => $"presence-active:{playerId}";
private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}"; private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}";
@@ -0,0 +1,198 @@
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
/// <summary>
/// A sub-room's role-permission matrix: the cells the 2023 client reads from
/// <c>GET photon_access_token</c> (<c>ALIIIAGAPND.Permissions</c>, a list of
/// <c>BNLPBMJJOMM</c>) and writes with
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>.
///
/// <para>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.</para>
///
/// <para>Cell keys (formatter RecNet.Runtime/HDOJMEBCAGP.txt):
/// <c>Permission</c> (string), <c>Role</c> (OMMBGJMJJPN), <c>Override</c>
/// (bool), <c>Type</c> (FEOJFGECKGH: Bool 0 / Int 1 / StringList 2),
/// <c>Value</c> (string). Role and Type go out as numbers; requests are
/// accepted with numbers, numeric strings or enum names.</para>
/// </summary>
public static class RoomPermissionMatrix
{
/// <summary>One matrix cell.</summary>
public sealed record Cell(string Permission, int Role, bool Override, int Type, string Value);
/// <summary>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.</summary>
public static IReadOnlyList<Cell> Defaults { get; } = BuildDefaults();
private static IReadOnlyList<Cell> 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<Cell>();
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;
}
/// <summary>Defaults merged with the stored cells.</summary>
public static List<Cell> Merge(IEnumerable<RoomRolePermissionEntity> 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.OrdinalIgnoreCase));
if (at >= 0) cells[at] = cell;
else cells.Add(cell);
}
return cells;
}
/// <summary>The served matrix for one sub-room (by OrderIndex).</summary>
public static async Task<List<Cell>> 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);
}
/// <summary>The <c>ALIIIAGAPND</c> body: <c>{RoomInstanceId,
/// PhotonAccessToken, Permissions}</c>. Used both by
/// <c>GET photon_access_token</c> and the <c>PhotonAccessToken</c> hub
/// push (IBEOONPEELF.txt:519 registers it as Action&lt;ALIIIAGAPND&gt;).</summary>
public static Dictionary<string, object> AccessTokenBody(long roomInstanceId, IEnumerable<Cell> 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,
};
/// <summary>Parse the PUT body: a JSON array of cells (an object wrapping
/// a <c>Permissions</c> array is accepted too). Returns null when the body
/// is not JSON or holds no array.</summary>
public static List<Cell>? 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<Cell>();
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<string, int?> 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,
};
}
+222
View File
@@ -0,0 +1,222 @@
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
/// <summary>
/// The 2023 room-role ladder and who may change whose role.
///
/// <para><b>Wire enum.</b> The client's room role is <c>OMMBGJMJJPN : byte</c>
/// (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. <see cref="RoomRoleEntity.Role"/> stores a compact kind instead
/// (0 = CoOwner, 1 = Moderator, 2 = Host); <see cref="ToWire"/> and
/// <see cref="TryFromWire"/> convert.</para>
///
/// <para><b>The creator is 255, not 30.</b> The client reads the local
/// player's role straight out of the room details <c>Roles</c> list
/// (<c>FGCPNAACHIK.ONECLLALJEO</c>: 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
/// (<c>OMJKHJLFOCO.DIIMLMKEJLC</c>,
/// IsilDump/RecRoom.RoomPermissions.Runtime/OMJKHJLFOCO.txt:2367:
/// <c>if role == 255 return true; if target &gt;= role return false</c>).
/// Advertising the creator as CoOwner (30) therefore locked the room's own
/// creator out of the Co-Owner and Creator permission rows.</para>
///
/// <para><b>Server rule</b> (<see cref="CheckChange"/>): 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.</para>
/// </summary>
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;
/// <summary>Stored kind → wire role.</summary>
public static int ToWire(int storedRole) => storedRole switch
{
StoredCoOwner => WireCoOwner,
StoredModerator => WireModerator,
StoredHost => WireHost,
_ => WireNone,
};
/// <summary>Wire role → stored kind. Only the three persistable roles
/// map; None, Banned, TemporaryCoOwner (an in-instance grant) and Creator
/// (ownership, changed through <c>rooms/{id}/creator</c>) do not.</summary>
public static bool TryFromWire(int wireRole, out int storedRole)
{
storedRole = wireRole switch
{
WireCoOwner => StoredCoOwner,
WireModerator => StoredModerator,
WireHost => StoredHost,
_ => -1,
};
return storedRole >= 0;
}
/// <summary>The player's accepted wire role in the room: 255 for the
/// creator, the highest accepted grant otherwise, 0 for none.</summary>
public static int AcceptedRank(RoomEntity room, IEnumerable<RoomRoleEntity> 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();
}
/// <summary>The highest role the player has been invited to and not yet
/// accepted, 0 for none.</summary>
public static int InvitedRank(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
=> rows
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId && !r.Accepted)
.Select(r => ToWire(r.Role))
.DefaultIfEmpty(WireNone)
.Max();
/// <summary>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.</summary>
public static bool CanEditRoom(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
=> AcceptedRank(room, rows, playerId) >= WireCoOwner;
/// <summary>What a role write will do once <see cref="CheckChange"/> allows it.</summary>
public enum ChangeKind
{
/// <summary>Grant or replace the target's role (accepted at once).</summary>
Grant,
/// <summary>Record a pending invite the target must accept.</summary>
Invite,
/// <summary>The target accepts their own pending invite.</summary>
AcceptInvite,
/// <summary>Remove the target's role and any pending invite.</summary>
Clear,
}
/// <summary>Decide whether <paramref name="actorId"/> may set
/// <paramref name="targetId"/>'s role to <paramref name="requestedWireRole"/>.
/// Returns null when allowed, otherwise an error code for the response.
/// <paramref name="kind"/> is what the caller should then do.</summary>
public static string? CheckChange(
RoomEntity room,
IReadOnlyCollection<RoomRoleEntity> 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;
}
/// <summary>True when <paramref name="playerId"/> may write the
/// permission row for <paramref name="wireRole"/>: 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.</summary>
public static bool CanEditRolePermissions(
RoomEntity room, IEnumerable<RoomRoleEntity> 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;
}
/// <summary>The room details / roles-list rows: one per account, with
/// the creator first as 255 and every other account's accepted role in
/// <c>Role</c> and pending invite in <c>InvitedRole</c>. Rows the
/// creator happens to own in RoomRoles are ignored so the creator is
/// never listed twice (the client takes the first match).</summary>
public static List<RoomRoleWire> BuildWires(RoomEntity room, IEnumerable<RoomRoleEntity> rows)
{
var list = new List<RoomRoleWire>
{
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;
}
/// <summary>One <c>EFHPLDPNGIM</c> row: <c>{AccountId: Int32, Role,
/// InvitedRole, LastChangedByAccountId: Int32?}</c>.</summary>
public sealed record RoomRoleWire(int AccountId, int Role, int InvitedRole, int? LastChangedByAccountId);
/// <summary>Account ids are Int32 in the 2023 role DTO. Clamp rather than
/// wrap so an out-of-range id is obviously wrong.</summary>
public static int ToClientAccountId(long accountId)
=> accountId > int.MaxValue
? int.MaxValue
: accountId < int.MinValue
? int.MinValue
: (int)accountId;
}
+44
View File
@@ -0,0 +1,44 @@
namespace DorkNet.Server.Services;
/// <summary>
/// Converts between a sub-room's position in the room
/// (<see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>, 0-based, what the
/// server stores and indexes on) and the <c>SubRoomId</c> the 2023 client
/// sees on the wire.
///
/// <para><b>Why they differ.</b> The client refuses to load a sub-room whose
/// id is not positive: <c>SubroomModel.DOKCPLJOKIE</c>
/// (IsilDump/Assembly-CSharp/RRUI/Data/SubroomModel.txt:3) is
/// <c>RoomId &gt; 0 &amp;&amp; SubroomId &gt; 0</c>, and
/// <c>Model`1.LNPEMMMGCPC</c> skips the load when it is false. With the
/// first sub-room advertised as id 0, its Room Settings &gt; Subrooms row
/// never fetched its details (<c>SubroomModel.KBOJBJNCOGK</c> →
/// <c>FGCPNAACHIK.OBFPEGLLELP(long?)</c>), 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".</para>
///
/// <para><b>Mapping.</b> Wire id = OrderIndex + 1, so a room's first
/// sub-room is 1. Incoming ids go back through <see cref="ToOrderIndex"/>;
/// 0 (and anything negative) is accepted as an alias of the first sub-room,
/// which keeps cached client state, old presence rows and bare
/// <c>matchmake/room/{id}</c> joins working.</para>
///
/// <para><b>Where the conversion happens.</b> Only at the wire: the room
/// details <c>SubRooms[]</c>, the sub-room routes
/// (<c>rooms/{id}/subrooms/{sub}/*</c>), <c>matchmake/room/{id}/{sub}</c>,
/// the room-instance JSON (<c>RoomInstanceDto.subRoomId</c>), the instance
/// browser, sub-room save DTOs and permission rows. Everything stored
/// (RoomScenes, PrivateInstances, RoomDataBlobs, presence) keeps the
/// OrderIndex.</para>
/// </summary>
public static class RoomSceneIds
{
/// <summary>OrderIndex → wire SubRoomId (always ≥ 1).</summary>
public static long ToWire(long orderIndex) => Math.Max(0, orderIndex) + 1;
/// <summary>Wire SubRoomId → OrderIndex. 0 and negatives map to the first
/// sub-room.</summary>
public static int ToOrderIndex(long wireSubRoomId)
=> wireSubRoomId <= 1 ? 0 : (int)Math.Min(int.MaxValue, wireSubRoomId - 1);
}
+89 -1
View File
@@ -546,6 +546,71 @@ public class RoomService(DorkNetDbContext db)
return existing; return existing;
} }
/// <summary>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.</summary>
public static string DefaultSubRoomName(int orderIndex)
=> orderIndex <= 0 ? "Home" : $"Subroom {orderIndex + 1}";
/// <summary>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.</summary>
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,
};
/// <summary>Startup self-heal for sub-rooms (idempotent).
///
/// <para>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 &gt;
/// 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.</para>
///
/// <para>Scene ids are assigned explicitly (max + 1) for the same reason
/// the clone handler does: seeded rows were inserted with manual ids
/// without advancing the Postgres identity sequence.</para>
/// Returns (rooms given a scene, scenes renamed).</summary>
public async Task<(int ScenesAdded, int ScenesRenamed)> RepairRoomScenesAsync()
{
var roomsWithoutScenes = await db.Rooms
.Where(r => !r.IsDormRoom && !db.RoomScenes.Any(s => s.RoomId == r.Id))
.ToListAsync();
var nextSceneId = await db.RoomScenes.MaxAsync(s => (long?)s.Id) ?? 0L;
foreach (var room in roomsWithoutScenes)
{
var scene = NewHomeScene(room);
scene.Id = ++nextSceneId;
db.RoomScenes.Add(scene);
}
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 (roomsWithoutScenes.Count > 0 || unnamed.Count > 0)
await db.SaveChangesAsync();
return (roomsWithoutScenes.Count, unnamed.Count);
}
/// <summary>Run once at startup to backfill <see cref="RoomEntity"/> /// <summary>Run once at startup to backfill <see cref="RoomEntity"/>
/// + <see cref="DormStateEntity"/> rows for every account that /// + <see cref="DormStateEntity"/> rows for every account that
/// pre-dates the auto-create-on-signup work. Idempotent — calls /// pre-dates the auto-create-on-signup work. Idempotent — calls
@@ -1068,6 +1133,10 @@ public class RoomService(DorkNetDbContext db)
}; };
db.Rooms.Add(clone); db.Rooms.Add(clone);
// Every room owns a real entry sub-room (see RepairRoomScenesAsync).
var home = NewHomeScene(clone);
home.Id = (await db.RoomScenes.MaxAsync(s => (long?)s.Id) ?? 0L) + 1;
db.RoomScenes.Add(home);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return new CloneResult(0 /* Success */, clone); return new CloneResult(0 /* Success */, clone);
} }
@@ -1081,6 +1150,25 @@ public class RoomService(DorkNetDbContext db)
/// AllowsJuniors, RoomWarningMask, CustomRoomWarning /// AllowsJuniors, RoomWarningMask, CustomRoomWarning
/// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute. /// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute.
/// </summary> /// </summary>
/// <summary>The <c>IsRRO</c> 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 <see cref="RoomEntity.IsAGRoom"/> for the baked
/// blob handling.
///
/// <para>The client gates the whole owner UI on it: RoomModel
/// get_CanPublishRoom, CanUnpublishRoom, CanDeleteRoom,
/// CanChangeRoomCreator, CanChangeRoomSetting, CanChangeCustomWarning,
/// CanChangeLoadingScreen and CanCreateNewSubroom all require
/// <c>!IsRRO</c> (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.</para></summary>
public static bool WireIsRRO(RoomEntity r)
=> r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId;
public static object ToWireRoom(RoomEntity r) => new public static object ToWireRoom(RoomEntity r) => new
{ {
RoomId = r.Id, RoomId = r.Id,
@@ -1093,7 +1181,7 @@ public class RoomService(DorkNetDbContext db)
Accessibility = r.Accessibility, Accessibility = r.Accessibility,
SupportsLevelVoting = r.SupportsLevelVoting, SupportsLevelVoting = r.SupportsLevelVoting,
IsAGRoom = r.IsAGRoom, IsAGRoom = r.IsAGRoom,
IsRRO = r.IsAGRoom, IsRRO = WireIsRRO(r),
// 2020.12 Room.Deserialize (KLCOGEIGEBJ.PPGFHEDFBEA, the Room base) // 2020.12 Room.Deserialize (KLCOGEIGEBJ.PPGFHEDFBEA, the Room base)
// reads required keys: RoomId, IsDorm, CloningAllowed, DisableMicAutoMute, // reads required keys: RoomId, IsDorm, CloningAllowed, DisableMicAutoMute,
// DisableRoomComments, EncryptVoiceChat. The 2020.03 key was IsDormRoom; // DisableRoomComments, EncryptVoiceChat. The 2020.03 key was IsDormRoom;
@@ -164,6 +164,11 @@ public static class DatabaseBootstrap
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir); 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. // Seed default curated playlists, club category tags, store catalog.
// Must run after SeedAsync so they can reference RR-Originals. // Must run after SeedAsync so they can reference RR-Originals.
var playlistService = scope.ServiceProvider.GetRequiredService<PlaylistService>(); var playlistService = scope.ServiceProvider.GetRequiredService<PlaylistService>();
@@ -899,6 +904,23 @@ public static class DatabaseBootstrap
); );
""", """,
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");", @"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) foreach (var sql in statements)
await db.Database.ExecuteSqlRawAsync(sql); await db.Database.ExecuteSqlRawAsync(sql);
@@ -976,6 +998,21 @@ public static class DatabaseBootstrap
); );
""", """,
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");", @"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 // CREATE ... IF NOT EXISTS is already idempotent; a failure here is a
// real problem and must fail startup. // real problem and must fail startup.
+104 -31
View File
@@ -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, Ids the client handles that this server never sends (dead client features,
available if wanted): 3, 5, 23, 32, 60, 110, 120, 121, plus a long tail of available if wanted): 3, 5, 23, 32, 60, 110, 120, 121, plus a long tail of
string channels — `GoToFailure`, `PhotonAccessToken`, `AppVersionUpdate`, string channels — `GoToFailure`, `AppVersionUpdate`,
`GameConfig.Refresh`, `ReputationUpdate`, `KeepsakeInstanceAdded/Removed`, `GameConfig.Refresh`, `ReputationUpdate`, `KeepsakeInstanceAdded/Removed`,
`RoomCurrencyCreated/Modified/Deleted`, `RoomCommentDeleted` and others. `RoomCurrencyCreated/Modified/Deleted`, `RoomCommentDeleted` and others.
@@ -622,6 +622,57 @@ 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. 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 three cut across the whole rooms surface.
- **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 #### Endpoints
| Verb | Route | Request | Response | | Verb | Route | Request | Response |
@@ -630,7 +681,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/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<Int64>), Details (String), ReportCategory (Int32 enum FPIBGPIAOBI/BDOGOIGCKMK) | none parsed — logged as 'Failed to report room' on error; return 200 | | POST | `api/rooms/v2/report` | x-www-form-urlencoded fields (PascalCase!): RoomId (Int64), RoomKeyId (Nullable<Int64>), 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 | `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}/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 | | 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 | | 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 +708,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/me` | query params: skip (Int32), take (Int32) | [NEMINAEBALC] |
| GET | `rooms/visitedby/{0}` | 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 | | 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}/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 | | 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. | | 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 +732,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_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 + 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 | | 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 | `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). | GET → EFHPLDPNGIM single object. PUT → FGCPNAACHIK full room details. | | 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) | 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]} | | 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) | | 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 | | DELETE | `rooms/{0}/subrooms/{1}` | none | FGCPNAACHIK full room details |
@@ -694,7 +745,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 | | 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<Int64> — target room) | FGCPNAACHIK full room details | | POST | `rooms/{0}/subrooms/{1}/move` | form field: newRoomId (Nullable<Int64> — 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}/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 | | 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 | | 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 | | PUT | `rooms/{0}/tags` | form fields: tag (repeated String), autoTag (repeated String) | FGCPNAACHIK full room details |
@@ -948,6 +999,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. **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) ##### `PUT rooms/{0}/allow_new_users` — SHAPE_MISMATCH (degraded)
Twins + form key allowNewUsers OK; response is ToWireRoom (same ApplyAndReturn defect as accessibility). Twins + form key allowNewUsers OK; response is ToWireRoom (same ApplyAndReturn defect as accessibility).
@@ -1036,29 +1089,33 @@ Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:8
**Fix.** Same ApplyAndReturn fix. **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). Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs` (`GrantRole`, `InviteRole`, `ChangeRoomAccountRoleAsync`), rules in `DorkNet.Server/Services/RoomRoleRules.cs`
##### `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.
##### `POST rooms/{0}/subrooms/{1}/clone` — SHAPE_MISMATCH (degraded) ##### `POST rooms/{0}/subrooms/{1}/clone` — SHAPE_MISMATCH (degraded)
@@ -1084,13 +1141,29 @@ Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2656`
**Fix.** Drop the [FromBody] parameter; return BuildRoomServerDetailsWithRolesAsync. **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<ALIIIAGAPND>`, 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) ##### `POST rooms/{0}/subrooms/{1}/publish_save` — SHAPE_MISMATCH (degraded)
+4 -3
View File
@@ -69,7 +69,7 @@ today.
| Key | Msg | Sent by | | 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) | | `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 | | `3` MessageDeleted | `{Id}` | not sent |
| `5` RefreshLogin | none | 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) | | `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`) | | `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) | | `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. | | `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<ALIIIAGAPND>`) | 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 | | `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 | | `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`) | | `PlayerProgressionLevelUpdate` | `{PlayerId, Level, XP}`, as `api/players/v2/progression` returns it | every XP award (`LevelService.AwardXpAsync`) |
| `CommunityBoardUpdate` | community board | community board edits | | `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, **Not registered at all** (a push with these keys is dropped): numeric 4, 11,
12, 13, 15, 21, 24 (ModerationRoomBan), 40, 85, 90, 95. 12, 13, 15, 21, 24 (ModerationRoomBan), 40, 85, 90, 95.