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
@@ -1254,8 +1254,10 @@ public class InventionsController(
if (!canPush) return Forbid();
var subRoomId = req.SubRoomId;
// Wire SubRoomId (OrderIndex + 1) → the scene's OrderIndex.
var sceneOrderIndex = Services.RoomSceneIds.ToOrderIndex(subRoomId);
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == sceneOrderIndex);
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
if (subRoomDataFilename.Length == 0)
@@ -1269,7 +1271,7 @@ public class InventionsController(
if (subRoomBlob is null)
return BadRequest(new { Error = "unknown SubRoomData.Filename" });
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
subRoomBlob.SubRoomId ??= subRoomId;
subRoomBlob.SubRoomId ??= sceneOrderIndex;
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
if (roomDataFilename.Length > 0)
@@ -394,7 +394,11 @@ public class PlayerEventsController(
if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null;
// The event stores the client's wire SubRoomId; presence and private
// instances hold the OrderIndex (RoomSceneIds).
var subRoomId = extras.SubRoomId is long sub && sub > 0
? RoomSceneIds.ToOrderIndex(sub)
: (long?)null;
// Same sourcing as the room browser: presence rows are the live truth
// (one instance per distinct PhotonRoomId), and the caller's OWN private
@@ -417,7 +421,7 @@ public class PlayerEventsController(
{
roomInstanceId = room.RoomInstanceId,
roomId = room.RoomId,
subRoomId = room.SubRoomId,
subRoomId = RoomSceneIds.ToWire(room.SubRoomId),
// MaxCapacity is the per-instance cap the matchmaker handed
// out, so this is a real answer rather than a constant false.
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
@@ -438,7 +442,7 @@ public class PlayerEventsController(
{
roomInstanceId = p.Id,
roomId = p.RoomId,
subRoomId = p.SubRoomId,
subRoomId = RoomSceneIds.ToWire(p.SubRoomId),
isFull = false,
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
playerIds = Array.Empty<int>(),
@@ -35,6 +35,7 @@ public class RoomsController(
DomainConfig domain,
NotificationService notifications,
ServerSettingsService serverSettings,
SystemNotificationService systemNotifications,
ILogger<RoomsController> logger) : ControllerBase
{
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
@@ -574,6 +575,7 @@ public class RoomsController(
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
@@ -586,7 +588,7 @@ public class RoomsController(
.Take(50)
.Select(b => new
{
SubRoomId = subRoomId,
SubRoomId = RoomSceneIds.ToWire(subRoomId),
DataBlob = b.BlobName,
SavedByAccountId = b.UploadedByPlayerId,
CreatedAt = b.UploadedAt,
@@ -613,6 +615,7 @@ public class RoomsController(
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -661,6 +664,7 @@ public class RoomsController(
[Authorize]
public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -689,6 +693,7 @@ public class RoomsController(
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -731,6 +736,7 @@ public class RoomsController(
long roomId, long subRoomId,
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -763,7 +769,7 @@ public class RoomsController(
saves.Add(new
{
SubRoomDataSaveId = currentSaveId,
SubRoomId = subRoomId,
SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -809,7 +815,7 @@ public class RoomsController(
var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
return new
{
SubRoomId = (long)orderIndex,
SubRoomId = RoomSceneIds.ToWire(orderIndex),
RoomId = room.Id,
Name = scene?.Name ?? "Home",
DataBlob = dataBlob,
@@ -824,7 +830,7 @@ public class RoomsController(
CurrentSave = new
{
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
SubRoomId = (long)orderIndex,
SubRoomId = RoomSceneIds.ToWire(orderIndex),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -871,7 +877,7 @@ public class RoomsController(
return new
{
SubRoomDataSaveId = saveId,
SubRoomId = subRoomId,
SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = unityAssetId,
SubRoomUnityAssetId = unityAssetId,
ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -916,13 +922,14 @@ public class RoomsController(
public async Task<IActionResult> SubRoomRestoreData(
long roomId, long subRoomId, [FromForm] string? filename)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
if (string.IsNullOrWhiteSpace(filename))
return BadRequest(new { error = "missing_filename" });
var pid = this.RequireCurrentPlayerId();
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
if (room.CreatorPlayerId != pid) return Forbid();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var blob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
@@ -2001,7 +2008,7 @@ public class RoomsController(
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
SubRoomId = (long)s.OrderIndex,
SubRoomId = RoomSceneIds.ToWire(s.OrderIndex),
RoomId = room.Id,
Name = s.Name,
UnityAssetId = s.StudioUnityAssetId,
@@ -2027,7 +2034,7 @@ public class RoomsController(
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
SubRoomId = 0L,
SubRoomId = RoomSceneIds.ToWire(0),
RoomId = room.Id,
Name = "Home",
UnityAssetId = string.Empty,
@@ -2046,11 +2053,10 @@ public class RoomsController(
},
};
var wireRoles = new List<object>
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
};
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
// The creator is Creator (255), never CoOwner (30): the client reads
// the local player's role from this list and only lets a player edit
// roles strictly below their own (see RoomRoleRules).
var wireRoles = RoomRoleRules.BuildWires(room, roleList);
var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>()
@@ -2075,7 +2081,9 @@ public class RoomsController(
State = room.State,
Accessibility = room.Accessibility,
SupportsLevelVoting = room.SupportsLevelVoting,
IsRRO = room.IsAGRoom,
// Player rooms are never RRO on the wire (see RoomService.WireIsRRO):
// the client refuses publish and room settings for an RRO.
IsRRO = RoomService.WireIsRRO(room),
SupportsScreens = room.SupportsScreens,
SupportsWalkVR = room.SupportsWalkVR,
SupportsTeleportVR = room.SupportsTeleportVR,
@@ -2922,9 +2930,9 @@ public class RoomsController(
{
// RoomRoles co-owner check — same authorisation as the
// existing modify endpoint, lets co-owners rename too.
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
if (!coOwner) return Forbid();
// Room settings are creator / co-owner only; moderators and
// hosts moderate players, they don't edit the room.
if (!await CanEditRoomAsync(room, me)) return Forbid();
}
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
@@ -2964,6 +2972,7 @@ public class RoomsController(
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var newName = body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
@@ -2976,9 +2985,9 @@ public class RoomsController(
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
if (room.CreatorPlayerId != me)
{
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
if (!coOwner) return Forbid();
// Room settings are creator / co-owner only; moderators and
// hosts moderate players, they don't edit the room.
if (!await CanEditRoomAsync(room, me)) return Forbid();
}
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
@@ -3614,7 +3623,7 @@ public class RoomsController(
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var name = (req.Name ?? string.Empty).Trim();
if (name.Length == 0) name = "NewScene";
@@ -3651,10 +3660,11 @@ public class RoomsController(
[Authorize]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
@@ -3710,20 +3720,6 @@ public class RoomsController(
/// <summary>One row of the per-subroom role-permission matrix the 2023
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
/// 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 long? SubRoomDataSaveId { get; set; }
@@ -3773,45 +3769,103 @@ public class RoomsController(
s.CanMatchmakeInto = a != AccessibilityPrivate;
});
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — the
/// role-permission matrix. Client:
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — "change
/// subroom permissions", the role-permission matrix. Client:
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList&lt;BNLPBMJJOMM&gt;)</c>
/// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT
/// form-encode: the closure calls <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c>
/// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string
/// (BNDIAONDFFF.txt:275, <c>mov dword ptr [rbx+38h],4</c>) — i.e. a raw
/// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON
/// deserialize into the List).
/// (NLDBPDCNNCF.txt:9198, verb 3, same typed helper as every other room
/// mutation, so the response is the full <c>FGCPNAACHIK</c> details).
/// Unlike its siblings the body is NOT form-encoded: the closure calls
/// <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c> (CCNOMNNHNNG.txt ISIL 009), a raw
/// JSON array of cells. The editor sends one changed cell per call
/// (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
/// per-subroom permission storage: <see cref="RoomSceneEntity"/> has no
/// column for it and there is no permissions table. Adding one means an
/// entity + DbContext + migration, which is outside this file. Until then
/// the matrix is accepted, logged, and the room details echoed back so the
/// client's promise resolves instead of 415-ing; the values do not survive
/// a round-trip. The old handler silently bound an unrelated DTO whose keys
/// the client never sends, which was strictly worse.</para></summary>
/// <para>Read back through <c>GET photon_access_token</c> (its
/// <c>Permissions</c> list), and pushed to everyone in that sub-room as
/// the <c>PhotonAccessToken</c> hub message so the change takes effect
/// without a rejoin.</para>
///
/// <para>Authorization mirrors the client's own rule
/// (OMJKHJLFOCO.DIIMLMKEJLC): the creator may edit every role, including
/// Co-Owner and Creator; a co-owner only the roles below their own;
/// nobody else may edit (<see cref="RoomRoleRules.CanEditRolePermissions"/>).</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize]
public async Task<IActionResult> SubRoomPermissions(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
public async Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var roleRows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
if (!RoomRoleRules.CanEditRoom(room, roleRows, pid)) return Forbid();
string body;
using (var reader = new StreamReader(Request.Body))
body = await reader.ReadToEndAsync();
var cells = RoomPermissionMatrix.Parse(body);
if (cells is null) return BadRequest(new { error = "invalid_permissions_body" });
var denied = cells.Where(c => !RoomRoleRules.CanEditRolePermissions(room, roleRows, pid, c.Role)).ToList();
if (denied.Count > 0)
{
logger.LogWarning(
"[subroom-permissions] denied room={Room} sub={Sub} by={By} roles={Roles}",
roomId, subRoomId, pid, string.Join(",", denied.Select(d => d.Role).Distinct()));
return Forbid();
}
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
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();
logger.LogWarning(
"[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)",
roomId, subRoomId, pid, req?.Count ?? 0);
logger.LogInformation(
"[subroom-permissions] room={Room} sub={Sub} by={By} cells={Count}",
roomId, subRoomId, pid, cells.Count);
// Everyone in that sub-room gets the new matrix; the client's
// PhotonAccessToken handler re-initialises its permissions from it.
var matrix = await RoomPermissionMatrix.LoadAsync(db, roomId, subRoomId);
foreach (var (instance, players) in presence.EnumerateActiveInstances(roomId, includePrivate: true))
{
if (instance.SubRoomId != subRoomId) continue;
var tokenBody = RoomPermissionMatrix.AccessTokenBody(instance.RoomInstanceId, matrix);
foreach (var other in players)
await notifications.NotifyTypedAsync(other, "PhotonAccessToken", tokenBody);
}
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
@@ -3842,10 +3896,11 @@ public class RoomsController(
[Authorize]
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
@@ -3921,10 +3976,11 @@ public class RoomsController(
[Authorize]
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var src = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (src is null) return NotFound();
@@ -4015,6 +4071,7 @@ public class RoomsController(
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
@@ -4105,6 +4162,7 @@ public class RoomsController(
[Authorize]
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var body = await ReadSaveRoomSceneRequestAsync();
body.RoomSceneId = subRoomId;
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
@@ -4165,10 +4223,11 @@ public class RoomsController(
/// successful mutation surface as a client-side parse failure.</summary>
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
mutator(scene);
@@ -4219,12 +4278,7 @@ public class RoomsController(
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var list = new List<object>(rows.Count + 1)
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
};
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
return Ok(list);
return Ok(RoomRoleRules.BuildWires(room, rows));
}
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
@@ -4339,30 +4393,38 @@ public class RoomsController(
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task&lt;EFHPLDPNGIM&gt;</c>,
/// 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("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId == playerId)
return Ok(BuildRoomAccountRoleWire(playerId, 30));
var row = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
if (row is null) return NotFound();
return Ok(BuildRoomRoleGrantWire(row));
var rows = await db.RoomRoles
.Where(r => r.RoomId == roomId && r.PlayerId == playerId)
.ToListAsync();
var wire = RoomRoleRules.BuildWires(room, rows)
.FirstOrDefault(w => w.AccountId == RoomRoleRules.ToClientAccountId(playerId));
if (wire is null) return NotFound();
return Ok(wire);
}
public sealed class GrantRoleRequest { public int? Role { get; set; } }
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — owner grants
/// a role to a player. Auto-accepted (no invite step).
/// <c>NLDBPDCNNCF.LMIEIJMCEAB</c> ("change room account role",
/// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key <c>role</c> carrying a
/// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full
/// <c>FGCPNAACHIK</c> details — which this already does. Only the
/// roomserver/ twin was missing.</summary>
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — "change room account
/// role" (<c>NLDBPDCNNCF.LMIEIJMCEAB</c>, NLDBPDCNNCF.txt:7516, verb 3 =
/// PUT, single form key <c>role</c> boxed as Int32 —
/// NLDBPDCNNCF_NestedType_BFKNABDHMGG.txt ISIL 015-024), response the full
/// <c>FGCPNAACHIK</c> details. Three callers:
/// <list type="bullet">
/// <item>the owner's role button (AddRemovePlayerRoomRoleConditionalButton
/// .EEGJBELCBDI): Host/Moderator are set directly, and "Remove" sends
/// 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}")]
[HttpPost("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}")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[Authorize]
public async Task<IActionResult> GrantRole(long roomId, long playerId)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
var role = await ReadClientRoomRoleAsync();
if (role is null) return BadRequest(new { error = "missing_role" });
if (role.Value == 0)
return await ClearRoomAccountRoleAsync(room, playerId);
public Task<IActionResult> GrantRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
return BadRequest(new { error = "invalid_role", role });
var existing = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
if (existing is null)
{
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = true, GrantedByPlayerId = me,
});
}
else
{
existing.Accepted = true;
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <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>
/// <summary>PUT <c>rooms/{id}/roles/{playerId}/invite</c> — "invite account
/// to role" (<c>NLDBPDCNNCF.KIJDCLINOIC</c>, NLDBPDCNNCF.txt:7686, verb 3,
/// form key <c>role</c> as Int32 — NLDBPDCNNCF_NestedType_ENHMMCAOING.txt).
/// Only Co-Owner goes through an invite in this client: the owner's button
/// sends role 30 here, the invitee is told with a Type 62
/// (RoomCoOwnerInvited) message carrying RoomId, and either accepts with
/// <c>PUT roles/{me}</c> role 30 or rejects by sending role 0 back to THIS
/// route on their own id (KPFKPIKDBFJ.OGONAKHNMGN, KLNLKDJJDLE Reject),
/// which clears the pending invite but leaves any role they already
/// hold.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
@@ -4420,62 +4456,167 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[Authorize]
public async Task<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 room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
var role = await ReadClientRoomRoleAsync();
if (role is null) return BadRequest(new { error = "missing_role" });
if (role.Value == 0)
return await ClearRoomAccountRoleAsync(room, playerId);
var requested = await ReadClientRoomRoleAsync();
if (requested is null) return BadRequest(new { error = "missing_role" });
if (playerId <= 0 || !await db.Players.AnyAsync(p => p.Id == playerId))
return NotFound(new { error = "unknown_player" });
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
return BadRequest(new { error = "invalid_role", role });
var existing = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
if (existing is null)
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var error = RoomRoleRules.CheckChange(
room, rows, me, playerId, requested.Value, isInvite, out var kind);
if (error is not null)
{
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity
logger.LogWarning(
"[room-roles] denied room={Room} actor={Actor} target={Target} role={Role} invite={Invite} error={Error}",
roomId, me, playerId, requested.Value, isInvite, error);
return error == "cannot_change_creator_role" || error == "invalid_role"
? BadRequest(new { error, role = requested.Value })
: Forbid();
}
var targetRows = rows.Where(r => r.PlayerId == playerId).ToList();
var wasCoOwner = targetRows.Any(r => r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
var hadCoOwnerInvite = targetRows.Any(r => !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
RoomRoleRules.TryFromWire(requested.Value, out var storedRole);
switch (kind)
{
case RoomRoleRules.ChangeKind.Clear when isInvite:
// Reject / withdraw an invite: only the pending rows go.
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted));
break;
case RoomRoleRules.ChangeKind.Clear:
db.RoomRoles.RemoveRange(targetRows);
break;
case RoomRoleRules.ChangeKind.Invite:
{
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = false, GrantedByPlayerId = me,
});
// Already holding the role: nothing to invite to. Otherwise
// replace any other pending invite but keep the role the
// player holds until they accept.
if (targetRows.Any(r => r.Accepted && r.Role == storedRole)) break;
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted && r.Role != storedRole));
var pending = targetRows.FirstOrDefault(r => !r.Accepted && r.Role == storedRole);
if (pending is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = storedRole,
Accepted = false, GrantedByPlayerId = me, GrantedAt = DateTime.UtcNow,
});
}
else
{
pending.GrantedByPlayerId = me;
pending.GrantedAt = DateTime.UtcNow;
}
break;
}
case RoomRoleRules.ChangeKind.AcceptInvite:
case RoomRoleRules.ChangeKind.Grant:
{
// One role per account: the new one replaces the rest
// (an accepted invite replaces the role held meanwhile).
var keep = targetRows.FirstOrDefault(r => r.Role == storedRole);
db.RoomRoles.RemoveRange(targetRows.Where(r => !ReferenceEquals(r, keep)));
if (keep is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = storedRole,
Accepted = true,
GrantedByPlayerId = kind == RoomRoleRules.ChangeKind.AcceptInvite
? targetRows.FirstOrDefault(r => !r.Accepted)?.GrantedByPlayerId ?? me
: me,
GrantedAt = DateTime.UtcNow,
});
}
else
{
keep.Accepted = true;
if (kind == RoomRoleRules.ChangeKind.Grant) keep.GrantedByPlayerId = me;
keep.GrantedAt = DateTime.UtcNow;
}
break;
}
}
else
await db.SaveChangesAsync();
var isCoOwnerNow = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
var hasCoOwnerInviteNow = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
logger.LogInformation(
"[room-roles] room={Room} actor={Actor} target={Target} role={Role} invite={Invite} kind={Kind} coOwner={Was}->{Now}",
roomId, me, playerId, requested.Value, isInvite, kind, wasCoOwner, isCoOwnerNow);
// The invitee's notifications are how the role reaches them: the
// 2023 client renders message Types 60/61/62 (factory
// OCGKOJMFLAI.ALEHDHAEFOK, enum KJECOLODAFA.MEPCALGGMJC) and reads
// only RoomId from them. Self-service changes (accept / reject /
// leave) notify nobody.
if (playerId != me)
{
existing.Accepted = false;
if (!hadCoOwnerInvite && hasCoOwnerInviteNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerInvited, me, roomId: roomId);
if (!wasCoOwner && isCoOwnerNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerAdded, me, roomId: roomId);
if (wasCoOwner && !isCoOwnerNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerRemoved, me, roomId: roomId);
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
if (hadCoOwnerInvite && !hasCoOwnerInviteNow)
await MarkCoOwnerInviteMessagesReadAsync(playerId, roomId);
var details = await BuildRoomServerDetailsWithRolesAsync(roomId);
await PushRoomUpdateAsync(roomId, details, playerId, me);
return Ok(details);
}
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId)
/// <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)
{
if (room.CreatorPlayerId == playerId)
return BadRequest(new { error = "cannot_change_creator_role" });
var rows = await db.RoomRoles
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
var stale = await db.Messages
.Where(m => m.RecipientPlayerId == playerId
&& m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited
&& m.ReadAt == null)
.ToListAsync();
if (rows.Count > 0)
db.RoomRoles.RemoveRange(rows);
if (stale.Count == 0) return;
foreach (var m in stale) m.ReadAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
}
private async Task<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;
return await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id
&& r.PlayerId == playerId
&& r.Accepted
&& r.Role == 0);
var recipients = presence.EnumerateActiveInstances(roomId, includePrivate: true)
.SelectMany(i => i.PlayerIds)
.Concat(alsoNotify)
.Where(pid => pid > 0)
.Distinct()
.ToList();
foreach (var pid in recipients)
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, details);
}
private async Task<int?> ReadClientRoomRoleAsync()
@@ -4486,6 +4627,8 @@ public class RoomsController(
var form = await Request.ReadFormAsync();
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
if (TryParseRoleName(form["role"].FirstOrDefault() ?? form["Role"].FirstOrDefault(), out parsed))
return parsed;
}
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
@@ -4495,10 +4638,20 @@ public class RoomsController(
{
try
{
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (body?.Role is int bodyRole) return bodyRole;
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.Name.Equals("role", StringComparison.OrdinalIgnoreCase)) continue;
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out parsed))
return parsed;
if (prop.Value.ValueKind == JsonValueKind.String
&& (int.TryParse(prop.Value.GetString(), out parsed)
|| TryParseRoleName(prop.Value.GetString(), out parsed)))
return parsed;
}
}
}
catch (JsonException)
{
@@ -4509,64 +4662,34 @@ public class RoomsController(
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
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole)
.ToListAsync();
if (rows.Count > 0)
db.RoomRoles.RemoveRange(rows);
}
private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole)
{
roomRole = clientRole switch
role = (raw ?? string.Empty).Trim().ToLowerInvariant() switch
{
30 => 0, // CoOwner
20 => 1, // Moderator
10 => 2, // Host
"none" => RoomRoleRules.WireNone,
"banned" => RoomRoleRules.WireBanned,
"host" => RoomRoleRules.WireHost,
"moderator" => RoomRoleRules.WireModerator,
"coowner" => RoomRoleRules.WireCoOwner,
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
"creator" => RoomRoleRules.WireCreator,
_ => -1,
};
return roomRole >= 0;
return role >= 0;
}
private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
=> BuildRoomAccountRoleWire(
role.PlayerId,
ToClientRoomRole(role.Role),
role.Accepted,
role.GrantedByPlayerId);
private static object BuildRoomAccountRoleWire(
long accountId,
int role,
bool accepted = true,
long? lastChangedByAccountId = null)
=> new
{
AccountId = ToClientAccountId(accountId),
Role = accepted ? role : 0,
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
InvitedRole = accepted ? 0 : role,
};
private static int ToClientRoomRole(int roomRole) => roomRole switch
/// <summary>Creator or accepted co-owner (see
/// <see cref="RoomRoleRules.CanEditRoom"/>).</summary>
private async Task<bool> CanEditRoomAsync(RoomEntity room, long playerId)
{
0 => 30, // CoOwner
1 => 20, // Moderator
2 => 10, // Host
_ => 0,
};
private static int ToClientAccountId(long accountId)
=> accountId > int.MaxValue
? int.MaxValue
: accountId < int.MinValue
? int.MinValue
: (int)accountId;
private static int? ToClientAccountId(long? accountId)
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
if (room.CreatorPlayerId == playerId) return true;
var rows = await db.RoomRoles
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
.ToListAsync();
return RoomRoleRules.CanEditRoom(room, rows, playerId);
}
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
{
@@ -1432,6 +1432,13 @@ public class RoomsModerationController(
// it editable. Safe for admin: the purge gate and "RR Original"
// badge both key on CreatorPlayerId == SystemAccountId, so a
// user-owned clone is still shown as Custom and stays purgeable.
//
// The WIRE IsRRO is a different thing: it is false for every
// player-owned room (RoomService.WireIsRRO). The 2023 client's
// room-load pipeline never reads IsRRO (the baked scene comes
// from the sub-room's UnitySceneId, the blob step from DataBlob),
// while its owner UI refuses publish and every room setting on
// an RRO, which is what kept cloned rooms from being published.
IsAGRoom = source.IsAGRoom,
IsDormRoom = false,
CloningAllowed = source.CloningAllowed,
@@ -1509,6 +1516,18 @@ public class RoomsModerationController(
CanMatchmakeInto = s.CanMatchmakeInto,
DataModifiedAt = DateTime.UtcNow,
}).ToList();
// A template with no scene rows still has to give the new room a
// real entry sub-room (named "Home", from the clone's own location,
// blob and capacity). Without it the details served a synthesised
// placeholder and Room Settings > Subrooms showed an empty row.
if (clonedScenes.Count == 0)
{
var home = Services.RoomService.NewHomeScene(clone);
home.Id = ++nextSceneId;
clonedScenes.Add(home);
}
foreach (var scene in clonedScenes.Where(s => string.IsNullOrWhiteSpace(s.Name)))
scene.Name = Services.RoomService.DefaultSubRoomName(scene.OrderIndex);
if (clonedScenes.Count > 0)
{
db.RoomScenes.AddRange(clonedScenes);
@@ -1580,8 +1599,11 @@ public class RoomsModerationController(
// The scene was created as a shallow copy, so its DataBlobName is
// currently the SOURCE's. Either a copy replaces it or it gets
// cleared — it must never survive as-is.
// A synthesised Home scene (template had no rows) has no source
// scene; it starts from the room-level blob it was built with.
var sourceBlob = i < sourceScenes.Count ? sourceScenes[i].DataBlobName : target.DataBlobName;
var copied = await CopyBlobForCloneAsync(
sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId);
sourceBlob, clone.Id, target.OrderIndex, callerId);
target.DataBlobName = copied ?? string.Empty;
target.DataModifiedAt = DateTime.UtcNow;
@@ -317,6 +317,10 @@ public class GoToController(
[FromForm(Name = "JoinMode")] int joinMode = 0,
[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);
if (room is null)
{
@@ -1552,9 +1556,25 @@ public class RoomInstanceDto
[JsonPropertyName("roomId")]
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; }
/// <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
// AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via
// 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")]
public IActionResult PhotonAccessToken()
[HttpGet("/roomserver/photon_access_token")]
public async Task<IActionResult> PhotonAccessToken()
{
var pid = this.RequireCurrentPlayerId();
var appId = config["Photon:AppId"] ?? string.Empty;
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
var currentRoom = presence.GetRoom(pid);
var permissions = BuildPhotonPermissionSeed();
var permissions = currentRoom is null
? RoomPermissionMatrix.Merge(Array.Empty<RoomRolePermissionEntity>())
: await RoomPermissionMatrix.LoadAsync(db, currentRoom.RoomId, currentRoom.SubRoomId);
logger.LogInformation(
"[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length);
return Ok(new Dictionary<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,
"[photon-token] player={PlayerId} room={RoomId} sub={SubRoomId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
pid, currentRoom?.RoomId ?? 0L, currentRoom?.SubRoomId ?? 0L, currentRoom?.RoomInstanceId ?? 0L, permissions.Count);
// Existing custom-auth/app-id shape kept for Photon auth callers.
["ResultCode"] = 1,
["Message"] = string.Empty,
["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture),
["PhotonAppId"] = appId,
["PhotonVoiceAppId"] = voiceAppId,
["AppId"] = appId,
["VoiceAppId"] = voiceAppId,
["CloudRegion"] = region,
["Region"] = region,
["Token"] = string.Empty,
});
var body = RoomPermissionMatrix.AccessTokenBody(currentRoom?.RoomInstanceId ?? 0L, permissions);
// Existing custom-auth/app-id shape kept for Photon auth callers; the
// ALIIIAGAPND reader ignores keys it does not register.
body["ResultCode"] = 1;
body["Message"] = string.Empty;
body["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture);
body["PhotonAppId"] = appId;
body["PhotonVoiceAppId"] = voiceAppId;
body["AppId"] = appId;
body["VoiceAppId"] = voiceAppId;
body["CloudRegion"] = region;
body["Region"] = region;
body["Token"] = string.Empty;
return Ok(body);
}
private static object[] BuildPhotonPermissionSeed()
=>
[
BuildPhotonPermission(13, 30), // Can Use Maker Pen
BuildPhotonPermission(20, 30), // Can Spawn Inventions
BuildPhotonPermission(21, 30), // Can Spawn Consumables
BuildPhotonPermission(22, 30), // Can Use Room Reset Button
BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle
BuildPhotonPermission(13, 255), // Creator alias
BuildPhotonPermission(20, 255),
BuildPhotonPermission(21, 255),
BuildPhotonPermission(22, 255),
BuildPhotonPermission(23, 255),
];
private static object BuildPhotonPermission(int permission, int role) => new
{
Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture),
Role = role,
Override = true,
Type = 0,
Value = "true",
};
[HttpPost("/matchmake/dorm")]
public async Task<IActionResult> MatchmakeDorm()
{
@@ -490,97 +472,125 @@ public class MatchController(
return Ok(session);
}
/// <summary>GET <c>match.*/room/{id}/instances</c> — the
/// "RoomInstanceBrowser" the watch shows when you tap a room tile.
/// Returns the currently-active instances so the player can see
/// which one their friends are in (and join it) instead of being
/// matchmade into a fresh empty room. Verified URL at
/// Matchmaking.txt:6286; response is a
/// <c>List&lt;SimpleRoomInstance&gt;</c> per
/// <c>SimpleRoomInstance.cs</c>: RoomInstanceId/RoomId/SubRoomId,
/// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount,
/// HasModPresent, HashedInstanceId.
/// <summary>GET <c>match.*/room/{id}/instances</c> — the room instance
/// browser.
///
/// We derive instances from PlayerPresenceService's per-player
/// RoomInstanceDto: every distinct PhotonRoomId in the same RoomId
/// is one instance, with the players currently presenced there.
/// PrivateInstances are surfaced as well so the inviter and
/// invitee see the same room in the browser.</summary>
/// <para><b>Client contract.</b> <c>RecNet.Matchmaking.KAIPGDGACGG(long)</c>
/// (RecNet.Runtime/RecNet/Matchmaking.txt:13966, route at :14237, verb 0 =
/// GET on the matchmaking host) returns <c>List&lt;PNDCMIMEJLD&gt;</c>, a bare
/// array. The reader (JPNMBICAMEE.txt) takes exactly RoomInstanceId,
/// RoomId, SubRoomId, IsFull, CreatedAt and PlayerIds; the post-parse hook
/// (PNDCMIMEJLD.FKDDCNLJOLF) derives PlayerCount from PlayerIds.Count,
/// SubroomName from the cached room details' SubRooms by SubRoomId, and
/// HasModPresent from the room's Roles. The screen
/// (RoomInstanceBrowserScreen / RoomInstancePagedGrid) shows every row it
/// is given, current instance first then oldest CreatedAt first, and
/// renders the age as UtcNow - CreatedAt, so CreatedAt must be UTC.</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")]
[AllowAnonymous]
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 privates = pid == 0
? new List<Data.Entities.PrivateInstanceEntity>()
: await db.PrivateInstances
.Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid)
.ToListAsync();
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return Ok(Array.Empty<object>());
// Merge: presence rows are authoritative (player currently
// there); add any owned private instance the inviter created
// but nobody's joined yet so the inviter can still see it.
var byKey = new Dictionary<string, SimpleInstance>(StringComparer.Ordinal);
foreach (var (room, playerIds) in presenced)
var live = presence.EnumerateActiveInstances(roomId, includePrivate: true).ToList();
var privateIds = live.Where(l => l.Room.IsPrivate).Select(l => l.Room.RoomInstanceId).Distinct().ToList();
var privateRows = privateIds.Count == 0
? new Dictionary<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;
byKey[room.PhotonRoomId] = new SimpleInstance(
RoomInstanceId: room.RoomInstanceId,
RoomId: room.RoomId,
SubRoomId: room.SubRoomId,
IsFull: false,
CreatedAt: DateTime.UtcNow,
PlayerIds: playerIds.Select(p => (int)p).ToList(),
SubroomName: room.Name ?? string.Empty,
PlayerCount: playerIds.Count,
HasModPresent: false,
HashedInstanceId: room.PhotonRoomId);
}
foreach (var p in privates)
{
if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
byKey[p.PhotonRoomId] = new SimpleInstance(
RoomInstanceId: p.Id,
RoomId: p.RoomId,
SubRoomId: p.SubRoomId,
IsFull: false,
CreatedAt: DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
PlayerIds: new List<int>(),
SubroomName: p.Name,
PlayerCount: 0,
HasModPresent: false,
HashedInstanceId: p.PhotonRoomId);
privateRows.TryGetValue(inst.RoomInstanceId, out var privateRow);
if (inst.IsPrivate && !CanSeePrivateInstance(
pid, callerHasRole, playerIds, privateRow, invitedTo.Contains(inst.RoomInstanceId)))
continue;
var capacity = inst.MaxCapacity > 0 ? inst.MaxCapacity : privateRow?.MaxCapacity ?? 0;
rows.Add(new RoomInstanceRow(
RoomInstanceId: inst.RoomInstanceId,
RoomId: inst.RoomId,
SubRoomId: inst.SubRoomId,
IsFull: capacity > 0 && playerIds.Count >= capacity,
CreatedAt: privateRow is not null
? DateTime.SpecifyKind(privateRow.CreatedAt, DateTimeKind.Utc)
: DateTime.UtcNow,
PlayerIds: playerIds.Distinct().OrderBy(p => p).ToList()));
}
// Wire shape: SimpleRoomInstance.Deserialize reads CAMELCASE
// keys — verified at Cpp2IL_ISIL/.../RecNet/SimpleRoomInstance.txt
// lines 765-808 ({roomInstanceId, roomId, subRoomId, isFull,
// createdAt, playerIds}). SubroomName / PlayerCount /
// HasModPresent / HashedInstanceId are populated locally on the
// watch (PlayerIds.Count, local hash, etc.) — not read from
// JSON, so we don't send them. Earlier PascalCase response made
// every field default to 0/null on the watch, breaking the
// browser.
return Ok(byKey.Values.Select(i => new
{
roomInstanceId = i.RoomInstanceId,
roomId = i.RoomId,
subRoomId = i.SubRoomId,
isFull = i.IsFull,
createdAt = i.CreatedAt,
playerIds = i.PlayerIds.ToArray(),
}));
logger.LogInformation(
"[room-instances] room={Room} caller={Caller} live={Live} listed={Listed}",
roomId, pid, live.Count, rows.Count);
return Ok(rows.OrderBy(r => r.CreatedAt).Select(ToWire));
}
private sealed record SimpleInstance(
/// <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,
DateTime CreatedAt, List<int> PlayerIds, string SubroomName,
int PlayerCount, bool HasModPresent, string HashedInstanceId);
DateTime CreatedAt, List<long> PlayerIds);
/// <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
/// 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<RoomEntity> Rooms => Set<RoomEntity>();
public DbSet<RoomRoleEntity> RoomRoles => Set<RoomRoleEntity>();
public DbSet<RoomRolePermissionEntity> RoomRolePermissions => Set<RoomRolePermissionEntity>();
public DbSet<LeaderboardChannelMetaEntity> LeaderboardChannelMeta => Set<LeaderboardChannelMetaEntity>();
public DbSet<RoomBookmarkEntity> RoomBookmarks => Set<RoomBookmarkEntity>();
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();
});
// 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 =>
{
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");
});
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")
@@ -311,7 +311,7 @@ public class NotificationService(
{
["roomInstanceId"] = redacted.RoomInstanceId,
["roomId"] = redacted.RoomId,
["subRoomId"] = redacted.SubRoomId,
["subRoomId"] = redacted.WireSubRoomId,
["roomInstanceType"] = redacted.RoomInstanceType,
["location"] = redacted.Location,
["dataBlob"] = redacted.DataBlob,
@@ -105,6 +105,15 @@ public class PlayerPresenceService
var db = mux.GetDatabase();
db.StringSet(Key(playerId), json, PresenceTtl);
db.KeyDelete(NoRoomKey(playerId));
// Per-room membership index for the instance browser
// (EnumerateActiveInstances). Members are validated against
// the player's live presence on read, so a player who moved
// on is pruned lazily instead of needing the old room here.
if (room is not null && room.RoomId > 0)
{
db.SetAdd(RoomIndexKey(room.RoomId), playerId);
db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl);
}
_log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
playerId, room?.Name ?? "<null>", json.Length);
}
@@ -459,45 +468,97 @@ public class PlayerPresenceService
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
}
/// <summary>List the currently-active instances of a given room,
/// grouped by PhotonRoomId, with the player ids currently presenced
/// in each. Drives the watch's RoomInstanceBrowser
/// (<c>GET match.*/room/{id}/instances</c>) so players can see
/// which instance their friends are in.
/// <summary>List the live instances of a given room, grouped by
/// PhotonRoomId (or RoomInstanceId when a row has none), with the player
/// ids currently presenced in each. Drives the room instance browser
/// (<c>GET match.*/room/{id}/instances</c>) and the event instance list.
///
/// Redis caveat: without a secondary index per room we'd need a
/// SCAN over every presence key — too expensive to do on each
/// request. For now the Redis path returns empty and the local
/// in-process dictionary is the source of truth. Single-instance
/// deployments work; multi-replica Redis deployments need a
/// per-room set index to be added before the browser is reliable
/// there.</summary>
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(long roomId)
/// <para>Only players whose presence is still live count: an expired
/// room presence (<see cref="PresenceTtl"/>) is dropped, and so is a
/// player whose activity marker (<see cref="ActivityTtl"/>, refreshed by
/// every heartbeat and join) has lapsed. A client that crashed keeps its
/// room presence for up to ten minutes, but it is not in the instance and
/// must not inflate the player count or keep an empty instance listed.</para>
///
/// <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 groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
foreach (var kv in _local)
var members = new List<(long PlayerId, RoomInstanceDto Room)>();
if (_redis is { } mux)
{
var room = kv.Value;
if (room is null || room.RoomId != roomId) continue;
// Skip private instances — those are surfaced separately
// in the controller via PrivateInstanceService so we never
// leak a photonRoomId for an invite-only match into the
// public browser.
if (room.IsPrivate) continue;
var key = string.IsNullOrEmpty(room.PhotonRoomId)
? $"<{room.RoomInstanceId}>"
: room.PhotonRoomId;
try
{
var db = mux.GetDatabase();
var stale = new List<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);
foreach (var (playerId, room) in members)
{
if (room.IsPrivate && !includePrivate) continue;
if (!IsRecentlyActive(playerId)) continue;
var key = InstanceKey(room);
if (!groups.TryGetValue(key, out var entry))
{
entry = (room, new List<long>());
groups[key] = entry;
}
entry.PlayerIds.Add(kv.Key);
entry.PlayerIds.Add(playerId);
}
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 ActiveKey(long playerId) => $"presence-active:{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;
}
/// <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"/>
/// + <see cref="DormStateEntity"/> rows for every account that
/// pre-dates the auto-create-on-signup work. Idempotent — calls
@@ -1068,6 +1133,10 @@ public class RoomService(DorkNetDbContext db)
};
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();
return new CloneResult(0 /* Success */, clone);
}
@@ -1081,6 +1150,25 @@ public class RoomService(DorkNetDbContext db)
/// AllowsJuniors, RoomWarningMask, CustomRoomWarning
/// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute.
/// </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
{
RoomId = r.Id,
@@ -1093,7 +1181,7 @@ public class RoomService(DorkNetDbContext db)
Accessibility = r.Accessibility,
SupportsLevelVoting = r.SupportsLevelVoting,
IsAGRoom = r.IsAGRoom,
IsRRO = r.IsAGRoom,
IsRRO = WireIsRRO(r),
// 2020.12 Room.Deserialize (KLCOGEIGEBJ.PPGFHEDFBEA, the Room base)
// reads required keys: RoomId, IsDorm, CloningAllowed, DisableMicAutoMute,
// DisableRoomComments, EncryptVoiceChat. The 2020.03 key was IsDormRoom;
@@ -164,6 +164,11 @@ public static class DatabaseBootstrap
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir);
}
// Every non-dorm room needs a real, named entry sub-room (see
// RoomService.RepairRoomScenesAsync). Runs after the seed and the
// canonical overrides so it only fills what they left missing.
await roomService.RepairRoomScenesAsync();
// Seed default curated playlists, club category tags, store catalog.
// Must run after SeedAsync so they can reference RR-Originals.
var playlistService = scope.ServiceProvider.GetRequiredService<PlaylistService>();
@@ -899,6 +904,23 @@ public static class DatabaseBootstrap
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");",
// 2023 sub-room role-permission matrix
// (PUT rooms/{id}/subrooms/{sub}/permissions → photon_access_token).
"""
CREATE TABLE IF NOT EXISTS "RoomRolePermissions" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"RoomId" bigint NOT NULL,
"SubRoomId" bigint NOT NULL,
"Role" integer NOT NULL,
"Permission" character varying(128) NOT NULL DEFAULT '',
"Type" integer NOT NULL DEFAULT 0,
"Override" boolean NOT NULL DEFAULT false,
"Value" character varying(4096) NOT NULL DEFAULT '',
"UpdatedByPlayerId" bigint NOT NULL DEFAULT 0,
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission"" ON ""RoomRolePermissions"" (""RoomId"", ""SubRoomId"", ""Role"", ""Permission"");",
};
foreach (var sql in statements)
await db.Database.ExecuteSqlRawAsync(sql);
@@ -976,6 +998,21 @@ public static class DatabaseBootstrap
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");",
"""
CREATE TABLE IF NOT EXISTS "RoomRolePermissions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_RoomRolePermissions" PRIMARY KEY AUTOINCREMENT,
"RoomId" INTEGER NOT NULL,
"SubRoomId" INTEGER NOT NULL,
"Role" INTEGER NOT NULL,
"Permission" TEXT NOT NULL DEFAULT '',
"Type" INTEGER NOT NULL DEFAULT 0,
"Override" INTEGER NOT NULL DEFAULT 0,
"Value" TEXT NOT NULL DEFAULT '',
"UpdatedByPlayerId" INTEGER NOT NULL DEFAULT 0,
"UpdatedAt" TEXT NOT NULL DEFAULT ''
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission"" ON ""RoomRolePermissions"" (""RoomId"", ""SubRoomId"", ""Role"", ""Permission"");",
};
// CREATE ... IF NOT EXISTS is already idempotent; a failure here is a
// real problem and must fail startup.