diff --git a/Migrations/20260801124127_RecalculateActivityLevels.Designer.cs b/Migrations/20260801124127_RecalculateActivityLevels.Designer.cs
new file mode 100644
index 0000000..afde4a5
--- /dev/null
+++ b/Migrations/20260801124127_RecalculateActivityLevels.Designer.cs
@@ -0,0 +1,1388 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Morpheus.Database;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Morpheus.Migrations
+{
+ [DbContext(typeof(DB))]
+ [Migration("20260801124127_RecalculateActivityLevels")]
+ partial class RecalculateActivityLevels
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Morpheus.Database.Models.BotSetting", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Key")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdateDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("BotSettings");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.ButtonGamePress", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("GuildId")
+ .HasColumnType("integer");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Score")
+ .HasColumnType("bigint");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InsertDate");
+
+ b.HasIndex("GuildId", "InsertDate");
+
+ b.HasIndex("UserId", "InsertDate");
+
+ b.ToTable("ButtonGamePresses");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Channel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DiscordId")
+ .IsUnique();
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Guild", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("HoneypotChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LevelUpMessages")
+ .HasColumnType("boolean");
+
+ b.Property("LevelUpMessagesChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("LevelUpQuotes")
+ .HasColumnType("boolean");
+
+ b.Property("LevelUpQuotesChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PinsChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("Prefix")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("QuoteAddRequiredApprovals")
+ .HasColumnType("integer");
+
+ b.Property("QuoteRemoveRequiredApprovals")
+ .HasColumnType("integer");
+
+ b.Property("QuotesApprovalChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("SendHoneypotMessages")
+ .HasColumnType("boolean");
+
+ b.Property("UseActivityRoles")
+ .HasColumnType("boolean");
+
+ b.Property("UseGlobalQuotes")
+ .HasColumnType("boolean");
+
+ b.Property("WelcomeChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("WelcomeMessages")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DiscordId")
+ .IsUnique();
+
+ b.ToTable("Guilds");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Log", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Severity")
+ .HasColumnType("integer");
+
+ b.Property("Version")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InsertDate", "Severity");
+
+ b.ToTable("Logs");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Quote", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Approved")
+ .HasColumnType("boolean");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("GuildId")
+ .HasColumnType("integer");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Removed")
+ .HasColumnType("boolean");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("InsertDate", "Approved", "Removed");
+
+ b.ToTable("Quotes");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.QuoteApproval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("QuoteApprovalMessageId")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .HasColumnType("numeric(20,0)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("QuoteApprovalMessageId", "UserId")
+ .IsUnique();
+
+ b.ToTable("QuoteApprovals");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.QuoteApprovalMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApprovalMessageId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("Approved")
+ .HasColumnType("boolean");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("QuoteId")
+ .HasColumnType("integer");
+
+ b.Property("Score")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("QuoteId");
+
+ b.HasIndex("Approved", "InsertDate");
+
+ b.ToTable("QuoteApprovalMessages");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.QuoteScore", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("QuoteId")
+ .HasColumnType("integer");
+
+ b.Property("Score")
+ .HasColumnType("integer");
+
+ b.Property("UpdateDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("QuoteId", "UserId")
+ .IsUnique();
+
+ b.ToTable("QuoteScores");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.ReactionRoleItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CustomId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Emoji")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ReactionRoleMessageId")
+ .HasColumnType("integer");
+
+ b.Property("RoleId")
+ .HasColumnType("numeric(20,0)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ReactionRoleMessageId", "Emoji")
+ .IsUnique();
+
+ b.HasIndex("ReactionRoleMessageId", "RoleId")
+ .IsUnique();
+
+ b.ToTable("ReactionRoleItems");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.ReactionRoleMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("GuildId")
+ .HasColumnType("integer");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MessageId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("UseButtons")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("MessageId")
+ .IsUnique();
+
+ b.ToTable("ReactionRoleMessages");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Reminder", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("DeliveryFailureCount")
+ .HasColumnType("integer");
+
+ b.Property("DueDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FirstDeliveryFailureAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GuildId")
+ .HasColumnType("integer");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NextDeliveryAttemptAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Text")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Reminders");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Role", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("GuildId")
+ .HasColumnType("integer");
+
+ b.Property("RoleId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("RoleType")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.ToTable("Roles");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.RssSeenEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("EntryId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FeedUrl")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SeenAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FeedUrl", "EntryId")
+ .IsUnique();
+
+ b.ToTable("RssSeenEntries");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.RssSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AvatarUrl")
+ .HasColumnType("text");
+
+ b.Property("ChannelDiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FeedUrl")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("GuildDiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WebhookId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FeedUrl");
+
+ b.HasIndex("WebhookId");
+
+ b.HasIndex("ChannelDiscordId", "FeedUrl")
+ .IsUnique();
+
+ b.ToTable("RssSubscriptions");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Stock", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DailyChangePercent")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("EntityId")
+ .HasColumnType("integer");
+
+ b.Property("EntityType")
+ .HasColumnType("integer");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastUpdatedDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PreviousPrice")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Price")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("UpdateTimeMinutes")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EntityType", "EntityId")
+ .IsUnique();
+
+ b.HasIndex("LastUpdatedDate", "UpdateTimeMinutes");
+
+ b.ToTable("Stocks");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.StockHolding", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Shares")
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("StockId")
+ .HasColumnType("integer");
+
+ b.Property("TotalInvested")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StockId");
+
+ b.HasIndex("UserId", "StockId")
+ .IsUnique();
+
+ b.ToTable("StockHoldings");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.StockTransaction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Amount")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Fee")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PriceAtTransaction")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Shares")
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("StockId")
+ .HasColumnType("integer");
+
+ b.Property("TargetUserId")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InsertDate");
+
+ b.HasIndex("TargetUserId");
+
+ b.HasIndex("StockId", "InsertDate");
+
+ b.HasIndex("UserId", "InsertDate");
+
+ b.ToTable("StockTransactions");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.TemporaryBan", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GuildId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Reason")
+ .HasColumnType("text");
+
+ b.Property("UnbannedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("numeric(20,0)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExpiresAt", "UnbannedAt");
+
+ b.HasIndex("GuildId", "UserId");
+
+ b.ToTable("TemporaryBans");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.TwitchSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AvatarUrl")
+ .HasColumnType("text");
+
+ b.Property("ChannelDiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("GuildDiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsLive")
+ .HasColumnType("boolean");
+
+ b.Property("LastAnnouncedStreamId")
+ .HasColumnType("text");
+
+ b.Property("TwitchDisplayName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TwitchLogin")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TwitchUserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("WebhookId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TwitchUserId");
+
+ b.HasIndex("WebhookId");
+
+ b.HasIndex("ChannelDiscordId", "TwitchUserId")
+ .IsUnique();
+
+ b.ToTable("TwitchSubscriptions");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Balance")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("decimal(18,4)")
+ .HasDefaultValue(1000.00m);
+
+ b.Property("DiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastRobberyAttempt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSuccessfullyRobbed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastUsernameCheck")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LevelUpMessages")
+ .HasColumnType("boolean");
+
+ b.Property("LevelUpQuotes")
+ .HasColumnType("boolean");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DiscordId")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.UserActivity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("DiscordMessageId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("GuildAverageMessageLength")
+ .HasColumnType("double precision");
+
+ b.Property("GuildId")
+ .HasColumnType("integer");
+
+ b.Property("GuildMessageCount")
+ .HasColumnType("integer");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MessageHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MessageLength")
+ .HasColumnType("integer");
+
+ b.Property("MessageSimHash")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("NormalizedLength")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.Property("XpGained")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InsertDate");
+
+ b.HasIndex("DiscordChannelId", "InsertDate");
+
+ b.HasIndex("GuildId", "InsertDate");
+
+ b.HasIndex("InsertDate", "DiscordChannelId");
+
+ b.HasIndex("InsertDate", "GuildId");
+
+ b.HasIndex("InsertDate", "UserId");
+
+ b.HasIndex("UserId", "GuildId", "InsertDate");
+
+ b.ToTable("UserActivity");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.UserLevels", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("GuildId")
+ .HasColumnType("integer");
+
+ b.Property("Level")
+ .HasColumnType("integer");
+
+ b.Property("TotalXp")
+ .HasColumnType("integer");
+
+ b.Property("UserAverageMessageLength")
+ .HasColumnType("double precision");
+
+ b.Property("UserAverageMessageLengthEma")
+ .HasColumnType("double precision");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.Property("UserMessageCount")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("UserId", "GuildId")
+ .IsUnique();
+
+ b.ToTable("UserLevels");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Webhook", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChannelDiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("GuildDiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastCheckedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("WebhookId")
+ .HasColumnType("numeric(20,0)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelDiscordId")
+ .IsUnique();
+
+ b.ToTable("Webhooks");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.XkcdDeliveryRetry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AttemptCount")
+ .HasColumnType("integer");
+
+ b.Property("LastAttemptAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Link")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Link")
+ .IsUnique();
+
+ b.ToTable("XkcdDeliveryRetries");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.XkcdSeen", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Link")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SeenAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Link")
+ .IsUnique();
+
+ b.ToTable("XkcdSeen");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.XkcdSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChannelDiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("GuildDiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WebhookId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelDiscordId")
+ .IsUnique();
+
+ b.HasIndex("WebhookId");
+
+ b.ToTable("XkcdSubscriptions");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.YoutubeSeenVideo", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("SeenAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("VideoId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("YoutubeChannelId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("VideoId")
+ .IsUnique();
+
+ b.ToTable("YoutubeSeenVideos");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.YoutubeSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChannelDiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("GuildDiscordId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("InsertDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WebhookId")
+ .HasColumnType("integer");
+
+ b.Property("YoutubeAvatarUrl")
+ .HasColumnType("text");
+
+ b.Property("YoutubeChannelId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("YoutubeChannelTitle")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("WebhookId");
+
+ b.HasIndex("YoutubeChannelId");
+
+ b.HasIndex("ChannelDiscordId", "YoutubeChannelId")
+ .IsUnique();
+
+ b.ToTable("YoutubeSubscriptions");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.ButtonGamePress", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Guild", "Guild")
+ .WithMany("ButtonGamePresses")
+ .HasForeignKey("GuildId");
+
+ b.HasOne("Morpheus.Database.Models.User", "User")
+ .WithMany("ButtonGamePresses")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Guild");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Quote", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Guild", "Guild")
+ .WithMany("Quotes")
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Morpheus.Database.Models.User", "User")
+ .WithMany("Quotes")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Guild");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.QuoteApproval", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.QuoteApprovalMessage", "ApprovalMessage")
+ .WithMany()
+ .HasForeignKey("QuoteApprovalMessageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ApprovalMessage");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.QuoteApprovalMessage", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Quote", "Quote")
+ .WithMany()
+ .HasForeignKey("QuoteId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Quote");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.QuoteScore", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Quote", "Quote")
+ .WithMany("Scores")
+ .HasForeignKey("QuoteId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Morpheus.Database.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Quote");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.ReactionRoleItem", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.ReactionRoleMessage", "ReactionRoleMessage")
+ .WithMany("Items")
+ .HasForeignKey("ReactionRoleMessageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ReactionRoleMessage");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.ReactionRoleMessage", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Guild", "Guild")
+ .WithMany()
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Guild");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Reminder", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Guild", "Guild")
+ .WithMany()
+ .HasForeignKey("GuildId");
+
+ b.HasOne("Morpheus.Database.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId");
+
+ b.Navigation("Guild");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Role", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Guild", "Guild")
+ .WithMany("Roles")
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Guild");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.RssSubscription", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Webhook", "Webhook")
+ .WithMany()
+ .HasForeignKey("WebhookId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Webhook");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.StockHolding", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Stock", "Stock")
+ .WithMany("StockHoldings")
+ .HasForeignKey("StockId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Morpheus.Database.Models.User", "User")
+ .WithMany("StockHoldings")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Stock");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.StockTransaction", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Stock", "Stock")
+ .WithMany()
+ .HasForeignKey("StockId");
+
+ b.HasOne("Morpheus.Database.Models.User", "TargetUser")
+ .WithMany()
+ .HasForeignKey("TargetUserId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("Morpheus.Database.Models.User", "User")
+ .WithMany("StockTransactions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Stock");
+
+ b.Navigation("TargetUser");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.TwitchSubscription", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Webhook", "Webhook")
+ .WithMany()
+ .HasForeignKey("WebhookId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Webhook");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.UserActivity", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Guild", "Guild")
+ .WithMany("UserActivity")
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Morpheus.Database.Models.User", "User")
+ .WithMany("UserActivity")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Guild");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.UserLevels", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Guild", "Guild")
+ .WithMany("UserLevels")
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Morpheus.Database.Models.User", "User")
+ .WithMany("UserLevels")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Guild");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.XkcdSubscription", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Webhook", "Webhook")
+ .WithMany()
+ .HasForeignKey("WebhookId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Webhook");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.YoutubeSubscription", b =>
+ {
+ b.HasOne("Morpheus.Database.Models.Webhook", "Webhook")
+ .WithMany()
+ .HasForeignKey("WebhookId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Webhook");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Guild", b =>
+ {
+ b.Navigation("ButtonGamePresses");
+
+ b.Navigation("Quotes");
+
+ b.Navigation("Roles");
+
+ b.Navigation("UserActivity");
+
+ b.Navigation("UserLevels");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Quote", b =>
+ {
+ b.Navigation("Scores");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.ReactionRoleMessage", b =>
+ {
+ b.Navigation("Items");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.Stock", b =>
+ {
+ b.Navigation("StockHoldings");
+ });
+
+ modelBuilder.Entity("Morpheus.Database.Models.User", b =>
+ {
+ b.Navigation("ButtonGamePresses");
+
+ b.Navigation("Quotes");
+
+ b.Navigation("StockHoldings");
+
+ b.Navigation("StockTransactions");
+
+ b.Navigation("UserActivity");
+
+ b.Navigation("UserLevels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Migrations/20260801124127_RecalculateActivityLevels.cs b/Migrations/20260801124127_RecalculateActivityLevels.cs
new file mode 100644
index 0000000..8ea4209
--- /dev/null
+++ b/Migrations/20260801124127_RecalculateActivityLevels.cs
@@ -0,0 +1,32 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Morpheus.Migrations
+{
+ ///
+ public partial class RecalculateActivityLevels : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("""
+ UPDATE "UserLevels"
+ SET "Level" = FLOOR(POWER(LOG(("TotalXp"::double precision + 111.0) / 111.0), 5.0243))::integer
+ WHERE "TotalXp" >= 0
+ AND "Level" IS DISTINCT FROM FLOOR(POWER(LOG(("TotalXp"::double precision + 111.0) / 111.0), 5.0243))::integer;
+ """);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("""
+ UPDATE "UserLevels"
+ SET "Level" = FLOOR(POWER(LOG(FLOOR(("TotalXp"::double precision + 111.0) / 111.0)), 5.0243))::integer
+ WHERE "TotalXp" >= 0
+ AND "Level" IS DISTINCT FROM FLOOR(POWER(LOG(FLOOR(("TotalXp"::double precision + 111.0) / 111.0)), 5.0243))::integer;
+ """);
+ }
+ }
+}
diff --git a/Morpheus.Tests/ActivityLevelServiceTests.cs b/Morpheus.Tests/ActivityLevelServiceTests.cs
index 28bea74..d7c64e4 100644
--- a/Morpheus.Tests/ActivityLevelServiceTests.cs
+++ b/Morpheus.Tests/ActivityLevelServiceTests.cs
@@ -9,11 +9,19 @@ public class ActivityLevelServiceTests
[InlineData(998, 0)]
[InlineData(999, 1)]
[InlineData(1000, 1)]
+ [InlineData(1449, 1)]
+ [InlineData(1450, 2)]
public void CalculateLevel_ReturnsExpectedBoundaryLevels(long xp, int expectedLevel)
{
Assert.Equal(expectedLevel, ActivityLevelService.CalculateLevel(xp));
}
+ [Fact]
+ public void CalculateLevel_HandlesMaximumXp()
+ {
+ Assert.True(ActivityLevelService.CalculateLevel(long.MaxValue) > 0);
+ }
+
[Theory]
[InlineData(1)]
[InlineData(2)]
diff --git a/Services/ActivityLevelService.cs b/Services/ActivityLevelService.cs
index ed990b6..e6da49c 100644
--- a/Services/ActivityLevelService.cs
+++ b/Services/ActivityLevelService.cs
@@ -68,7 +68,7 @@ FOR UPDATE
public static int CalculateLevel(long xp)
{
- return (int)Math.Pow(Math.Log10((xp + 111) / 111), 5.0243);
+ return (int)Math.Pow(Math.Log10((xp + 111d) / 111d), 5.0243);
}
public static int CalculateXp(int level)