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