From 4ea203aa4fe87087ee2acf2529a131b4a121f51f Mon Sep 17 00:00:00 2001 From: Simon <63975668+Simyon264@users.noreply.github.com> Date: Mon, 11 Nov 2024 03:23:27 +0100 Subject: [PATCH 1/3] DB leaderboards initial commit --- .../20241111013501_DbLeaderboards.Designer.cs | 718 ++++++++++++++++++ .../20241111013501_DbLeaderboards.cs | 70 ++ .../ReplayDbContextModelSnapshot.cs | 68 ++ .../Data/Models/LeaderboardDefinition.cs | 27 + .../Data/Models/LeaderboardPosition.cs | 54 ++ ReplayBrowser/Data/ReplayDbContext.cs | 3 + ReplayBrowser/Models/LeaderboardData.cs | 7 - ReplayBrowser/Pages/Leaderboard.razor | 15 +- ReplayBrowser/Services/LeaderboardService.cs | 221 ++++-- 9 files changed, 1112 insertions(+), 71 deletions(-) create mode 100644 ReplayBrowser/Data/Migrations/20241111013501_DbLeaderboards.Designer.cs create mode 100644 ReplayBrowser/Data/Migrations/20241111013501_DbLeaderboards.cs create mode 100644 ReplayBrowser/Data/Models/LeaderboardDefinition.cs create mode 100644 ReplayBrowser/Data/Models/LeaderboardPosition.cs diff --git a/ReplayBrowser/Data/Migrations/20241111013501_DbLeaderboards.Designer.cs b/ReplayBrowser/Data/Migrations/20241111013501_DbLeaderboards.Designer.cs new file mode 100644 index 0000000..cd9b81b --- /dev/null +++ b/ReplayBrowser/Data/Migrations/20241111013501_DbLeaderboards.Designer.cs @@ -0,0 +1,718 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; +using ReplayBrowser.Data; + +#nullable disable + +namespace Server.Migrations +{ + [DbContext(typeof(ReplayDbContext))] + [Migration("20241111013501_DbLeaderboards")] + partial class DbLeaderboards + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Account.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property>("FavoriteReplays") + .IsRequired() + .HasColumnType("integer[]"); + + b.Property("Guid") + .HasColumnType("uuid"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("Protected") + .HasColumnType("boolean"); + + b.Property>("SavedProfiles") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("SettingsId") + .HasColumnType("integer"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Guid") + .IsUnique(); + + b.HasIndex("SettingsId"); + + b.HasIndex("Username"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Account.AccountSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property>("Friends") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("RedactInformation") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("AccountSettings"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Account.HistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .HasColumnType("text"); + + b.Property("Time") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("HistoryEntry"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Account.Webhook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer"); + + b.Property("Servers") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Webhook"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Account.WebhookHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ResponseBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResponseCode") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebhookId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WebhookId"); + + b.ToTable("WebhookHistory"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.CharacterData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CharacterName") + .IsRequired() + .HasColumnType("text"); + + b.Property("CollectedPlayerDataPlayerGuid") + .HasColumnType("uuid"); + + b.Property("LastPlayed") + .HasColumnType("timestamp with time zone"); + + b.Property("RoundsPlayed") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CollectedPlayerDataPlayerGuid"); + + b.ToTable("CharacterData"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.CollectedPlayerData", b => + { + b.Property("PlayerGuid") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsWatched") + .HasColumnType("boolean"); + + b.Property("LastSeen") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayerDataId") + .HasColumnType("integer"); + + b.Property("TotalAntagRoundsPlayed") + .HasColumnType("integer"); + + b.Property("TotalEstimatedPlaytime") + .HasColumnType("interval"); + + b.Property("TotalRoundsPlayed") + .HasColumnType("integer"); + + b.HasKey("PlayerGuid"); + + b.HasIndex("PlayerDataId"); + + b.HasIndex("PlayerGuid") + .IsUnique(); + + b.ToTable("PlayerProfiles"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.GdprRequest", b => + { + b.Property("Guid") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.HasKey("Guid"); + + b.ToTable("GdprRequests"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.JobCountData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CollectedPlayerDataPlayerGuid") + .HasColumnType("uuid"); + + b.Property("JobPrototype") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastPlayed") + .HasColumnType("timestamp with time zone"); + + b.Property("RoundsPlayed") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CollectedPlayerDataPlayerGuid"); + + b.ToTable("JobCountData"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.JobDepartment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Department") + .IsRequired() + .HasColumnType("text"); + + b.Property("Job") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Job") + .IsUnique(); + + b.ToTable("JobDepartments"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.LeaderboardDefinition", b => + { + b.Property("Name") + .HasColumnType("text"); + + b.Property("ExtraInfo") + .HasColumnType("text"); + + b.Property("NameColumn") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackedData") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Name"); + + b.ToTable("LeaderboardDefinitions"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.LeaderboardPosition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("LeaderboardDefinitionName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerGuid") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property>("Servers") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LeaderboardDefinitionName"); + + b.ToTable("Leaderboards"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Notice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notices"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.ParsedReplay", b => + { + b.Property("Name") + .HasColumnType("text"); + + b.HasKey("Name"); + + b.ToTable("ParsedReplays"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Antag") + .HasColumnType("boolean"); + + b.Property>("AntagPrototypes") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("EffectiveJobId") + .HasColumnType("integer"); + + b.Property>("JobPrototypes") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ParticipantId") + .HasColumnType("integer"); + + b.Property("PlayerIcName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EffectiveJobId"); + + b.HasIndex("ParticipantId"); + + b.HasIndex("PlayerIcName"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.PlayerData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PlayerGuid") + .HasColumnType("uuid"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("PlayerData"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Replay", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndTick") + .HasColumnType("integer"); + + b.Property("EndTime") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileCount") + .HasColumnType("integer"); + + b.Property("Gamemode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Link") + .IsRequired() + .HasColumnType("text"); + + b.Property("Map") + .HasColumnType("text"); + + b.Property>("Maps") + .HasColumnType("text[]"); + + b.Property("RoundEndText") + .HasColumnType("text"); + + b.Property("RoundEndTextSearchVector") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasAnnotation("Npgsql:TsVectorConfig", "english") + .HasAnnotation("Npgsql:TsVectorProperties", new[] { "RoundEndText" }); + + b.Property("RoundId") + .HasColumnType("integer"); + + b.Property("ServerId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServerName") + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("UncompressedSize") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Gamemode"); + + b.HasIndex("Map"); + + b.HasIndex("RoundEndTextSearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("RoundEndTextSearchVector"), "GIN"); + + b.HasIndex("ServerId"); + + b.HasIndex("ServerName"); + + b.ToTable("Replays"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.ReplayParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PlayerGuid") + .HasColumnType("uuid"); + + b.Property("ReplayId") + .HasColumnType("integer"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ReplayId"); + + b.HasIndex("Username"); + + b.HasIndex("PlayerGuid", "ReplayId") + .IsUnique(); + + b.ToTable("ReplayParticipants"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.ServerToken", b => + { + b.Property("Token") + .HasColumnType("text"); + + b.HasKey("Token"); + + b.HasIndex("Token"); + + b.ToTable("ServerTokens"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Account.Account", b => + { + b.HasOne("ReplayBrowser.Data.Models.Account.AccountSettings", "Settings") + .WithMany() + .HasForeignKey("SettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Settings"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Account.HistoryEntry", b => + { + b.HasOne("ReplayBrowser.Data.Models.Account.Account", "Account") + .WithMany("History") + .HasForeignKey("AccountId"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Account.Webhook", b => + { + b.HasOne("ReplayBrowser.Data.Models.Account.Account", null) + .WithMany("Webhooks") + .HasForeignKey("AccountId"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Account.WebhookHistory", b => + { + b.HasOne("ReplayBrowser.Data.Models.Account.Webhook", "Webhook") + .WithMany("Logs") + .HasForeignKey("WebhookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Webhook"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.CharacterData", b => + { + b.HasOne("ReplayBrowser.Data.Models.CollectedPlayerData", null) + .WithMany("Characters") + .HasForeignKey("CollectedPlayerDataPlayerGuid"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.CollectedPlayerData", b => + { + b.HasOne("ReplayBrowser.Data.Models.PlayerData", "PlayerData") + .WithMany() + .HasForeignKey("PlayerDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayerData"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.JobCountData", b => + { + b.HasOne("ReplayBrowser.Data.Models.CollectedPlayerData", null) + .WithMany("JobCount") + .HasForeignKey("CollectedPlayerDataPlayerGuid"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.LeaderboardPosition", b => + { + b.HasOne("ReplayBrowser.Data.Models.LeaderboardDefinition", "LeaderboardDefinition") + .WithMany() + .HasForeignKey("LeaderboardDefinitionName") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LeaderboardDefinition"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Player", b => + { + b.HasOne("ReplayBrowser.Data.Models.JobDepartment", "EffectiveJob") + .WithMany() + .HasForeignKey("EffectiveJobId"); + + b.HasOne("ReplayBrowser.Data.Models.ReplayParticipant", "Participant") + .WithMany("Players") + .HasForeignKey("ParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EffectiveJob"); + + b.Navigation("Participant"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.ReplayParticipant", b => + { + b.HasOne("ReplayBrowser.Data.Models.Replay", "Replay") + .WithMany("RoundParticipants") + .HasForeignKey("ReplayId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Replay"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Account.Account", b => + { + b.Navigation("History"); + + b.Navigation("Webhooks"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Account.Webhook", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.CollectedPlayerData", b => + { + b.Navigation("Characters"); + + b.Navigation("JobCount"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.Replay", b => + { + b.Navigation("RoundParticipants"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.ReplayParticipant", b => + { + b.Navigation("Players"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ReplayBrowser/Data/Migrations/20241111013501_DbLeaderboards.cs b/ReplayBrowser/Data/Migrations/20241111013501_DbLeaderboards.cs new file mode 100644 index 0000000..6fc92b9 --- /dev/null +++ b/ReplayBrowser/Data/Migrations/20241111013501_DbLeaderboards.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Server.Migrations +{ + /// + public partial class DbLeaderboards : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "LeaderboardDefinitions", + columns: table => new + { + Name = table.Column(type: "text", nullable: false), + TrackedData = table.Column(type: "text", nullable: false), + ExtraInfo = table.Column(type: "text", nullable: true), + NameColumn = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LeaderboardDefinitions", x => x.Name); + }); + + migrationBuilder.CreateTable( + name: "Leaderboards", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Servers = table.Column>(type: "text[]", nullable: false), + Count = table.Column(type: "integer", nullable: false), + Position = table.Column(type: "integer", nullable: false), + PlayerGuid = table.Column(type: "uuid", nullable: true), + Username = table.Column(type: "text", nullable: false), + LeaderboardDefinitionName = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Leaderboards", x => x.Id); + table.ForeignKey( + name: "FK_Leaderboards_LeaderboardDefinitions_LeaderboardDefinitionNa~", + column: x => x.LeaderboardDefinitionName, + principalTable: "LeaderboardDefinitions", + principalColumn: "Name", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Leaderboards_LeaderboardDefinitionName", + table: "Leaderboards", + column: "LeaderboardDefinitionName"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Leaderboards"); + + migrationBuilder.DropTable( + name: "LeaderboardDefinitions"); + } + } +} diff --git a/ReplayBrowser/Data/Migrations/ReplayDbContextModelSnapshot.cs b/ReplayBrowser/Data/Migrations/ReplayDbContextModelSnapshot.cs index 658f96c..7d12d25 100644 --- a/ReplayBrowser/Data/Migrations/ReplayDbContextModelSnapshot.cs +++ b/ReplayBrowser/Data/Migrations/ReplayDbContextModelSnapshot.cs @@ -301,6 +301,63 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("JobDepartments"); }); + modelBuilder.Entity("ReplayBrowser.Data.Models.LeaderboardDefinition", b => + { + b.Property("Name") + .HasColumnType("text"); + + b.Property("ExtraInfo") + .HasColumnType("text"); + + b.Property("NameColumn") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackedData") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Name"); + + b.ToTable("LeaderboardDefinitions"); + }); + + modelBuilder.Entity("ReplayBrowser.Data.Models.LeaderboardPosition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("LeaderboardDefinitionName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerGuid") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property>("Servers") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LeaderboardDefinitionName"); + + b.ToTable("Leaderboards"); + }); + modelBuilder.Entity("ReplayBrowser.Data.Models.Notice", b => { b.Property("Id") @@ -585,6 +642,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("CollectedPlayerDataPlayerGuid"); }); + modelBuilder.Entity("ReplayBrowser.Data.Models.LeaderboardPosition", b => + { + b.HasOne("ReplayBrowser.Data.Models.LeaderboardDefinition", "LeaderboardDefinition") + .WithMany() + .HasForeignKey("LeaderboardDefinitionName") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LeaderboardDefinition"); + }); + modelBuilder.Entity("ReplayBrowser.Data.Models.Player", b => { b.HasOne("ReplayBrowser.Data.Models.JobDepartment", "EffectiveJob") diff --git a/ReplayBrowser/Data/Models/LeaderboardDefinition.cs b/ReplayBrowser/Data/Models/LeaderboardDefinition.cs new file mode 100644 index 0000000..b5ef5d7 --- /dev/null +++ b/ReplayBrowser/Data/Models/LeaderboardDefinition.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ReplayBrowser.Data.Models; + +public class LeaderboardDefinition : IEntityTypeConfiguration +{ + public required string Name { get; set; } + + /// + /// The text that will appear for the "Count" column. + /// + public required string TrackedData { get; set; } + + /// + /// Will be displayed in a small font below the name. + /// + public string? ExtraInfo { get; set; } + public string NameColumn { get; set; } = "Player Name"; + + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Name); + builder.Property(x => x.TrackedData).IsRequired(); + builder.Property(x => x.NameColumn).IsRequired(); + } +} \ No newline at end of file diff --git a/ReplayBrowser/Data/Models/LeaderboardPosition.cs b/ReplayBrowser/Data/Models/LeaderboardPosition.cs new file mode 100644 index 0000000..b01e175 --- /dev/null +++ b/ReplayBrowser/Data/Models/LeaderboardPosition.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ReplayBrowser.Data.Models; + +public class LeaderboardPosition : IEntityTypeConfiguration +{ + public int Id { get; set; } + + /// + /// The servers that this position is for. + /// + public required List Servers { get; set; } = null!; + + /// + /// The number next to the position. For example "most times played" would be a count of how many times the player has played. + /// + public required int Count { get; set; } + + /// + /// The position of the player in the leaderboard. + /// + public int Position { get; set; } + + /// + /// The GUID of the player. If null, position is not for a player, but rather a general statistic. + /// + public Guid? PlayerGuid { get; set; } + + /// + /// The display value of the player or statistic. + /// + public string Username { get; set; } = null!; + + + public string LeaderboardDefinitionName { get; set; } = null!; + public LeaderboardDefinition LeaderboardDefinition { get; set; } = null!; + + + + public void Configure(EntityTypeBuilder builder) + { + builder.HasOne(lp => lp.LeaderboardDefinition) + .WithMany() + .HasForeignKey(lp => lp.LeaderboardDefinitionName); + + builder.HasKey(x => x.Id); + builder.Property(x => x.Servers).IsRequired(); + builder.Property(x => x.Count).IsRequired(); + builder.Property(x => x.Position).IsRequired(); + builder.Property(x => x.PlayerGuid).IsRequired(false); + builder.Property(x => x.Username).IsRequired(); + } +} \ No newline at end of file diff --git a/ReplayBrowser/Data/ReplayDbContext.cs b/ReplayBrowser/Data/ReplayDbContext.cs index e7f2160..ce54f79 100644 --- a/ReplayBrowser/Data/ReplayDbContext.cs +++ b/ReplayBrowser/Data/ReplayDbContext.cs @@ -49,4 +49,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet ReplayParticipants { get; set; } public DbSet ServerTokens { get; set; } + + public DbSet Leaderboards { get; set; } + public DbSet LeaderboardDefinitions { get; set; } } \ No newline at end of file diff --git a/ReplayBrowser/Models/LeaderboardData.cs b/ReplayBrowser/Models/LeaderboardData.cs index 4d7bc41..9534829 100644 --- a/ReplayBrowser/Models/LeaderboardData.cs +++ b/ReplayBrowser/Models/LeaderboardData.cs @@ -3,13 +3,6 @@ namespace ReplayBrowser.Models; -public class LeaderboardData -{ - public bool IsCache { get; set; } = false; - - public List Leaderboards { get; set; } = null!; -} - public class PlayerCount { public PlayerData? Player { get; set; } diff --git a/ReplayBrowser/Pages/Leaderboard.razor b/ReplayBrowser/Pages/Leaderboard.razor index 623ef79..8f30a9b 100644 --- a/ReplayBrowser/Pages/Leaderboard.razor +++ b/ReplayBrowser/Pages/Leaderboard.razor @@ -105,7 +105,14 @@ else
- @foreach (var leaderboard in LeaderboardData.Leaderboards) + @if(LeaderboardData.Length == 0) + { + + } + + @foreach (var leaderboard in LeaderboardData) {

@leaderboard.Name

if (leaderboard.ExtraInfo != null) @@ -233,7 +240,7 @@ else @code{ private bool IsLoading { get; set; } = true; - private LeaderboardData? LeaderboardData { get; set; } = null; + private Models.Leaderboard[]? LeaderboardData { get; set; } = null; private bool RequestedPrivate { get; set; } = false; protected override async Task OnInitializedAsync() @@ -267,7 +274,7 @@ else } } - LeaderboardData? leaderboard = null; + Models.Leaderboard[]? leaderboard = null; var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); try { @@ -279,7 +286,7 @@ else } var selectedServersArray = selectedServers.Split(','); - leaderboard = await LeaderboardService.GetLeaderboard(timeRangeEnum, username, selectedServersArray, authState, entries); + leaderboard = await LeaderboardService.GetLeaderboards(timeRangeEnum, username, selectedServersArray, authState, entries); } catch (UnauthorizedAccessException) { diff --git a/ReplayBrowser/Services/LeaderboardService.cs b/ReplayBrowser/Services/LeaderboardService.cs index c36a26f..61ac4d9 100644 --- a/ReplayBrowser/Services/LeaderboardService.cs +++ b/ReplayBrowser/Services/LeaderboardService.cs @@ -22,7 +22,6 @@ public class LeaderboardService : IHostedService, IDisposable ]; private Timer? _timer = null; - private readonly IMemoryCache _cache; private readonly Ss14ApiHelper _apiHelper; private readonly IServiceScopeFactory _scopeFactory; private readonly AccountService _accountService; @@ -30,9 +29,8 @@ public class LeaderboardService : IHostedService, IDisposable private List RedactedAccounts; - public LeaderboardService(IMemoryCache cache, Ss14ApiHelper apiHelper, IServiceScopeFactory factory, AccountService accountService, IConfiguration configuration) + public LeaderboardService(Ss14ApiHelper apiHelper, IServiceScopeFactory factory, AccountService accountService, IConfiguration configuration) { - _cache = cache; _apiHelper = apiHelper; _scopeFactory = factory; _accountService = accountService; @@ -63,17 +61,41 @@ private async void DoWork(object? state) .ToListAsync(); } - // Loop through every range option. - foreach (var rangeOption in Enum.GetValues()) + var servers = _configuration.GetSection("ReplayUrls").Get()!.Select(x => x.FallBackServerName) + .Distinct().ToList(); + + var combinations = GetCombinations(servers).ToList(); + + foreach (var serverArr in combinations) { - var anonymousAuth = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); - await GetLeaderboard(rangeOption, null, [], anonymousAuth, 10, false); + foreach (var rangeOption in Enum.GetValues()) + { + await GenerateLeaderboard(rangeOption, serverArr.ToArray()); + } } sw.Stop(); Log.Information("Leaderboards updated in {Time}", sw.Elapsed); } + static IEnumerable> GetCombinations(List list) + { + var subsetCount = 1 << list.Count; + + for (var i = 1; i < subsetCount; i++) + { + var combination = new List(); + for (var j = 0; j < list.Count; j++) + { + if ((i & (1 << j)) != 0) + { + combination.Add(list[j]); + } + } + yield return combination; + } + } + public Task StopAsync(CancellationToken cancellationToken) { _timer?.Change(Timeout.Infinite, 0); @@ -85,7 +107,8 @@ public void Dispose() _timer?.Dispose(); } - public async Task GetLeaderboard(RangeOption rangeOption, string? username, string[]? servers, AuthenticationState authenticationState, int entries = 10, bool logAction = true) + public async Task GetLeaderboards(RangeOption rangeOption, string? username, string[]? servers, + AuthenticationState authenticationState, int entries = 10, bool logAction = true) { if (servers == null || servers.Length == 0) { @@ -128,30 +151,69 @@ public async Task GetLeaderboard(RangeOption rangeOption, strin } } - // First, try to get the leaderboard from the cache - var usernameCacheKey = username - ?.ToLower() - .Replace(" ", "-") - .Replace(".", "-") - .Replace("_", "-"); + var redactedAccounts = await context.Accounts + .Where(a => a.Settings.RedactInformation) + .Select(a => a.Guid) + .ToListAsync(); + + entries += redactedAccounts.Count; // Add the redacted accounts to the count so that removed listings still show the correct amount of entries + + var leaderboards = await context.Leaderboards + .Where(l => l.Servers.SequenceEqual(servers)) + .Where(l => l.Position <= entries || l.Username == username) + .Include(l => l.LeaderboardDefinition) + .ToListAsync(); - var serversCacheKey = string.Join("-", servers); + var finalReturned = new Dictionary(); - var cacheKey = "leaderboard-" + rangeOption + "-" + usernameCacheKey + "-" + serversCacheKey + "-" + entries; - if (_cache.TryGetValue(cacheKey, out LeaderboardData? leaderboardData)) + foreach (var position in leaderboards) { - return leaderboardData!; + // Remove positions that are redacted + if (position.PlayerGuid != null && redactedAccounts.Contains((Guid)position.PlayerGuid)) + { + continue; + } + + if (!finalReturned.ContainsKey(position.LeaderboardDefinition.Name)) + { + finalReturned.Add(position.LeaderboardDefinition.Name, new Leaderboard() + { + Name = position.LeaderboardDefinition.Name, + TrackedData = position.LeaderboardDefinition.TrackedData, + Data = new Dictionary() + }); + } + + finalReturned[position.LeaderboardDefinition.Name].Data[position.PlayerGuid.ToString() ?? GenerateRandomGuid().ToString()] = new PlayerCount() + { + Count = position.Count, + Player = new PlayerData() + { + PlayerGuid = position.PlayerGuid, + Username = position.Username + }, + Position = position.Position + }; } - var usernameGuid = Guid.Empty; - if (!string.IsNullOrWhiteSpace(username)) + var returnList = new List(); + foreach (var (key, value) in finalReturned) { - // Fetch the GUID for the username - var player = await context.ReplayParticipants - .FirstOrDefaultAsync(p => p.Username.ToLower() == username.ToLower()); - if (player != null) usernameGuid = player.PlayerGuid; + returnList.Add(await FinalizeLeaderboard(key, value.NameColumn, value, accountCaller?.Guid ?? Guid.Empty, entries)); } + return returnList.ToArray(); + } + + public async Task GenerateLeaderboard(RangeOption rangeOption, string[]? servers) + { + if (servers == null || servers.Length == 0) + { + servers = _configuration.GetSection("ReplayUrls").Get()!.Select(x => x.FallBackServerName).ToArray(); + } + + using var scope = _scopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); var stopwatch = new Stopwatch(); long stopwatchPrevious = 0; @@ -400,36 +462,71 @@ public async Task GetLeaderboard(RangeOption rangeOption, strin Log.Information("SQL queries took {TimeTotal}ms", stopwatch.ElapsedMilliseconds); stopwatch.Restart(); - // Need to calculate the position of every player in the leaderboard. + // Set the positions foreach (var leaderboard in leaderboards) { - var leaderboardResult = await GenerateLeaderboard(leaderboard.Key, leaderboard.Key, leaderboard.Value, usernameGuid, entries); - leaderboards[leaderboard.Key].Data = leaderboardResult.Data; + var players = leaderboard.Value.Data.Values.ToList(); + players.Sort((a, b) => b.Count.CompareTo(a.Count)); + for (var i = 0; i < players.Count; i++) + { + players[i].Position = i + 1; + } } - stopwatch.Stop(); - Log.Information("Calculating leaderboard took {Time}ms", stopwatch.ElapsedMilliseconds); - - // Save leaderboard to cache (its expensive as fuck to calculate) - var cacheEntryOptions = new MemoryCacheEntryOptions() - .SetAbsoluteExpiration(TimeSpan.FromHours(5)); - var cacheLeaderboard = new LeaderboardData() + var dbLeaderboards = new List(); + foreach (var (key, leaderboard) in leaderboards) { - Leaderboards = leaderboards.Values.ToList(), - IsCache = true - }; + // Ensure a leaderboard definition exists + var leaderboardDefinition = await context.LeaderboardDefinitions + .FirstOrDefaultAsync(l => l.Name == key); - _cache.Set(cacheKey, cacheLeaderboard, cacheEntryOptions); + if (leaderboardDefinition == null) + { + leaderboardDefinition = new LeaderboardDefinition() + { + Name = key, + TrackedData = leaderboard.TrackedData, + NameColumn = leaderboard.NameColumn, + ExtraInfo = leaderboard.ExtraInfo + }; + await context.LeaderboardDefinitions.AddAsync(leaderboardDefinition); + } else + { + leaderboardDefinition.TrackedData = leaderboard.TrackedData; + leaderboardDefinition.NameColumn = leaderboard.NameColumn; + leaderboardDefinition.ExtraInfo = leaderboard.ExtraInfo; + } + await context.SaveChangesAsync(); - return new LeaderboardData() - { - Leaderboards = leaderboards.Values.ToList(), - IsCache = false - }; + foreach (var (s, value) in leaderboard.Data) + { + if (string.IsNullOrEmpty(value.Player?.Username) && value.Player?.PlayerGuid != null) + { + value.Player.Username = await GetNameFromDbOrApi((Guid)value.Player.PlayerGuid); + } + + // S is player guid + dbLeaderboards.Add(new LeaderboardPosition() + { + Servers = servers.ToList(), + Count = value.Count, + PlayerGuid = value.Player?.PlayerGuid, + Username = value.Player?.Username ?? string.Empty, + LeaderboardDefinitionName = key, + }); + } + } + + context.Leaderboards.RemoveRange(context.Leaderboards.Where(l => l.Servers.SequenceEqual(servers))); + await context.Leaderboards.AddRangeAsync(dbLeaderboards); + await context.SaveChangesAsync(); + + stopwatch.Stop(); + Log.Information("Calculating leaderboard took {Time}ms", stopwatch.ElapsedMilliseconds); } - private async Task GenerateLeaderboard( + private async Task FinalizeLeaderboard( string name, string columnName, Leaderboard data, @@ -479,22 +576,7 @@ private async Task GenerateLeaderboard( if (player.Value.Player?.PlayerGuid == null) continue; - // get the latest name from the db - var playerData = await context.ReplayParticipants - .Where(p => p.PlayerGuid == player.Value.Player.PlayerGuid) - .OrderByDescending(p => p.Id) - .FirstOrDefaultAsync(); - - if (playerData == null) - { - // ??? try to get using api - var playerDataApi = await _apiHelper.FetchPlayerDataFromGuid((Guid)player.Value.Player.PlayerGuid); - player.Value.Player.Username = playerDataApi.Username; - } - else - { - player.Value.Player.Username = playerData.Username; - } + player.Value.Player.Username = await GetNameFromDbOrApi((Guid)player.Value.Player.PlayerGuid); } stopwatch.Stop(); Log.Verbose("Fetching player data took {Time}ms", stopwatch.ElapsedMilliseconds); @@ -502,6 +584,25 @@ private async Task GenerateLeaderboard( return returnValue; } + private async Task GetNameFromDbOrApi(Guid guid) + { + using var scope = _scopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + // get the latest name from the db + var playerData = await context.ReplayParticipants + .Where(p => p.PlayerGuid == guid) + .OrderByDescending(p => p.Id) + .FirstOrDefaultAsync(); + + if (playerData != null) return playerData.Username; + + // ??? try to get using api + var playerDataApi = await _apiHelper.FetchPlayerDataFromGuid(guid); + return playerDataApi.Username; + + } + private Guid GenerateRandomGuid() { var guidBytes = new byte[16]; From da28392a5097831b530f7b5553fc0ea4e4f34e9c Mon Sep 17 00:00:00 2001 From: Simon <63975668+Simyon264@users.noreply.github.com> Date: Mon, 11 Nov 2024 03:52:45 +0100 Subject: [PATCH 2/3] Clarify message --- ReplayBrowser/Pages/Leaderboard.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ReplayBrowser/Pages/Leaderboard.razor b/ReplayBrowser/Pages/Leaderboard.razor index 8f30a9b..41415b6 100644 --- a/ReplayBrowser/Pages/Leaderboard.razor +++ b/ReplayBrowser/Pages/Leaderboard.razor @@ -108,7 +108,7 @@ else @if(LeaderboardData.Length == 0) { } From a2b82c6b64ab121de11381db7767e0bff29cd33f Mon Sep 17 00:00:00 2001 From: Simon <63975668+Simyon264@users.noreply.github.com> Date: Mon, 11 Nov 2024 04:39:16 +0100 Subject: [PATCH 3/3] uuuhhh AMONG US --- ReplayBrowser/Pages/Leaderboard.razor | 9 +++ ReplayBrowser/Services/LeaderboardService.cs | 79 +++++++++++++++----- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/ReplayBrowser/Pages/Leaderboard.razor b/ReplayBrowser/Pages/Leaderboard.razor index 41415b6..d1d2547 100644 --- a/ReplayBrowser/Pages/Leaderboard.razor +++ b/ReplayBrowser/Pages/Leaderboard.razor @@ -1,4 +1,5 @@ @page "/leaderboard" +@using Humanizer @using Microsoft.AspNetCore.Components.Authorization @using ReplayBrowser.Models @using ReplayBrowser.Services @@ -20,6 +21,14 @@ Leaderboard

Leaderboards

+Leaderboards update every 24 hours. +@if(LeaderboardService.IsUpdating) { + +} + @if(RequestedPrivate) {