mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
@@ -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<BNLPBMJJOMM>)</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.<LocalRequestModifyRolePermission>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<EFHPLDPNGIM></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<FGCPNAACHIK>) to everyone currently
|
||||
/// in the room plus the players whose view changed, so the role list,
|
||||
/// the role-gated buttons and the local player's own role refresh
|
||||
/// without leaving the room.</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<SimpleRoomInstance></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<PNDCMIMEJLD></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
|
||||
|
||||
Reference in New Issue
Block a user