using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Data; using System.IO; using System.Text.Json; using System.Text.Json.Nodes; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Enums.Moderation; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.RecNet.Notifications; using RadicadeServer.RecNet.Progression; using RadicadeServer.RecNet.Responses; using RadicadeServer.RecNet.Rooms; using RadicadeServer.Utils; namespace RadicadeServer.Admin { [Route("admin/rooms")] [EnableCors("AdminPanel")] public class AdminRoomAPI(RecDBContext db) : Controller { private Task RejectInvalidKey(string key) { return Task.FromResult(key.IsAdminKey() ? null : Unauthorized("Invalid secret")); } [HttpGet("room/{roomId}")] public async Task GetRoom([FromQuery] string key, long roomId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await RoomUtils.GetRoom(roomId); if (room == null) return NotFound(); return Ok(room); } [HttpPost("create/room")] public async Task CreateRoom([FromQuery] string key, [FromQuery] long newOwner, [FromBody] JsonNode fullRoom) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var roomData = new RoomData { EncryptVoiceChat = fullRoom.Get("EncryptVoiceChat", false), Accessibility = fullRoom.Get("Accessibility", Accessibility.Private), CloningAllowed = fullRoom.Get("CloningAllowed", false), CreatedAt = fullRoom["CreatedAt"]?.GetValue() ?? DateTime.UtcNow, CreatorAccountId = newOwner, CustomWarning = fullRoom.Get("CustomWarning", ""), Description = fullRoom.Get("Description", ""), DisableMicAutoMute = fullRoom.Get("DisableMicAutoMute", false), DisableRoomComments = fullRoom.Get("DisableRoomComments", false), ImageName = fullRoom.Get("ImageName", ""), IsDorm = fullRoom.Get("IsDorm", false), IsRRO = fullRoom.Get("IsRRO", false), IsDeveloperOwned = fullRoom["IsDeveloperOwned"] != null ? fullRoom.Get("IsDeveloperOwned", false) : await db.IsDeveloper(newOwner), loadScreen = fullRoom["LoadScreens"]?.ToJson() ?? "[]", MaxPlayerCalculationMode = fullRoom.Get("MaxPlayerCalculationMode", MaxPlayerMode.AllSubrooms), MaxPlayers = fullRoom.Get("MaxPlayers", 10), MinLevel = fullRoom.Get("MinLevel", 0), Name = fullRoom.Get("Name", ""), State = fullRoom.Get("State", RoomState.Active), SupportsJuniors = fullRoom.Get("SupportsJuniors", false), SupportsLevelVoting = fullRoom.Get("SupportsLevelVoting", false), SupportsMobile = fullRoom.Get("SupportsMobile", false), SupportsQuest2 = fullRoom.Get("SupportsQuest2", false), SupportsScreens = fullRoom.Get("SupportsScreens", false), SupportsTeleportVR = fullRoom.Get("SupportsTeleportVR", false), SupportsVRLow = fullRoom.Get("SupportsVRLow", false), SupportsWalkVR = fullRoom.Get("SupportsWalkVR", false), Tags = StringTools.CreateTagString(fullRoom.Get>("Tags", [])), Version = 1, WarningMask = fullRoom.Get("WarningMask", WarningMask.None) }; await db.Rooms.AddAsync(roomData); await db.SaveChangesAsync(); foreach (var subRoom in fullRoom["SubRooms"]?.AsArray() ?? new JsonArray()) { if (subRoom == null) continue; var subRoomSceneId = subRoom["UnitySceneId"]?.ToString() ?? SceneLocations.MakerRoom; if (!SceneLocations.IsValid(subRoomSceneId)) subRoomSceneId = SceneLocations.MakerRoom; // A new room's subroom can arrive with its Unity bundle already uploaded, // named exactly as it was imported (see UploadRoom). var bundleName = subRoom["UnityAssetId"]?.ToString(); if (!string.IsNullOrWhiteSpace(bundleName)) { if (!AdminUtils.TryResolveBundleName(bundleName, out var resolvedBundle, out var bundleError)) return BadRequest(bundleError); bundleName = resolvedBundle; } await db.AddAsync(new SubRoom { Accessibility = (Accessibility)(subRoom["Accessibility"]?.GetValue() ?? 0), CreatedAt = subRoom["CreatedAt"]?.GetValue() ?? DateTime.UtcNow, SavedByAccountId = newOwner, IsSandbox = subRoom["IsSandbox"]?.GetValue() ?? false, MaxPlayers = subRoom["MaxPlayers"]?.GetValue() ?? 0, Name = subRoom["Name"]?.ToString() ?? "", RoomId = roomData.RoomId, UnitySceneId = subRoomSceneId, DataBlob = subRoom["DataBlob"]?.ToString() ?? "", UnityAssetId = bundleName ?? "", }); } await db.RoomRoles.AddAsync(new RoomRole { AccountId = newOwner, InvitedRole = RoleType.None, Role = RoleType.Creator, RoomId = roomData.RoomId }); await db.SaveChangesAsync(); return Ok($"Created as {roomData.RoomId}!"); } [HttpGet("rename/room")] public async Task RenameRoom([FromQuery] string key, [FromQuery] long roomId, [FromQuery] string newName) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); room.Name = newName; await db.SaveChangesAsync(); return Ok($"Changed to ^{newName}!"); } [HttpGet("delete/room")] public async Task DeleteRoom([FromQuery] string key, [FromQuery] long roomId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); if (room.IsRRO || room.IsDorm || room.CreatorAccountId == 1) return Ok("You cannot delete RROs."); db.Rooms.Remove(room); await db.SaveChangesAsync(); return Ok($"Deleted."); } [HttpGet("set/datablob")] public async Task SetDatablob([FromQuery] string key, [FromQuery] long roomId, [FromQuery] string subRoomName, [FromQuery] string datablobName) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId && x.Name == subRoomName); if (subRoom == null) return NotFound("Subroom not found."); subRoom.DataBlob = datablobName; await db.SaveChangesAsync(); return Ok($"Set datablob to {datablobName} for {subRoom.Name}!"); } // The other half of importing a subroom: its .assetbundle. The file name is // the asset id - RoomServerAPI serves /rooms/unity_assets/ out of the CDN // by that name - so binding means pointing the subroom at an uploaded bundle, // and an empty name clears it again. BindAssetBundle below is the narrower, // Studio-side counterpart for 40-character manifest ids; this one takes the // '.assetbundle' files an import actually produces. [HttpGet("set/bundle")] public async Task SetBundle([FromQuery] string key, [FromQuery] long roomId, [FromQuery] string subRoomName, [FromQuery] string bundleName) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId && x.Name == subRoomName); if (subRoom == null) return NotFound("Subroom not found."); if (string.IsNullOrWhiteSpace(bundleName)) { subRoom.UnityAssetId = ""; await db.SaveChangesAsync(); return Ok($"Cleared the Unity bundle for {subRoom.Name}!"); } if (!AdminUtils.TryResolveBundleName(bundleName, out var resolvedBundle, out var bundleError)) return BadRequest(bundleError); subRoom.UnityAssetId = resolvedBundle ?? ""; await db.SaveChangesAsync(); return Ok($"Set bundle to {subRoom.UnityAssetId} for {subRoom.Name}!"); } [HttpPost("create/subroom")] public async Task CreateSubRoom([FromQuery] string key, [FromQuery] long roomId, [FromBody] JsonNode subRoom) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); var name = (subRoom.Get("Name", "") ?? "").Trim(); if (string.IsNullOrWhiteSpace(name)) return BadRequest("Subroom name is required."); if (await db.SubRooms.AnyAsync(x => x.RoomId == roomId && x.Name == name)) return BadRequest($"A subroom named \"{name}\" already exists in this room."); // The Unity bundle the subroom loads, if one was imported alongside the save. var bundleName = subRoom.Get("UnityAssetId", "") ?? ""; if (!string.IsNullOrWhiteSpace(bundleName)) { if (!AdminUtils.TryResolveBundleName(bundleName, out var resolvedBundle, out var bundleError)) return BadRequest(bundleError); bundleName = resolvedBundle ?? ""; } var mainSubRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId); var maxPlayers = subRoom.Get("MaxPlayers", mainSubRoom?.MaxPlayers ?? 10); if (maxPlayers <= 0) maxPlayers = mainSubRoom?.MaxPlayers ?? 10; var unitySceneId = subRoom.Get("UnitySceneId", ""); if (string.IsNullOrWhiteSpace(unitySceneId)) unitySceneId = mainSubRoom?.UnitySceneId ?? "a75f7547-79eb-47c6-8986-6767abcb4f92"; var dbSubRoom = new SubRoom { Accessibility = (Accessibility)(subRoom["Accessibility"]?.GetValue() ?? (int)Accessibility.Public), CreatedAt = subRoom.Get("CreatedAt", DateTime.UtcNow), SavedByAccountId = room.CreatorAccountId, IsSandbox = subRoom.Get("IsSandbox", true), MaxPlayers = maxPlayers, Name = name, RoomId = roomId, UnitySceneId = unitySceneId, DataBlob = subRoom.Get("DataBlob", ""), UnityAssetId = bundleName, }; await db.AddAsync(dbSubRoom); await db.SaveChangesAsync(); return Ok($"Created as {dbSubRoom.SubRoomId}!"); } [HttpGet("rename/subroom")] public async Task RenameSubRoom([FromQuery] string key, [FromQuery] long roomId, [FromQuery] long subRoomId, [FromQuery] string newName) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId); if (subRoom == null) return NotFound("Subroom not found."); newName = (newName ?? "").Trim(); if (string.IsNullOrWhiteSpace(newName)) return BadRequest("Subroom name is required."); if (await db.SubRooms.AnyAsync(x => x.RoomId == roomId && x.SubRoomId != subRoomId && x.Name == newName)) return BadRequest($"A subroom named \"{newName}\" already exists in this room."); subRoom.Name = newName; await db.SaveChangesAsync(); return Ok($"Renamed to {newName}!"); } [HttpGet("delete/subroom")] public async Task DeleteSubRoom([FromQuery] string key, [FromQuery] long roomId, [FromQuery] long subRoomId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId); if (subRoom == null) return NotFound("Subroom not found."); var subroomCount = await db.SubRooms.CountAsync(x => x.RoomId == roomId); if (subroomCount <= 1) return BadRequest("A room must keep at least one subroom."); var history = await db.SubRoomHistory.Where(x => x.RoomId == roomId && x.SubRoomId == subRoomId).ToListAsync(); foreach (var historyItem in history) db.SubRoomHistory.Remove(historyItem); db.SubRooms.Remove(subRoom); await db.SaveChangesAsync(); return Ok($"Deleted {subRoom.Name}!"); } [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/assetbundle")] public async Task BindAssetBundle([FromQuery] string key, long roomId, long subRoomId, [FromBody] string assetId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (!ModelState.IsValid || !RadicadeServer.API.Studio.StudioUnityAssetRegistry.IsValidAssetId(assetId)) return BadRequest("Expected a 40-character Unity asset ID."); var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId && x.SubRoomId == subRoomId); if (subRoom == null) return NotFound("Subroom not found in this room."); if (!System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, assetId + ".assetbundle"))) return BadRequest("Upload the bundle as .assetbundle before linking it."); if (System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, assetId))) return BadRequest("This ID belongs to a Studio manifest; choose an ID for the archived bundle."); var previousAssetId = subRoom.UnityAssetId; subRoom.UnityAssetId = assetId; await db.SaveChangesAsync(); return Ok(new { RoomId = roomId, SubRoomId = subRoomId, PreviousUnityAssetId = previousAssetId, UnityAssetId = assetId }); } [HttpPost("upload/datablob")] [RequestSizeLimit(256L * 1024 * 1024)] public async Task UploadDatablob([FromQuery] string key, [FromQuery] string name, [FromForm] IFormFile file) { if (!ModelState.IsValid) return BadRequest("Invalid upload request. Use a multipart form no larger than 256 MiB."); var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var path = Path.Join(DBConfig.CDNStorage, name); using (var stream = new FileStream(path, FileMode.Create)) { await file.CopyToAsync(stream); } return Ok($"Uploaded {name}!\nURL: {DBConfig.ServerUrl}/cdn/room/{name}"); } [HttpPost("upload/image")] public async Task UploadImage([FromQuery] string key, [FromQuery] long roomId, [FromForm] IFormFile file) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); if (file == null || file.Length == 0) return BadRequest("No file provided."); var allowed = new[] { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp" }; var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (string.IsNullOrEmpty(ext) || !allowed.Contains(ext)) return BadRequest($"Unsupported image format '{(string.IsNullOrEmpty(ext) ? "no extension" : ext)}'. Use png/jpg/jpeg/gif/webp."); string guid = Guid.NewGuid().ToString(); var path = Path.Join(DBConfig.CDNImages, guid + ext); using (var stream = new FileStream(path, FileMode.Create)) { await file.CopyToAsync(stream); } room.ImageName = guid + ext; await db.SaveChangesAsync(); return Ok($"Uploaded {guid}{ext}!\nURL: {DBConfig.ServerUrl}/images/img/{guid}{ext}"); } [HttpGet("set/image")] public async Task SetImage([FromQuery] string key, [FromQuery] long roomId, [FromQuery] string imgName) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); imgName = Path.GetFileName(imgName ?? "").Trim(); room.ImageName = imgName; await db.SaveChangesAsync(); return Ok($"Set to {imgName}!\nURL: {DBConfig.ServerUrl}/images/img/{imgName}"); } [HttpGet("set/role/{roleType}")] public async Task SetRoleOfUser([FromQuery] string key, [FromQuery] long roomId, [FromQuery] long target, RoleType roleType) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var existingRole = await db.RoomRoles.FirstOrDefaultAsync(x => x.AccountId == target && x.RoomId == roomId); if (existingRole == null) { var newRole = new RoomRole { AccountId = target, InvitedRole = RoleType.None, Role = roleType, RoomId = roomId }; await db.RoomRoles.AddAsync(newRole); } else { if (existingRole.Role == roleType) return Ok($"Player already has {Enum.GetName(roleType)}."); existingRole.Role = roleType; } await db.SaveChangesAsync(); try { var updatedRoom = await RoomUtils.GetRoom(roomId); if (updatedRoom != null) await Notify.SendNotify(target, new Notification { Id = "RoomUpdate", Msg = updatedRoom }); } catch (Exception) { } return Ok($"Set to {Enum.GetName(roleType)}!"); } [HttpGet("set/rro")] public async Task SetRRO([FromQuery] string key, [FromQuery] long roomId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); room.IsRRO = true; var tags = StringTools.ReadTags(room.Tags); if (tags.FirstOrDefault(x => x.Tag == "rro" && x.Type == TagType.Auto) == null) tags.Add(new ItemTag { Type = TagType.Auto, Tag = "rro" }); room.Tags = StringTools.CreateTagString(tags); await db.SaveChangesAsync(); return Ok($"Set to RRO {room.Name}!"); } [HttpGet("remove/rro")] public async Task RemoveRRO([FromQuery] string key, [FromQuery] long roomId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); room.IsRRO = false; var tags = StringTools.ReadTags(room.Tags); var existingRROTag = tags.FirstOrDefault(x => x.Tag == "rro" && x.Type == TagType.Auto); if (existingRROTag != null) tags.Remove(existingRROTag); room.Tags = StringTools.CreateTagString(tags); await db.SaveChangesAsync(); return Ok($"Removed RRO from {room.Name}!"); } [HttpGet("fulldelete/room")] public async Task FullyDeleteRoom([FromQuery] string key, [FromQuery] long roomId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); if (room.IsRRO || room.IsDorm || room.CreatorAccountId == 1) return Ok("You cannot delete RROs."); var roles = await db.RoomRoles.Where(x => x.RoomId == roomId).ToListAsync(); foreach (var role in roles) { db.RoomRoles.Remove(role); } var interactions = await db.RoomInteractions.Where(x => x.RoomId == roomId).ToListAsync(); foreach (var i in interactions) { db.RoomInteractions.Remove(i); } var keys = await db.RoomKeys.Where(x => x.RoomId == roomId).ToListAsync(); foreach (var k in keys) { db.RoomKeys.Remove(k); } var stats = await db.RoomStats.FirstOrDefaultAsync(x => x.RoomId == roomId); if (stats != null) db.RoomStats.Remove(stats); var subrooms = await db.SubRooms.Where(x => x.RoomId == roomId).ToListAsync(); foreach (var subroom in subrooms) { db.SubRooms.Remove(subroom); } var prevSubrooms = await db.SubRoomHistory.Where(x => x.RoomId == roomId).ToListAsync(); foreach (var subroom in prevSubrooms) { db.SubRoomHistory.Remove(subroom); } await db.SaveChangesAsync(); return Ok("Deleted."); } [HttpGet("set/owner")] public async Task SetRoomOwner([FromQuery] string key, [FromQuery] long roomId, [FromQuery] long target) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); long oldOwnerId = room.CreatorAccountId; room.CreatorAccountId = target; var existingRole = await db.RoomRoles.FirstOrDefaultAsync(x => x.AccountId == target && x.RoomId == roomId); if (existingRole == null) { var newRole = new RoomRole { AccountId = target, InvitedRole = RoleType.None, Role = RoleType.Creator, RoomId = roomId }; await db.RoomRoles.AddAsync(newRole); } else { existingRole.Role = RoleType.Creator; } if (oldOwnerId != target) { var oldOwnerRole = await db.RoomRoles.FirstOrDefaultAsync(x => x.AccountId == oldOwnerId && x.RoomId == roomId); if (oldOwnerRole != null && oldOwnerRole.Role == RoleType.Creator) oldOwnerRole.Role = RoleType.CoOwner; } await db.SaveChangesAsync(); return Ok($"Set to owner!"); } [HttpGet("instances/live")] public async Task GetLiveRoomInstances([FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var instances = await db.RoomInstances.ToListAsync(); var roomIds = instances.Select(i => i.roomId).Distinct().ToList(); var rooms = roomIds.Count > 0 ? await db.Rooms.Where(r => roomIds.Contains(r.RoomId)).ToDictionaryAsync(r => r.RoomId) : new Dictionary(); var presences = await db.Presences.Where(p => p.isOnline && p.roomInstance != null).ToListAsync(); var profileIds = presences.Select(p => p.playerId).Distinct().ToList(); var profiles = profileIds.Count > 0 ? await db.Profiles.Where(x => profileIds.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId) : new Dictionary(); var result = instances.Select(instance => { var players = presences .Where(p => p.roomInstance == instance.roomInstanceId) .Select(p => new { player_id = p.playerId, name = profiles.TryGetValue(p.playerId, out var profile) ? profile.displayName : $"Player {p.playerId}" }) .OrderBy(p => p.player_id) .ToList(); return new { instance_id = instance.roomInstanceId, room_id = instance.roomId, room_name = rooms.TryGetValue(instance.roomId, out var room) ? room.Name : "Unknown Room", sub_room_id = instance.subRoomId, event_id = instance.eventId, instance_type = instance.roomInstanceType.ToString(), location = instance.location ?? "", room_code = instance.roomCode ?? "", photon_room_id = instance.photonRoomId, is_private = instance.isPrivate, is_in_progress = instance.isInProgress, is_full = instance.isFull, max_capacity = instance.maxCapacity, player_count = players.Count, players }; }) .OrderByDescending(i => i.player_count) .ToList(); return Ok(new { count = result.Count, instances = result }); } [HttpGet("instances/{instanceId:long}")] public async Task GetLiveInstanceDetail(long instanceId, [FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var instance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == instanceId); if (instance == null) return NotFound("Instance not found."); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == instance.roomId); var subrooms = await db.SubRooms.Where(s => s.RoomId == instance.roomId).ToListAsync(); var presences = await db.Presences.Where(p => p.isOnline && p.roomInstance == instanceId).ToListAsync(); var profileIds = presences.Select(p => p.playerId).Distinct().ToList(); var profiles = profileIds.Count > 0 ? await db.Profiles.Where(x => profileIds.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId) : new Dictionary(); var playerData = profileIds.Count > 0 ? await db.Players.Where(x => profileIds.Contains(x.AccountId)).ToDictionaryAsync(x => x.AccountId) : new Dictionary(); var progression = profileIds.Count > 0 ? await db.Progression.Where(x => profileIds.Contains(x.PlayerId)).ToDictionaryAsync(x => x.PlayerId) : new Dictionary(); var playerRows = presences.Select(p => { profiles.TryGetValue(p.playerId, out var profile); playerData.TryGetValue(p.playerId, out var player); progression.TryGetValue(p.playerId, out var progress); return new { player_id = p.playerId, name = profile?.displayName ?? $"Player {p.playerId}", username = profile?.username ?? "", level = progress?.Level ?? 0, device_class = p.deviceClass.ToString(), is_online = p.isOnline }; }).OrderBy(p => p.player_id).ToList(); return Ok(new { instance = new { instance_id = instance.roomInstanceId, room_id = instance.roomId, room_name = room?.Name ?? "Unknown Room", sub_room_id = instance.subRoomId, instance_type = instance.roomInstanceType.ToString(), location = instance.location ?? "", room_code = instance.roomCode ?? "", is_private = instance.isPrivate, is_in_progress = instance.isInProgress, is_full = instance.isFull, max_capacity = instance.maxCapacity, player_count = playerRows.Count }, players = playerRows, subrooms = subrooms.Select(s => new { sub_room_id = s.SubRoomId, name = s.Name, max_players = s.MaxPlayers }).ToList() }); } [HttpPost("instances/{instanceId:long}/kick")] public async Task KickFromInstance(long instanceId, [FromQuery] string key, [FromQuery] long playerId, [FromQuery] string? reason = null) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var instance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == instanceId); if (instance == null) return NotFound("Instance not found."); var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId); if (presence == null || presence.roomInstance != instanceId) return NotFound("Player is not in this instance."); var dorm = await RoomUtils.GetOrCreateDorm(playerId); RoomInstance? dormInstance = null; if (dorm != null) dormInstance = await MatchmakingUtils.CreateRoomInstance(dorm.RoomId, true, null, null, null); if (dormInstance == null) return StatusCode(500, "Could not resolve a dorm instance for this player."); presence.roomInstance = dormInstance.roomInstanceId; presence.errorCode = JoinStatus.Success; presence.isOnline = true; MatchmakingUtils.MarkInRoom(playerId); await RecomputeInstanceCapacity(instance); await db.SaveChangesAsync(); try { await Notify.SendNotify(playerId, new Notification { Id = NotificationType.ModerationKick, Msg = PlayerUtils.BuildBlockDetails(false, ReportCategory.Misc, reason ?? "Kicked from room", null, isHostKick: true) }); } catch (Exception) { } try { await Notify.SendNotify(playerId, new Notification { Id = "PresenceUpdate", Msg = await Presence.CreateAsync(db, presence, dormInstance) }); } catch (Exception) { } var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId); return Ok(new { player_id = playerId, name = profile?.displayName ?? $"Player {playerId}", kicked = true, instance_id = instanceId }); } [HttpPost("instances/{instanceId:long}/move")] public async Task MovePlayerToSubRoom(long instanceId, [FromQuery] string key, [FromQuery] long playerId, [FromQuery] long toSubRoomId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var instance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == instanceId); if (instance == null) return NotFound("Instance not found."); var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId); if (presence == null || presence.roomInstance != instanceId) return NotFound("Player is not in this instance."); var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == instance.roomId && x.SubRoomId == toSubRoomId); if (subRoom == null) return BadRequest("Target subroom does not belong to this room."); if (instance.subRoomId == toSubRoomId) return Ok(new { player_id = playerId, already_in_subroom = true, instance_id = instanceId }); var targetInstance = await db.RoomInstances .FirstOrDefaultAsync(x => x.roomId == instance.roomId && x.subRoomId == toSubRoomId && !x.isFull); bool created = false; if (targetInstance == null) { targetInstance = await MatchmakingUtils.CreateRoomInstance(instance.roomId, instance.isPrivate, toSubRoomId, instance.clubId, instance.eventId); if (targetInstance == null) return StatusCode(500, "Could not create the target subroom instance."); db.RoomInstances.Attach(targetInstance); created = true; } presence.roomInstance = targetInstance.roomInstanceId; presence.errorCode = JoinStatus.Success; presence.isOnline = true; MatchmakingUtils.MarkInRoom(playerId); await RecomputeInstanceCapacity(instance); await RecomputeInstanceCapacity(targetInstance); await db.SaveChangesAsync(); try { await Notify.SendNotify(playerId, new Notification { Id = "PresenceUpdate", Msg = await Presence.CreateAsync(db, presence, targetInstance) }); } catch (Exception) { } var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId); return Ok(new { player_id = playerId, name = profile?.displayName ?? $"Player {playerId}", instance_id = targetInstance.roomInstanceId, sub_room_id = toSubRoomId, created }); } private async Task RecomputeInstanceCapacity(RoomInstance instance) { var count = await MatchmakingUtils.GetPlayerCountInInstance(instance.roomInstanceId); instance.isFull = count >= instance.maxCapacity; } [HttpGet("roles")] public async Task GetRoomRoles([FromQuery] string key, [FromQuery] long roomId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); var roles = await db.RoomRoles.Where(x => x.RoomId == roomId).ToListAsync(); var accountIds = roles.Select(r => r.AccountId).Distinct().ToList(); var profiles = accountIds.Count > 0 ? await db.Profiles.Where(x => accountIds.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId) : new Dictionary(); string? NameOf(long id) => profiles.TryGetValue(id, out var p) ? $"{p.displayName} (@{p.username})" : id.ToString(); var result = roles.Select(r => new { account_id = r.AccountId, name = NameOf(r.AccountId), role = r.Role.ToString(), role_value = (int)r.Role, invited_role = r.InvitedRole.ToString(), is_creator = r.AccountId == room.CreatorAccountId || r.Role == RoleType.Creator }).ToList(); if (!result.Any(x => x.account_id == room.CreatorAccountId)) { result.Insert(0, new { account_id = room.CreatorAccountId, name = NameOf(room.CreatorAccountId), role = "Creator", role_value = (int)RoleType.Creator, invited_role = "None", is_creator = true }); } return Ok(new { room_id = roomId, creator_account_id = room.CreatorAccountId, roles = result }); } [HttpGet("room/{roomId}/bans")] public async Task GetRoomBans([FromQuery] string key, long roomId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); var bans = await db.RoomBans .Where(x => x.roomId == roomId && (!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow)) .OrderByDescending(x => x.bannedAt) .ToListAsync(); var ids = bans.Select(b => b.bannedPlayerId).Concat(bans.Select(b => b.actorId)).Distinct().ToList(); var profiles = ids.Count > 0 ? await db.Profiles.AsNoTracking().Where(x => ids.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId) : new Dictionary(); string? NameOf(long id) => profiles.TryGetValue(id, out var p) ? p.displayName : null; var result = bans.Select(b => new { ban_id = b.BanId, room_id = b.roomId, player_id = b.bannedPlayerId, player_name = NameOf(b.bannedPlayerId), banned_by_id = b.actorId, banned_by_name = string.IsNullOrWhiteSpace(b.bannedByName) ? (NameOf(b.actorId) ?? $"Player {b.actorId}") : b.bannedByName, reason = b.reason, banned_at = b.bannedAt, expires_at = b.expiresAt }).ToList(); return Ok(new { room_id = roomId, room_name = room.Name, count = result.Count, bans = result }); } [HttpPost("room/{roomId}/bans")] public async Task BanPlayerFromRoom([FromQuery] string key, long roomId, [FromBody] JsonNode body) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); long playerId = body["player_id"]?.GetValue() ?? 0; if (playerId <= 0) return BadRequest("player_id is required."); var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId); if (profile == null) return NotFound("Player does not exist."); var existing = await db.RoomBans.FirstOrDefaultAsync(x => x.roomId == roomId && x.bannedPlayerId == playerId && (!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow)); if (existing != null) return Ok($"Player is already banned from this room."); var reason = body["reason"]?.GetValue()?.Trim() ?? ""; var ban = new RoomBan { roomId = roomId, actorId = 1, bannedPlayerId = playerId, bannedByName = "Admin", reason = reason, bannedAt = DateTime.UtcNow, expiresAt = null }; await db.RoomBans.AddAsync(ban); await db.SaveChangesAsync(); await MatchmakingUtils.KickPlayerFromRoom(playerId, roomId, string.IsNullOrWhiteSpace(reason) ? "You were banned from this room." : $"You were banned from this room: {reason}"); return Ok(new { ban_id = ban.BanId, room_id = roomId, player_id = playerId, player_name = profile.displayName, reason = reason, banned_at = ban.bannedAt }); } [HttpDelete("room/{roomId}/bans/{playerId}")] public async Task UnbanPlayerFromRoom([FromQuery] string key, long roomId, long playerId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); var ban = await db.RoomBans.FirstOrDefaultAsync(x => x.roomId == roomId && x.bannedPlayerId == playerId); if (ban == null) return NotFound("Player is not banned from this room."); db.RoomBans.Remove(ban); await db.SaveChangesAsync(); return Ok($"Ban lifted for player {playerId}."); } } }