Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
namespace RadicadeServer.RecNet.Rooms
{
public class CloneRequest
{
public string Name { get; set; } = null!;
public long RoomId { get; set; }
}
}
+18
View File
@@ -0,0 +1,18 @@
using RadicadeServer.RecNet.Enums.Rooms;
namespace RadicadeServer.RecNet.Rooms
{
public class ActualRoomRole
{
public long AccountId { get; set; }
public RoleType InvitedRole { get; set; }
public RoleType Role { get; set; }
public ActualRoomRole(RoomRole role)
{
AccountId = role.AccountId;
InvitedRole = role.InvitedRole;
Role = role.Role;
}
}
}
+828
View File
@@ -0,0 +1,828 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.RecNet;
using RadicadeServer.RecNet.Config;
using RadicadeServer.RecNet.Enums;
using RadicadeServer.RecNet.Enums.Rooms;
using RadicadeServer.RecNet.Rooms;
namespace RadicadeServer.Utils
{
public static class DefaultRooms
{
private const string AlignmentKey = "DefaultRooms.AlignmentVersion";
private const int AlignmentVersion = 1;
private const string RroCorrectionKey = "DefaultRooms.RroCorrectionVersion";
private const string RecCenterRepairKey = "DefaultRooms.RecCenterRepairVersion";
private const int RecCenterRepairVersion = 1;
private const string RecCenterRepairRoom = "RecCenter";
private const string CloningCorrectionKey = "DefaultRooms.NoCloneVersion";
private const int CloningCorrectionVersion = 1;
private const int RroCorrectionVersion = 2;
private const string ShippedRoomResetKey = "DefaultRooms.ShippedRoomResetVersion";
private const int ShippedRoomResetVersion = 3;
public static IReadOnlyList<DefaultRoom> All { get; } = new List<DefaultRoom>
{
new DefaultRoom
{
Name = "Orientation", Description = "An introductory tour of Rec Room!",
MaxPlayers = 1, SceneId = "c79709d8-a31b-48aa-9eb8-cc31ba9505e8",
Tags = Rro(), CloningAllowed = false, DisableRoomComments = true
},
new DefaultRoom
{
Name = "RecCenter", Description = "A social hub to meet and mingle with friends new and old.",
MaxPlayers = 12, SceneId = "cbad71af-0831-44d8-b8ef-69edafa841f6",
Tags = Rro(), CloningAllowed = false
},
new DefaultRoom
{
Name = "TheRiseofJumbotron", Description = "Robot invaders threaten the galaxy! Team up with your friends and bring the laser heat!",
MaxPlayers = 4, SceneId = "acc06e66-c2d0-4361-b0cd-46246a4c455c",
Tags = RroWith("quest")
},
new DefaultRoom
{
Name = "GoldenTrophy", Description = "The goblin king stole Coach's Golden Trophy. Team up and embark on an epic quest to recover it!",
MaxPlayers = 4, SceneId = "91e16e35-f48f-4700-ab8a-a1b79e50e51b",
Tags = RroWith("quest")
},
new DefaultRoom
{
Name = "Crescendo", Description = "Gather your vampire hunting crew, conquer a legendary castle, and restore peace to Rec Room!",
MaxPlayers = 3, SceneId = "49cb8993-a956-43e2-86f4-1318f279b22a",
Tags = RroWith("quest")
},
new DefaultRoom
{
Name = "CrimsonCauldron", Description = "Can your band of adventurers brave the enchanted wilds, and lift the curse of the crimson cauldron?",
MaxPlayers = 4, SceneId = "949fa41f-4347-45c0-b7ac-489129174045",
Tags = RroWith("quest")
},
new DefaultRoom
{
Name = "IsleOfLostSkulls", Description = "Can your pirate crew get to the Isle, defeat its fearsome guardian, and escape with the gold?",
MaxPlayers = 3, SceneId = "7e01cfe0-820a-406f-b1b3-0a5bf575235c",
Tags = RroWith("quest")
},
new DefaultRoom
{
Name = "StuntRunner", Description = "Sprint, climb, and wall jump in this high-speed obstacle course where every second counts!",
MaxPlayers = 12, SceneId = "b7281665-a715-4051-826b-8e08e69c6172", SubRoomName = "StuntRunner",
Tags = RroWith("sports"), CloningAllowed = false,
ExtraSubRooms = new List<DefaultSubRoom>
{
new() { Name = "TheMainEvent", SceneId = "3a636bd2-f896-424c-9225-c184522c0d87", MaxPlayers = 4 }
}
},
new DefaultRoom
{
Name = "3DCharades", Description = "Take turns drawing, acting, and guessing funny phrases with your friends!",
MaxPlayers = 8, SceneId = "a673712c-877f-4749-b69a-4a4c6310d545", SubRoomName = "InkSpaceHome",
Tags = Rro(), DisableRoomComments = true
},
new DefaultRoom
{
Name = "Legacy3DCharades", Description = "Take turns drawing, acting, and guessing funny phrases with your friends!",
MaxPlayers = 8, SceneId = "4078dfed-24bb-4db7-863f-578ba48d726b",
Tags = Rro(), DisableRoomComments = true
},
new DefaultRoom
{
Name = "TheInkSpace", Description = "A cozy event venue with a stage. The entire room is made of props, so customize to your heart's content!",
MaxPlayers = 8, SceneId = "1fa06e3c-c307-4c11-a91b-1fabcddb8a96",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true, DisableRoomComments = true
},
new DefaultRoom
{
Name = "Dodgeball", Description = "Throw dodgeballs to knock out your friends in this gym classic!",
MaxPlayers = 6, SceneId = "3d474b26-26f7-45e9-9a36-9b02847d5e6f",
Tags = RroWith("sports")
},
new DefaultRoom
{
Name = "DodgeballVR", Description = "Throw dodgeballs to knock out your friends in this gym classic!",
MaxPlayers = 6, SceneId = "3d474b26-26f7-45e9-9a36-9b02847d5e6f"
},
new DefaultRoom
{
Name = "Bowling", Description = "Hang out with friends, bowl a few games, eat snacks!",
MaxPlayers = 8, SceneId = "ae929543-9a07-41d5-8ee9-dbbee8c36800",
Tags = RroWith("sports")
},
new DefaultRoom
{
Name = "BowlingAlley", Description = "Lone Shoe Lanes is the hottest hangout in town.",
MaxPlayers = 8, SceneId = "ae929543-9a07-41d5-8ee9-dbbee8c36800",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "Paddleball", Description = "A simple rally game between two players in a plexiglass tube with a zero-g ball.",
MaxPlayers = 2, SceneId = "d89f74fa-d51e-477a-a425-025a891dd499",
Tags = RroWith("sports")
},
new DefaultRoom
{
Name = "Soccer", Description = "Teams of three run around slamming themselves into an over-sized soccer ball. Goal!",
MaxPlayers = 6, SceneId = "6d5eea4b-f069-4ed0-9916-0e2f07df0d03",
Tags = RroWith("sports")
},
new DefaultRoom
{
Name = "DiscGolfLake", Description = "A leisurely stroll through the grass. Throw your disc into the goal. Sounds easy, right?",
MaxPlayers = 4, SceneId = "f6f7256c-e438-4299-b99e-d20bef8cf7e0",
Tags = RroWith("sports")
},
new DefaultRoom
{
Name = "DiscGolfPropulsion", Description = "Throw your disc through hazards and around wind machines on this challenging course!",
MaxPlayers = 4, SceneId = "d9378c9f-80bc-46fb-ad1e-1bed8a674f55",
Tags = RroWith("sports")
},
new DefaultRoom
{
Name = "Paintball", Description = "Red and Blue teams splat each other in capture the flag and team battle.",
MaxPlayers = 8, SceneId = "e122fe98-e7db-49e8-a1b1-105424b6e1f0", SubRoomName = "River",
Tags = RroWith("sports"),
ExtraSubRooms = new List<DefaultSubRoom>
{
new() { Name = "Homestead", SceneId = "a785267d-c579-42ea-be43-fec1992d1ca7" },
new() { Name = "Quarry", SceneId = "ff4c6427-7079-4f59-b22a-69b089420827" },
new() { Name = "Clearcut", SceneId = "380d18b5-de9c-49f3-80f7-f4a95c1de161" },
new() { Name = "Spillway", SceneId = "58763055-2dfb-4814-80b8-16fac5c85709" },
new() { Name = "Drive-in", SceneId = "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3" }
}
},
new DefaultRoom
{
Name = "LaserTag", Description = "Teams battle each other and waves of robots.",
MaxPlayers = 8, SceneId = "239e676c-f12f-489f-bf3a-d4c383d692c3", SubRoomName = "Hangar",
Tags = RroWith("pvp"),
ExtraSubRooms = new List<DefaultSubRoom>
{
new() { Name = "CyberJunkCity", SceneId = "9d6456ce-6264-48b4-808d-2d96b3d91038" }
}
},
new DefaultRoom
{
Name = "RecRoyaleSquads", Description = "Squads of three battle it out on Frontier Island. Last squad standing wins!",
MaxPlayers = 18, SceneId = "253fa009-6e65-4c90-91a1-7137a56a267f", ImageName = "RecRoyaleSquads",
Tags = RroWith("pvp"), CloningAllowed = false
},
new DefaultRoom
{
Name = "RecRoyaleSolos", Description = "Battle it out on Frontier Island. Last person standing wins!",
MaxPlayers = 16, SceneId = "b010171f-4875-4e89-baba-61e878cd41e1", ImageName = "RecRoyaleSolos",
Tags = RroWith("pvp"), CloningAllowed = false, Aliases = new[] { "RecRoyaleSolo" }
},
new DefaultRoom
{
Name = "Showdown", Description = "Showdown",
MaxPlayers = 16, SceneId = SceneLocations.MakerRoom, ImageName = "Showdown",
Tags = new List<ItemTag>
{
new() { Tag = "rro", Type = TagType.AGOnly },
new() { Tag = "games", Type = TagType.General },
new() { Tag = "beta", Type = TagType.Auto }
},
CloningAllowed = true,
SaveName = "Showdown.room", BundleName = "Showdown_0_1.assetbundle"
},
new DefaultRoom
{
Name = "Lounge", Description = "A low-key lounge to chill with your friends. Great for private parties!",
MaxPlayers = 20, SceneId = "a067557f-ca32-43e6-b6e5-daaec60b4f5a",
Tags = Rro(), SubRoomSandbox = true
},
new DefaultRoom
{
Name = "Park", Description = "A sprawling park with amphitheater, play fields, and a cave.",
MaxPlayers = 20, SceneId = "0a864c86-5a71-4e18-8041-8124e4dc9d98",
Tags = Rro(), SubRoomSandbox = true
},
new DefaultRoom
{
Name = "MakerRoom", Description = "This room is a blank canvas. Make it into whatever you like!",
MaxPlayers = 20, SceneId = "a75f7547-79eb-47c6-8986-6767abcb4f92", ImageName = "MakerRoom",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "Gym", Description = "A school gymnasium for smaller sporting events.",
MaxPlayers = 20, SceneId = "3d474b26-26f7-45e9-9a36-9b02847d5e6f",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "Stadium", Description = "A professional stadium for larger sporting events.",
MaxPlayers = 20, SceneId = "6d5eea4b-f069-4ed0-9916-0e2f07df0d03",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "River", Description = "The original outdoor paintball course. Simple, balanced, classic.",
MaxPlayers = 20, SceneId = "e122fe98-e7db-49e8-a1b1-105424b6e1f0",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "Homestead", Description = "A day on the farm. Great for asymmetrical battles!",
MaxPlayers = 20, SceneId = "a785267d-c579-42ea-be43-fec1992d1ca7",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "Quarry", Description = "The sun sets on this construction site in the desert. Great for sniping battles!",
MaxPlayers = 20, SceneId = "ff4c6427-7079-4f59-b22a-69b089420827",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "Clearcut", Description = "The sun rises on this logging camp in the forest. Great for mid-range splatting!",
MaxPlayers = 20, SceneId = "380d18b5-de9c-49f3-80f7-f4a95c1de161",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "Spillway", Description = "Night shift at the hydroelectric plant. Great for stealthy splatting!",
MaxPlayers = 20, SceneId = "58763055-2dfb-4814-80b8-16fac5c85709",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "Lake", Description = "A leisurely trail to the lake and beyond.",
MaxPlayers = 20, SceneId = "f6f7256c-e438-4299-b99e-d20bef8cf7e0",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "PropulsionTestRange", Description = "The science department left some of their equipment laying around. What will you do with it?",
MaxPlayers = 20, SceneId = "d9378c9f-80bc-46fb-ad1e-1bed8a674f55",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "Hangar", Description = "A late-eighties-style laser tag battle arcade.",
MaxPlayers = 20, SceneId = "239e676c-f12f-489f-bf3a-d4c383d692c3",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "CyberJunkCity", Description = "A late-2080s-style cyberpunk dystopian laser tag battle arcade",
MaxPlayers = 20, SceneId = "9d6456ce-6264-48b4-808d-2d96b3d91038",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "StuntRunnerBaseRoom", Description = "A soundstage warehouse used to film Stunt Runner and other RecTV shows.",
MaxPlayers = 20, SceneId = "882e9b96-7115-4b03-86f6-c0c9d8e22e00",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "Registration", Description = "Registration",
MaxPlayers = 1, SceneId = "cf61556d-68fd-4288-9ae5-7a512621e569",
IsRRO = false, Accessibility = Accessibility.Unlised, CloningAllowed = false
},
new DefaultRoom
{
Name = "ARRoom", Description = "Your real room!",
MaxPlayers = 4, SceneId = "bf268f5f-b55b-41af-8628-32fa4b5d70b6",
IsRRO = false, Accessibility = Accessibility.Unlised, CloningAllowed = false, SubRoomSandbox = true,
WarningMask = WarningMask.Custom
},
new DefaultRoom
{
Name = "DriveIn", Description = "A drive-in theater, diner, and truck stop make this a multi-area paint arena",
MaxPlayers = 20, SceneId = "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3",
IsRRO = false, Accessibility = Accessibility.Unlised, SubRoomSandbox = true
},
new DefaultRoom
{
Name = "RecRally", Description = "A six-player off-road rally race where you boost, bump, and powerslide your way to the finish line!",
MaxPlayers = 6, SceneId = "56193568-9ae0-498c-8a77-4df79dec91f5",
Tags = RroWith("racing", "driving", "recrally"),
WarningMask = WarningMask.IntenseMotion, SupportsTeleportVR = false
},
new DefaultRoom
{
Name = "FrontierUGC", Description = "A public copyable room featuring the Rec Royale map!",
MaxPlayers = 40, SceneId = "a16bfd31-ffb9-46ac-a199-362c163130c0", ImageName = "RecRoyale",
IsRRO = false, Accessibility = Accessibility.Unlised
}
};
public static async Task EnsureAsync(RecDBContext db)
{
try
{
await ResetRoomsAsync(db);
await EnsureCatalogAsync(db);
await RemoveStrayRroAsync(db);
await DisableCloningAsync(db);
await RepairRecCenterAsync(db);
}
catch (Exception)
{
db.ChangeTracker.Clear();
}
}
private static async Task RepairRecCenterAsync(RecDBContext db)
{
try
{
if (await HasRunAsync(db, RecCenterRepairKey, RecCenterRepairVersion)) return;
var reference = All.FirstOrDefault(room => Matches(RecCenterRepairRoom, room));
if (reference == null)
{
await MarkRunAsync(db, RecCenterRepairKey, RecCenterRepairVersion);
return;
}
var rooms = await db.Rooms.ToListAsync();
var existing = rooms.FirstOrDefault(room => Matches(room.Name, reference));
if (existing == null)
{
await MarkRunAsync(db, RecCenterRepairKey, RecCenterRepairVersion);
return;
}
var subRooms = await db.SubRooms.ToListAsync();
var changes = await AlignAsync(db, existing, subRooms, reference);
foreach (var sub in subRooms.Where(sub => sub.RoomId == existing.RoomId))
{
if (string.IsNullOrEmpty(sub.DataBlob)) continue;
changes.Add($"subroom {sub.Name} content reset to the stock scene ({sub.DataBlob.Length} chars discarded)");
sub.DataBlob = string.Empty;
sub.UnityAssetId = string.Empty;
}
await db.SaveChangesAsync();
await MarkRunAsync(db, RecCenterRepairKey, RecCenterRepairVersion);
}
catch (Exception)
{
db.ChangeTracker.Clear();
}
}
private static async Task ResetRoomsAsync(RecDBContext db)
{
if (await HasRunAsync(db, ShippedRoomResetKey, ShippedRoomResetVersion)) return;
var rooms = await db.Rooms.ToListAsync();
if (rooms.Count == 0)
{
await MarkRunAsync(db, ShippedRoomResetKey, ShippedRoomResetVersion);
return;
}
var ids = rooms.Select(room => room.RoomId).ToList();
var subRoomIds = await db.SubRooms.Where(sub => ids.Contains(sub.RoomId)).Select(sub => sub.SubRoomId).ToListAsync();
db.SubRoomPermissions.RemoveRange(await db.SubRoomPermissions.Where(permission => subRoomIds.Contains(permission.subRoomId)).ToListAsync());
db.SubRoomHistory.RemoveRange(await db.SubRoomHistory.Where(history => ids.Contains(history.RoomId)).ToListAsync());
db.SubRooms.RemoveRange(await db.SubRooms.Where(sub => ids.Contains(sub.RoomId)).ToListAsync());
db.RoomStats.RemoveRange(await db.RoomStats.Where(stats => ids.Contains(stats.RoomId)).ToListAsync());
db.RoomRoles.RemoveRange(await db.RoomRoles.Where(role => ids.Contains(role.RoomId)).ToListAsync());
db.RoomKeys.RemoveRange(await db.RoomKeys.Where(key => ids.Contains(key.RoomId)).ToListAsync());
db.RoomBans.RemoveRange(await db.RoomBans.Where(ban => ids.Contains(ban.roomId)).ToListAsync());
db.RoomComments.RemoveRange(await db.RoomComments.Where(comment => ids.Contains(comment.RoomId)).ToListAsync());
db.RoomInteractions.RemoveRange(await db.RoomInteractions.Where(interaction => ids.Contains(interaction.RoomId)).ToListAsync());
db.RoomInstances.RemoveRange(await db.RoomInstances.Where(instance => ids.Contains(instance.roomId) || subRoomIds.Contains(instance.subRoomId)).ToListAsync());
db.LeaderboardScores.RemoveRange(await db.LeaderboardScores.Where(score => ids.Contains(score.RoomId)).ToListAsync());
var events = await db.PlayerEvents.Where(playerEvent => ids.Contains(playerEvent.RoomId)).ToListAsync();
if (events.Count > 0)
{
var eventIds = events.Select(playerEvent => playerEvent.PlayerEventId).ToList();
db.PlayerEventResponses.RemoveRange(await db.PlayerEventResponses.Where(response => eventIds.Contains(response.PlayerEventId)).ToListAsync());
db.PlayerEvents.RemoveRange(events);
}
foreach (var image in await db.Images.Where(image => image.RoomId.HasValue && ids.Contains(image.RoomId.Value)).ToListAsync())
image.RoomId = null;
foreach (var message in await db.Messages.Where(message => message.RoomId.HasValue && ids.Contains(message.RoomId.Value)).ToListAsync())
message.RoomId = null;
db.Rooms.RemoveRange(rooms);
await db.SaveChangesAsync();
await db.AddDormRoom();
await ClearCatalogMarkerAsync(db);
await MarkRunAsync(db, ShippedRoomResetKey, ShippedRoomResetVersion);
var dorm = await db.Rooms.FirstOrDefaultAsync(room => room.IsDorm && room.CreatorAccountId == 1);
}
private static async Task ClearCatalogMarkerAsync(RecDBContext db)
{
var row = await db.GameConfig.FirstOrDefaultAsync(config => config.Key == AlignmentKey);
if (row == null)
await db.GameConfig.AddAsync(new ConfigVal(AlignmentKey, "0"));
else
row.Value = "0";
await db.SaveChangesAsync();
}
private static async Task EnsureCatalogAsync(RecDBContext db)
{
try
{
if (await IsAlignedAsync(db)) return;
var rooms = await db.Rooms.ToListAsync();
var subRooms = await db.SubRooms.ToListAsync();
var statsRoomIds = await db.RoomStats.Select(x => x.RoomId).ToListAsync();
var created = new List<string>();
var aligned = new List<string>();
foreach (var reference in All)
{
var existing = rooms.FirstOrDefault(room => Matches(room.Name, reference));
if (existing == null)
{
var room = await CreateAsync(db, reference, statsRoomIds);
rooms.Add(room);
created.Add($"{reference.Name} ({reference.MaxPlayers} players)");
continue;
}
var changes = await AlignAsync(db, existing, subRooms, reference);
if (changes.Count > 0)
aligned.Add($"{existing.Name}: {string.Join(", ", changes)}");
}
await db.SaveChangesAsync();
await MarkAlignedAsync(db);
foreach (var line in created) ; foreach (var line in aligned) ; }
catch (Exception)
{
db.ChangeTracker.Clear();
}
}
private static async Task RemoveStrayRroAsync(RecDBContext db)
{
if (await HasRunAsync(db, RroCorrectionKey, RroCorrectionVersion)) return;
var rooms = await db.Rooms.ToListAsync();
var corrected = new List<string>();
foreach (var room in rooms)
{
var entry = All.FirstOrDefault(candidate => !candidate.IsRRO && Matches(room.Name, candidate));
if (entry == null) continue;
var tags = StringTools.ReadTags(room.Tags);
var strayTags = tags.Where(tag => string.Equals(tag.Tag, "rro", StringComparison.OrdinalIgnoreCase)).ToList();
if (strayTags.Count == 0 && !room.IsRRO) continue;
foreach (var tag in strayTags)
tags.Remove(tag);
room.Tags = StringTools.CreateTagString(tags);
room.IsRRO = false;
corrected.Add(room.Name);
}
if (corrected.Count > 0)
await db.SaveChangesAsync();
await MarkRunAsync(db, RroCorrectionKey, RroCorrectionVersion);
}
private static async Task DisableCloningAsync(RecDBContext db)
{
if (await HasRunAsync(db, CloningCorrectionKey, CloningCorrectionVersion)) return;
var rooms = await db.Rooms.ToListAsync();
var off = new List<string>();
var on = new List<string>();
foreach (var room in rooms)
{
var entry = All.FirstOrDefault(candidate => Matches(room.Name, candidate));
if (entry == null || entry.CloningAllowed == room.CloningAllowed) continue;
room.CloningAllowed = entry.CloningAllowed;
(entry.CloningAllowed ? on : off).Add(room.Name);
}
if (off.Count > 0 || on.Count > 0)
await db.SaveChangesAsync();
await MarkRunAsync(db, CloningCorrectionKey, CloningCorrectionVersion);
if (off.Count == 0 && on.Count == 0)
{
return;
}
}
private static async Task<RoomData> CreateAsync(RecDBContext db, DefaultRoom reference, List<long> statsRoomIds)
{
var room = new RoomData
{
Name = reference.Name,
Description = reference.Description,
ImageName = reference.ImageName ?? reference.Name,
MaxPlayers = reference.MaxPlayers,
MaxPlayerCalculationMode = MaxPlayerMode.OnlyEntrySubrooms,
CreatorAccountId = 1,
IsRRO = reference.IsRRO,
IsDorm = false,
Accessibility = reference.Accessibility,
CreatedAt = DateTime.Now,
State = RoomState.Active,
CloningAllowed = reference.CloningAllowed,
DisableRoomComments = reference.DisableRoomComments,
WarningMask = reference.WarningMask,
SupportsTeleportVR = reference.SupportsTeleportVR,
Tags = StringTools.CreateTagString(reference.Tags.ToList())
};
await db.Rooms.AddAsync(room);
await db.SaveChangesAsync();
await AddSubRoomAsync(db, room.RoomId, reference.SubRoomName, reference.SceneId, reference.MaxPlayers,
reference.Accessibility, reference.SubRoomSandbox, reference.SaveName, reference.BundleName);
foreach (var extra in reference.ExtraSubRooms)
await AddSubRoomAsync(db, room.RoomId, extra.Name, extra.SceneId, ExtraSubRoomSize(extra, reference),
reference.Accessibility, reference.SubRoomSandbox);
if (!statsRoomIds.Contains(room.RoomId))
{
await db.RoomStats.AddAsync(new RoomStats { RoomId = room.RoomId });
statsRoomIds.Add(room.RoomId);
}
await db.RoomRoles.AddAsync(new RoomRole
{
RoomId = room.RoomId,
AccountId = 1,
Role = RoleType.Creator,
InvitedRole = RoleType.None
});
await db.SaveChangesAsync();
return room;
}
private static async Task<List<string>> AlignAsync(RecDBContext db, RoomData room, List<SubRoom> subRooms, DefaultRoom reference)
{
var changes = new List<string>();
var image = reference.ImageName ?? reference.Name;
if (room.MaxPlayers != reference.MaxPlayers)
{
changes.Add($"max players {room.MaxPlayers} -> {reference.MaxPlayers}");
room.MaxPlayers = reference.MaxPlayers;
}
if (!string.Equals(room.ImageName, image, StringComparison.Ordinal))
{
changes.Add($"image '{room.ImageName}' -> '{image}'");
room.ImageName = image;
}
if (room.Accessibility != reference.Accessibility)
{
changes.Add($"accessibility {room.Accessibility} -> {reference.Accessibility}");
room.Accessibility = reference.Accessibility;
}
if (room.CloningAllowed != reference.CloningAllowed)
{
changes.Add($"cloning {room.CloningAllowed} -> {reference.CloningAllowed}");
room.CloningAllowed = reference.CloningAllowed;
}
if (room.DisableRoomComments != reference.DisableRoomComments)
{
changes.Add($"room comments {(reference.DisableRoomComments ? "off" : "on")}");
room.DisableRoomComments = reference.DisableRoomComments;
}
if (room.WarningMask != reference.WarningMask)
{
changes.Add($"warning mask {room.WarningMask} -> {reference.WarningMask}");
room.WarningMask = reference.WarningMask;
}
if (room.SupportsTeleportVR != reference.SupportsTeleportVR)
{
changes.Add($"teleport VR {reference.SupportsTeleportVR}");
room.SupportsTeleportVR = reference.SupportsTeleportVR;
}
if (room.IsRRO != reference.IsRRO)
{
changes.Add(reference.IsRRO ? "marked IsRRO" : "no longer marked IsRRO");
room.IsRRO = reference.IsRRO;
}
var mine = subRooms.Where(sub => sub.RoomId == room.RoomId).OrderBy(sub => sub.SubRoomId).ToList();
var entry = mine.FirstOrDefault();
if (entry == null)
{
subRooms.Add(await AddSubRoomAsync(db, room.RoomId, reference.SubRoomName, reference.SceneId,
reference.MaxPlayers, reference.Accessibility, reference.SubRoomSandbox, reference.SaveName,
reference.BundleName));
changes.Add($"added the {reference.SubRoomName} subroom");
}
else
{
AlignSubRoom(changes, entry, reference.SubRoomName, reference.SceneId, reference.MaxPlayers,
reference.Accessibility, reference.SubRoomSandbox);
}
foreach (var extra in reference.ExtraSubRooms)
{
var match = subRooms.FirstOrDefault(sub => sub.RoomId == room.RoomId
&& string.Equals(sub.UnitySceneId, extra.SceneId, StringComparison.OrdinalIgnoreCase));
if (match == null)
{
subRooms.Add(await AddSubRoomAsync(db, room.RoomId, extra.Name, extra.SceneId,
ExtraSubRoomSize(extra, reference), reference.Accessibility, reference.SubRoomSandbox));
changes.Add($"added the {extra.Name} subroom");
}
else
{
AlignSubRoom(changes, match, extra.Name, extra.SceneId, ExtraSubRoomSize(extra, reference),
reference.Accessibility, reference.SubRoomSandbox);
}
}
return changes;
}
private static void AlignSubRoom(List<string> changes, SubRoom sub, string name, string sceneId, int maxPlayers, Accessibility accessibility, bool sandbox, string? saveName = null, string? bundleName = null)
{
var what = $"subroom {sub.Name}";
if (!string.Equals(sub.Name, name, StringComparison.Ordinal))
{
changes.Add($"{what} renamed to {name}");
sub.Name = name;
}
if (!string.Equals(sub.UnitySceneId, sceneId, StringComparison.OrdinalIgnoreCase))
{
changes.Add($"{what} scene {sub.UnitySceneId} -> {sceneId}");
sub.UnitySceneId = sceneId;
}
if (sub.MaxPlayers != maxPlayers)
{
changes.Add($"{what} max players {sub.MaxPlayers} -> {maxPlayers}");
sub.MaxPlayers = maxPlayers;
}
if (sub.Accessibility != accessibility)
{
changes.Add($"{what} accessibility {sub.Accessibility} -> {accessibility}");
sub.Accessibility = accessibility;
}
if (sub.IsSandbox != sandbox)
{
changes.Add($"{what} sandbox {sandbox}");
sub.IsSandbox = sandbox;
}
if (saveName != null && !string.Equals(sub.DataBlob, saveName, StringComparison.Ordinal))
{
changes.Add($"{what} room file '{(sub.DataBlob.Length == 0 ? "none" : sub.DataBlob)}' -> '{saveName}'");
sub.DataBlob = saveName;
}
if (bundleName != null && !string.Equals(sub.UnityAssetId, bundleName, StringComparison.Ordinal))
{
changes.Add($"{what} bundle '{(sub.UnityAssetId.Length == 0 ? "none" : sub.UnityAssetId)}' -> '{bundleName}'");
sub.UnityAssetId = bundleName;
}
}
private static async Task<SubRoom> AddSubRoomAsync(RecDBContext db, long roomId, string name, string sceneId, int maxPlayers, Accessibility accessibility, bool sandbox, string? saveName = null, string? bundleName = null)
{
var subRoom = new SubRoom
{
RoomId = roomId,
SavedByAccountId = 1,
Name = name,
DataBlob = saveName ?? "",
UnityAssetId = bundleName ?? "",
IsSandbox = sandbox,
MaxPlayers = maxPlayers,
Accessibility = accessibility,
UnitySceneId = sceneId,
CreatedAt = DateTime.Now
};
await db.SubRooms.AddAsync(subRoom);
return subRoom;
}
private static int ExtraSubRoomSize(DefaultSubRoom subRoom, DefaultRoom room) =>
subRoom.MaxPlayers > 0 ? subRoom.MaxPlayers : room.MaxPlayers;
private static Task<bool> IsAlignedAsync(RecDBContext db) => HasRunAsync(db, AlignmentKey, AlignmentVersion);
private static Task MarkAlignedAsync(RecDBContext db) => MarkRunAsync(db, AlignmentKey, AlignmentVersion);
private static async Task<bool> HasRunAsync(RecDBContext db, string key, int version)
{
var row = await db.GameConfig.FirstOrDefaultAsync(x => x.Key == key);
return int.TryParse(row?.Value, out var recorded) && recorded >= version;
}
private static async Task MarkRunAsync(RecDBContext db, string key, int version)
{
var row = await db.GameConfig.FirstOrDefaultAsync(x => x.Key == key);
if (row == null)
await db.GameConfig.AddAsync(new ConfigVal(key, version.ToString()));
else
row.Value = version.ToString();
await db.SaveChangesAsync();
}
private static bool Matches(string? existingName, DefaultRoom reference)
{
var key = NormalizeName(existingName);
if (key.Length == 0) return false;
return key == NormalizeName(reference.Name)
|| reference.Aliases.Any(alias => key == NormalizeName(alias));
}
private static string NormalizeName(string? name) =>
string.Concat((name ?? "").Where(char.IsLetterOrDigit)).ToLowerInvariant();
private static List<ItemTag> Rro() => new() { new ItemTag { Tag = "rro", Type = TagType.AGOnly } };
private static List<ItemTag> RroWith(params string[] tags)
{
var list = Rro();
list.AddRange(tags.Select(tag => new ItemTag { Tag = tag, Type = TagType.General }));
return list;
}
}
public sealed class DefaultRoom
{
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public int MaxPlayers { get; init; }
public string SceneId { get; init; } = "";
public string SubRoomName { get; init; } = "Home";
public string? ImageName { get; init; }
public string? SaveName { get; init; }
public string? BundleName { get; init; }
public IReadOnlyList<ItemTag> Tags { get; init; } = Array.Empty<ItemTag>();
public IReadOnlyList<DefaultSubRoom> ExtraSubRooms { get; init; } = Array.Empty<DefaultSubRoom>();
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
public Accessibility Accessibility { get; init; } = Accessibility.Public;
public bool CloningAllowed
{
get => cloningAllowedOverride ?? Accessibility == Accessibility.Unlised;
init => cloningAllowedOverride = value;
}
private bool? cloningAllowedOverride;
public bool IsRRO { get; init; } = true;
public bool DisableRoomComments { get; init; }
public bool SubRoomSandbox { get; init; }
public WarningMask WarningMask { get; init; } = WarningMask.None;
public bool SupportsTeleportVR { get; init; } = true;
}
public sealed class DefaultSubRoom
{
public string Name { get; init; } = "";
public string SceneId { get; init; } = "";
public int MaxPlayers { get; init; }
}
}
+10
View File
@@ -0,0 +1,10 @@
using RadicadeServer.RecNet.Enums.Board;
namespace RadicadeServer.RecNet.Rooms
{
public class ExternalContent
{
public ExternalContentType Type { get; set; }
public string Reference { get; set; } = null!;
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace RadicadeServer.RecNet.Rooms
{
public class FeaturedRoomGroup
{
public long FeaturedRoomGroupId { get; set; }
public string Name { get; set; } = null!;
public List<FeaturedRoom> Rooms { get; set; } = null!;
}
public class FeaturedRoom
{
public long RoomId { get; set; }
public string RoomName { get; set; } = null!;
public string ImageName { get; set; } = "default.png";
}
}
+59
View File
@@ -0,0 +1,59 @@
namespace RadicadeServer.RecNet.Rooms
{
public class FullRoom : RoomDetail
{
public List<SubRoom> SubRooms { get; set; } = null!;
public List<RoomRole> Roles { get; set; } = null!;
public List<RoomLoadScreen> LoadScreens { get; set; } = new();
public List<string> PromoImages { get; set; } = new();
public List<ExternalContent> PromoExternalContent { get; set; } = new();
public string? DataBlob { get; set; }
public List<ItemTag> Tags { get; set; } = new();
public FullRoom CopyTo(FullRoom target)
{
target.RoomId = RoomId;
target.IsDorm = IsDorm;
target.MaxPlayers = MaxPlayers;
target.MaxPlayerCalculationMode = MaxPlayerCalculationMode;
target.CloningAllowed = CloningAllowed;
target.DisableMicAutoMute = DisableMicAutoMute;
target.DisableRoomComments = DisableRoomComments;
target.EncryptVoiceChat = EncryptVoiceChat;
target.Name = Name;
target.Description = Description;
target.ImageName = ImageName;
target.WarningMask = WarningMask;
target.CustomWarning = CustomWarning;
target.CreatorAccountId = CreatorAccountId;
target.State = State;
target.Accessibility = Accessibility;
target.SupportsLevelVoting = SupportsLevelVoting;
target.IsRRO = IsRRO;
target.IsDeveloperOwned = IsDeveloperOwned;
target.SupportsScreens = SupportsScreens;
target.SupportsWalkVR = SupportsWalkVR;
target.SupportsTeleportVR = SupportsTeleportVR;
target.SupportsVRLow = SupportsVRLow;
target.SupportsQuest2 = SupportsQuest2;
target.SupportsMobile = SupportsMobile;
target.SupportsJuniors = SupportsJuniors;
target.MinLevel = MinLevel;
target.CreatedAt = CreatedAt;
target.Stats = Stats;
target.Version = Version;
target.SubRooms = SubRooms;
target.Roles = Roles;
target.LoadScreens = LoadScreens;
target.PromoImages = PromoImages;
target.PromoExternalContent = PromoExternalContent;
target.DataBlob = DataBlob;
target.Tags = Tags;
return target;
}
}
}
+86
View File
@@ -0,0 +1,86 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Enums;
using RadicadeServer.RecNet.Enums.Rooms;
using RadicadeServer.RecNet.Rooms;
namespace RadicadeServer.Lists
{
[Route("lists/algorithmiclists")]
public class AlgorithmicListsAPI : Controller
{
private const int Rooms = 1;
private const int RowSize = 20;
RecDBContext db;
public AlgorithmicListsAPI(RecDBContext context)
{
this.db = context;
}
[HttpGet("{list}")]
public async Task<IActionResult> GetAlgorithmicList(string list, [FromQuery] int? type)
{
var echoed = type is >= 0 and <= 255 ? type.Value : Rooms;
return Ok(new AlgorithmicList
{
Type = echoed,
Entities = (await GetRowIds(list)).Select(roomId => new AlgorithmicListEntity
{
Id = roomId.ToString(),
Context = null,
}).ToList(),
});
}
private async Task<List<long>> GetRowIds(string? list)
{
var key = (list ?? string.Empty).Trim().ToLowerInvariant();
var rooms = await db.Rooms
.Where(x => x.Accessibility == Accessibility.Public
&& x.State == RoomState.Active
&& !x.IsDorm)
.ToListAsync();
if (key == "new")
{
return rooms
.OrderByDescending(x => x.PublishedAt)
.ThenBy(x => x.RoomId)
.Take(RowSize)
.Select(x => x.RoomId)
.ToList();
}
var visits = await db.RoomStats
.Select(x => new { x.RoomId, x.VisitCount })
.ToDictionaryAsync(x => x.RoomId, x => x.VisitCount);
return rooms
.OrderByDescending(x => visits.TryGetValue(x.RoomId, out var count) ? count : 0)
.ThenBy(x => x.RoomId)
.Take(RowSize)
.Select(x => x.RoomId)
.ToList();
}
}
public class AlgorithmicList
{
public int Type { get; set; }
public List<AlgorithmicListEntity> Entities { get; set; } = new();
}
public class AlgorithmicListEntity
{
public string Id { get; set; } = "";
public string? Context { get; set; }
}
}
+15
View File
@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace RadicadeServer.Lists
{
[Route("lists/curatedlists")]
public class CuratedListsAPI : Controller
{
[HttpGet("bulk")]
public IActionResult GetBulk()
{
return Ok(Array.Empty<object>());
}
}
}
+31
View File
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RadicadeServer.RecNet.Rooms
{
public class PlayerInteraction
{
public bool Cheered { get; set; }
public bool Favorited { get; set; }
public PlayerInteraction(PlayerInteractionDB db) { Cheered = db.Cheered; Favorited = db.Favorited; }
public PlayerInteraction() { }
}
public class PlayerInteractionDB
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long InteractionId { get; set; }
public long PlayerId { get; set; }
public long RoomId { get; set; }
public bool Cheered { get; set; }
public bool Favorited { get; set; }
public DateTime CheeredAt { get; set; } = DateTime.Now;
public DateTime FavoritedAt { get; set; } = DateTime.Now;
public int VisitCount { get; set; }
public bool Visited { get; set; }
public DateTime LastVisted { get; set; } = DateTime.Now;
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace RadicadeServer.RecNet.Rooms
{
public class PlaylistRoom : FullRoom
{
public List<FullRoom> Rooms { get; set; } = new();
public PlaylistRoom(FullRoom room)
{
room.CopyTo(this);
}
}
public class SeededRoom
{
public FullRoom SeedRoom { get; set; } = null!;
public List<string> Rooms { get; set; } = [];
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace RadicadeServer.Radicade.Public
{
public class PublicRoomData
{
public string ImageCdnUrl { get; set; } = DBConfig.DefaultRoomImage;
public string RoomName { get; set; } = "^Nowhere";
public string OwnerName { get; set; } = "@Noone";
public int Cheers { get; set; } = 0;
public int Favorites { get; set; } = 0;
}
}
+33
View File
@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace RadicadeServer.RecNet.Rooms
{
public class RestorableSubRoom
{
[JsonIgnore, Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long HistoryId { get; set; }
public long SubRoomId { get; set; }
[JsonIgnore]
public long RoomId { get; set; }
public long SavedByAccountId { get; set; }
public string DataBlob { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public RestorableSubRoom() { }
public RestorableSubRoom(SubRoom subRoom)
{
this.RoomId = subRoom.RoomId;
this.SubRoomId = subRoom.SubRoomId;
this.DataBlob = subRoom.DataBlob;
this.CreatedAt = subRoom.CreatedAt;
this.SavedByAccountId = subRoom.SavedByAccountId;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
using RadicadeServer.RecNet.Enums;
using RadicadeServer.RecNet.Enums.Rooms;
namespace RadicadeServer.RecNet.Rooms
{
public class Room
{
public string Name { get; set; } = null!;
public string Description { get; set; } = null!;
public string ImageName { get; set; } = null!;
public WarningMask WarningMask { get; set; }
public string CustomWarning { get; set; } = null!;
public long CreatorAccountId { get; set; }
public RoomState State { get; set; }
public Accessibility Accessibility { get; set; }
public bool SupportsLevelVoting { get; set; }
public bool IsRRO { get; set; }
public bool IsDeveloperOwned { get; set; }
public bool SupportsScreens { get; set; }
public bool SupportsWalkVR { get; set; }
public bool SupportsTeleportVR { get; set; }
public bool SupportsVRLow { get; set; }
public bool SupportsQuest2 { get; set; }
public bool SupportsMobile { get; set; }
public bool SupportsJuniors { get; set; }
public int MinLevel { get; set; }
public DateTime CreatedAt { get; set; }
public RoomStats Stats { get; set; } = null!;
public int Version { get; set; } = 1;
}
}
+95
View File
@@ -0,0 +1,95 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Config;
using System.Text.Json;
namespace RadicadeServer.API.Rooms.V1;
internal sealed class RoomAssetRecord
{
public string Id { get; set; } = "";
public long RoomId { get; set; }
public string Kind { get; set; } = "";
public Dictionary<string, object?> Fields { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
internal static class RoomAssetStore
{
private const string ConfigKey = "RoomAssets";
private static readonly SemaphoreSlim Gate = new(1, 1);
public static async Task<List<RoomAssetRecord>> LoadAsync(RecDBContext db)
{
var config = await db.ServerConfig.AsNoTracking().FirstOrDefaultAsync(row => row.Key == ConfigKey);
if (string.IsNullOrWhiteSpace(config?.Value)) return new List<RoomAssetRecord>();
try
{
return JsonSerializer.Deserialize<List<RoomAssetRecord>>(config.Value,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List<RoomAssetRecord>();
}
catch (JsonException)
{
return new List<RoomAssetRecord>();
}
}
public static async Task<List<RoomAssetRecord>> ForRoomAsync(RecDBContext db, long roomId, string kind)
{
var records = await LoadAsync(db);
return records.Where(record => record.RoomId == roomId &&
string.Equals(record.Kind, kind, StringComparison.OrdinalIgnoreCase)).ToList();
}
public static async Task SaveAsync(RecDBContext db, RoomAssetRecord record)
{
await Gate.WaitAsync();
try
{
var config = await db.ServerConfig.FirstOrDefaultAsync(row => row.Key == ConfigKey);
List<RoomAssetRecord> records;
try
{
records = string.IsNullOrWhiteSpace(config?.Value)
? new List<RoomAssetRecord>()
: JsonSerializer.Deserialize<List<RoomAssetRecord>>(config.Value,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List<RoomAssetRecord>();
}
catch (JsonException)
{
records = new List<RoomAssetRecord>();
}
records.RemoveAll(existing => existing.RoomId == record.RoomId &&
string.Equals(existing.Kind, record.Kind, StringComparison.OrdinalIgnoreCase) &&
existing.Id == record.Id);
records.Add(record);
if (config == null)
{
config = new ServerConfigVal(ConfigKey, JsonSerializer.Serialize(records));
await db.ServerConfig.AddAsync(config);
}
else
{
config.Value = JsonSerializer.Serialize(records);
}
await db.SaveChangesAsync();
}
finally
{
Gate.Release();
}
}
public static async Task<List<Dictionary<string, object?>>> GetDtosAsync(RecDBContext db, long roomId, string kind)
{
var records = await ForRoomAsync(db, roomId, kind);
return records.Select(record =>
{
var dto = new Dictionary<string, object?>(record.Fields, StringComparer.OrdinalIgnoreCase);
dto[kind.Equals("currency", StringComparison.OrdinalIgnoreCase) ? "CurrencyId" : "ConsumableId"] = record.Id;
dto["RoomId"] = record.RoomId;
return dto;
}).ToList();
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RadicadeServer.RecNet.Rooms
{
public class RoomBan
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long BanId { get; set; }
public long roomId { get; set; }
public long actorId { get; set; }
public long bannedPlayerId { get; set; }
public string bannedByName { get; set; } = "";
public string reason { get; set; } = "";
public DateTime bannedAt { get; set; } = DateTime.UtcNow;
public DateTime? expiresAt { get; set; }
}
}
+25
View File
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RadicadeServer.RecNet.Rooms
{
public class RoomComment
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; set; }
public long RoomId { get; set; }
public long CreatorAccountId { get; set; }
public string Comment { get; set; } = string.Empty;
public string CommentType { get; set; } = "Feedback";
public float? PositionX { get; set; }
public float? PositionY { get; set; }
public float? PositionZ { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+50
View File
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.API.Rooms;
using RadicadeServer.API.Rooms.V1;
using RadicadeServer.DB;
namespace RadicadeServer.API.Rooms
{
[Route("api/roomconsumables/v1")]
[Route("econ/api/roomconsumables/v1")]
public class RoomConsumablesAPI(RecDBContext db) : Controller
{
[HttpGet("roomConsumable/room/{roomId:long}")]
public async Task<IActionResult> GetRoomConsumablesInRoom(long roomId)
{
return Ok(await RoomAssetStore.GetDtosAsync(db, roomId, "consumable"));
}
[HttpGet("roomConsumable/room/{roomId:long}/me")]
public IActionResult GetOwnedRoomConsumablesInRoom(long roomId)
{
return Ok(Array.Empty<object>());
}
[HttpPut("roomConsumable")]
[HttpPost("roomConsumable")]
public async Task<IActionResult> CreateRoomConsumable()
{
var body = await RoomEconomyPayload.ReadBodyAsync(Request);
var roomId = await RoomEconomyPayload.RoomIdAsync(db, Request, body, "roomId", "RoomId");
if (roomId is not > 0) return BadRequest(new { Message = "roomId is required." });
var consumableId = RoomEconomyPayload.String(body, "consumableId", "ConsumableId") ?? RoomEconomyPayload.NewId();
var consumable = RoomEconomyPayload.Fields(body);
consumable["ConsumableId"] = consumableId;
consumable["RoomId"] = roomId.Value;
consumable["Name"] = RoomEconomyPayload.String(body, "name", "Name") ?? "Consumable";
consumable["Description"] = RoomEconomyPayload.String(body, "description", "Description") ?? string.Empty;
await RoomAssetStore.SaveAsync(db, new RoomAssetRecord
{
Id = consumableId,
RoomId = roomId.Value,
Kind = "consumable",
Fields = consumable
});
return Ok(consumable);
}
}
}
+73
View File
@@ -0,0 +1,73 @@
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.API.Rooms;
using RadicadeServer.API.Rooms.V1;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
namespace RadicadeServer.API.Rooms.Currency.V1
{
[Route("api/roomcurrencies/v1")]
[Route("econ/api/roomcurrencies/v1")]
public class RoomCurrencyAPI(RecDBContext db) : Controller
{
[HttpGet("betaEnabled")]
public bool IsBetaEnabled()
{
return true;
}
[HttpGet("currencies")]
public async Task<IActionResult> GetRoomCurrencies([FromQuery] long roomId)
{
return Ok(await RoomAssetStore.GetDtosAsync(db, roomId, "currency"));
}
[HttpGet("getAllBalances")]
public List<object> GetAllBalances([FromQuery] long roomId)
{
return [];
}
[HttpGet("getBalance")]
public IActionResult GetBalance(
[FromQuery] string? currencyId,
[FromQuery] long? accountId,
[FromQuery] long? roomId)
{
return Ok(new
{
CurrencyId = currencyId ?? string.Empty,
AccountId = accountId ?? User.GetUserId() ?? 0,
RoomId = roomId ?? 0,
Balance = 0
});
}
[HttpPost("createCurrency")]
public async Task<IActionResult> CreateCurrency()
{
var body = await RoomEconomyPayload.ReadBodyAsync(Request);
var roomId = await RoomEconomyPayload.RoomIdAsync(db, Request, body, "roomId", "RoomId");
if (roomId is not > 0) return BadRequest(new { Message = "roomId is required." });
var currencyId = RoomEconomyPayload.String(body, "currencyId", "CurrencyId") ?? RoomEconomyPayload.NewId();
var currency = RoomEconomyPayload.Fields(body);
currency["CurrencyId"] = currencyId;
currency["RoomId"] = roomId.Value;
currency["CurrencyType"] = currency.TryGetValue("CurrencyType", out var type) && type != null ? type : "RoomCurrency";
currency["Name"] = RoomEconomyPayload.String(body, "name", "Name") ?? "Currency";
currency["Description"] = RoomEconomyPayload.String(body, "description", "Description") ?? string.Empty;
currency["InitialBalance"] = RoomEconomyPayload.Long(body, "initialBalance", "InitialBalance") ?? 0;
await RoomAssetStore.SaveAsync(db, new RoomAssetRecord
{
Id = currencyId,
RoomId = roomId.Value,
Kind = "currency",
Fields = currency
});
return Ok(currency);
}
}
}
+92
View File
@@ -0,0 +1,92 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using RadicadeServer.RecNet.Enums;
using RadicadeServer.RecNet.Enums.Players.Avatar;
using RadicadeServer.RecNet.Enums.Rooms;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.Radicade.Gifts.Enums;
namespace RadicadeServer.RecNet.Rooms
{
public class RoomData
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long RoomId { get; set; }
public string Tags { get; set; } = string.Empty;
public bool IsDorm { get; set; } = false;
public int MaxPlayers { get; set; } = 10;
public MaxPlayerMode MaxPlayerCalculationMode { get; set; } = MaxPlayerMode.OnlyEntrySubrooms;
public bool CloningAllowed { get; set; } = false;
public bool DisableMicAutoMute { get; set; } = false;
public bool DisableRoomComments { get; set; } = false;
public bool EncryptVoiceChat { get; set; } = false;
public string Name { get; set; } = "";
public string? Description { get; set; } = "";
public string ImageName { get; set; } = "default.png";
public WarningMask WarningMask { get; set; }
public string? CustomWarning { get; set; } = null;
public long CreatorAccountId { get; set; } = 1;
public RoomState State { get; set; } = RoomState.Active;
public Accessibility Accessibility { get; set; } = Accessibility.Private;
public bool SupportsLevelVoting { get; set; } = false;
public bool IsRRO { get; set; } = false;
public bool IsDeveloperOwned { get; set; } = false;
public bool SupportsScreens { get; set; } = true;
public bool SupportsWalkVR { get; set; } = true;
public bool SupportsTeleportVR { get; set; } = true;
public bool SupportsVRLow { get; set; } = true;
public bool SupportsQuest2 { get; set; } = true;
public bool SupportsMobile { get; set; } = true;
public bool SupportsJuniors { get; set; } = true;
public int MinLevel { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.MinValue;
[JsonIgnore] public DateTime PublishedAt { get; set; } = DateTime.Now;
[JsonIgnore] public bool WasPublished = false;
public string loadScreen { get; set; } = "[]";
public int Version { get; set; } = 1;
public bool JoinGiftEnabled { get; set; } = false;
public bool JoinGiftOncePerPlayer { get; set; } = true;
public int JoinGiftVersion { get; set; } = 1;
public string JoinGiftTitle { get; set; } = "Room Visit Gift";
public string JoinGiftMessage { get; set; } = "Thanks for visiting!";
public string JoinGiftAvatarItem { get; set; } = "";
public string JoinGiftEquipmentItem { get; set; } = "";
public string JoinGiftConsumableItem { get; set; } = "";
public int JoinGiftCurrencyAmount { get; set; } = 0;
public CurrencyType JoinGiftCurrencyType { get; set; } = CurrencyType.RecCenterTokens;
public int JoinGiftXp { get; set; } = 0;
public GiftContext JoinGiftContext { get; set; } = GiftContext.Game_Drop;
public Rarity JoinGiftRarity { get; set; } = Rarity.Common;
public AvatarItemType JoinGiftAvatarItemType { get; set; } = AvatarItemType.Outfit;
public string JoinGiftEquipmentModificationGuid { get; set; } = "";
/// <summary>
/// The account the join box is from, which the client shows on the box and reads off
/// RecNet's Gift.FromPlayerId. Zero means the room names no sender, as it did before this
/// was configurable.
/// </summary>
public long JoinGiftFromPlayerId { get; set; } = 0;
}
}
+22
View File
@@ -0,0 +1,22 @@
using RadicadeServer.RecNet.Enums.Rooms;
namespace RadicadeServer.RecNet.Rooms
{
public class RoomDetail : Room
{
public long RoomId { get; set; }
public bool IsDorm { get; set; }
public int MaxPlayers { get; set; }
public MaxPlayerMode MaxPlayerCalculationMode { get; set; }
public bool CloningAllowed { get; set; }
public bool DisableMicAutoMute { get; set; }
public bool DisableRoomComments { get; set; }
public bool EncryptVoiceChat { get; set; }
}
}
+27
View File
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Mvc;
namespace RadicadeServer.API.Rooms.Earnings.V1
{
[Route("api/roomEarningsDistributions/v1")]
[Route("econ/api/roomEarningsDistributions/v1")]
public class RoomEarningsAPI : Controller
{
[HttpGet("earningsDistribution/{roomId}")]
public IActionResult GetEarningsDistribution(long roomId)
{
return Ok(new
{
RoomId = roomId,
CurrencyId = string.Empty,
TotalEarnings = 0,
TotalSpent = 0,
UnclaimedEarnings = 0,
LifetimeEarnings = 0,
IsAffiliatedWithCreator = false,
LastPaidAt = (string?)null,
Distributions = Array.Empty<object>()
});
}
}
}
+228
View File
@@ -0,0 +1,228 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
namespace RadicadeServer.API.Rooms
{
internal static class RoomEconomyPayload
{
private const string RawKey = "RoomEconomyPayload.Raw";
public static async Task<JsonElement?> ReadBodyAsync(HttpRequest request)
{
if (request.HasFormContentType)
{
try
{
var form = await request.ReadFormAsync();
if (form.Count > 0)
{
var fields = new Dictionary<string, object?>();
foreach (var field in form)
fields[field.Key] = field.Value.ToString();
foreach (var file in form.Files)
fields[file.Name] = file.FileName;
return JsonSerializer.SerializeToElement(fields);
}
}
catch (Exception ex)
{
request.HttpContext.Items[RawKey] = $"<form body could not be read: {ex.Message}>";
}
}
string raw;
using (var reader = new StreamReader(request.Body))
raw = await reader.ReadToEndAsync();
var text = raw.TrimStart('\uFEFF', ' ', '\t', '\r', '\n');
if (text.Length == 0) return null;
if (text[0] is '{' or '[')
{
try
{
using var doc = JsonDocument.Parse(text);
return doc.RootElement.Clone();
}
catch (JsonException)
{
}
}
var pairs = ParsePairs(raw);
if (pairs.Count > 0)
{
request.HttpContext.Items[RawKey] = raw;
return JsonSerializer.SerializeToElement(pairs);
}
request.HttpContext.Items[RawKey] = raw;
return null;
}
public static string RawBody(HttpRequest request) =>
request.HttpContext.Items.TryGetValue(RawKey, out var raw) ? raw as string ?? "" : "";
public static JsonElement? Value(JsonElement? body, params string[] names)
{
if (body == null) return null;
var stack = new Stack<JsonElement>();
stack.Push(body.Value);
var visited = 0;
while (stack.Count > 0 && visited++ < 2000)
{
var element = stack.Pop();
if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray()) stack.Push(item);
continue;
}
if (element.ValueKind != JsonValueKind.Object) continue;
foreach (var property in element.EnumerateObject())
{
if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
{
stack.Push(property.Value);
continue;
}
if (Matches(property.Name, names)) return property.Value;
}
}
return null;
}
public static string? String(JsonElement? body, params string[] names)
{
var value = Value(body, names);
if (value == null) return null;
if (value.Value.ValueKind == JsonValueKind.String) return value.Value.GetString();
if (value.Value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False)
return value.Value.ToString();
return null;
}
public static long? Long(JsonElement? body, params string[] names)
{
var value = Value(body, names);
if (value == null) return null;
if (value.Value.ValueKind == JsonValueKind.Number)
{
if (value.Value.TryGetInt64(out var number)) return number;
if (value.Value.TryGetDouble(out var real)) return (long)real;
}
if (value.Value.ValueKind == JsonValueKind.String && long.TryParse(value.Value.GetString(), out var parsed))
return parsed;
return null;
}
public static bool? Bool(JsonElement? body, params string[] names)
{
var value = Value(body, names);
if (value == null) return null;
if (value.Value.ValueKind == JsonValueKind.True) return true;
if (value.Value.ValueKind == JsonValueKind.False) return false;
return null;
}
public static long? LongFromRequest(HttpRequest request, params string[] names)
{
foreach (var pair in request.Query)
{
if (!Matches(pair.Key, names)) continue;
if (long.TryParse(pair.Value.FirstOrDefault(), out var fromQuery)) return fromQuery;
}
foreach (var pair in request.RouteValues)
{
if (!Matches(pair.Key, names)) continue;
if (long.TryParse(pair.Value?.ToString(), out var fromRoute)) return fromRoute;
}
return null;
}
public static Dictionary<string, object?> Fields(JsonElement? body)
{
var fields = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (body == null || body.Value.ValueKind != JsonValueKind.Object) return fields;
foreach (var property in body.Value.EnumerateObject())
{
fields[property.Name] = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Number => property.Value.TryGetInt64(out var number) ? number : property.Value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => property.Value.Clone()
};
}
return fields;
}
public static async Task<long?> RoomIdAsync(RecDBContext db, HttpRequest request, JsonElement? body, params string[] names)
{
var roomId = Long(body, names) ?? LongFromRequest(request, names);
if (roomId is > 0) return roomId;
var accountId = request.HttpContext.User.GetUserId();
if (accountId is not > 0) return null;
var presence = await db.Presences.AsNoTracking()
.FirstOrDefaultAsync(item => item.playerId == accountId.Value);
if (presence?.roomInstance is not > 0) return null;
return await db.RoomInstances.AsNoTracking()
.Where(instance => instance.roomInstanceId == presence.roomInstance.Value)
.Select(instance => (long?)instance.roomId)
.FirstOrDefaultAsync();
}
public static string NewId()
{
var bytes = RandomNumberGenerator.GetBytes(16);
return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
}
private static Dictionary<string, object?> ParsePairs(string raw)
{
var pairs = new Dictionary<string, object?>();
foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var at = pair.IndexOf('=');
var key = at < 0 ? pair : pair[..at];
var value = at < 0 ? "" : pair[(at + 1)..];
key = key.Trim();
if (key.Length == 0) continue;
try
{
pairs[Uri.UnescapeDataString(key.Replace('+', ' '))] =
Uri.UnescapeDataString(value.Replace('+', ' '));
}
catch (UriFormatException)
{
pairs[key] = value;
}
}
return pairs;
}
private static bool Matches(string name, string[] names)
{
foreach (var wanted in names)
{
if (string.Equals(Normalize(name), Normalize(wanted), StringComparison.Ordinal)) return true;
}
return false;
}
private static string Normalize(string name) =>
string.Concat(name.Where(char.IsLetterOrDigit)).ToLowerInvariant();
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace RadicadeServer.RecNet.Rooms
{
public class RoomFilters
{
public List<string> PinnedFilters { get; set; } = null!;
public List<string> PopularFilters { get; set; } = null!;
public List<string>? TrendingFilters { get; set; }
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using RadicadeServer.RecNet.Enums.Rooms;
namespace RadicadeServer.RecNet.Rooms
{
public class RoomInstance
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long roomInstanceId { get; set; }
public long roomId { get; set; } = 1;
public long subRoomId { get; set; } = 1;
public RoomInstanceType roomInstanceType { get; set; } = RoomInstanceType.Dormroom;
public string? location { get; set; } = "";
public string dataBlob { get; set; } = "";
public long? eventId { get; set; } = 0;
public long? clubId { get; set; } = 0;
public string? roomCode { get; set; } = "";
[NotMapped]
public string photonRegion { get; set; } = "us";
public string photonRegionId { get; set; } = "us";
public string photonRoomId { get; set; } = $"Radicade{Random.Shared.Next(232)}-{Guid.NewGuid().ToString().Replace("-", "")}";
public string name { get; set; } = "DormRoom";
public int maxCapacity { get; set; } = 4;
public bool isFull { get; set; } = false;
public bool isPrivate { get; set; } = false;
public bool isInProgress { get; set; } = false;
public bool EncryptVoiceChat { get; set; } = false;
public RoomInstance Copy() => (RoomInstance)MemberwiseClone();
}
public class FullRoomInstance : RoomInstance
{
public List<long> playerIds { get; set; } = new();
}
}
+59
View File
@@ -0,0 +1,59 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using RadicadeServer.RecNet.Enums.Players.Avatar;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.Radicade.Gifts.Enums;
namespace RadicadeServer.RecNet.Rooms
{
/// <summary>
/// One box a room hands a player when they join it. A room used to be able to describe
/// exactly one box in its own Rooms row; a row here is one more box, and the panel shows
/// them as a list. A room with no rows here still has its one box - see
/// <see cref="Utils.RoomJoinGiftUtils"/> - so a room configured before this table existed
/// keeps handing out what it always did.
/// </summary>
public class RoomJoinGift
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long RoomJoinGiftId { get; set; }
public long RoomId { get; set; }
/// <summary>Where the box sits in the room's list. Boxes are delivered in this order.</summary>
public int SortOrder { get; set; }
/// <summary>
/// Bumped whenever this box changes. The version is part of the box's gift drop id and
/// of a once-per-player claim, so a changed box is handed out again rather than being
/// suppressed by the claim a player already made on the old one.
/// </summary>
public int Version { get; set; } = 1;
public bool Enabled { get; set; } = true;
public bool OncePerPlayer { get; set; } = true;
public string Title { get; set; } = "Room Visit Gift";
public string Message { get; set; } = "Thanks for visiting!";
public string AvatarItem { get; set; } = "";
public AvatarItemType AvatarItemType { get; set; } = AvatarItemType.Outfit;
public string EquipmentItem { get; set; } = "";
public string EquipmentModificationGuid { get; set; } = "";
public string ConsumableItem { get; set; } = "";
public CurrencyType CurrencyType { get; set; } = CurrencyType.RecCenterTokens;
public int CurrencyAmount { get; set; } = 0;
public int Xp { get; set; } = 0;
public GiftContext Context { get; set; } = GiftContext.Game_Drop;
public Rarity Rarity { get; set; } = Rarity.Common;
/// <summary>The account the box is from, or zero for no sender.</summary>
public long FromPlayerId { get; set; } = 0;
}
}
+22
View File
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RadicadeServer.RecNet.Rooms
{
public class RoomKey
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long RoomKeyId { get; set; }
public string ReplicationId { get; set; } = null!;
public long RoomId { get; set; }
public string Name { get; set; } = null!;
public string Description { get; set; } = null!;
public int Price { get; set; }
}
}
+120
View File
@@ -0,0 +1,120 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Rooms;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Config;
using RadicadeServer.RecNet.Rooms;
namespace RadicadeServer.API.Rooms.Keys.V1
{
[Route("api/roomkeys/v1")]
[Route("econ/api/roomkeys/v1")]
public class RoomKeysAPI : Controller
{
private const int DefaultMaxKeysPerRoom = 500;
RecDBContext db;
public RoomKeysAPI(RecDBContext context) {
this.db = context;
}
[HttpGet("room")]
public IActionResult GetRoomKeysForRoom(long? roomId)
{
if (roomId is not > 0)
return Ok(Array.Empty<RoomKey>());
return Ok(
db.RoomKeys.Where(roomKey => roomKey.RoomId == roomId.Value).ToList()
);
}
[HttpGet("mine")]
public async Task<IActionResult> GetOwnedRoomkeys([FromQuery] long? roomId)
{
var userId = User.GetUserId();
if (roomId == null || userId == null) return Ok(Array.Empty<object>());
var ownedIds = (await db.Unlocks.AsNoTracking()
.FirstOrDefaultAsync(unlocks => unlocks.AccountId == userId.Value))?.RoomKeys
.FromJson<List<long>>() ?? new List<long>();
var keys = await db.RoomKeys.AsNoTracking()
.Where(key => key.RoomId == roomId.Value && ownedIds.Contains(key.RoomKeyId))
.ToListAsync();
return Ok(keys);
}
[HttpGet("purchased/{roomKeyId:long}")]
public async Task<IActionResult> IsRoomKeyPurchased(long roomKeyId)
{
var userId = User.GetUserId();
if (userId == null || roomKeyId <= 0) return Ok(false);
var roomKey = await db.RoomKeys.AsNoTracking()
.FirstOrDefaultAsync(key => key.RoomKeyId == roomKeyId);
if (roomKey == null) return Ok(false);
var unlocks = await db.Unlocks.AsNoTracking()
.FirstOrDefaultAsync(row => row.AccountId == userId.Value);
var ownedIds = unlocks?.RoomKeys.FromJson<List<long>>() ?? new List<long>();
return Ok(ownedIds.Contains(roomKeyId));
}
[HttpPost("create")]
[HttpPost("create/{roomId}")]
public async Task<IActionResult> CreateRoomKey()
{
var body = await RoomEconomyPayload.ReadBodyAsync(Request);
var roomId = await RoomEconomyPayload.RoomIdAsync(db, Request, body, "roomId", "roomKeyRoomId")
?? ReadRoomIdFromReferrer();
if (roomId is not > 0) return BadRequest(new { Message = "roomId is required." });
var key = new RoomKey
{
RoomId = roomId.Value,
Name = RoomEconomyPayload.String(body, "name", "keyName") ?? "Room Key",
Description = RoomEconomyPayload.String(body, "description") ?? string.Empty,
Price = (int)(RoomEconomyPayload.Long(body, "price", "tokenPrice", "cost") ?? 0),
ReplicationId = RoomEconomyPayload.String(body, "replicationId") ?? Guid.NewGuid().ToString()
};
var maxKeys = await MaxKeysPerRoomAsync();
var existing = await db.RoomKeys.CountAsync(existing => existing.RoomId == roomId.Value);
if (existing >= maxKeys)
{
return BadRequest(new { Message = $"A room can have at most {maxKeys} keys." });
}
await db.RoomKeys.AddAsync(key);
await db.SaveChangesAsync();
return Ok(key);
}
private long? ReadRoomIdFromReferrer()
{
var referer = Request.Headers.Referer.FirstOrDefault();
if (!Uri.TryCreate(referer, UriKind.Absolute, out var uri)) return null;
var parts = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
for (var index = parts.Length - 1; index >= 0; index--)
if (long.TryParse(parts[index], out var roomId) && roomId > 0) return roomId;
return null;
}
private string Describe(System.Text.Json.JsonElement? body)
{
var raw = RoomEconomyPayload.RawBody(Request);
if (raw.Length == 0 && body == null) return "(empty)";
if (raw.Length == 0) raw = body?.ToString() ?? "";
return raw.Length <= 1000 ? raw : raw[..1000] + "...";
}
private async Task<int> MaxKeysPerRoomAsync()
{
var config = await db.Config.FirstOrDefaultAsync();
var limit = config?.RoomKeyConfig?.FromJson<RoomKeyConfig>()?.MaxKeysPerRoom ?? 0;
return limit > 0 ? limit : DefaultMaxKeysPerRoom;
}
}
}
+13
View File
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RadicadeServer.RecNet.Rooms
{
public class RoomLoadScreen
{
public long RoomId { get; set; }
public string ImageName { get; set; } = null!;
public string Title { get; set; } = null!;
public string Subtitle { get; set; } = null!;
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace RadicadeServer.RecNet.Rooms
{
public class RoomPlayerData
{
public string data { get; set; } = null!;
}
}
+206
View File
@@ -0,0 +1,206 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.RecNet;
using RadicadeServer.RecNet.DB;
using RadicadeServer.RecNet.Rooms;
using RadicadeServer.Utils;
using static Microsoft.EntityFrameworkCore.DbLoggerCategory;
namespace RadicadeServer.RoomServer
{
[Route("rooms")]
public class RoomPlaylistAPI(RecDBContext db) : Controller
{
private async Task<PlaylistRoom?> GetBestOfPlaylist()
{
var topRooms = await db.Rooms
.Select(r => new
{
r.RoomId,
r.CreatorAccountId,
Engagement = db.RoomInteractions
.Where(i => i.RoomId == r.RoomId)
.Sum(i => (i.Favorited ? 1 : 0) + (i.Cheered ? 1 : 0))
})
.OrderByDescending(x => x.Engagement)
.ToListAsync();
var convertedRooms = new List<FullRoom>();
var tags = new List<ItemTag>();
foreach (var foundRoom in topRooms)
{
var room = await RoomUtils.GetRoom(foundRoom.RoomId);
if (room == null || room.Accessibility != RecNet.Enums.Accessibility.Public || room.IsDorm) continue;
convertedRooms.Add(room);
foreach (var tag in room.Tags)
tags.Add(tag);
}
if (convertedRooms.Count <= 0)
return null;
var mainRoom = new PlaylistRoom(convertedRooms[0]);
return mainRoom;
}
[HttpGet("playlists/{playlistId}")]
public async Task<object?> GetPlaylistRooms(long playlistId)
{
List<FullRoom> convertedRooms;
var tags = new List<ItemTag>();
if (playlistId == 24226488)
{
var mainRoom = await GetBestOfPlaylist();
if (mainRoom == null) return null;
convertedRooms = mainRoom.Rooms;
tags = mainRoom.Tags;
}
else
{
var playlist = await db.Playlists.FirstOrDefaultAsync(x => x.PlaylistId == playlistId);
if (playlist == null) return null;
var keywords = playlist.GetKeywords();
var foundRooms = await db.Rooms
.Where(room => room.Accessibility == RecNet.Enums.Accessibility.Public
&& keywords.Any(k => room.Name.Contains(k) || room.Tags.Contains(k)))
.ToListAsync();
convertedRooms = new List<FullRoom>();
foreach (var foundRoom in foundRooms)
{
var room = await RoomUtils.GetRoom(foundRoom.RoomId);
if (room != null)
{
convertedRooms.Add(room);
foreach (var tag in room.Tags)
tags.Add(tag);
}
}
}
var roomDtos = convertedRooms.Select(room => ToPlaylistRoomDict(room, playlistId)).ToArray();
var tagDtos = tags
.Select(t => new Dictionary<string, object?> { ["Tag"] = t.Tag, ["Type"] = (int)t.Type })
.ToArray();
var root = ToPlaylistRoomDict(convertedRooms.FirstOrDefault(), playlistId);
root["Rooms"] = roomDtos;
root["Tags"] = tagDtos;
root["community"] = Array.Empty<object>();
root["junior"] = Array.Empty<object>();
root["pickup"] = Array.Empty<object>();
root["screen"] = Array.Empty<object>();
root["teleportvr"] = Array.Empty<object>();
root["walkvr"] = Array.Empty<object>();
root["PinnedFilters"] = Array.Empty<object>();
root["PopularFilters"] = Array.Empty<object>();
root["TrendingFilters"] = Array.Empty<object>();
return root;
}
private static Dictionary<string, object?> ToPlaylistRoomDict(FullRoom? room, long playlistId)
{
var dict = new Dictionary<string, object?>
{
["AnnouncementId"] = 0L,
["AnnouncementType"] = 0,
["Body"] = "",
["LinkName"] = "",
["LinkType"] = 0,
["LinkUri"] = "",
["Platform"] = 0,
["Title"] = "",
["PlaylistId"] = playlistId,
["RoomId"] = 0L,
["IsDorm"] = false,
["MaxPlayers"] = 0,
["MaxPlayerCalculationMode"] = 0,
["CloningAllowed"] = false,
["DisableMicAutoMute"] = false,
["DisableRoomComments"] = false,
["EncryptVoiceChat"] = false,
["Name"] = "",
["Description"] = "",
["ImageName"] = "",
["WarningMask"] = 0,
["CustomWarning"] = "",
["CreatorAccountId"] = 0L,
["State"] = 0,
["Accessibility"] = 1,
["SupportsLevelVoting"] = false,
["IsRRO"] = false,
["SupportsScreens"] = false,
["SupportsWalkVR"] = false,
["SupportsTeleportVR"] = false,
["SupportsVRLow"] = false,
["SupportsQuest2"] = false,
["SupportsMobile"] = false,
["SupportsJuniors"] = false,
["MinLevel"] = 0,
["CreatedAt"] = DateTime.UnixEpoch,
["Stats"] = new Dictionary<string, object?>
{
["RoomId"] = 0L,
["CheerCount"] = 0,
["FavoriteCount"] = 0,
["VisitorCount"] = 0,
["VisitCount"] = 0
},
["Version"] = 1,
["SubRooms"] = Array.Empty<object>(),
["Roles"] = Array.Empty<object>(),
["LoadScreens"] = Array.Empty<object>(),
["PromoImages"] = Array.Empty<object>(),
["PromoExternalContent"] = Array.Empty<object>(),
["DataBlob"] = "",
["Tags"] = Array.Empty<object>()
};
if (room != null)
{
var element = System.Text.Json.JsonSerializer.SerializeToElement(room, room.GetType());
var roomDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(element) ?? new Dictionary<string, object?>();
foreach (var kv in roomDict)
dict[kv.Key] = kv.Value;
}
return dict;
}
[HttpGet("rooms/recommendations")]
public async Task<List<FullRoom>?> GetRecommendedRooms()
{
var topRooms = await db.Rooms
.Select(r => new
{
r.RoomId,
r.CreatorAccountId,
Engagement = db.RoomInteractions
.Where(i => i.RoomId == r.RoomId)
.Sum(i => (i.Favorited ? 1 : 0) + (i.Cheered ? 1 : 0))
})
.OrderByDescending(x => x.Engagement)
.Take(10)
.ToListAsync();
var convertedRooms = new List<FullRoom>();
foreach (var room in topRooms)
{
var converted = await RoomUtils.GetRoom(room.RoomId);
if (converted != null && converted.Accessibility == RecNet.Enums.Accessibility.Public && !converted.IsDorm)
convertedRooms.Add(converted);
}
return convertedRooms;
}
}
}
+203
View File
@@ -0,0 +1,203 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Notifications;
using RadicadeServer.API.Storefront;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Enums.Messages;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.RecNet.Notifications;
using RadicadeServer.RecNet.Progression;
namespace RadicadeServer.Utils
{
public static class RoomProgressionService
{
public const int XpPerInterval = 10;
public static readonly TimeSpan Interval = TimeSpan.FromMinutes(5);
public const int RoomXpCap = 60;
public const int DailyXpCap = 150;
public static readonly TimeSpan DailyCapCooldown = TimeSpan.FromHours(24);
public const string ProgressionLevelUpdateNotificationId = "PlayerProgressionLevelUpdate";
public static async Task<int> AwardRoomXpAsync(RecDBContext db, long playerId, long roomId, bool isPublicRoom)
{
if (playerId <= 0 || roomId <= 0 || !isPublicRoom) return 0;
var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
if (progression == null)
{
progression = new ProgressionData { PlayerId = playerId };
await db.Progression.AddAsync(progression);
}
var json = progression.GetProgressionJson() ?? new ProgressionJson();
var now = DateTime.UtcNow;
if (json.XpEarnedTodayDate != now.Date)
{
json.XpEarnedToday = 0;
json.XpEarnedTodayDate = now.Date;
json.XpDailyCapReachedAt = null;
}
if (json.XpEarnedToday >= DailyXpCap)
{
if (json.XpDailyCapReachedAt is { } reachedAt && now >= reachedAt.Add(DailyCapCooldown))
{
json.XpEarnedToday = 0;
json.XpEarnedTodayDate = now.Date;
json.XpDailyCapReachedAt = null;
}
else
{
json.XpDailyCapReachedAt ??= now;
progression.Progress = json.ToJson();
await db.SaveChangesAsync();
return 0;
}
}
if (json.RoomXpRoomId != roomId)
{
json.RoomXpRoomId = roomId;
json.RoomXpEarned = 0;
json.RoomXpLastGrantAt = now;
progression.Progress = json.ToJson();
await db.SaveChangesAsync();
return 0;
}
if (json.RoomXpEarned >= RoomXpCap)
{
progression.Progress = json.ToJson();
await db.SaveChangesAsync();
return 0;
}
json.RoomXpLastGrantAt ??= now;
int granted = 0;
while (now - json.RoomXpLastGrantAt.Value >= Interval)
{
int roomHeadroom = RoomXpCap - (json.RoomXpEarned + granted);
int dailyHeadroom = DailyXpCap - (json.XpEarnedToday + granted);
int instalment = Math.Min(XpPerInterval, Math.Min(roomHeadroom, dailyHeadroom));
if (instalment <= 0) break;
granted += instalment;
json.RoomXpLastGrantAt = json.RoomXpLastGrantAt.Value.Add(Interval);
}
if (granted == 0 && now - json.RoomXpLastGrantAt.Value >= Interval)
json.RoomXpLastGrantAt = now;
if (granted == 0)
{
progression.Progress = json.ToJson();
await db.SaveChangesAsync();
return 0;
}
int applied = (int)Math.Min(granted, int.MaxValue);
json.RoomXpEarned += applied;
json.XpEarnedToday += applied;
json.XpEarnedTodayDate = now.Date;
json.LastXpEarnedAt = now;
if (json.XpEarnedToday >= DailyXpCap)
json.XpDailyCapReachedAt ??= now;
int oldLevel = progression.Level;
progression.XP = (int)Math.Min(progression.XP + applied, int.MaxValue);
progression.Progress = json.ToJson();
await db.SaveChangesAsync();
await StorefrontShared.SendXpBalanceUpdateAsync(playerId,
LevelUpGiftService.XpIntoCurrentLevel(progression), applied,
StorefrontBalanceAddTypes.DirectBalanceWithMultiplier);
int computedLevel = LevelUpGiftService.ComputeLevel(LevelUpGiftService.ParseMap(), progression.XP);
if (computedLevel > progression.Level) progression.Level = computedLevel;
await LevelUpGiftService.DeliverLevelUpGiftsAsync(db, playerId,
recomputeFromXp: true, fromLevel: oldLevel, notify: true);
await SendProgressionLevelUpdateAsync(db, playerId, progression);
if (progression.Level > oldLevel)
{
await PushProgressionUpdateAsync(db, playerId);
}
return applied;
}
public static async Task SendProgressionLevelUpdateAsync(RecDBContext db, long playerId,
ProgressionData? progression = null)
{
if (playerId <= 0) return;
try
{
progression ??= await db.Progression.AsNoTracking()
.FirstOrDefaultAsync(x => x.PlayerId == playerId);
if (progression == null) return;
var notification = new Notification
{
Id = ProgressionLevelUpdateNotificationId,
Msg = new PlayerProgressionLevelUpdateNotification(progression)
};
await Notify.SendNotify(playerId, notification);
await Notify.SendToSubscribers(playerId, notification);
}
catch (Exception)
{
}
}
public static async Task PushProgressionUpdateAsync(RecDBContext db, long playerId)
{
await SendProgressionLevelUpdateAsync(db, playerId);
try
{
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == playerId);
if (profile == null) return;
var plrData = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == playerId);
var progression = await db.Progression.AsNoTracking().FirstOrDefaultAsync(x => x.PlayerId == playerId);
if (progression != null)
await StorefrontShared.SendXpBalanceUpdateAsync(playerId,
LevelUpGiftService.XpIntoCurrentLevel(progression), 0,
StorefrontBalanceAddTypes.LevelUp);
await Notify.SendNotify(playerId, new Notification
{
Id = "AccountUpdate",
Msg = profile
});
await Notify.UpdateSubscriptions(playerId, Notifs.SubscriptionUpdateProfile, profile);
if (plrData != null)
await Notify.SendNotify(playerId, new Notification
{
Id = "SelfAccountUpdate",
Msg = PlayerUtils.BuildSelfAccount(profile, plrData)
});
}
catch (Exception)
{
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using RadicadeServer.RecNet.Enums.Rooms;
namespace RadicadeServer.RecNet.Rooms
{
public class RoomRole
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity), JsonIgnore]
public long RoleId { get; set; }
[JsonIgnore]
public long RoomId { get; set; }
public long AccountId { get; set; }
public RoleType Role { get; set; }
public RoleType InvitedRole { get; set; }
public long? LastChangedByAccountId { get; set; }
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace RadicadeServer.RecNet.Rooms
{
public class RoomRoleResponse
{
public string context { get; set; } = null!;
public long? creatorPlayerCoOwnerRoomPermission { get; set; }
public long? creatorPlayerHostRoomPermission { get; set; }
public long? creatorPlayerModeratorRoomPermission { get; set; }
public long? creatorPlayerRole { get; set; }
public long roomId { get; set; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace RadicadeServer.RecNet.Rooms
{
public class RoomSearchResult
{
public long TotalResults { get; set; }
public List<RoomDetail> Results { get; set; } = null!;
}
}
+1860
View File
@@ -0,0 +1,1860 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SixLabors.ImageSharp;
using System;
using System.Text;
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.Enums;
using RadicadeServer.RecNet.Enums.Messages;
using RadicadeServer.RecNet.Enums.Rooms;
using RadicadeServer.RecNet.Messages;
using RadicadeServer.RecNet.Notifications;
using RadicadeServer.RecNet.Responses;
using RadicadeServer.RecNet.Rooms;
using RadicadeServer.API.Studio;
using RadicadeServer.Radicade.Discord;
using RadicadeServer.Radicade.Discord.Templates;
using RadicadeServer.Utils;
namespace RadicadeServer.RoomServer
{
[Route("rooms/")]
public class RoomServerAPI : Controller
{
private const int RoomSearchHardLimit = 50;
RecDBContext db;
public RoomServerAPI(RecDBContext context)
{
this.db = context;
}
[HttpGet("showcase/{playerId}")]
public async Task<ActionResult<long[]>> GetPlayerShowcaseRooms(long playerId)
{
var roomIds = await db.RoomRoles
.Where(x => x.AccountId == playerId && x.Role >= RoleType.CoOwner)
.Select(x => x.RoomId)
.ToListAsync();
if (roomIds.Count == 0)
{
roomIds = await db.Rooms
.Where(x => x.CreatorAccountId == playerId && x.Accessibility >= Accessibility.Public)
.Select(x => x.RoomId)
.ToListAsync();
}
var showcaseRooms = await RoomUtils.VisibleRooms(
await db.Rooms.Where(x => roomIds.Contains(x.RoomId)).ToListAsync(),
User.GetUserId());
return Ok(showcaseRooms.Select(x => x.RoomId).Distinct().ToArray());
}
[HttpDelete("rooms/{roomId}"), Authorize]
public async Task<Success> DeleteRoom(long roomId)
{
var userId = User.GetUserId();
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return new Success(false, "Room does not exist.", null);
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.Creator);
if (!canModify) return Success.Error("Insufficient permissions");
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 key in keys)
{
db.RoomKeys.Remove(key);
}
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);
}
db.Rooms.Remove(room);
await db.SaveChangesAsync();
return new Success(true, null, null);
}
[HttpDelete("rooms/{roomId}/subrooms/{subroomId}"), Authorize]
public async Task<Success> DeleteSubRoom(long roomId, long subroomId)
{
var userId = User.GetUserId();
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return new Success(false, "Room does not exist.", null);
var subroom = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subroomId && x.RoomId == roomId);
if (subroom == null) return new Success(false, "Subroom does not exist.", null);
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.Creator);
if (!canModify) return Success.Error("Insufficient permissions");
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();
var fullRoom = await RoomUtils.GetRoom(roomId);
await Notify.SendNotify((long)userId!, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, null);
}
[HttpGet("rooms/{roomId}/playerdata/me")]
public ActionResult<RoomPlayerData> GetRoomPlayerData()
{
return Ok(new RoomPlayerData { data = "" });
}
[HttpGet("rooms/curated_playlists")]
public async Task<IActionResult> GetCuratedPlaylists()
{
var playlistIds = await db.Playlists
.OrderBy(x => x.PlaylistId)
.Select(x => x.PlaylistId)
.ToListAsync();
if (playlistIds.Count == 0)
playlistIds.Add(24226488);
return Ok(playlistIds.Distinct().ToArray());
}
[HttpGet("rooms/moderatedby/me"), Authorize]
public async Task<List<RoomData>?> GetRoomsModeratedByMe()
{
long? userId = User.GetUserId();
if (userId == null) return null;
var roles = await db.RoomRoles.Where(x => x.AccountId == userId && x.Role >= RoleType.Moderator).ToListAsync();
var rooms = new List<RoomData>();
foreach (var role in roles)
{
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == role.RoomId);
if (room != null) rooms.Add(room);
}
return await RoomUtils.VisibleRooms(rooms, userId);
}
[HttpGet("rooms/visitedby/{playerId:long}"), Authorize]
public async Task<IActionResult> GetVisitedRoomsForPlayer(long playerId, [FromQuery] int skip = 0, [FromQuery] int take = 10)
{
var viewerId = User.GetUserId();
skip = Math.Max(skip, 0);
take = Math.Clamp(take, 1, 100);
var roomIds = await db.RoomInteractions
.Where(x => x.PlayerId == playerId && x.Visited)
.OrderByDescending(x => x.LastVisted)
.Select(x => x.RoomId)
.Distinct()
.Skip(skip)
.Take(take)
.ToListAsync();
var rooms = new List<RoomData>();
foreach (var roomId in roomIds)
{
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room != null) rooms.Add(room);
}
rooms = await RoomUtils.VisibleRooms(rooms, viewerId);
var convert = await RoomUtils.BulkConvertRoomDataToRoomDetail(rooms);
return Ok(convert);
}
[HttpGet("rooms/visitedby/me"), Authorize]
public async Task<IActionResult> GetVisitedRooms([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
long? userId = User.GetUserId();
if (userId == null) return Unauthorized();
skip = Math.Max(skip, 0);
take = Math.Clamp(take, 1, 100);
var roomIds = await db.RoomInteractions
.Where(x => x.PlayerId == userId && x.Visited)
.OrderByDescending(x => x.LastVisted)
.Select(x => x.RoomId)
.Distinct()
.Skip(skip)
.Take(take)
.ToListAsync();
var rooms = new List<RoomData>();
foreach (var roomId in roomIds)
{
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room != null) rooms.Add(room);
}
rooms = await RoomUtils.VisibleRooms(rooms, userId);
var convert = await RoomUtils.BulkConvertRoomDataToRoomDetail(rooms);
return Ok(convert);
}
[HttpGet("rooms/topcreators")]
public async Task<List<RoomDetail>?> GetRoomsFromTopCreators()
{
var topCreators = await db.RoomInteractions.Where(i => i.Favorited)
.Join(db.Rooms,
interaction => interaction.RoomId,
room => room.RoomId,
(interaction, room) => new
{
room.CreatorAccountId
}).GroupBy(x => x.CreatorAccountId)
.Select(g => new
{
CreatorId = g.Key,
FavoriteCount = g.Count()
}).OrderByDescending(x => x.FavoriteCount).Take(10).Select(x => x.CreatorId).ToListAsync();
if (!topCreators.Any())
return [];
var rooms = await db.Rooms.Where(r => topCreators.Contains(r.CreatorAccountId)).OrderByDescending(r => r.RoomId).Take(10 * 3).ToListAsync();
rooms.RemoveAll(x => x.Accessibility != Accessibility.Public);
return await RoomUtils.BulkConvertRoomDataToRoomDetail(rooms);
}
[HttpGet("rooms/bulk")]
public async Task <List<RoomData>?> GetBulkRooms([FromQuery] string? name, [FromQuery] List<long>? id)
{
List<RoomData> rooms = new List<RoomData>();
if (name != null)
{
rooms = await db.Rooms.Where(x => x.Name.Contains(name)).ToListAsync();
} else if (id != null)
{
rooms.Clear();
foreach (var i in id)
{
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == i);
if (room != null)
rooms.Add(room);
}
}
return await RoomUtils.VisibleRooms(rooms, User.GetUserId());
}
[HttpGet("featuredrooms/current")]
public async Task<IActionResult> GetCurrentFeaturedRooms()
{
var candidates = await db.Rooms.AsNoTracking()
.Where(x => x.State == RoomState.Active && x.Tags.ToLower().Contains("featured"))
.OrderBy(x => x.RoomId)
.ToListAsync();
var featured = candidates
.Where(room => StringTools.ReadTags(room.Tags)
.Any(tag => string.Equals(tag.Tag, "featured", StringComparison.OrdinalIgnoreCase)))
.ToList();
featured = await RoomUtils.VisibleRooms(featured, User.GetUserId());
if (featured.Count == 0)
{
return NotFound();
}
return Ok(new FeaturedRoomGroup
{
FeaturedRoomGroupId = 1,
Name = "Featured Rooms",
Rooms = featured.Select(room => new FeaturedRoom
{
RoomId = room.RoomId,
RoomName = room.Name,
ImageName = string.IsNullOrWhiteSpace(room.ImageName) ? room.Name : room.ImageName
}).ToList()
});
}
[HttpGet("roomsandplaylists/search")]
public async Task<RoomSearchResult?> GetBulkRoomsOld([FromQuery] string query)
{
if (query == null) return null;
query = query.Replace("#", "").Trim();
var rooms = await db.Rooms.Where(x => (x.Name.ToLower().Contains(query) || x.Tags.ToLower().Contains(query)) && x.Accessibility == Accessibility.Public).ToListAsync();
var convert = await RoomUtils.BulkConvertRoomDataToRoomDetail(rooms);
return new RoomSearchResult { TotalResults = rooms.Count, Results = convert };
}
[HttpGet("rooms/search_rooms/{source}")]
public async Task<IActionResult> SearchRoomsBySource(string source, [FromQuery] int skip = -1, [FromQuery] int take = 20)
{
var (src, pSkip, pTake) = ParseEmbeddedQuery(source, 20);
if (skip < 0) skip = pSkip;
if (take == 20 && pTake != 20) take = pTake;
skip = Math.Max(skip, 0);
take = Math.Clamp(take, 1, 100);
if (string.Equals(src, "MyClubhouses", StringComparison.OrdinalIgnoreCase) ||
string.Equals(src, "MyClubs", StringComparison.OrdinalIgnoreCase) ||
string.Equals(src, "Clubhouse", StringComparison.OrdinalIgnoreCase))
{
return Ok(new RoomSearchResult { TotalResults = 0, Results = new List<RoomDetail>() });
}
IQueryable<RoomData> q;
if (!string.IsNullOrWhiteSpace(src) && !src.Contains('&'))
{
var kw = Uri.UnescapeDataString(src).Replace('+', ' ').Trim().Split(' ',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(k => k.TrimStart('#')).Where(k => k.Length > 0).ToList();
q = db.Rooms.Where(x => x.Accessibility == Accessibility.Public && x.State == RoomState.Active
&& kw.All(k => x.Name.ToLower().Contains(k) || x.Tags.ToLower().Contains(k)));
}
else
{
q = db.Rooms.Where(x => x.Accessibility == Accessibility.Public && x.State == RoomState.Active);
}
var rooms = await q.OrderBy(x => x.RoomId).Skip(skip).Take(take).ToListAsync();
var convert = await RoomUtils.BulkConvertRoomDataToRoomDetail(rooms);
return Ok(new RoomSearchResult { TotalResults = convert.Count, Results = convert });
}
private static (string Source, int Skip, int Take) ParseEmbeddedQuery(string? source, int defTake)
{
var src = source ?? string.Empty;
int skip = 0, take = defTake;
int amp = src.IndexOf('&');
if (amp >= 0)
{
foreach (var pair in src.Substring(amp + 1).Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var kv = pair.Split('=', 2);
if (kv.Length == 2 && int.TryParse(kv[1], out var n))
{
if (kv[0] == "skip") skip = n;
else if (kv[0] == "take") take = n;
}
}
src = src.Substring(0, amp);
}
return (Uri.UnescapeDataString(src), Math.Max(skip, 0), Math.Clamp(take, 1, 100));
}
[HttpGet("rooms/hot_rooms/{*source}")]
[HttpGet("hot_rooms/{*source}")]
public async Task<IActionResult> GetHotRoomsPaged(string? source)
{
var (query, skip, take) = ParseEmbeddedQuery(source, 24);
query = query.Trim();
IQueryable<RoomData> q = db.Rooms
.Where(x => x.Accessibility == Accessibility.Public && x.State == RoomState.Active);
if (!string.IsNullOrWhiteSpace(query) && query != "all")
{
var tags = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => t.TrimStart('#')).Where(t => t.Length > 0).ToList();
if (tags.Count > 0)
q = q.Where(x => tags.All(t => x.Tags.Contains(t)));
}
var rooms = await q.OrderBy(x => x.RoomId).Skip(skip).Take(take).ToListAsync();
if (rooms.Count == 0 && skip == 0)
{
rooms = await db.Rooms
.Where(x => x.Accessibility == Accessibility.Public && x.State == RoomState.Active)
.OrderBy(x => x.RoomId).Take(take).ToListAsync();
}
return Ok(await RoomUtils.BulkConvertRoomDataToRoomDetail(rooms));
}
[HttpGet("rooms/carousel/{*source}")]
public async Task<IActionResult> GetCarouselRooms(string? source, [FromQuery] int skip = -1, [FromQuery] int take = 20)
{
var (src, pSkip, pTake) = ParseEmbeddedQuery(source, 20);
if (skip < 0) skip = pSkip;
if (take == 20 && pTake != 20) take = pTake;
skip = Math.Max(skip, 0);
take = Math.Clamp(take, 1, 100);
var q = db.Rooms.Where(x => x.Accessibility == Accessibility.Public && x.State == RoomState.Active);
var keyword = src.Trim().TrimStart('#').ToLowerInvariant();
if (keyword.Length > 0)
q = q.Where(x => x.Name.ToLower().Contains(keyword) || x.Tags.ToLower().Contains(keyword));
var rooms = await q.OrderBy(x => x.RoomId).Skip(skip).Take(take).ToListAsync();
if (rooms.Count == 0 && skip == 0)
{
rooms = await db.Rooms
.Where(x => x.Accessibility == Accessibility.Public && x.State == RoomState.Active)
.OrderBy(x => x.RoomId).Take(take).ToListAsync();
}
var convert = await RoomUtils.BulkConvertRoomDataToRoomDetail(rooms);
return Ok(new RoomSearchResult { Results = convert, TotalResults = convert.Count });
}
[HttpGet("rooms/my_visited_rooms/{*source}"), Authorize]
[HttpGet("my_visited_rooms/{*source}"), Authorize]
public async Task<IActionResult> GetMyVisitedRoomsPaged(string? source)
{
var userId = User.GetUserId();
if (!userId.HasValue) return Unauthorized();
var (_, skip, take) = ParseEmbeddedQuery(source, 24);
var rooms = await db.Rooms
.Where(x => db.RoomInteractions.Any(i => i.PlayerId == userId && i.RoomId == x.RoomId)
&& x.Accessibility == Accessibility.Public)
.OrderByDescending(x => x.RoomId).Skip(skip).Take(take).ToListAsync();
return Ok(await RoomUtils.BulkConvertRoomDataToRoomDetail(rooms));
}
[HttpGet("rooms/roomsbycreators/{*source}")]
[HttpGet("roomsbycreators/{*source}")]
public async Task<IActionResult> GetRoomsByCreatorsPaged(string? source)
{
var (ids, skip, take) = ParseEmbeddedQuery(source, 24);
var creatorIds = ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var n) ? n : -1)
.Where(n => n > 0).ToList();
var rooms = creatorIds.Count == 0
? new List<RoomData>()
: await db.Rooms.Where(x => creatorIds.Contains(x.CreatorAccountId)
&& x.Accessibility == Accessibility.Public && x.State == RoomState.Active)
.OrderBy(x => x.RoomId).Skip(skip).Take(take).ToListAsync();
return Ok(await RoomUtils.BulkConvertRoomDataToRoomDetail(rooms));
}
[HttpGet("rooms/rro_ids")]
public async Task<IActionResult> GetRroRoomIds()
{
var ids = await db.Rooms.Where(x => x.IsRRO && x.Accessibility == Accessibility.Public)
.OrderBy(x => x.RoomId).Select(x => x.RoomId).ToListAsync();
return Ok(ids);
}
[HttpGet("rooms/search")]
public async Task<RoomSearchResult?> SearchRooms([FromQuery] string query)
{
if (query == null) return null;
query = query.ToLower();
query = query.Replace("+", " ").Trim();
query = query.Replace("#", "").Trim();
var keywords = query.Split(' ', StringSplitOptions.TrimEntries);
var rooms = await db.Rooms.Where(x =>
keywords.All(k => (x.Name.ToLower().Contains(k) || x.Tags.ToLower().Contains(k)) && x.Accessibility == Accessibility.Public)
).ToListAsync();
var convert = await RoomUtils.BulkConvertRoomDataToRoomDetail(rooms);
return new RoomSearchResult { TotalResults = rooms.Count, Results = convert };
}
[HttpGet("rooms/createdby/{who}"), Authorize]
public async Task<IActionResult> GetRoomsBy(string who)
{
long? userId;
if (who == "me")
userId = User.GetUserId();
else
userId = db.Profiles.FirstOrDefault(x => x.username == who)?.accountId;
if (userId == null)
userId = db.Profiles.FirstOrDefault(x => x.accountId.ToString() == who)?.accountId;
if (userId == null) return Unauthorized();
var roles = await db.RoomRoles.Where(x => x.AccountId == userId && x.Role >= RoleType.CoOwner).ToListAsync();
var rooms = new List<RoomData>();
foreach (var role in roles)
{
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == role.RoomId);
if (room != null)
rooms.Add(room);
}
return Ok(await RoomUtils.BulkConvertRoomDataToRoomDetail(
await RoomUtils.VisibleRooms(rooms, User.GetUserId())));
}
[HttpGet("rooms/ownedby/{who}"), Authorize]
public async Task<IActionResult> GetRoomsByOld(string who)
{
long? userId;
if (who == "me")
userId = User.GetUserId();
else
userId = db.Profiles.FirstOrDefault(x => x.username == who)?.accountId;
if (userId == null)
userId = db.Profiles.FirstOrDefault(x => x.accountId.ToString() == who)?.accountId;
if (userId == null) return Unauthorized();
var roomIds = await db.RoomRoles
.Where(x => x.AccountId == userId && x.Role >= RoleType.CoOwner)
.Select(x => x.RoomId)
.Distinct()
.ToListAsync();
var rooms = new List<RoomData>();
foreach (var roomId in roomIds)
{
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room != null) rooms.Add(room);
}
return Ok(await RoomUtils.BulkConvertRoomDataToRoomDetail(
await RoomUtils.VisibleRooms(rooms, User.GetUserId())));
}
[HttpGet("rooms/hot")]
public async Task<RoomSearchResult?> GetHotRooms(string? tag)
{
if (tag == null)
{
var list = await db.Rooms.Where(x => x.Accessibility == Accessibility.Public && x.Name != "DormRoom").ToListAsync();
var bulkConvert = await RoomUtils.BulkConvertRoomDataToRoomDetail(list);
return new RoomSearchResult { Results = bulkConvert, TotalResults = bulkConvert.Count };
}
else
{
switch (tag)
{
default:
var list1 = await db.Rooms.Where(x => x.Tags.Contains(tag) && x.Accessibility == Accessibility.Public && !x.IsDorm).ToListAsync();
if (list1.Count == 0)
{
list1 = await db.Rooms.Where(x => x.Accessibility == Accessibility.Public && !x.IsDorm).ToListAsync();
}
var bulkConvert1 = await RoomUtils.BulkConvertRoomDataToRoomDetail(list1);
return new RoomSearchResult { Results = bulkConvert1, TotalResults = bulkConvert1.Count };
case "new":
var list2 = await db.Rooms.Where(x => x.Accessibility == Accessibility.Public && !x.IsDorm).OrderByDescending(x => x.PublishedAt).ToListAsync();
var bulkConvert2 = await RoomUtils.BulkConvertRoomDataToRoomDetail(list2);
return new RoomSearchResult { Results = bulkConvert2, TotalResults = bulkConvert2.Count };
case "rro":
var list3 = await db.Rooms.Where(x => x.IsRRO && !x.IsDorm).OrderByDescending(x => x.PublishedAt).ToListAsync();
var bulkConvert3 = await RoomUtils.BulkConvertRoomDataToRoomDetail(list3);
return new RoomSearchResult { Results = bulkConvert3, TotalResults = bulkConvert3.Count };
}
}
}
[HttpGet("roomsandplaylists/hot")]
public async Task<RoomSearchResult?> GetHotRoomsOld()
{
var list = await db.Rooms.Where(x => x.Accessibility == Accessibility.Public && x.Name != "DormRoom").ToListAsync();
var convert = await RoomUtils.BulkConvertRoomDataToRoomDetail(list);
return new RoomSearchResult { Results = convert, TotalResults = convert.Count };
}
[HttpPut("rooms/{id}/interactionby/me/favorite"), Authorize]
public async Task<PlayerInteraction?> FavouriteRoom(long id)
{
var userId = User.GetUserId();
PlayerInteractionDB? interaction = await db.RoomInteractions.FirstOrDefaultAsync(x => x.PlayerId == userId && x.RoomId == id);
if (interaction == null)
{
var favoriteResult = new PlayerInteractionDB { PlayerId = (long)userId!, RoomId = id, Cheered = false, Favorited = true };
await db.RoomInteractions.AddAsync(favoriteResult);
await db.SaveChangesAsync();
interaction = favoriteResult;
} else
{
interaction.Favorited = true;
await db.SaveChangesAsync();
}
return new PlayerInteraction(interaction);
}
[HttpDelete("rooms/{id}/interactionby/me/favorite"), Authorize]
public async Task<PlayerInteraction?> UnfavouriteRoom(long id)
{
var userId = User.GetUserId();
PlayerInteractionDB? interaction = await db.RoomInteractions.FirstOrDefaultAsync(x => x.PlayerId == userId && x.RoomId == id);
if (interaction == null)
{
var favoriteResult = new PlayerInteractionDB { PlayerId = (long)userId!, RoomId = id, Cheered = false, Favorited = false };
await db.RoomInteractions.AddAsync(favoriteResult);
await db.SaveChangesAsync();
interaction = favoriteResult;
}
else
{
interaction.Favorited = false;
await db.SaveChangesAsync();
}
return new PlayerInteraction(interaction);
}
[HttpPut("rooms/{id}/interactionby/me/cheer"), Authorize]
public async Task<PlayerInteraction?> CheerRoom(long id)
{
var userId = User.GetUserId();
var interaction = await db.RoomInteractions.FirstOrDefaultAsync(x => x.PlayerId == userId && x.RoomId == id);
if (interaction == null)
{
var favoriteResult = new PlayerInteractionDB { PlayerId = (long)userId!, RoomId = id, Cheered = true, Favorited = false };
await db.RoomInteractions.AddAsync(favoriteResult);
await db.SaveChangesAsync();
interaction = favoriteResult;
}
else
{
interaction.Cheered = true;
await db.SaveChangesAsync();
}
return new PlayerInteraction(interaction);
}
[HttpDelete("rooms/{id}/interactionby/me/cheer"), Authorize]
public async Task<PlayerInteraction?> UncheerRoom(long id)
{
var userId = User.GetUserId();
var interaction = await db.RoomInteractions.FirstOrDefaultAsync(x => x.PlayerId == userId && x.RoomId == id);
if (interaction == null)
{
var favoriteResult = new PlayerInteractionDB { PlayerId = (long)userId!, RoomId = id, Cheered = false, Favorited = false };
await db.RoomInteractions.AddAsync(favoriteResult);
await db.SaveChangesAsync();
interaction = favoriteResult;
}
else
{
interaction.Cheered = false;
await db.SaveChangesAsync();
}
return new PlayerInteraction(interaction);
}
[HttpGet("rooms/favoritedby/{who}"), Authorize]
public async Task<IActionResult> GetFavouritedBy(string who)
{
long? userId;
if (who == "me")
userId = User.GetUserId();
else
userId = db.Profiles.FirstOrDefault(x => x.username == who)?.accountId;
if (userId == null)
userId = db.Profiles.FirstOrDefault(x => x.accountId.ToString() == who)?.accountId;
if (userId == null) return Unauthorized();
var roomIds = await db.RoomInteractions
.Where(x => x.PlayerId == userId && x.Favorited)
.OrderByDescending(x => x.FavoritedAt)
.Select(x => x.RoomId)
.Distinct()
.ToListAsync();
var rooms = new List<RoomData>();
foreach (var roomId in roomIds)
{
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room != null) rooms.Add(room);
}
return Ok(await RoomUtils.BulkConvertRoomDataToRoomDetail(
await RoomUtils.VisibleRooms(rooms, User.GetUserId())));
}
[HttpGet("rooms/{id}/interactionby/{who}"), Authorize]
public async Task<PlayerInteraction?> GetInteractionBy(long id, string who)
{
long? userId;
if (who == "me")
userId = User.GetUserId();
else
userId = db.Profiles.FirstOrDefault(x => x.username == who)?.accountId;
if (userId == null)
userId = db.Profiles.FirstOrDefault(x => x.accountId.ToString() == who)?.accountId;
if (userId == null) return null;
var interaction = await db.RoomInteractions.FirstOrDefaultAsync(x => x.PlayerId == userId && x.RoomId == id);
if (interaction == null) return new PlayerInteraction { Cheered = false, Favorited = false };
return new PlayerInteraction(interaction);
}
[HttpGet("rooms/base")]
public async Task<List<FullRoom>?> GetBaseRooms()
{
var roomData = await db.Rooms.Where(x => x.CloningAllowed && x.Accessibility != Accessibility.Private && x.CreatorAccountId == 1).ToListAsync();
var rooms = new List<FullRoom>();
foreach (var room in roomData)
{
var fullRoom = await RoomUtils.GetRoom(room.RoomId);
if (fullRoom == null) continue;
rooms.Add(fullRoom);
}
return rooms;
}
[HttpPost("rooms/{id}/clone"), Authorize]
public async Task<Success?> CloneRoom(long id, [FromForm] string name)
{
var userId = User.GetUserId();
if (userId == null) return new Success(false, "You are not signed in.", null);
if (!name.IsPure()) return new Success(false, "Name contains offensive language.", null);
var source = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == id);
if (source == null) return new Success(false, "Unknown error", null);
if (!source.CloningAllowed)
{
return new Success(false, "This room cannot be cloned.", null);
}
if (await RoomUtils.IsHiddenFrom(source, userId))
{
return new Success(false, "This room cannot be cloned.", null);
}
var doesRoomExist = (await db.Rooms.FirstOrDefaultAsync(x => x.Name == name)) != null;
if (doesRoomExist) return new Success(false, "Room already exists.", null);
var roomData = await RoomUtils.CloneRoom(id, (long)userId!, name);
if (roomData == null) return new Success(false, "Unknown error", null);
return new Success(true, null, roomData);
}
private sealed record RoomPermissionRow(string Permission, long Role, bool Override, int Type, string Value);
private static List<RoomPermissionRow> DefaultPermissions()
{
static RoomPermissionRow Row(string permission, long role, bool overridden) =>
new(permission, role, overridden, 0, "True");
return new List<RoomPermissionRow>
{
Row("CAN_USE_ROOM_RESET_BUTTON", 0, true),
Row("CAN_USE_DELETE_ALL_BUTTON", 0, true),
Row("CAN_SAVE_INVENTIONS", 0, true),
Row("CAN_SPAWN_INVENTIONS", 0, true),
Row("CAN_USE_PLAY_GIZMOS_TOGGLE", 0, true),
Row("CAN_INVITE", 0, true),
Row("CAN_USE_MAKER_PEN", 30, false),
Row("CAN_USE_ROOM_RESET_BUTTON", 30, true),
Row("CAN_USE_DELETE_ALL_BUTTON", 30, true),
Row("CAN_SAVE_INVENTIONS", 30, true),
Row("CAN_SPAWN_INVENTIONS", 30, true),
Row("CAN_USE_PLAY_GIZMOS_TOGGLE", 30, true),
};
}
[HttpGet("photon_access_token")]
public async Task<IActionResult> GetPhotonAccessToken()
{
long? userId = User.GetUserId();
if (userId == null) return Unauthorized();
var presence = await db.Presences
.AsNoTracking()
.FirstOrDefaultAsync(x => x.playerId == userId.Value);
var permissions = DefaultPermissions();
var profile = await db.Profiles
.AsNoTracking()
.FirstOrDefaultAsync(x => x.accountId == userId.Value);
if (profile != null && (profile.HasRole("developer") || profile.HasRole("moderator")))
permissions.Insert(0, new RoomPermissionRow("CAN_USE_MAKER_PEN", 0, true, 0, "True"));
long? subRoomId = null;
if (presence?.roomInstance != null)
{
subRoomId = await db.RoomInstances
.AsNoTracking()
.Where(x => x.roomInstanceId == presence.roomInstance)
.Select(x => (long?)x.subRoomId)
.FirstOrDefaultAsync();
}
if (subRoomId != null)
{
var overrides = await db.SubRoomPermissions
.AsNoTracking()
.Where(x => x.subRoomId == subRoomId)
.OrderBy(x => x.Id)
.ToListAsync();
foreach (var stored in overrides)
{
var entry = new RoomPermissionRow(stored.permission, stored.role, true, stored.type, stored.value);
var index = permissions.FindIndex(x =>
x.Permission == entry.Permission && x.Role == entry.Role);
if (index == -1) permissions.Add(entry);
else permissions[index] = entry;
}
}
return Ok(new Dictionary<string, object?>
{
["Permissions"] = permissions,
["PhotonAccessToken"] = "",
["RoomInstanceId"] = presence?.roomInstance
});
}
[HttpGet("rooms/{id}")]
public async Task<ActionResult<FullRoom>> GetRoomById(long id)
{
var roomData = db.Rooms.FirstOrDefault(x => x.RoomId == id);
if (roomData == null)
{
var userId = User.GetUserId();
if (userId != null)
{
var dorm = await RoomUtils.GetOrCreateDorm((long)userId!);
if (dorm != null)
return Ok(dorm);
}
return NotFound();
}
if (!await RoomUtils.CanSeeRoom(roomData, User.GetUserId()))
return roomData.IsDorm ? NotFound() : Ok(RoomUtils.MaskHiddenRoom(roomData));
var fullRoom = await RoomUtils.GetRoom(roomData.RoomId);
if (fullRoom == null) return NotFound();
return Ok(fullRoom);
}
[HttpGet("rooms/{roomId}/subrooms/{subRoomId}/datahistory"), Authorize]
public async Task<ActionResult<List<RestorableSubRoom>>> GetRoomDataHistory(long roomId, long subRoomId)
{
var userId = User.GetUserId();
var roomData = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (roomData == null) return NotFound();
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == (long)subRoomId);
if (subRoom == null) return NotFound();
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.CoOwner);
if (!canModify) return Forbid();
var history = await db.SubRoomHistory.Where(x => x.RoomId == roomId && x.SubRoomId == subRoomId).ToListAsync();
return Ok(history);
}
[HttpGet("rooms/{roomId}/subrooms/{subRoomId}/saves"), Authorize]
public async Task<IActionResult> GetSubRoomSaves(long roomId, long subRoomId, int? skip = null, int? take = null)
{
var (refusal, saves) = await LoadSubRoomSavesAsync(roomId, subRoomId);
if (refusal != null) return refusal;
var page = PageSaves(saves, skip, take);
return Ok(new { Results = page, TotalResults = saves.Count, TotalCount = saves.Count });
}
[HttpGet("rooms/{roomId}/subrooms/{subRoomId}/saves/no_unity_assets"), Authorize]
public async Task<IActionResult> GetSubRoomSavesWithoutUnityAssets(long roomId, long subRoomId, int? skip = null, int? take = null)
{
var (refusal, saves) = await LoadSubRoomSavesAsync(roomId, subRoomId);
if (refusal != null) return refusal;
var page = PageSaves(saves, skip, take)
.Select(x => new
{
x.SubRoomDataSaveId,
x.SubRoomId,
x.DataBlob,
x.ReferencedUnityAssetIds,
x.PersistenceVersion,
x.OMVersion,
x.UgcSubVersion,
x.SavedByAccountId,
x.SavedOnPlatform,
x.SavedOnDeviceClass,
x.Description,
x.ModerationState,
x.CreatedAt,
x.UnityAssetId
})
.ToList();
return Ok(new { Results = page, TotalResults = saves.Count, TotalCount = saves.Count });
}
private async Task<(IActionResult? Refusal, List<SubRoomDataSave> Saves)> LoadSubRoomSavesAsync(long roomId, long subRoomId)
{
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId);
var subRoomExists = await db.SubRooms.AsNoTracking().AnyAsync(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (room == null || !subRoomExists) return (Ok(EmptySavePage()), new List<SubRoomDataSave>());
var userId = User.GetUserId();
if (userId == null) return (Unauthorized(), new List<SubRoomDataSave>());
if (!await CanReadSavesAsync((long)userId!, room)) return (StatusCode(StatusCodes.Status403Forbidden), new List<SubRoomDataSave>());
return (null, await RoomUtils.GetSaveHistory(db, roomId, subRoomId));
}
private async Task<bool> CanReadSavesAsync(long userId, RoomData room)
{
if (room.CreatorAccountId == userId) return true;
if (await MatchmakingUtils.IsAtLeastRole(userId, room.RoomId, RoleType.CoOwner)) return true;
var presence = await db.Presences.AsNoTracking().FirstOrDefaultAsync(x => x.playerId == userId);
if (presence?.roomInstance == null) return false;
var instance = await db.RoomInstances.AsNoTracking()
.FirstOrDefaultAsync(x => x.roomInstanceId == presence.roomInstance);
return instance != null && instance.roomId == room.RoomId;
}
private static object EmptySavePage() => new { Results = new List<SubRoomDataSave>(), TotalResults = 0, TotalCount = 0 };
private static List<SubRoomDataSave> PageSaves(List<SubRoomDataSave> saves, int? skip, int? take)
{
var from = skip == null || skip < 0 ? 0 : skip.Value;
var remaining = saves.Skip(from);
return (take == null || take < 0 ? remaining : remaining.Take(take.Value)).ToList();
}
private async Task<Dictionary<string, string>> ReadSaveValuesAsync()
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (Request.HasFormContentType)
{
foreach (var (key, value) in Request.Form)
values[key] = value.ToString();
return values;
}
Request.EnableBuffering();
string raw;
using (var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true))
raw = (await reader.ReadToEndAsync()).Trim();
Request.Body.Position = 0;
if (raw.Length == 0) return values;
try
{
if (JsonNode.Parse(raw) is JsonObject json) FlattenSaveJson(json, string.Empty, values);
}
catch (JsonException) { }
return values;
}
private static void FlattenSaveJson(JsonObject json, string prefix, Dictionary<string, string> values)
{
foreach (var (key, node) in json)
{
var path = prefix.Length == 0 ? key : prefix + "." + key;
if (node is JsonObject nested) FlattenSaveJson(nested, path, values);
else if (node != null && node is not JsonArray) values[path] = node.ToString();
}
}
private static string? ReadSaveValue(Dictionary<string, string> values, params string[] keys)
{
foreach (var key in keys)
if (values.TryGetValue(key, out var raw) && !string.IsNullOrWhiteSpace(raw))
return raw;
return null;
}
private static JsonObject BuildRoomSaveValue(FullRoom room, SubRoom subRoom, string? dataBlobHash)
{
var roomNode = JsonSerializer.SerializeToNode(room) as JsonObject ?? new JsonObject();
var value = new JsonObject { ["room"] = roomNode.DeepClone() };
foreach (var property in roomNode)
value[property.Key] = property.Value?.DeepClone();
value["subRoomDataSave"] = JsonSerializer.SerializeToNode(RoomUtils.BuildSaveResponse(subRoom, dataBlobHash));
return value;
}
private sealed record StudioSaveResult(bool success, string? error, object? value);
[HttpPost("rooms/{roomId}/subrooms/{subRoomId}/data"), Authorize]
public async Task<object?> UploadRoomData(long roomId, long subRoomId)
{
var userId = User.GetUserId();
if (userId == null) return Success.Error("Invalid auth");
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var roomData = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (roomData == null) return new Success(false, "Invalid room.", null);
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId);
if (subRoom == null) return new Success(false, "Invalid subroom.", null);
var values = await ReadSaveValuesAsync();
var blobName = ReadSaveValue(values, "subroomdata.filename", "filename", "datablob", "blob");
if (string.IsNullOrWhiteSpace(blobName))
{
return new Success(false, "No room data was uploaded.", null);
}
var savedBy = (long)userId!;
var unityAssetId = ReadSaveValue(values, "unityassetid", "subroomdata.unityassetid");
var unityAssetFilename = ReadSaveValue(values, "unityasset.filename", "unityasset");
if (!string.IsNullOrWhiteSpace(unityAssetFilename))
{
if (!StudioUnityAssetRegistry.IsValidAssetId(unityAssetFilename))
return new StudioSaveResult(false, "Invalid Unity source-manifest filename.", null);
if (await StudioUnityAssetRegistry.ReadAsync(unityAssetFilename, HttpContext.RequestAborted) == null)
return new StudioSaveResult(false, "Unity source manifest has not been uploaded.", null);
}
var blobHash = ReadSaveValue(values, "subroomdata.hash", "hash", "datablobhash");
await db.SubRoomHistory.AddAsync(new RestorableSubRoom(subRoom));
subRoom.DataBlob = blobName;
subRoom.SavedByAccountId = savedBy;
subRoom.CreatedAt = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(unityAssetId)) subRoom.UnityAssetId = unityAssetId;
if (!string.IsNullOrWhiteSpace(unityAssetFilename)) subRoom.UnityAssetId = unityAssetFilename;
await db.SaveChangesAsync();
var room = await RoomUtils.GetRoom(roomId);
if (room == null) return new Success(false, "Invalid room.", null);
await Notify.SendNotify((long)userId!, new Notification { Id = "RoomUpdate", Msg = room });
return new StudioSaveResult(true, null, BuildRoomSaveValue(room, subRoom, blobHash));
}
[HttpGet("rooms/{roomId}/subrooms/{subRoomId}/unityasset")]
public async Task<IActionResult> GetStudioUnityAsset(long roomId, long subRoomId, [FromQuery] string? unityAssetId = null)
{
var subRoom = await db.SubRooms.AsNoTracking()
.FirstOrDefaultAsync(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (subRoom == null) return NotFound();
var assetId = string.IsNullOrWhiteSpace(unityAssetId) ? subRoom.UnityAssetId : unityAssetId;
if (!string.Equals(assetId, subRoom.UnityAssetId, StringComparison.Ordinal)) return NotFound();
var userId = User.GetUserId();
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null || !await RoomUtils.CanSeeRoom(room, userId)) return NotFound();
var asset = await StudioUnityAssetRegistry.ReadAsync(assetId, HttpContext.RequestAborted);
return asset == null ? NotFound() : Ok(asset);
}
[HttpPost("rooms/{roomId}/subrooms/{subRoomId}/restoredata"), Authorize]
public async Task<Success?> RestoreRoomData(long roomId, long subRoomId)
{
var userId = User.GetUserId();
if (userId == null) return Success.Error("Invalid auth");
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var roomData = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (roomData == null) return new Success(false, "Invalid room.", null);
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId);
if (subRoom == null) return new Success(false, "Invalid subroom.", null);
var values = await ReadSaveValuesAsync();
var blobName = ReadSaveValue(values, "filename", "subroomdata.filename", "datablob", "blob");
if (string.IsNullOrWhiteSpace(blobName)) return new Success(false, "No room data was given.", null);
if (!System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, blobName)))
return new Success(false, "That save's data is no longer available.", null);
if (!string.IsNullOrEmpty(subRoom.DataBlob) && subRoom.DataBlob != blobName)
{
await db.SubRoomHistory.AddAsync(new RestorableSubRoom(subRoom));
}
subRoom.DataBlob = blobName;
subRoom.SavedByAccountId = (long)userId!;
subRoom.CreatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var room = await RoomUtils.GetRoom(roomId);
if (room == null) return new Success(false, "Invalid room.", null);
await Notify.SendNotify((long)userId!, new Notification { Id = "RoomUpdate", Msg = room });
return new Success(true, null, BuildRoomSaveValue(room, subRoom, null));
}
[HttpPost("rooms/{roomId}/subrooms"), Authorize]
public async Task<Success?> CreateSubroom(long roomId, [FromForm] string name)
{
var userId = User.GetUserId();
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var roomData = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (roomData == null) return new Success(false, "Invalid room.", null);
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.Name == name && x.RoomId == roomId);
if (subRoom != null) return new Success(false, "A subroom already exists with that name!", null);
var mainSubRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (mainSubRoom == null) return new Success(false, "There is no main subroom?", null);
var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == userId);
if (presence == null) return new Success(false, "Invalid auth", null);
if (!name.IsPure()) return new Success(false, "Name contains offensive language.", null);
var newSubRoom = new SubRoom
{
DataBlob = "",
CreatedAt = DateTime.Now,
Accessibility = Accessibility.Unlised,
IsSandbox = true,
MaxPlayers = mainSubRoom.MaxPlayers,
Name = name,
RoomId = roomId,
SavedByAccountId = (long)userId!,
UnitySceneId = mainSubRoom.UnitySceneId
};
await db.SubRooms.AddAsync(newSubRoom);
await db.SaveChangesAsync();
var room = await RoomUtils.GetRoom((long)roomId);
if (room == null) return new Success(false, "WHAT THE FUCK???", null);
return new Success(true, null, room);
}
[HttpPost("rooms/{roomId}/subrooms/{subRoomId}/move"), Authorize]
public async Task<Success?> MoveSubroom(long roomId, long subRoomId, [FromForm] long newRoomId)
{
var userId = User.GetUserId();
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, newRoomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
var newRoom = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == newRoomId);
var subroom = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId);
if (room == null) return Success.Error("Room not found");
if (newRoom == null) return new Success(false, "Target room not found", null);
if (subroom == null) return new Success(false, "Subroom not found", null);
subroom.RoomId = newRoomId;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom(roomId);
await Notify.SendNotify((long)userId!, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, null);
}
[HttpPut("rooms/{roomId}/subrooms/{subRoomId}/modify")]
public async Task<Success> ModifySubroom(long roomId, long subRoomId, string? name, Accessibility? accessibility, int? maxPlayers)
{
var userId = User.GetUserId();
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
var subroom = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId);
if (room == null) return Success.Error("Room not found");
if (subroom == null) return new Success(false, "Subroom not found", null);
if (name != null)
subroom.Name = name;
if (accessibility != null)
subroom.Accessibility = (Accessibility)accessibility;
if (maxPlayers != null)
subroom.MaxPlayers = (int)maxPlayers;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom(roomId);
await Notify.SendNotify((long)userId!, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, null);
}
[HttpPut("rooms/{roomId}/modify"), Authorize]
public async Task<Success?> ModifyRoom(long roomId, string name, string? description, Accessibility accessibility, bool supportsScreens, bool supportsWalkVR, bool supportsTeleportVR, bool supportsJuniors, bool cloningAllowed, bool disableMicAutoMute, bool disableRoomComments, bool encryptVoiceChat)
{
var userId = User.GetUserId();
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return Success.Error("Room not found");
if (!name.IsPure()) return new Success(false, "Name contains offensive language.", null);
if (!(description ?? string.Empty).IsPure()) return new Success(false, "Description contains offensive language.", null);
room.Name = name;
room.Description = description;
room.SupportsScreens = supportsScreens;
room.SupportsWalkVR = supportsWalkVR;
room.SupportsTeleportVR = supportsTeleportVR;
room.SupportsJuniors = supportsJuniors;
room.Accessibility = accessibility;
room.CloningAllowed = cloningAllowed;
room.DisableMicAutoMute = disableMicAutoMute;
room.DisableRoomComments = disableRoomComments;
room.EncryptVoiceChat = encryptVoiceChat;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom(roomId);
await Notify.SendNotify((long)userId!, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, null);
}
[HttpPut("rooms/{roomId}/image"), Authorize]
public async Task<Success?> ModifyImage(long roomId, string imageName)
{
var userId = User.GetUserId();
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return Success.Error("Room not found");
room.ImageName = imageName;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom(roomId);
await Notify.SendNotify((long)userId!, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, null);
}
private static readonly RoleType[] AssignableRoles =
{
RoleType.None, RoleType.Host, RoleType.Moderator, RoleType.CoOwner, RoleType.TemporaryCoOwner
};
private async Task<(RoleType? Role, long? ActorId)> ReadRoleRequestAsync()
{
var userId = User.GetUserId();
if (userId == null) return (null, null);
foreach (var key in new[] { "role", "Role", "roletype", "RoleType", "roleType", "invitedrole", "InvitedRole" })
{
var raw = Request.HasFormContentType && Request.Form.TryGetValue(key, out var form)
? form.ToString()
: Request.Query.TryGetValue(key, out var query) ? query.ToString() : null;
if (raw == null && Request.RouteValues.TryGetValue(key, out var route)) raw = route?.ToString();
if (!int.TryParse(raw, out var parsed)) continue;
if (!Enum.IsDefined(typeof(RoleType), parsed)) continue;
return ((RoleType)parsed, userId);
}
return (null, userId);
}
private async Task PushRoomRoleUpdate(long roomId, long actorId, long targetId, FullRoom? room)
{
room ??= await RoomUtils.GetRoom(roomId);
if (room == null) return;
foreach (var accountId in new[] { targetId, actorId }.Distinct())
await Notify.SendNotify(accountId, new Notification { Id = "RoomUpdate", Msg = room });
}
[HttpPut("rooms/{roomId}/roles/{targetId}/invite"), Authorize]
public async Task<Success?> InvitePlayerToRole(long roomId, long targetId)
{
var (role, userId) = await ReadRoleRequestAsync();
if (userId == null) return Success.Error("Invalid auth");
if (targetId == userId.Value) return Success.Error("You cannot invite yourself");
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return Success.Error("Room not found");
var player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == targetId);
if (player == null) return Success.Error("Player not found");
var existingRole = await db.RoomRoles.FirstOrDefaultAsync(x => x.AccountId == targetId && x.RoomId == roomId);
if (role == null) return Success.Error("You must provide a valid role");
if (!AssignableRoles.Contains(role.Value)) return Success.Error($"Unknown role {role.Value}");
if (role.Value == RoleType.None) return Success.Error("You must provide a valid role");
if (existingRole?.Role == role.Value && existingRole.InvitedRole == RoleType.None)
return Success.Error("Player already has this role");
if (existingRole != null &&
existingRole.Role != RoleType.None &&
existingRole.Role != RoleType.Banned)
return Success.Error("Player already has a room role");
if (existingRole == null)
{
var newRole = new RoomRole
{
AccountId = targetId,
InvitedRole = role.Value,
Role = RoleType.None,
RoomId = roomId,
LastChangedByAccountId = userId.Value
};
await db.RoomRoles.AddAsync(newRole);
}
else
{
existingRole.InvitedRole = role.Value;
existingRole.LastChangedByAccountId = userId.Value;
}
await db.SaveChangesAsync();
if (role.Value == RoleType.CoOwner)
{
await Notify.SendMessage(targetId, new Message
{
FromPlayerId = userId.Value,
RoomId = roomId,
Type = MessageType.RoomCoOwnerInvited
});
}
var fullRoom = await RoomUtils.GetRoom(roomId);
await PushRoomRoleUpdate(roomId, userId.Value, targetId, fullRoom);
return new Success(true, null, fullRoom);
}
[HttpPut("rooms/{roomId}/roles/{targetId}"), Authorize]
public async Task<Success?> SetPlayerRole(long roomId, long targetId)
{
var (role, userId) = await ReadRoleRequestAsync();
if (userId == null) return Success.Error("Invalid auth");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return Success.Error("Room not found");
if (targetId == room.CreatorAccountId)
return Success.Error("The room's creator cannot be given a different role");
var player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == targetId);
if (player == null) return Success.Error("Player not found");
var existingRole = await db.RoomRoles.FirstOrDefaultAsync(x => x.AccountId == targetId && x.RoomId == roomId);
var wasCoOwner = existingRole?.Role == RoleType.CoOwner;
var acceptingInvite = targetId == userId.Value &&
existingRole is { Role: RoleType.None, InvitedRole: not RoleType.None };
if (acceptingInvite && role == null) role = existingRole!.InvitedRole;
if (role == null) return Success.Error("You must provide a valid role");
if (!AssignableRoles.Contains(role.Value)) return Success.Error($"Unknown role {role.Value}");
if (acceptingInvite)
{
if (role.Value != existingRole!.InvitedRole)
return Success.Error("The requested role does not match the pending invitation");
}
else
{
var canModify = await MatchmakingUtils.IsAtLeastRole(userId.Value, roomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
}
if (role.Value == RoleType.None)
{
if (existingRole == null) return Success.Error("Player already has this role");
if (acceptingInvite) return Success.Error("The pending invitation cannot be accepted as None");
db.RoomRoles.Remove(existingRole);
}
else if (existingRole == null)
{
await db.RoomRoles.AddAsync(new RoomRole
{
AccountId = targetId,
InvitedRole = RoleType.None,
Role = role.Value,
RoomId = roomId,
LastChangedByAccountId = userId.Value
});
}
else
{
if (existingRole.Role == role.Value && existingRole.InvitedRole == RoleType.None)
return Success.Error("Player already has this role");
existingRole.Role = role.Value;
existingRole.InvitedRole = RoleType.None;
existingRole.LastChangedByAccountId = (long)userId!;
}
await db.SaveChangesAsync();
if (wasCoOwner && role.Value < RoleType.CoOwner)
{
await Notify.SendMessage(targetId, new Message
{
FromPlayerId = userId.Value,
RoomId = roomId,
Type = MessageType.RoomCoOwnerRemoved
});
}
if (role.Value == RoleType.CoOwner && !wasCoOwner)
{
await Notify.SendMessage(targetId, new Message
{
FromPlayerId = userId.Value,
RoomId = roomId,
Type = MessageType.RoomCoOwnerAdded
});
}
var fullRoom = await RoomUtils.GetRoom(roomId);
await PushRoomRoleUpdate(roomId, userId.Value, targetId, fullRoom);
return new Success(true, null, fullRoom);
}
[HttpPut("rooms/{roomId}/tags"), Authorize]
public async Task<Success?> ModifyTags(long roomId, string[]? tag, string[]? autoTag)
{
var userId = User.GetUserId();
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return Success.Error("Room not found");
foreach (var t in tag ?? Array.Empty<string>())
{
if (!t.TagAllowed())
return new Success(false, "Tag not allowed: " + t, null);
}
if (tag != null || autoTag != null) {
List<ItemTag> tagsToAdd = new();
if (tag != null)
foreach (var t in tag)
tagsToAdd.Add(new ItemTag { Tag = t, Type = TagType.General });
if (autoTag != null)
foreach (var t in autoTag)
tagsToAdd.Add(new ItemTag { Tag = t, Type = TagType.Auto });
room.Tags = StringTools.CreateTagString(tagsToAdd);
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom(roomId);
await Notify.SendNotify((long)userId!, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, null);
} else
{
room.Tags = "";
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom(roomId);
await Notify.SendNotify((long)userId!, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, null);
}
}
[HttpPut("rooms/{roomId}/accessibility"), Authorize]
public async Task<Success?> ModifyAccessibility(long roomId, Accessibility accessibility)
{
var userId = User.GetUserId();
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return Success.Error("Room not found");
bool wasPublished = room.WasPublished;
room.Accessibility = accessibility;
if (!room.WasPublished && accessibility == Accessibility.Public) {
room.WasPublished = true;
room.PublishedAt = DateTime.Now;
}
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom(roomId);
await Notify.SendNotify((long)userId!, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, null);
}
[HttpGet("rooms/{roomId}/subrooms/{subRoomId}/permissions"), Authorize]
public async Task<IActionResult> GetSubRoomPermissions(long roomId, long subRoomId)
{
if (User.GetUserId() == null) return Unauthorized();
var subRoom = await db.SubRooms
.AsNoTracking()
.FirstOrDefaultAsync(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (subRoom == null) return NotFound();
var stored = await db.SubRoomPermissions
.AsNoTracking()
.Where(x => x.subRoomId == subRoomId)
.OrderBy(x => x.Id)
.ToListAsync();
return Ok(stored
.Select(x => new RoomPermissionRow(x.permission, x.role, true, x.type, x.value))
.ToList());
}
[HttpPut("rooms/{roomId}/subrooms/{subRoomId}/permissions"), Authorize]
public async Task<IActionResult> SetSubRoomPermissions(long roomId, long subRoomId)
{
long? userId = User.GetUserId();
if (userId == null) return Unauthorized();
var subRoom = await db.SubRooms
.AsNoTracking()
.FirstOrDefaultAsync(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (subRoom == null) return NotFound();
if (!await MatchmakingUtils.IsAtLeastRole(userId.Value, roomId, RoleType.CoOwner))
return Forbid();
string? body;
using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
body = await reader.ReadToEndAsync();
var entries = ParseSubRoomPermissions(body);
foreach (var entry in entries)
{
var existing = await db.SubRoomPermissions.FirstOrDefaultAsync(x =>
x.subRoomId == subRoomId && x.permission == entry.Permission && x.role == entry.Role);
if (!entry.Override)
{
if (existing != null) db.SubRoomPermissions.Remove(existing);
continue;
}
if (existing == null)
{
await db.SubRoomPermissions.AddAsync(new SubRoomPermission
{
subRoomId = subRoomId,
permission = entry.Permission,
role = entry.Role,
type = entry.Type,
value = entry.Value,
});
}
else
{
existing.type = entry.Type;
existing.value = entry.Value;
}
}
await db.SaveChangesAsync();
var logged = body ?? "";
if (logged.Length > 800) logged = logged[..800] + "...";
return Ok(new { Permissions = Array.Empty<object>() });
}
private static List<RoomPermissionRow> ParseSubRoomPermissions(string? body)
{
var entries = new List<RoomPermissionRow>();
if (string.IsNullOrWhiteSpace(body)) return entries;
JsonDocument document;
try
{
document = JsonDocument.Parse(body);
}
catch (JsonException)
{
return entries;
}
using (document)
{
if (document.RootElement.ValueKind != JsonValueKind.Array) return entries;
foreach (var element in document.RootElement.EnumerateArray())
{
if (element.ValueKind != JsonValueKind.Object) continue;
var permission = ReadPermissionName(element);
if (permission.Length == 0) continue;
var role = ReadPermissionRole(element);
if (role == null) continue;
entries.Add(new RoomPermissionRow(
permission,
role.Value,
ReadPermissionBool(element, "Override"),
(int)(ReadPermissionLong(element, "Type") ?? 0),
ReadPermissionValue(element, "Value")));
}
}
return entries;
}
private static bool TryGetPermissionField(JsonElement entry, string key, out JsonElement value)
{
foreach (var property in entry.EnumerateObject())
{
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
value = default;
return false;
}
private static string? ReadPermissionString(JsonElement entry, string key)
{
if (!TryGetPermissionField(entry, key, out var value)) return null;
return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString();
}
private static long? ReadPermissionLong(JsonElement entry, string key)
{
if (!TryGetPermissionField(entry, key, out var value)) return null;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var number))
return number;
if (value.ValueKind == JsonValueKind.String &&
long.TryParse(value.GetString(), out var parsed))
return parsed;
return null;
}
private static bool ReadPermissionBool(JsonElement entry, string key)
{
if (!TryGetPermissionField(entry, key, out var value)) return false;
if (value.ValueKind == JsonValueKind.True) return true;
if (value.ValueKind == JsonValueKind.False) return false;
return string.Equals(value.ToString(), "true", StringComparison.OrdinalIgnoreCase);
}
private static string ReadPermissionValue(JsonElement entry, string key)
{
if (!TryGetPermissionField(entry, key, out var value)) return "";
if (value.ValueKind == JsonValueKind.String) return value.GetString() ?? "";
if (value.ValueKind == JsonValueKind.True) return "True";
if (value.ValueKind == JsonValueKind.False) return "False";
return value.ToString();
}
private static string ReadPermissionName(JsonElement entry)
{
var raw = (ReadPermissionString(entry, "Permission")
?? ReadPermissionString(entry, "PermissionType")
?? ReadPermissionString(entry, "PermissionTypeOld")
?? "").Trim();
if (raw.Length == 0) return "";
if (long.TryParse(raw, out var number) &&
Enum.IsDefined(typeof(RolePermissions), (int)number))
return ((RolePermissions)(int)number).ToString();
return raw;
}
private static long? ReadPermissionRole(JsonElement entry)
{
var numeric = ReadPermissionLong(entry, "Role") ?? ReadPermissionLong(entry, "RoleId");
if (numeric != null) return numeric;
var name = ReadPermissionString(entry, "Role")?.Trim();
if (!string.IsNullOrEmpty(name) && Enum.TryParse<RoleType>(name, true, out var parsed))
return (long)parsed;
return null;
}
[HttpPut("rooms/{roomId}/subrooms/{subRoomId}/accessibility"), Authorize]
public async Task<Success?> ModifySubRoomAccessibility(long roomId, long subRoomId, Accessibility accessibility)
{
var userId = User.GetUserId();
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId!, roomId, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return Success.Error("Room not found");
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (subRoom == null) return new Success(false, "Subroom not found", null);
subRoom.Accessibility = accessibility;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom(roomId);
await Notify.SendNotify((long)userId!, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, null);
}
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RadicadeServer.RecNet.Rooms
{
public class RoomStats
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
public long RoomId { get; set; }
public int CheerCount { get; set; } = 0;
public int FavoriteCount { get; set; } = 0;
public int VisitorCount { get; set; } = 0;
public int VisitCount { get; set; } = 0;
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace RadicadeServer.RecNet.Rooms
{
public class RoomUploadResponse
{
public Room Room { get; set; } = null!;
public SubRoom SubRoomDataSave { get; set; } = null!;
}
}
+574
View File
@@ -0,0 +1,574 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Enums;
using RadicadeServer.RecNet.Enums.Messages;
using RadicadeServer.RecNet.Enums.Rooms;
using RadicadeServer.RecNet.Messages;
using RadicadeServer.RecNet.Rooms;
namespace RadicadeServer.Utils
{
public static class RoomUtils
{
public static IDbContextFactory<RecDBContext> DbFactory = null!;
public static async Task<bool> VerifyRole(long playerId, long roomId, RoleType roleType)
{
var role = await GetRoleForPlayer(playerId, roomId);
if (role == null) return false;
return role.Role >= roleType;
}
public static async Task<bool> CanSeeRoom(RoomData room, long? accountId)
{
if (room.Accessibility != Accessibility.Private) return true;
if (accountId == null) return false;
if (room.CreatorAccountId == accountId.Value) return true;
if (await VerifyRole(accountId.Value, room.RoomId, RoleType.CoOwner)) return true;
if (DbFactory != null)
{
await using var db = await DbFactory.CreateDbContextAsync();
if (await (
from presence in db.Presences.AsNoTracking()
join instance in db.RoomInstances.AsNoTracking()
on presence.roomInstance equals instance.roomInstanceId
where presence.playerId == accountId.Value && presence.isOnline &&
instance.roomId == room.RoomId
select presence.playerId).AnyAsync()) return true;
}
return await HasLiveGameInvite(room.RoomId, accountId.Value);
}
public static async Task<bool> HasLiveGameInvite(long roomId, long accountId)
{
if (DbFactory == null) return false;
await using var db = await DbFactory.CreateDbContextAsync();
long? inviter = await db.Messages.AsNoTracking()
.Where(message =>
message.ToPlayerId == accountId &&
(message.Type == MessageType.GameInvite || message.Type == MessageType.GameInviteV2) &&
message.RoomId == roomId)
.OrderByDescending(message => message.Id)
.Select(message => (long?)message.FromPlayerId)
.FirstOrDefaultAsync();
if (inviter == null) return false;
return await (
from presence in db.Presences.AsNoTracking()
join instance in db.RoomInstances.AsNoTracking()
on presence.roomInstance equals instance.roomInstanceId
where presence.playerId == inviter.Value &&
presence.isOnline &&
instance.roomId == roomId
select presence.playerId).AnyAsync();
}
public static async Task<List<RoomData>> VisibleRooms(IEnumerable<RoomData> rooms, long? accountId)
{
var visible = new List<RoomData>();
foreach (var room in rooms)
{
if (await CanSeeRoom(room, accountId)) visible.Add(room);
}
return visible;
}
public const string PrivateRoomName = "PRIVATE ROOM";
public static async Task<bool> IsHiddenFrom(RoomData room, long? accountId)
=> !room.IsDorm && !await CanSeeRoom(room, accountId);
public static FullRoom MaskHiddenRoom(RoomData room)
{
return new FullRoom
{
RoomId = room.RoomId,
Name = PrivateRoomName,
Description = string.Empty,
ImageName = DBConfig.DefaultRoomImage,
IsDorm = false,
State = room.State,
Accessibility = Accessibility.Private,
CreatedAt = room.CreatedAt,
CreatorAccountId = room.CreatorAccountId,
MaxPlayers = room.MaxPlayers,
MaxPlayerCalculationMode = room.MaxPlayerCalculationMode,
CloningAllowed = false,
DisableRoomComments = true,
CustomWarning = string.Empty,
SubRooms = new List<SubRoom>(),
Roles = new List<RoomRole>(),
LoadScreens = new List<RoomLoadScreen>(),
PromoImages = new List<string>(),
PromoExternalContent = new List<ExternalContent>(),
DataBlob = string.Empty,
Tags = new List<ItemTag>(),
Stats = new RoomStats { RoomId = room.RoomId },
Version = 1
};
}
public static async Task<RoomInstance?> ForViewerAsync(RoomInstance? instance, long? viewerId)
{
if (instance == null) return null;
await using var db = await DbFactory.CreateDbContextAsync();
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == instance.roomId);
if (room == null || !await IsHiddenFrom(room, viewerId)) return instance;
var masked = instance.Copy();
masked.name = PrivateRoomName;
return masked;
}
public static async Task<List<RoomRole>> GetRoles(long roomId)
{
await using var db = await DbFactory.CreateDbContextAsync();
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null) return [];
var roomRole = await db.RoomRoles.AsNoTracking().Where(x => x.RoomId == roomId).ToListAsync();
var creatorRow = roomRole.FirstOrDefault(x => x.AccountId == room.CreatorAccountId);
if (creatorRow == null)
roomRole.Add(new RoomRole { AccountId = room.CreatorAccountId, InvitedRole = RoleType.None, Role = RoleType.Creator, RoomId = roomId });
else if (creatorRow.Role < RoleType.Creator)
creatorRow.Role = RoleType.Creator;
if (room.IsRRO)
{
var rroOwners = (await db.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == "HighRanks"))?.Value;
foreach (var dev in (rroOwners ?? string.Empty).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (!long.TryParse(dev, out long userId)) continue;
foreach (var role in new[] { RoleType.Creator, RoleType.Moderator })
{
if (roomRole.Any(x => x.AccountId == userId && x.Role == role)) continue;
roomRole.Add(new RoomRole { AccountId = userId, InvitedRole = RoleType.None, Role = role, RoomId = roomId });
}
}
}
return roomRole;
}
public static async Task<RoomRole?> GetRoleForPlayer(long playerId, long roomId)
{
var roomRoles = await GetRoles(roomId);
return roomRoles
.Where(x => x.AccountId == playerId)
.OrderByDescending(x => x.Role)
.FirstOrDefault();
}
public static SubRoomDataSave BuildCurrentSave(SubRoom subRoom)
{
return new SubRoomDataSave
{
SubRoomDataSaveId = 1,
SubRoomId = subRoom.SubRoomId,
DataBlob = subRoom.DataBlob,
SavedByAccountId = subRoom.SavedByAccountId,
CreatedAt = subRoom.CreatedAt,
UnityAssetId = string.IsNullOrEmpty(subRoom.UnityAssetId) ? null : subRoom.UnityAssetId,
PersistenceVersion = 1,
OMVersion = 1,
UgcSubVersion = 1,
SavedOnPlatform = 0,
SavedOnDeviceClass = 0,
Description = string.Empty,
ModerationState = 0
};
}
public static object BuildSaveResponse(SubRoom subRoom, string? dataBlobHash)
{
return new
{
subRoomDataSaveId = 1L,
subRoomId = subRoom.SubRoomId,
unityAssetId = string.IsNullOrEmpty(subRoom.UnityAssetId) ? null : subRoom.UnityAssetId,
unityAsset = (string?)null,
unityAssetHash = (string?)null,
dataBlob = subRoom.DataBlob ?? string.Empty,
dataBlobHash,
savedByAccountId = (long?)subRoom.SavedByAccountId,
savedOnPlatform = 0,
savedOnDeviceClass = 0,
description = string.Empty,
createdAt = subRoom.CreatedAt
};
}
public static async Task<List<SubRoomDataSave>> GetSaveHistory(RecDBContext ctx, long roomId, long subRoomId)
{
var subRoom = await ctx.SubRooms.AsNoTracking()
.FirstOrDefaultAsync(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
if (subRoom == null) return new List<SubRoomDataSave>();
var saves = new List<SubRoomDataSave>();
if (!string.IsNullOrEmpty(subRoom.DataBlob)) saves.Add(BuildCurrentSave(subRoom));
var history = await ctx.SubRoomHistory.AsNoTracking()
.Where(x => x.RoomId == roomId && x.SubRoomId == subRoomId && x.DataBlob != "")
.OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.HistoryId)
.ToListAsync();
saves.AddRange(history.Select(BuildRestorableSave));
return saves;
}
public static SubRoomDataSave BuildRestorableSave(RestorableSubRoom row)
{
return new SubRoomDataSave
{
SubRoomDataSaveId = row.HistoryId + 1,
SubRoomId = row.SubRoomId,
DataBlob = row.DataBlob,
SavedByAccountId = row.SavedByAccountId,
CreatedAt = row.CreatedAt,
PersistenceVersion = 1,
OMVersion = 1,
UgcSubVersion = 1,
SavedOnPlatform = 0,
SavedOnDeviceClass = 0,
Description = string.Empty,
ModerationState = 0
};
}
public static FullRoom ConvertToFull(this RoomData room, List<SubRoom> subRooms, RoomStats stats, List<RoomRole> roles)
{
FullRoom fullRoom = new FullRoom
{
Name = room.Name,
RoomId = room.RoomId,
Description = room.Description ?? string.Empty,
ImageName = room.ImageName,
IsRRO = room.IsRRO,
IsDeveloperOwned = room.IsDeveloperOwned,
IsDorm = room.IsDorm,
State = room.State,
Accessibility = room.Accessibility,
CreatedAt = room.CreatedAt,
CreatorAccountId = room.CreatorAccountId,
SupportsJuniors = room.SupportsJuniors,
SupportsLevelVoting = room.SupportsLevelVoting,
SupportsMobile = room.SupportsMobile,
SupportsQuest2 = room.SupportsQuest2,
SupportsScreens = room.SupportsScreens,
SupportsTeleportVR = room.SupportsTeleportVR,
SupportsVRLow = room.SupportsVRLow,
SupportsWalkVR = room.SupportsWalkVR,
EncryptVoiceChat = room.EncryptVoiceChat,
MaxPlayerCalculationMode = room.MaxPlayerCalculationMode,
MaxPlayers = room.MaxPlayers,
SubRooms = subRooms,
Stats = stats,
CloningAllowed = room.CloningAllowed,
CustomWarning = room.CustomWarning ?? string.Empty,
Roles = roles,
DataBlob = null,
Tags = StringTools.ReadTags(room.Tags),
LoadScreens = room.loadScreen.FromJson<List<RoomLoadScreen>>() ?? new(),
WarningMask = room.WarningMask,
Version = room.Version,
DisableRoomComments = room.DisableRoomComments,
DisableMicAutoMute = room.DisableMicAutoMute,
MinLevel = room.MinLevel
};
foreach (var subRoom in subRooms)
{
subRoom.CurrentSave = string.IsNullOrEmpty(subRoom.DataBlob) ? null : BuildCurrentSave(subRoom);
subRoom.CreatorAccountId = subRoom.SavedByAccountId != 0 ? subRoom.SavedByAccountId : null;
}
return fullRoom;
}
public static async Task<List<FullRoom>> BulkConvertRoomData(List<RoomData> data)
{
var fullRooms = new List<FullRoom>();
foreach (var d in data)
{
var found = await GetRoom(d.RoomId);
if (found != null)
fullRooms.Add(found);
}
return fullRooms;
}
public static async Task<List<RoomDetail>> BulkConvertRoomDataToRoomDetail(List<RoomData> data)
{
var rooms = new List<RoomDetail>();
foreach (var d in data)
{
var found = await GetRoom(d.RoomId);
if (found != null)
rooms.Add(found);
}
return rooms;
}
public static async Task<FullRoom?> GetRoom(string Name)
{
await using var ctx = await DbFactory.CreateDbContextAsync();
var room = await ctx.Rooms.AsNoTracking().FirstOrDefaultAsync((x) => x.Name == Name);
if (room == null && !string.IsNullOrWhiteSpace(Name) && Name.StartsWith("@", StringComparison.Ordinal) && Name.EndsWith("'s Dorm", StringComparison.OrdinalIgnoreCase))
{
var username = Name.Substring(1, Name.Length - 8);
var owner = await ctx.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.username == username || x.displayName == username);
if (owner != null)
{
room = await ctx.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.IsDorm && x.CreatorAccountId == owner.accountId);
}
}
if (room == null) return null;
return await GetRoom(room.RoomId);
}
public static async Task<FullRoom?> GetRoom(long Id)
{
await using var ctx = await DbFactory.CreateDbContextAsync();
var room = await ctx.Rooms.AsNoTracking().FirstOrDefaultAsync((x) => x.RoomId == Id);
if (room == null) return null;
var cheers = await ctx.RoomInteractions.AsNoTracking().CountAsync(x => x.RoomId == Id && x.Cheered);
var favourites = await ctx.RoomInteractions.AsNoTracking().CountAsync(x => x.RoomId == Id && x.Favorited);
var visitorCount = await ctx.RoomInteractions.AsNoTracking().CountAsync(x => x.RoomId == Id && x.Visited);
var visitCount = await ctx.RoomInteractions.AsNoTracking()
.Where(x => x.RoomId == Id && x.Visited)
.SumAsync(x => (int?)x.VisitCount) ?? 0;
var roomStats = new RoomStats { CheerCount = cheers, FavoriteCount = favourites, RoomId = Id, VisitCount = visitCount, VisitorCount = visitorCount };
var roomRoles = await GetRoles(Id);
var subRooms = await ctx.SubRooms.AsNoTracking().Where((x) => x.RoomId == Id).ToListAsync();
var missingBlobs = subRooms
.Where(x => !string.IsNullOrEmpty(x.DataBlob) && !File.Exists(Path.Join(DBConfig.CDNStorage, x.DataBlob)))
.ToList();
if (missingBlobs.Count > 0)
{
var missingIds = missingBlobs.Select(x => x.SubRoomId).ToList();
var candidates = await ctx.SubRoomHistory.AsNoTracking()
.Where(x => missingIds.Contains(x.SubRoomId) && x.DataBlob != "")
.OrderByDescending(x => x.CreatedAt)
.ToListAsync();
foreach (var subRoom in missingBlobs)
{
var recovered = candidates
.Where(x => x.SubRoomId == subRoom.SubRoomId)
.Select(x => x.DataBlob)
.FirstOrDefault(blob => !string.IsNullOrEmpty(blob) && File.Exists(Path.Join(DBConfig.CDNStorage, blob)));
subRoom.DataBlob = recovered ?? "";
ctx.SubRooms.Attach(subRoom);
ctx.Entry(subRoom).Property(x => x.DataBlob).IsModified = true;
}
await ctx.SaveChangesAsync();
}
return room.ConvertToFull(subRooms, roomStats, roomRoles);
}
public static async Task<RoomStats?> GetRoomStats(long roomId, long? playerId)
{
await using var ctx = await DbFactory.CreateDbContextAsync();
var cheers = await ctx.RoomInteractions.AsNoTracking().CountAsync(x => x.RoomId == roomId && x.Cheered);
var favourites = await ctx.RoomInteractions.AsNoTracking().CountAsync(x => x.RoomId == roomId && x.Favorited);
var visitorCount = await ctx.RoomInteractions.AsNoTracking().CountAsync(x => x.RoomId == roomId && x.Visited);
var visited = 0;
if (playerId != null)
{
var interaction = await ctx.RoomInteractions.AsNoTracking()
.FirstOrDefaultAsync(x => x.RoomId == roomId && x.PlayerId == playerId);
if (interaction != null)
visited = interaction.VisitCount;
}
return new RoomStats() { CheerCount = cheers, FavoriteCount = favourites, RoomId = roomId, VisitCount = visited, VisitorCount = visitorCount };
}
public static async Task<FullRoom?> CloneRoom(long originalRoomId, long newCreator, string newName)
{
await using var ctx = await DbFactory.CreateDbContextAsync();
FullRoom? room = await GetRoom(originalRoomId);
if (room == null)
return null;
var tags = room.Tags;
var rro = tags.Find(x => x.Tag.StartsWith("rro") && x.Type >= TagType.Auto);
if (rro != null)
tags.Remove(rro);
RoomData roomData = new RoomData
{
State = RoomState.Active,
SupportsJuniors = room.SupportsJuniors,
SupportsLevelVoting = room.SupportsLevelVoting,
SupportsMobile = room.SupportsMobile,
SupportsQuest2 = room.SupportsQuest2,
SupportsScreens = room.SupportsScreens,
SupportsTeleportVR = room.SupportsTeleportVR,
SupportsVRLow = room.SupportsVRLow,
SupportsWalkVR = room.SupportsWalkVR,
Accessibility = Accessibility.Private,
CloningAllowed = false,
CreatedAt = DateTime.Now,
CreatorAccountId = newCreator,
CustomWarning = room.CustomWarning,
Description = room.Description,
DisableMicAutoMute = room.DisableMicAutoMute,
DisableRoomComments = room.DisableRoomComments,
EncryptVoiceChat = room.EncryptVoiceChat,
ImageName = room.ImageName,
IsDorm = room.IsDorm,
IsRRO = false,
IsDeveloperOwned = !room.IsDorm && await ctx.IsDeveloper(newCreator),
MaxPlayerCalculationMode = room.MaxPlayerCalculationMode,
MaxPlayers = room.MaxPlayers,
MinLevel = room.MinLevel,
Name = newName,
Tags = StringTools.CreateTagString(tags),
WarningMask = room.WarningMask,
};
await ctx.AddAsync(roomData);
await ctx.SaveChangesAsync();
if (room.SubRooms == null || room.SubRooms.Count == 0)
{
ctx.Remove(roomData);
await ctx.SaveChangesAsync();
return null;
}
foreach (SubRoom subRoom in room.SubRooms)
{
if (subRoom == null) continue;
await ctx.SubRooms.AddAsync(new SubRoom
{
Accessibility = subRoom.Accessibility,
DataBlob = subRoom.DataBlob,
UnityAssetId = subRoom.UnityAssetId,
IsSandbox = subRoom.IsSandbox,
UnitySceneId = subRoom.UnitySceneId,
MaxPlayers = subRoom.MaxPlayers,
Name = subRoom.Name,
RoomId = roomData.RoomId
});
}
await ctx.SaveChangesAsync();
await ctx.RoomStats.AddAsync(new RoomStats
{
RoomId = roomData.RoomId,
FavoriteCount = 0,
CheerCount = 0,
VisitCount = 0,
VisitorCount = 0,
});
await ctx.RoomRoles.AddAsync(new RoomRole
{
RoomId = roomData.RoomId,
Role = RoleType.Creator,
InvitedRole = RoleType.None,
AccountId = newCreator
});
await ctx.SaveChangesAsync();
return await GetRoom(roomData.RoomId);
}
private static async Task<FullRoom?> GetDormTemplateRoom()
{
var templateRoom = await GetRoom(1);
if (templateRoom != null) return templateRoom;
await using var ctx = await DbFactory.CreateDbContextAsync();
var template = await ctx.Rooms.AsNoTracking().FirstOrDefaultAsync(room => room.IsDorm && room.CreatorAccountId == 1);
return template == null ? null : await GetRoom(template.RoomId);
}
public static async Task<FullRoom?> GetOrCreateDorm(long playerId)
{
await using var ctx = await DbFactory.CreateDbContextAsync();
var roomData = await ctx.Rooms.AsNoTracking().FirstOrDefaultAsync((x) => x.IsDorm && x.CreatorAccountId == playerId);
if (roomData == null)
{
var templateRoom = await GetDormTemplateRoom();
if (templateRoom == null)
{
return null;
}
return await CloneRoom(templateRoom.RoomId, playerId, "DormRoom");
}
var hasSubRoom = await ctx.SubRooms.AnyAsync(s => s.RoomId == roomData.RoomId);
if (!hasSubRoom)
{
var templateRoom = await GetDormTemplateRoom();
var templateSub = templateRoom?.SubRooms?.FirstOrDefault();
if (templateSub != null)
{
await ctx.SubRooms.AddAsync(new SubRoom
{
RoomId = roomData.RoomId,
DataBlob = templateSub.DataBlob,
UnityAssetId = templateSub.UnityAssetId,
IsSandbox = templateSub.IsSandbox,
UnitySceneId = templateSub.UnitySceneId,
MaxPlayers = templateSub.MaxPlayers,
Name = templateSub.Name,
Accessibility = templateSub.Accessibility
});
await ctx.SaveChangesAsync();
}
}
return await GetRoom(roomData.RoomId);
}
}
}
+48
View File
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc;
using SixLabors.ImageSharp;
namespace RadicadeServer.CDN
{
[Route("cdn/room/")]
[Route("room/")]
[Route("unity/")]
[Route("unity_assets/")]
[Route("roomserver/unity_assets/")]
public class RoomsCDN : Controller
{
[HttpGet]
public IActionResult Index() => Ok(new { status = "ok" });
[HttpGet("{datablob}")]
public IActionResult GetDataBlob(string datablob)
{
if (!CdnStorageNames.IsServable(datablob)) return BadRequest();
var name = CdnStorageNames.Resolve(datablob);
if (name == null) return NotFound();
CdnCaching.Apply(this, CdnCaching.ForBlob(name));
return PhysicalFile(Path.Join(DBConfig.CDNStorage, name), "application/octet-stream", enableRangeProcessing: true);
}
[HttpGet("{assetId}/{target}/{version}")]
[HttpGet("{assetId}/{target}")]
public IActionResult GetUnityAssetRaw(string assetId, int? target, int? version)
{
// The bytes half of the two-step download, for a client that asks by id rather than by
// the Filename the metadata route handed it: /unity/{id} is the id with no extension,
// while an imported bundle is stored as <id>.assetbundle, so both spellings resolve.
if (!CdnStorageNames.IsServable(assetId)) return BadRequest();
var name = CdnStorageNames.Resolve(assetId);
if (name == null) return NotFound();
CdnCaching.Apply(this, CdnCaching.Immutable);
return PhysicalFile(Path.Join(DBConfig.CDNStorage, name), "application/octet-stream", enableRangeProcessing: true);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Enums.Rooms;
using RadicadeServer.RecNet.Responses;
using RadicadeServer.RecNet.Rooms;
using RadicadeServer.Utils;
namespace RadicadeServer.API.Rooms.V1
{
[Route("api/rooms/v1")]
public class RoomsV1 : Controller
{
RecDBContext db;
public RoomsV1(RecDBContext context)
{
this.db = context;
}
[HttpGet("filters")]
public async Task<ActionResult<RoomFilters>> GetRoomFilters()
{
var dbFilters = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "Filters");
if (dbFilters == null)
return NotFound();
var filters = dbFilters.Value.Split(',', StringSplitOptions.TrimEntries).ToList();
return new RoomFilters { PinnedFilters = filters , PopularFilters = filters, TrendingFilters = filters };
}
[HttpPost("verifyRole"), Authorize]
public async Task<Success?> VerifyRole([FromForm] long roomId, [FromForm] RoleType role, [FromForm] string? context)
{
var userId = User.GetUserId();
var roomRole = await RoomUtils.VerifyRole((long)userId!, roomId, role);
if (!roomRole)
return new Success(false, "Invalid roles", false);
else
return new Success(true, null, null);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
namespace RadicadeServer.RecNet.Rooms
{
public static class SceneLocations
{
public const string MakerRoom = "a75f7547-79eb-47c6-8986-6767abcb4f92";
public const string Dorm = "76d98498-60a1-430c-ab76-b54a29b7a163";
private static readonly HashSet<string> KnownBadValues = new(StringComparer.OrdinalIgnoreCase)
{
"unknown",
"none",
"null",
};
public static bool IsValid(string? unitySceneId)
{
if (string.IsNullOrWhiteSpace(unitySceneId))
return false;
var trimmed = unitySceneId.Trim();
if (KnownBadValues.Contains(trimmed))
return false;
return Guid.TryParse(trimmed, out _);
}
public static string ResolveFallback(IEnumerable<string?> candidateUnitySceneIds, bool isDorm)
{
foreach (var id in candidateUnitySceneIds)
{
if (IsValid(id))
return id!.Trim();
}
return isDorm ? Dorm : MakerRoom;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using RadicadeServer.RecNet.Enums;
namespace RadicadeServer.RecNet.Rooms
{
public class SubRoom
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long SubRoomId { get; set; }
public long RoomId { get; set; }
public long SavedByAccountId { get; set; }
public string Name { get; set; } = "databasebroken";
public string DataBlob { get; set; } = "";
public string UnityAssetId { get; set; } = "";
[NotMapped]
public SubRoomDataSave? CurrentSave { get; set; }
[NotMapped]
public long? CreatorAccountId { get; set; }
public bool IsSandbox { get; set; }
public int MaxPlayers { get; set; }
public Accessibility Accessibility { get; set; }
public string UnitySceneId { get; set; } = "";
public DateTime CreatedAt { get; set; }
[NotMapped]
public DateTime DataSavedAt { get => CreatedAt; set => CreatedAt = value; }
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace RadicadeServer.RecNet.Rooms
{
public class SubRoomDataSave
{
public List<object> UnitySubAssets { get; set; } = new();
public List<object> ReferencedUnityAssets { get; set; } = new();
public long SubRoomDataSaveId { get; set; }
public long SubRoomId { get; set; }
public string DataBlob { get; set; } = string.Empty;
public List<string> ReferencedUnityAssetIds { get; set; } = new();
public int PersistenceVersion { get; set; }
public int OMVersion { get; set; }
public int UgcSubVersion { get; set; }
public long? SavedByAccountId { get; set; }
public int SavedOnPlatform { get; set; }
public int SavedOnDeviceClass { get; set; }
public string Description { get; set; } = string.Empty;
public List<object> Tags { get; set; } = new();
public int ModerationState { get; set; }
public DateTime CreatedAt { get; set; }
public string? UnityAssetId { get; set; }
}
}
+23
View File
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace RadicadeServer.RecNet.Rooms
{
public class SubRoomPermission
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; set; }
public long subRoomId { get; set; }
[MaxLength(128)]
public string permission { get; set; } = "";
public long role { get; set; }
public int type { get; set; }
public string value { get; set; } = "";
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace RadicadeServer.RecNet.Rooms
{
public class UserLoadScreen
{
public string imageName { get; set; } = null!;
public string title { get; set; } = null!;
public string subtitle { get; set; } = null!;
}
}
+723
View File
@@ -0,0 +1,723 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text;
using System.Text.Json.Nodes;
using RadicadeServer.API.Notifications;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Enums;
using RadicadeServer.RecNet.Enums.Rooms;
using RadicadeServer.RecNet.Notifications;
using RadicadeServer.RecNet.Responses;
using RadicadeServer.RecNet.Rooms;
using RadicadeServer.Utils;
namespace RadicadeServer.RoomServer
{
[Route("rooms/rooms/")]
public class V2_RoomsAPI(RecDBContext db) : Controller
{
[HttpGet("{id:long}/similar"), Authorize]
public async Task<IActionResult> GetSimilarRooms(long id, [FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Ok(EmptyPage());
var userId = User.GetUserId();
skip = Math.Max(skip, 0);
take = Math.Clamp(take, 1, 100);
var ownTags = StringTools.ReadTags(room.Tags)
.Select(t => t.Tag?.Trim().ToLowerInvariant())
.Where(t => !string.IsNullOrEmpty(t))
.ToHashSet();
var candidates = await db.Rooms
.Where(x => x.RoomId != id && x.Accessibility == Accessibility.Public)
.ToListAsync();
var scored = new List<(RoomData Room, int Score, int Visits)>();
foreach (var candidate in candidates)
{
if (!await RoomUtils.CanSeeRoom(candidate, userId)) continue;
int score;
if (ownTags.Count == 0)
{
score = candidate.CreatorAccountId == room.CreatorAccountId ? 1 : 0;
}
else
{
score = StringTools.ReadTags(candidate.Tags)
.Count(t => ownTags.Contains(t.Tag?.Trim().ToLowerInvariant()));
}
if (score <= 0) continue;
var stats = await db.RoomStats.FirstOrDefaultAsync(x => x.RoomId == candidate.RoomId);
scored.Add((candidate, score, stats?.VisitCount ?? 0));
}
var ordered = scored
.OrderByDescending(x => x.Score)
.ThenByDescending(x => x.Visits)
.Select(x => x.Room)
.ToList();
var picked = ordered.Skip(skip).Take(take).ToList();
var details = await RoomUtils.BulkConvertRoomDataToRoomDetail(picked);
return Ok(new RoomSearchResult { TotalResults = ordered.Count, Results = details });
}
private static RoomSearchResult EmptyPage() => new() { TotalResults = 0, Results = new List<RoomDetail>() };
[HttpGet("{id:long}/experience")]
public IActionResult GetRoomExperience(long id)
=> Ok(new { Enabled = false, DailyLimit = 1000 });
[HttpGet("{id:long}/experience/player")]
public IActionResult GetRoomExperienceForPlayer(long id)
=> Ok(Array.Empty<object>());
[HttpPut("{id}/description"), Authorize]
public async Task<Success> SetDescription(long? id, string description)
{
var userId = User.GetUserId();
if (userId == null) return new Success(false, "Invalid auth", null);
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
if (!description.IsPure()) return new Success(false, "Description contains offensive language.", null);
room.Description = description;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom((long)id!);
await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, fullRoom);
}
[HttpPut("{id}/name"), Authorize]
public async Task<Success> SetName(long? id, string name)
{
var userId = User.GetUserId();
if (userId == null) return new Success(false, "Invalid auth", null);
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
if (!name.IsPure()) return new Success(false, "Name contains offensive language.", null);
var sameRoomWithName = await db.Rooms.FirstOrDefaultAsync(x => x.Name == name);
if (sameRoomWithName != null) return new Success(false, "A room already exists with that name.", null);
room.Name = name;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom((long)id!);
await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, fullRoom);
}
[HttpPut("{id}/warning"), Authorize]
public async Task<Success> SetWarning(long? id, WarningMask? warningMask, string? customWarning)
{
var userId = User.GetUserId();
if (userId == null) return new Success(false, "Invalid auth", null);
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
if (warningMask != null)
room.WarningMask = warningMask.Value;
if (customWarning != null)
room.CustomWarning = customWarning;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom((long)id!);
await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, fullRoom);
}
[HttpPut("{id}/cloning"), Authorize]
public async Task<Success> SetAllowedCloning(long? id, bool cloningAllowed)
{
var userId = User.GetUserId();
if (userId == null) return new Success(false, "Invalid auth", null);
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
room.CloningAllowed = cloningAllowed;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom((long)id!);
await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, fullRoom);
}
[HttpPut("{id}/restrictions"), Authorize]
public async Task<Success> SetRestrictions(long? id, bool supportsScreens, bool supportsWalkVR, bool supportsTeleportVR, bool supportsJuniors)
{
var userId = User.GetUserId();
if (userId == null) return new Success(false, "Invalid auth", null);
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
room.SupportsTeleportVR = supportsTeleportVR;
room.SupportsWalkVR = supportsWalkVR;
room.SupportsScreens = supportsScreens;
room.SupportsJuniors = supportsJuniors;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom((long)id!);
await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, fullRoom);
}
[HttpPut("{id}/automute"), Authorize]
public async Task<Success> SetDisableAutoMute(long? id, bool disable)
{
var userId = User.GetUserId();
if (userId == null) return new Success(false, "Invalid auth", null);
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
room.DisableMicAutoMute = disable;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom((long)id!);
await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, fullRoom);
}
[HttpPut("{id}/comments"), Authorize]
public async Task<Success> SetDisableComments(long? id, bool disable)
{
var userId = User.GetUserId();
if (userId == null) return new Success(false, "Invalid auth", null);
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
room.DisableRoomComments = disable;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom((long)id!);
await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, fullRoom);
}
[HttpPut("{id}/voice_chat_encryption"), Authorize]
public async Task<Success> SetEncryptVoiceChat(long? id, bool encryptVoiceChat)
{
var userId = User.GetUserId();
if (userId == null) return new Success(false, "Invalid auth", null);
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
room.EncryptVoiceChat = encryptVoiceChat;
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom((long)id!);
await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, fullRoom);
}
[HttpPut("{id}/allow_new_users"), Authorize]
public async Task<Success> SetAllowNewUsers(long? id, bool allowNewUsers)
{
var userId = User.GetUserId();
if (userId == null) return new Success(false, "Invalid auth", null);
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
var fullRoom = await RoomUtils.GetRoom((long)id!);
await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, fullRoom);
}
[HttpGet("{id}/bans"), Authorize]
public async Task<List<RoomBan>> GetRoomBans(long? id)
{
var userId = User.GetUserId();
if (userId == null || id == null) return new List<RoomBan>();
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return new List<RoomBan>();
var canModify = await MatchmakingUtils.CanModerateRoom((long)userId, (long)id!);
if (!canModify) return new List<RoomBan>();
return await db.RoomBans
.Where(x => x.roomId == id && (!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow))
.OrderByDescending(x => x.bannedAt)
.ToListAsync();
}
[HttpGet("{id}/bans/{bannedPlayerId}"), Authorize]
public async Task<RoomBan?> GetRoomBan(long? id, long bannedPlayerId)
{
var userId = User.GetUserId();
if (userId == null || id == null) return null;
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return null;
if (bannedPlayerId != (long)userId)
{
var canModify = await MatchmakingUtils.CanModerateRoom((long)userId, (long)id!);
if (!canModify) return null;
}
return await db.RoomBans
.FirstOrDefaultAsync(x => x.roomId == id && x.bannedPlayerId == bannedPlayerId &&
(!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow));
}
[HttpPost("{id}/bans/import"), Authorize]
public async Task<Success> ImportRoomBans(long? id)
{
var userId = User.GetUserId();
if (userId == null || id == null) return new Success(false, "Invalid auth", null);
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
var canModify = await MatchmakingUtils.CanModerateRoom((long)userId, (long)id!);
if (!canModify) return Success.Error("Insufficient permissions");
var playerIds = new List<long>();
string rawBody = "";
try
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "playerIds", "PlayerIds", "ids", "Ids", "bannedPlayerIds", "BannedPlayerIds", "players", "Players" })
if (form.TryGetValue(key, out var values))
foreach (var value in values)
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var parsedId) && parsedId > 0)
playerIds.Add(parsedId);
}
if (Request.ContentLength is > 0)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
rawBody = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(rawBody))
{
var parsedBody = JsonNode.Parse(rawBody);
if (parsedBody is JsonArray array)
{
foreach (var item in array)
{
if (item is JsonValue value && value.TryGetValue<long>(out var bareId) && bareId > 0)
playerIds.Add(bareId);
else if (item is JsonObject obj)
playerIds.Add(ParsePlayerId(obj, new[] { "bannedPlayerId", "playerId", "accountId", "id" }));
}
}
else if (parsedBody is JsonObject body)
{
foreach (var key in new[] { "playerIds", "PlayerIds", "ids", "Ids", "bannedPlayerIds", "BannedPlayerIds", "players", "Players", "bans", "Bans" })
{
if (!body.TryGetPropertyValue(key, out var node) || node == null) continue;
if (node is JsonArray arr)
{
foreach (var item in arr)
{
if (item is JsonValue value && value.TryGetValue<long>(out var bareId) && bareId > 0)
playerIds.Add(bareId);
else if (item is JsonObject obj)
playerIds.Add(ParsePlayerId(obj, new[] { "bannedPlayerId", "playerId", "accountId", "id" }));
}
}
}
}
}
}
}
catch { }
playerIds = playerIds.Distinct().ToList();
if (playerIds.Count == 0)
return Success.Error("No valid player ids found in the import request");
var actorProfile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == (long)userId);
var imported = new List<RoomBan>();
var now = DateTime.UtcNow;
foreach (var targetPlayer in playerIds)
{
if (targetPlayer == (long)userId) continue;
if (await MatchmakingUtils.IsAtLeastRole(targetPlayer, (long)id!, RoleType.CoOwner)) continue;
if (await db.Profiles.AnyAsync(x => x.accountId == targetPlayer) == false) continue;
var existing = await db.RoomBans.FirstOrDefaultAsync(x => x.roomId == id && x.bannedPlayerId == targetPlayer &&
(!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow));
if (existing != null) continue;
var ban = new RoomBan
{
roomId = (long)id!,
actorId = (long)userId,
bannedPlayerId = targetPlayer,
bannedByName = actorProfile?.displayName ?? $"Player {(long)userId}",
reason = "",
bannedAt = now,
expiresAt = null
};
db.RoomBans.Add(ban);
imported.Add(ban);
}
await db.SaveChangesAsync();
return new Success(true, null, new Dictionary<string, object?>
{
["imported"] = imported.Count,
["importedCount"] = imported.Count,
["requested"] = playerIds.Count,
["bans"] = imported,
["playerIds"] = imported.Select(x => x.bannedPlayerId).ToArray()
});
}
[HttpPut("{id}/bans"), Authorize]
[HttpPost("{id}/bans"), Authorize]
public async Task<Success> AddRoomBan(long? id)
{
var userId = User.GetUserId();
if (userId == null || id == null) return new Success(false, "Invalid auth", null);
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
var canModify = await MatchmakingUtils.CanModerateRoom((long)userId, (long)id!);
if (!canModify)
{
var deniedRole = await RoomUtils.GetRoleForPlayer((long)userId, (long)id!);
var highRanksVal = await db.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == "HighRanks");
return Success.Error("Insufficient permissions");
}
long targetPlayer = 0;
string banReason = "";
string[] idKeys =
{
"bannedPlayerId", "bannedPlayerID", "bannedPlayer", "bannedplayerid",
"bannedId", "bannedid", "banned_id",
"bannerId", "bannerid", "banner_id",
"playerId", "playerid", "player_id", "player", "playerAccountId",
"bannedUserId", "bannedAccountId",
"accountId", "account_id",
"targetPlayerId", "targetPlayer", "targetId", "targetid", "target",
"victimId", "victimid",
"userId", "userid",
"id"
};
if (Request.HasFormContentType)
{
foreach (var key in idKeys)
if (targetPlayer <= 0 && long.TryParse(Request.Form[key], out var formPlayer)) targetPlayer = formPlayer;
if (string.IsNullOrEmpty(banReason))
foreach (var key in new[] { "reason", "banReason", "ban_reason" })
if (Request.Form.TryGetValue(key, out var formReason)) { banReason = formReason.ToString(); break; }
}
foreach (var key in idKeys)
if (targetPlayer <= 0 && long.TryParse(Request.Query[key], out var queryPlayer)) targetPlayer = queryPlayer;
if (string.IsNullOrEmpty(banReason))
foreach (var key in new[] { "reason", "banReason", "ban_reason" })
if (Request.Query.TryGetValue(key, out var queryReason)) { banReason = queryReason.ToString(); break; }
string rawBody = "";
try
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
rawBody = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(rawBody))
{
var parsedBody = JsonNode.Parse(rawBody);
if (parsedBody is JsonObject body)
{
if (targetPlayer <= 0) targetPlayer = ParsePlayerId(body, idKeys);
if (string.IsNullOrEmpty(banReason)) banReason = ParseJsonString(body, "reason", "banReason", "ban_reason");
}
else if (parsedBody is JsonValue bareValue && targetPlayer <= 0)
{
if (bareValue.TryGetValue<long>(out var bareId)) targetPlayer = bareId;
else if (bareValue.TryGetValue<string>(out var bareStr) && long.TryParse(bareStr, out var bareParsed)) targetPlayer = bareParsed;
}
}
}
catch { }
if (targetPlayer <= 0)
{
return Success.Error("bannedPlayerId is required");
}
if (targetPlayer == (long)userId)
{
return Success.Error("You cannot ban yourself");
}
if (await MatchmakingUtils.IsAtLeastRole(targetPlayer, (long)id!, RoleType.CoOwner))
return Success.Error("You cannot ban a Co-Owner or higher");
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == targetPlayer);
if (profile == null) return Success.Error("Player not found");
var existing = await db.RoomBans.FirstOrDefaultAsync(x => x.roomId == id && x.bannedPlayerId == targetPlayer &&
(!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow));
if (existing != null) return Success.Error("Player is already banned from this room");
var actorProfile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == (long)userId);
var ban = new RoomBan
{
roomId = (long)id!,
actorId = (long)userId,
bannedPlayerId = targetPlayer,
bannedByName = actorProfile?.displayName ?? $"Player {(long)userId}",
reason = banReason.Trim(),
bannedAt = DateTime.UtcNow,
expiresAt = null
};
await db.RoomBans.AddAsync(ban);
await db.SaveChangesAsync();
await MatchmakingUtils.KickPlayerFromRoom(targetPlayer, (long)id!,
string.IsNullOrWhiteSpace(ban.reason) ? "You were banned from this room." : $"You were banned from this room: {ban.reason}");
return new Success(true, null, ban);
}
[HttpDelete("{id}/bans/{bannedPlayerId}"), HttpDelete("{id}/bans"),
HttpPost("{id}/bans/{bannedPlayerId}"), HttpPut("{id}/bans/{bannedPlayerId}"), Authorize]
public async Task<Success> RemoveRoomBan(long? id, long? bannedPlayerId)
{
var userId = User.GetUserId();
if (userId == null || id == null) return new Success(false, "Invalid auth", null);
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
var canModify = await MatchmakingUtils.CanModerateRoom((long)userId, (long)id!);
if (!canModify) return Success.Error("Insufficient permissions");
long targetPlayer = bannedPlayerId ?? 0;
string[] idKeys =
{
"bannedPlayerId", "bannedPlayerID", "bannedId", "bannedid", "banned_id",
"playerId", "playerid", "player_id", "player",
"bannerId", "bannerid", "banner_id",
"userId", "userid", "id"
};
if (targetPlayer <= 0 && Request.HasFormContentType)
foreach (var key in idKeys)
if (long.TryParse(Request.Form[key], out var formPlayer)) { targetPlayer = formPlayer; break; }
if (targetPlayer <= 0)
foreach (var key in idKeys)
if (long.TryParse(Request.Query[key], out var queryPlayer)) { targetPlayer = queryPlayer; break; }
if (targetPlayer <= 0)
{
try
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
var raw = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(raw) && JsonNode.Parse(raw) is JsonObject body)
targetPlayer = ParsePlayerId(body, idKeys);
}
catch { }
}
if (targetPlayer <= 0)
{
return Success.Error("bannedPlayerId is required");
}
var ban = await db.RoomBans.FirstOrDefaultAsync(x => x.roomId == id && x.bannedPlayerId == targetPlayer);
if (ban == null) return Success.Error("Player is not banned from this room");
db.RoomBans.Remove(ban);
await db.SaveChangesAsync();
return new Success(true, null, ban);
}
[HttpPut("{id}/loadscreen")]
public async Task<Success> SetLoadingScreen(long id, string imageName, string title, string subtitle)
{
var userId = User.GetUserId();
if (userId == null) return new Success(false, "Invalid auth", null);
var canModify = await MatchmakingUtils.IsAtLeastRole((long)userId, (long)id!, RoleType.CoOwner);
if (!canModify) return Success.Error("Insufficient permissions");
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id);
if (room == null) return Success.Error("Room not found");
var entry = new RoomLoadScreen { ImageName = imageName, RoomId = room.RoomId, Subtitle = subtitle, Title = title };
List<RoomLoadScreen> loadScreen = room.loadScreen.FromJson<List<RoomLoadScreen>>() ?? new();
if (loadScreen.Count >= 1)
loadScreen[0] = entry;
else
loadScreen.Add(entry);
room.loadScreen = loadScreen.ToJson();
await db.SaveChangesAsync();
var fullRoom = await RoomUtils.GetRoom((long)id!);
await Notify.SendNotify((long)userId, new Notification { Id = "RoomUpdate", Msg = fullRoom });
return new Success(true, null, fullRoom);
}
private static long ParsePlayerId(JsonObject body, string[] idKeys)
{
foreach (var prop in body)
{
if (prop.Value is not JsonValue value) continue;
bool keyMatches = false;
foreach (var key in idKeys)
if (prop.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) { keyMatches = true; break; }
if (!keyMatches) continue;
if (value.TryGetValue<long>(out var longId)) return longId;
if (value.TryGetValue<string>(out var strId) && long.TryParse(strId, out var parsed)) return parsed;
if (value.TryGetValue<double>(out var doubleId) && doubleId > 0 && doubleId == Math.Floor(doubleId)) return (long)doubleId;
}
return 0;
}
private static string ParseJsonString(JsonObject body, params string[] keys)
{
foreach (var prop in body)
{
if (prop.Value is not JsonValue value) continue;
bool keyMatches = false;
foreach (var key in keys)
if (prop.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) { keyMatches = true; break; }
if (keyMatches && value.TryGetValue<string>(out var str)) return str ?? "";
}
return "";
}
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace RadicadeServer.RecNet.Rooms
{
public class VerifyRoleRequest
{
public long roomId { get; set; }
public RoomRole role { get; set; } = null!;
public string? context { get; set; }
}
}