diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index a765c78..081839a 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -1020,7 +1020,42 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase }; db.Rooms.Add(clone); await db.SaveChangesAsync(); - return Ok(Services.RoomService.ToWireRoom(clone)); + + // Copy the source's scenes (subrooms) so the clone is structurally + // complete. Without this the clone has zero subrooms and the client's + // room reader rejects it. Shallow copy — scenes point at the same data + // blobs (copy-on-write happens when the clone is later saved). + var sourceScenes = await db.RoomScenes + .Where(s => s.RoomId == source.Id) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + var clonedScenes = sourceScenes.Select(s => new RoomSceneEntity + { + RoomId = clone.Id, + OrderIndex = s.OrderIndex, + Name = s.Name, + RoomSceneLocationId = s.RoomSceneLocationId, + DataBlobName = s.DataBlobName, + StudioSubRoomDataSaveId = s.StudioSubRoomDataSaveId, + StudioUnityAssetId = s.StudioUnityAssetId, + StudioAssetBundleNamesCsv = s.StudioAssetBundleNamesCsv, + MaxPlayers = s.MaxPlayers, + IsSandbox = s.IsSandbox, + CanMatchmakeInto = s.CanMatchmakeInto, + DataModifiedAt = DateTime.UtcNow, + }).ToList(); + if (clonedScenes.Count > 0) + { + db.RoomScenes.AddRange(clonedScenes); + await db.SaveChangesAsync(); + } + + // The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN) + // deserializes the response as the FULL room-details object + // (FGCPNAACHIK — the same type get-by-id / rename return), NOT the + // slim list-room shape. Returning ToWireRoom here made the strict + // reader fail → "Failed to copy room: Failed to clone room". + return Ok(RoomsController.BuildRoomDetails(clone, clonedScenes)); } public sealed class CloneBareRequest { public string? Name { get; set; } } diff --git a/DorkNet.Server/Controllers/Cdn/CdnController.cs b/DorkNet.Server/Controllers/Cdn/CdnController.cs index 490f60b..8577d9d 100644 --- a/DorkNet.Server/Controllers/Cdn/CdnController.cs +++ b/DorkNet.Server/Controllers/Cdn/CdnController.cs @@ -551,25 +551,29 @@ public class CdnController( { if (string.IsNullOrWhiteSpace(fileName)) return false; + // MakerPen across ALL Rec Room Originals: overlay the permissive + // room-role data (which grants CanUseMakerPen etc.) onto any AG/RRO + // room's data blob. This used to also require an accepted RoomRole, + // which limited MakerPen to hand-role-granted rooms (Stunt Runner, + // Rec Center). Dropping that requirement gives every RRO editing + // rights. Chips are preserved because the overlay only REPLACES the + // blob's RoomRoleData field (RoomDataBlobService.OverlayRroEditable- + // RoleData) — the game-rule circuit data and GameRoleData are left + // intact — so quest rooms keep working, exactly as Stunt Runner does. if (await db.Rooms.AsNoTracking().AnyAsync(r => - r.IsAGRoom && - r.CurrentDataBlobName == fileName && - db.RoomRoles.Any(rr => rr.RoomId == r.Id && rr.Accepted))) + r.IsAGRoom && r.CurrentDataBlobName == fileName)) return true; if (fileName.StartsWith("room_", StringComparison.OrdinalIgnoreCase) && fileName.EndsWith("_v1.dat", StringComparison.OrdinalIgnoreCase) && long.TryParse(fileName[5..^7], out var roomId) && - await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom) && - await db.RoomRoles.AsNoTracking().AnyAsync(r => r.RoomId == roomId && r.Accepted)) + await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom)) return true; return await ( from scene in db.RoomScenes.AsNoTracking() join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id - where room.IsAGRoom && - scene.DataBlobName == fileName && - db.RoomRoles.Any(rr => rr.RoomId == room.Id && rr.Accepted) + where room.IsAGRoom && scene.DataBlobName == fileName select scene.Id ).AnyAsync(); }