diff --git a/CannedNet/Migrations/20260404153303_AddPlayerCountAndLastHeartbeat.Designer.cs b/CannedNet/Migrations/20260404153303_AddPlayerCountAndLastHeartbeat.Designer.cs new file mode 100644 index 0000000..e0c8366 --- /dev/null +++ b/CannedNet/Migrations/20260404153303_AddPlayerCountAndLastHeartbeat.Designer.cs @@ -0,0 +1,702 @@ +// +using System; +using CannedNet.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CannedNet.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260404153303_AddPlayerCountAndLastHeartbeat")] + partial class AddPlayerCountAndLastHeartbeat + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CannedNet.Account", b => + { + b.Property("AccountId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "accountId"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "createdAt"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "displayName"); + + b.Property("IdentityFlags") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "identityFlags"); + + b.Property("IsJunior") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "isJunior"); + + b.Property("PersonalPronouns") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "personalPronouns"); + + b.Property("Platforms") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "platforms"); + + b.Property("ProfileImage") + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "profileImage"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "username"); + + b.HasKey("AccountId"); + + b.ToTable("accounts", (string)null); + }); + + modelBuilder.Entity("CannedNet.AvatarItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarItemDesc") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "avatarItemDesc"); + + b.Property("AvatarItemType") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "avatarItemType"); + + b.Property("FriendlyName") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "friendlyName"); + + b.Property("OwnerAccountId") + .HasColumnType("integer"); + + b.Property("PlatformMask") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "platformMask"); + + b.Property("Rarity") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "rarity"); + + b.Property("Tooltip") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "tooltip"); + + b.HasKey("Id"); + + b.HasIndex("OwnerAccountId"); + + b.ToTable("avatar_items", (string)null); + }); + + modelBuilder.Entity("CannedNet.CachedLogin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "accountId"); + + b.Property("LastLoginTime") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "lastLoginTime"); + + b.Property("Platform") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "platform"); + + b.Property("PlatformID") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "platformId"); + + b.Property("RequirePassword") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "requirePassword"); + + b.HasKey("Id"); + + b.HasIndex("Platform", "PlatformID") + .IsUnique(); + + b.ToTable("cached_logins", (string)null); + }); + + modelBuilder.Entity("CannedNet.LoadScreen", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImageUrl") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "imageUrl"); + + b.Property("IsThumbnail") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "isThumbnail"); + + b.Property("RoomId") + .HasColumnType("integer"); + + b.Property("Tooltip") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "tooltip"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.ToTable("load_screens", (string)null); + }); + + modelBuilder.Entity("CannedNet.PlayerAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FaceFeatures") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "FaceFeatures"); + + b.Property("HairColor") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "HairColor"); + + b.Property("OutfitSelections") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "OutfitSelections"); + + b.Property("OwnerAccountId") + .HasColumnType("integer"); + + b.Property("SkinColor") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "SkinColor"); + + b.HasKey("Id"); + + b.HasIndex("OwnerAccountId") + .IsUnique(); + + b.ToTable("player_avatars", (string)null); + }); + + modelBuilder.Entity("CannedNet.PlayerProgression", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Level") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "level"); + + b.Property("PlayerId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "playerId"); + + b.Property("Xp") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "xp"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_progressions", (string)null); + }); + + modelBuilder.Entity("CannedNet.PlayerSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "key"); + + b.Property("PlayerId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "playerId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "value"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("player_settings", (string)null); + }); + + modelBuilder.Entity("CannedNet.PromoExternalContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RoomId") + .HasColumnType("integer"); + + b.Property("Tooltip") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "tooltip"); + + b.Property("Type") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "type"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "url"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.ToTable("promo_external_contents", (string)null); + }); + + modelBuilder.Entity("CannedNet.PromoImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImageUrl") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "imageUrl"); + + b.Property("RoomId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "sortOrder"); + + b.Property("Tooltip") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "tooltip"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.ToTable("promo_images", (string)null); + }); + + modelBuilder.Entity("CannedNet.Room", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Accessibility") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "accessibility"); + + b.Property("CloningAllowed") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "cloningAllowed"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "createdAt"); + + b.Property("CreatorAccountId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "creatorAccountId"); + + b.Property("CustomWarning") + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "customWarning"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "description"); + + b.Property("DisableMicAutoMute") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "disableMicAutoMute"); + + b.Property("DisableRoomComments") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "disableRoomComments"); + + b.Property("EncryptVoiceChat") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "encryptVoiceChat"); + + b.Property("ImageName") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "imageName"); + + b.Property("IsDorm") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "isDorm"); + + b.Property("IsRRO") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "isRRO"); + + b.Property("MinLevel") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "minLevel"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.Property("RoomId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "roomId"); + + b.Property("State") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "state"); + + b.Property("SupportsJuniors") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "supportsJuniors"); + + b.Property("SupportsLevelVoting") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "supportsLevelVoting"); + + b.Property("SupportsMobile") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "supportsMobile"); + + b.Property("SupportsQuest2") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "supportsQuest2"); + + b.Property("SupportsScreens") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "supportsScreens"); + + b.Property("SupportsTeleportVR") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "supportsTeleportVR"); + + b.Property("SupportsVRLow") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "supportsVRLow"); + + b.Property("SupportsWalkVR") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "supportsWalkVR"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "tags"); + + b.Property("WarningMask") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "warningMask"); + + b.HasKey("Id"); + + b.HasIndex("CreatorAccountId"); + + b.ToTable("rooms", (string)null); + }); + + modelBuilder.Entity("CannedNet.RoomInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EncryptVoiceChat") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "EncryptVoiceChat"); + + b.Property("OwnerAccountId") + .HasColumnType("integer"); + + b.Property("clubId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "clubId"); + + b.Property("dataBlob") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "dataBlob"); + + b.Property("eventId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "eventId"); + + b.Property("isFull") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "isFull"); + + b.Property("isInProgress") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "isInProgress"); + + b.Property("isPrivate") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "isPrivate"); + + b.Property("lastHeartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("location") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "location"); + + b.Property("maxCapacity") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "maxCapacity"); + + b.Property("name") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.Property("pendingJoin") + .HasColumnType("boolean"); + + b.Property("photonRegionId") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "photonRegionId"); + + b.Property("photonRoomId") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "photonRoomId"); + + b.Property("playerCount") + .HasColumnType("integer"); + + b.Property("roomCode") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "roomCode"); + + b.Property("roomId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "roomId"); + + b.Property("roomInstanceId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "roomInstanceId"); + + b.Property("roomInstanceType") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "roomInstanceType"); + + b.Property("subRoomId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "subRoomId"); + + b.HasKey("Id"); + + b.HasIndex("OwnerAccountId", "roomId"); + + b.ToTable("room_instances", (string)null); + }); + + modelBuilder.Entity("CannedNet.RoomRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "accountId"); + + b.Property("InvitedRole") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "invitedRole"); + + b.Property("Role") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "role"); + + b.Property("RoomId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.ToTable("room_roles", (string)null); + }); + + modelBuilder.Entity("CannedNet.SavedOutfit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FaceFeatures") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "FaceFeatures"); + + b.Property("HairColor") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "HairColor"); + + b.Property("OutfitSelections") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "OutfitSelections"); + + b.Property("OwnerAccountId") + .HasColumnType("integer"); + + b.Property("PreviewImageName") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "PreviewImageName"); + + b.Property("SkinColor") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "SkinColor"); + + b.Property("Slot") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "Slot"); + + b.HasKey("Id"); + + b.HasIndex("OwnerAccountId") + .IsUnique(); + + b.ToTable("saved_outfits", (string)null); + }); + + modelBuilder.Entity("CannedNet.SubRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Accessibility") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "accessibility"); + + b.Property("DataBlob") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "dataBlob"); + + b.Property("DataSavedAt") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "dataSavedAt"); + + b.Property("IsSandbox") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "isSandbox"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "maxPlayers"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.Property("RoomId") + .HasColumnType("integer"); + + b.Property("SubRoomId") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "subRoomId"); + + b.Property("UnitySceneId") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "unitySceneId"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.ToTable("sub_rooms", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CannedNet/Migrations/20260404153303_AddPlayerCountAndLastHeartbeat.cs b/CannedNet/Migrations/20260404153303_AddPlayerCountAndLastHeartbeat.cs new file mode 100644 index 0000000..c728973 --- /dev/null +++ b/CannedNet/Migrations/20260404153303_AddPlayerCountAndLastHeartbeat.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CannedNet.Migrations +{ + /// + public partial class AddPlayerCountAndLastHeartbeat : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "lastHeartbeat", + table: "room_instances", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "playerCount", + table: "room_instances", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "lastHeartbeat", + table: "room_instances"); + + migrationBuilder.DropColumn( + name: "playerCount", + table: "room_instances"); + } + } +} diff --git a/CannedNet/Migrations/AppDbContextModelSnapshot.cs b/CannedNet/Migrations/AppDbContextModelSnapshot.cs index 031b2a2..c18abc8 100644 --- a/CannedNet/Migrations/AppDbContextModelSnapshot.cs +++ b/CannedNet/Migrations/AppDbContextModelSnapshot.cs @@ -502,6 +502,9 @@ namespace CannedNet.Migrations .HasColumnType("boolean") .HasAnnotation("Relational:JsonPropertyName", "isPrivate"); + b.Property("lastHeartbeat") + .HasColumnType("timestamp with time zone"); + b.Property("location") .IsRequired() .HasColumnType("text") @@ -529,6 +532,9 @@ namespace CannedNet.Migrations .HasColumnType("text") .HasAnnotation("Relational:JsonPropertyName", "photonRoomId"); + b.Property("playerCount") + .HasColumnType("integer"); + b.Property("roomCode") .IsRequired() .HasColumnType("text") diff --git a/CannedNet/Services/MatchmakingService.cs b/CannedNet/Services/MatchmakingService.cs index f12bfcc..be6e0a2 100644 --- a/CannedNet/Services/MatchmakingService.cs +++ b/CannedNet/Services/MatchmakingService.cs @@ -244,6 +244,23 @@ public class MatchmakingService .OrderByDescending(r => r.Id) .FirstOrDefaultAsync(); + if (roomInstance != null) + { + roomInstance.lastHeartbeat = DateTime.UtcNow; + roomInstance.playerCount = Math.Max(roomInstance.playerCount, 1); + } + + var oldInstances = await db.RoomInstances + .Where(r => r.lastHeartbeat < DateTime.UtcNow.AddMinutes(-5) && r.playerCount <= 0) + .ToListAsync(); + + foreach (var old in oldInstances) + { + db.RoomInstances.Remove(old); + } + + await db.SaveChangesAsync(); + return Results.Json(new { playerId = heartbeat.playerId != 0 ? heartbeat.playerId : id,