From 6ca1cf055c0229b0b9ee516b03ad57dd769a4d99 Mon Sep 17 00:00:00 2001 From: barelyprofessional <150058423+barelyprofessional@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:59:09 -0500 Subject: [PATCH] Added the initial framework for the new Money system. Includes - 5 new tables: Gamblers, Transactions, Wagers, Exclusions, Perks - Still heavily WIP and not ready to be enabled, no games present and a lot of missing functionality - For now it's completely disabled until it's ready to be used. --- KfChatDotNetBot/ApplicationDbContext.cs | 5 + KfChatDotNetBot/ChatBot.cs | 1 + KfChatDotNetBot/Commands/ImageCommands.cs | 1 + .../{ => Extensions}/Extensions.cs | 2 +- KfChatDotNetBot/Extensions/MoneyExtensions.cs | 254 +++++++ .../20250820195308_Money.Designer.cs | 633 ++++++++++++++++++ .../Migrations/20250820195308_Money.cs | 194 ++++++ .../ApplicationDbContextModelSnapshot.cs | 237 +++++++ .../Models/DbModels/MoneyDbModels.cs | 157 ++++- KfChatDotNetBot/Models/MoneyMetaModels.cs | 3 + KfChatDotNetBot/Models/MoneyModels.cs | 57 ++ KfChatDotNetBot/Services/BotCommands.cs | 69 +- KfChatDotNetBot/Services/BotServices.cs | 1 + KfChatDotNetBot/Services/Money.cs | 199 ++++++ KfChatDotNetBot/Settings/BuiltIn.cs | 32 +- 15 files changed, 1831 insertions(+), 14 deletions(-) rename KfChatDotNetBot/{ => Extensions}/Extensions.cs (99%) create mode 100644 KfChatDotNetBot/Extensions/MoneyExtensions.cs create mode 100644 KfChatDotNetBot/Migrations/20250820195308_Money.Designer.cs create mode 100644 KfChatDotNetBot/Migrations/20250820195308_Money.cs create mode 100644 KfChatDotNetBot/Models/MoneyMetaModels.cs create mode 100644 KfChatDotNetBot/Models/MoneyModels.cs create mode 100644 KfChatDotNetBot/Services/Money.cs diff --git a/KfChatDotNetBot/ApplicationDbContext.cs b/KfChatDotNetBot/ApplicationDbContext.cs index f3047fd..10464e5 100644 --- a/KfChatDotNetBot/ApplicationDbContext.cs +++ b/KfChatDotNetBot/ApplicationDbContext.cs @@ -23,4 +23,9 @@ public class ApplicationDbContext : DbContext // public DbSet PocketWatchTransactions { get; set; } public DbSet Moms { get; set; } public DbSet Streams { get; set; } + public DbSet Gamblers { get; set; } + public DbSet Transactions { get; set; } + public DbSet Wagers { get; set; } + public DbSet Exclusions { get; set; } + public DbSet Perks { get; set; } } \ No newline at end of file diff --git a/KfChatDotNetBot/ChatBot.cs b/KfChatDotNetBot/ChatBot.cs index e6ed140..3397cd8 100644 --- a/KfChatDotNetBot/ChatBot.cs +++ b/KfChatDotNetBot/ChatBot.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text.Json; +using KfChatDotNetBot.Extensions; using KfChatDotNetBot.Models; using KfChatDotNetBot.Models.DbModels; using KfChatDotNetBot.Services; diff --git a/KfChatDotNetBot/Commands/ImageCommands.cs b/KfChatDotNetBot/Commands/ImageCommands.cs index 3fdec73..023257b 100644 --- a/KfChatDotNetBot/Commands/ImageCommands.cs +++ b/KfChatDotNetBot/Commands/ImageCommands.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using KfChatDotNetBot.Extensions; using KfChatDotNetBot.Models; using KfChatDotNetBot.Models.DbModels; using KfChatDotNetBot.Services; diff --git a/KfChatDotNetBot/Extensions.cs b/KfChatDotNetBot/Extensions/Extensions.cs similarity index 99% rename from KfChatDotNetBot/Extensions.cs rename to KfChatDotNetBot/Extensions/Extensions.cs index ae0a78c..d530871 100644 --- a/KfChatDotNetBot/Extensions.cs +++ b/KfChatDotNetBot/Extensions/Extensions.cs @@ -1,7 +1,7 @@ using System.Text; using System.Text.RegularExpressions; -namespace KfChatDotNetBot; +namespace KfChatDotNetBot.Extensions; public static class Extensions { diff --git a/KfChatDotNetBot/Extensions/MoneyExtensions.cs b/KfChatDotNetBot/Extensions/MoneyExtensions.cs new file mode 100644 index 0000000..1016c84 --- /dev/null +++ b/KfChatDotNetBot/Extensions/MoneyExtensions.cs @@ -0,0 +1,254 @@ +using KfChatDotNetBot.Models; +using KfChatDotNetBot.Models.DbModels; +using KfChatDotNetBot.Services; +using KfChatDotNetBot.Settings; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using NLog; +using JsonSerializer = System.Text.Json.JsonSerializer; + +namespace KfChatDotNetBot.Extensions; + +public static class MoneyExtensions +{ + /// + /// Retrieve a gambler entity for a given user + /// Returns null if createIfNoneExists is false and no gambler exists + /// Also returns null if the user was permanently banned from gambling + /// If there are multiple "active" gamblers, only the newest is returned + /// + /// User whose gambler entity you wish to retrieve + /// Whether to create a gambler entity if none exists already + /// Cancellation token + /// + public static async Task GetGamblerEntity(this UserDbModel user, bool createIfNoneExists = true, CancellationToken ct = default) + { + await using var db = new ApplicationDbContext(); + db.Attach(user); + var gambler = + await db.Gamblers.LastOrDefaultAsync(g => g.User == user && g.State != GamblerState.PermanentlyBanned, + cancellationToken: ct); + if (!createIfNoneExists) return gambler; + var permaBanned = await db.Gamblers.AnyAsync(g => g.User == user && g.State == GamblerState.PermanentlyBanned, cancellationToken: ct); + if (permaBanned) return null; + var initialBalance = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.MoneyInitialBalance)).ToType(); + await db.Gamblers.AddAsync(new GamblerDbModel + { + User = user, + Balance = initialBalance, + Created = DateTimeOffset.UtcNow, + RandomSeed = Guid.NewGuid().ToString(), + State = GamblerState.Active, + TotalWagered = 0, + NextVipLevelWagerRequirement = Money.VipLevels[0].BaseWagerRequirement + }, ct); + await db.SaveChangesAsync(ct); + return await db.Gamblers.LastOrDefaultAsync(g => g.User == user, cancellationToken: ct); + } + + /// + /// Simple check to see whether a user has been permanently banned from the kasino + /// + /// User to check for the permaban + /// Cancellation token + /// + public static async Task IsPermanentlyBanned(this UserDbModel user, CancellationToken ct = default) + { + await using var db = new ApplicationDbContext(); + db.Attach(user); + return await db.Gamblers.AnyAsync(u => u.User == user && u.State == GamblerState.PermanentlyBanned, + cancellationToken: ct); + } + + /// + /// Modify a gambler's balance by a given +/- amount + /// + /// Gambler entity whose balance you wish to modify + /// The 'effect' of this modification, as in how much to add or remove + /// The event which initiated this balance modification + /// Optional comment to provide for the transaction + /// If applicable, who sent the transaction (e.g. if a juicer) + /// Cancellation token + public static async Task ModifyBalance(this GamblerDbModel gambler, decimal effect, + TransactionSourceEventType eventSource, string? comment = null, GamblerDbModel? from = null, + CancellationToken ct = default) + { + await using var db = new ApplicationDbContext(); + db.Attach(gambler); + gambler.Balance += effect; + await db.Transactions.AddAsync(new TransactionDbModel + { + Gambler = gambler, + EventSource = eventSource, + Effect = effect, + Time = DateTimeOffset.UtcNow, + Comment = comment, + From = from, + NewBalance = gambler.Balance, + TimeUnixEpochSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }, ct); + await db.SaveChangesAsync(ct); + } + + /// + /// Add a wager to the database + /// Will also issue a balance update unless you explicitly disable autoModifyBalance + /// + /// Gambler who wagered + /// The amount they wagered + /// The effect of the wager on the gambler's balance. + /// Please note this includes the wager itself. So for a bet of 500 that paid out 50, you pass in an effect of -450 + /// If instead they won 600 then the effect would be +100. the wagered amount is not factored into balance changes, + /// it's just recorded for calculating bonuses and statistics + /// The game which was played as part of this wager + /// Whether tu automatically update the user's balance according to the wager effect. + /// Typically you should leave this on so as to ensure every wager has an associated transaction. + /// Optionally store metadata related to the wager, such as player choices, or game outcomes. + /// Data will be serialized to JSON. + /// Whether the game is 'complete'. Set to false for wagers with unknown outcomes. + /// NOTE: wagerEffect will be ignored, instead value will be derived from the wagerAmount + /// Cancellation token + public static async Task NewWager(this GamblerDbModel gambler, decimal wagerAmount, decimal wagerEffect, + WagerGame game, bool autoModifyBalance = true, dynamic? gameMeta = null, bool isComplete = true, + CancellationToken ct = default) + { + var logger = LogManager.GetCurrentClassLogger(); + await using var db = new ApplicationDbContext(); + db.Attach(gambler); + string? metaJson = null; + if (gameMeta != null) + { + metaJson = JsonConvert.SerializeObject(gameMeta, Formatting.Indented); + logger.Debug("Serialized metadata follows"); + logger.Debug(metaJson); + } + + if (!isComplete) + { + wagerEffect = -wagerAmount; + logger.Debug($"isComplete is false, set wagerEffect to {wagerEffect}"); + } + decimal multi = 0; + if (isComplete && wagerAmount > 0 && wagerEffect > 0 && wagerAmount + wagerEffect > 0) + { + multi = (wagerAmount + wagerEffect) / wagerAmount; + logger.Debug($"multi is {multi}"); + } + + gambler.TotalWagered += wagerAmount; + var wager = await db.Wagers.AddAsync(new WagerDbModel + { + Gambler = gambler, + Time = DateTimeOffset.UtcNow, + WagerAmount = wagerAmount, + WagerEffect = wagerEffect, + Multiplier = multi, + Game = game, + GameMeta = metaJson, + IsComplete = isComplete, + TimeUnixEpochSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }, ct); + await db.SaveChangesAsync(ct); + gambler.Balance += wagerEffect; + if (!autoModifyBalance) return; + + await db.Transactions.AddAsync(new TransactionDbModel + { + Gambler = gambler, + EventSource = TransactionSourceEventType.Gambling, + Time = DateTimeOffset.UtcNow, + Effect = wagerEffect, + Comment = $"Win from wager {wager.Entity.Id}", + From = null, + NewBalance = gambler.Balance, + TimeUnixEpochSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }, ct); + await db.SaveChangesAsync(ct); + } + + /// + /// Get an active exclusion, returns null if there's no active exclusion + /// If there's somehow multiple exclusions, will just grab the most recent one + /// + /// Gambler entity to retrieve the exclusion for + /// Cancellation token + /// + public static async Task GetActiveExclusion(this GamblerDbModel gambler, CancellationToken ct = default) + { + await using var db = new ApplicationDbContext(); + db.Attach(gambler); + return (await db.Exclusions.Where(g => g.Gambler == gambler).ToListAsync(ct)) + .LastOrDefault(e => e.Expires <= DateTimeOffset.UtcNow); + } + + /// + /// Get random number using the gambler's seed and a given number of iterations + /// + /// Gambler entity to reference their random seed + /// Minimum value for generating random + /// Maximum value for random, incremented by 1 if add1ToMaxParam is true + /// so it's consistent with the behavior of min + /// Number of random number generator iterations to run before returning a result + /// Increments the 'max' param by 1 as otherwise the value will never be returned by Random.Next() + /// This is because the default behavior of .NET is unintuitive, min value can be returned but max is never by default + /// A random number based on the given parameters + /// + public static int GetRandomNumber(this GamblerDbModel gambler, int min, int max, int iterations = 10, + bool incrementMaxParam = true) + { + var random = new Random(gambler.RandomSeed.GetHashCode()); + var result = 0; + var i = 0; + if (incrementMaxParam) max++; + if (iterations <= 0) throw new ArgumentException("Iterations cannot be 0 or lower"); + while (i < iterations) + { + i++; + result = random.Next(min, max); + } + + return result; + } + + /// + /// Get the user's current VIP level + /// + /// Gambler entity whose VIP level you want to get + /// Cancellation token + /// + public static async Task GetVipLevel(this GamblerDbModel gambler, CancellationToken ct = default) + { + await using var db = new ApplicationDbContext(); + db.Attach(gambler); + var perk = await db.Perks.LastOrDefaultAsync( + p => p.Gambler == gambler && p.PerkType == GamblerPerkType.VipLevel, ct); + return perk; + } + + public static async Task UpgradeVipLevel(this GamblerDbModel gambler, NextVipLevelModel nextVipLevel, + CancellationToken ct = default) + { + await using var db = new ApplicationDbContext(); + db.Attach(gambler); + var payout = nextVipLevel.VipLevel.BonusPayout; + if (nextVipLevel.Tier > 1) + { + payout = nextVipLevel.VipLevel.BonusPayout / (nextVipLevel.VipLevel.Tiers - 1); + } + + await db.Perks.AddAsync(new GamblerPerkDbModel + { + Gambler = gambler, + PerkType = GamblerPerkType.VipLevel, + PerkName = nextVipLevel.VipLevel.Name, + PerkTier = nextVipLevel.Tier, + Time = DateTimeOffset.UtcNow, + Payout = payout, + }, ct); + gambler.NextVipLevelWagerRequirement = nextVipLevel.WagerRequirement; + await db.SaveChangesAsync(ct); + await gambler.ModifyBalance(payout, TransactionSourceEventType.Bonus, + $"VIP Level '{nextVipLevel.VipLevel.Icon} {nextVipLevel.VipLevel.Name}' Tier {nextVipLevel.Tier} level up bonus", ct: ct); + return payout; + } +} diff --git a/KfChatDotNetBot/Migrations/20250820195308_Money.Designer.cs b/KfChatDotNetBot/Migrations/20250820195308_Money.Designer.cs new file mode 100644 index 0000000..f5e79bf --- /dev/null +++ b/KfChatDotNetBot/Migrations/20250820195308_Money.Designer.cs @@ -0,0 +1,633 @@ +// +using System; +using KfChatDotNetBot; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace KfChatDotNetBot.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250820195308_Money")] + partial class Money + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.ChipsggBetDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("REAL"); + + b.Property("BetId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CurrencyPrice") + .HasColumnType("REAL"); + + b.Property("GameTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Updated") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Win") + .HasColumnType("INTEGER"); + + b.Property("Winnings") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.ToTable("ChipsggBets"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("NextVipLevelWagerRequirement") + .HasColumnType("TEXT"); + + b.Property("RandomSeed") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("TotalWagered") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Gamblers"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerExclusionDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Expires") + .HasColumnType("TEXT"); + + b.Property("GamblerId") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GamblerId"); + + b.ToTable("Exclusions"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerPerkDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GamblerId") + .HasColumnType("INTEGER"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Payout") + .HasColumnType("TEXT"); + + b.Property("PerkName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PerkTier") + .HasColumnType("INTEGER"); + + b.Property("PerkType") + .HasColumnType("INTEGER"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GamblerId"); + + b.ToTable("Perks"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.HowlggBetsDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bet") + .HasColumnType("INTEGER"); + + b.Property("BetId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Game") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("INTEGER"); + + b.Property("Profit") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("HowlggBets"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.ImageDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("REAL"); + + b.Property("JuicedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Juicers"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.MomDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Moms"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.RainbetBetsDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BetId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BetSeenAt") + .HasColumnType("TEXT"); + + b.Property("GameName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Payout") + .HasColumnType("REAL"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RainbetUserId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.ToTable("RainbetBets"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.SettingDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CacheDuration") + .HasColumnType("REAL"); + + b.Property("Default") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsSecret") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Regex") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoCapture") + .HasColumnType("INTEGER"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("INTEGER"); + + b.Property("StreamUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Streams"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TransactionDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Effect") + .HasColumnType("TEXT"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("FromId") + .HasColumnType("INTEGER"); + + b.Property("GamblerId") + .HasColumnType("INTEGER"); + + b.Property("NewBalance") + .HasColumnType("TEXT"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.Property("TimeUnixEpochSeconds") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FromId"); + + b.HasIndex("GamblerId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TwitchViewCountDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ServerTime") + .HasColumnType("REAL"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Viewers") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TwitchViewCounts"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("KfId") + .HasColumnType("INTEGER"); + + b.Property("KfUsername") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserRight") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityType") + .HasColumnType("INTEGER"); + + b.Property("FirstOccurence") + .HasColumnType("TEXT"); + + b.Property("LatestOccurence") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UsersWhoWere"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.WagerDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GamblerId") + .HasColumnType("INTEGER"); + + b.Property("Game") + .HasColumnType("INTEGER"); + + b.Property("GameMeta") + .HasColumnType("TEXT"); + + b.Property("IsComplete") + .HasColumnType("INTEGER"); + + b.Property("Multiplier") + .HasColumnType("TEXT"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.Property("TimeUnixEpochSeconds") + .HasColumnType("INTEGER"); + + b.Property("WagerAmount") + .HasColumnType("TEXT"); + + b.Property("WagerEffect") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GamblerId"); + + b.ToTable("Wagers"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerExclusionDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler") + .WithMany() + .HasForeignKey("GamblerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gambler"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerPerkDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler") + .WithMany() + .HasForeignKey("GamblerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gambler"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.MomDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TransactionDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "From") + .WithMany() + .HasForeignKey("FromId"); + + b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler") + .WithMany() + .HasForeignKey("GamblerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("From"); + + b.Navigation("Gambler"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.WagerDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler") + .WithMany() + .HasForeignKey("GamblerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gambler"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/KfChatDotNetBot/Migrations/20250820195308_Money.cs b/KfChatDotNetBot/Migrations/20250820195308_Money.cs new file mode 100644 index 0000000..da514b8 --- /dev/null +++ b/KfChatDotNetBot/Migrations/20250820195308_Money.cs @@ -0,0 +1,194 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace KfChatDotNetBot.Migrations +{ + /// + public partial class Money : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Gamblers", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "INTEGER", nullable: false), + Balance = table.Column(type: "TEXT", nullable: false), + State = table.Column(type: "INTEGER", nullable: false), + RandomSeed = table.Column(type: "TEXT", maxLength: 256, nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + TotalWagered = table.Column(type: "TEXT", nullable: false), + NextVipLevelWagerRequirement = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Gamblers", x => x.Id); + table.ForeignKey( + name: "FK_Gamblers_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Exclusions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + GamblerId = table.Column(type: "INTEGER", nullable: false), + Expires = table.Column(type: "TEXT", nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + Source = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Exclusions", x => x.Id); + table.ForeignKey( + name: "FK_Exclusions_Gamblers_GamblerId", + column: x => x.GamblerId, + principalTable: "Gamblers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Perks", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + GamblerId = table.Column(type: "INTEGER", nullable: false), + PerkName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + Time = table.Column(type: "TEXT", nullable: false), + Metadata = table.Column(type: "TEXT", nullable: true), + PerkType = table.Column(type: "INTEGER", nullable: false), + PerkTier = table.Column(type: "INTEGER", nullable: true), + Payout = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Perks", x => x.Id); + table.ForeignKey( + name: "FK_Perks_Gamblers_GamblerId", + column: x => x.GamblerId, + principalTable: "Gamblers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Transactions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + GamblerId = table.Column(type: "INTEGER", nullable: false), + EventSource = table.Column(type: "INTEGER", nullable: false), + Time = table.Column(type: "TEXT", nullable: false), + TimeUnixEpochSeconds = table.Column(type: "INTEGER", nullable: false), + Effect = table.Column(type: "TEXT", nullable: false), + Comment = table.Column(type: "TEXT", nullable: true), + FromId = table.Column(type: "INTEGER", nullable: true), + NewBalance = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Transactions", x => x.Id); + table.ForeignKey( + name: "FK_Transactions_Gamblers_FromId", + column: x => x.FromId, + principalTable: "Gamblers", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_Transactions_Gamblers_GamblerId", + column: x => x.GamblerId, + principalTable: "Gamblers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Wagers", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + GamblerId = table.Column(type: "INTEGER", nullable: false), + Time = table.Column(type: "TEXT", nullable: false), + TimeUnixEpochSeconds = table.Column(type: "INTEGER", nullable: false), + WagerAmount = table.Column(type: "TEXT", nullable: false), + WagerEffect = table.Column(type: "TEXT", nullable: false), + Game = table.Column(type: "INTEGER", nullable: false), + Multiplier = table.Column(type: "TEXT", nullable: false), + GameMeta = table.Column(type: "TEXT", nullable: true), + IsComplete = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Wagers", x => x.Id); + table.ForeignKey( + name: "FK_Wagers_Gamblers_GamblerId", + column: x => x.GamblerId, + principalTable: "Gamblers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Exclusions_GamblerId", + table: "Exclusions", + column: "GamblerId"); + + migrationBuilder.CreateIndex( + name: "IX_Gamblers_UserId", + table: "Gamblers", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Perks_GamblerId", + table: "Perks", + column: "GamblerId"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_FromId", + table: "Transactions", + column: "FromId"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_GamblerId", + table: "Transactions", + column: "GamblerId"); + + migrationBuilder.CreateIndex( + name: "IX_Wagers_GamblerId", + table: "Wagers", + column: "GamblerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Exclusions"); + + migrationBuilder.DropTable( + name: "Perks"); + + migrationBuilder.DropTable( + name: "Transactions"); + + migrationBuilder.DropTable( + name: "Wagers"); + + migrationBuilder.DropTable( + name: "Gamblers"); + } + } +} diff --git a/KfChatDotNetBot/Migrations/ApplicationDbContextModelSnapshot.cs b/KfChatDotNetBot/Migrations/ApplicationDbContextModelSnapshot.cs index 67897fc..9d7532f 100644 --- a/KfChatDotNetBot/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/KfChatDotNetBot/Migrations/ApplicationDbContextModelSnapshot.cs @@ -69,6 +69,103 @@ namespace KfChatDotNetBot.Migrations b.ToTable("ChipsggBets"); }); + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("NextVipLevelWagerRequirement") + .HasColumnType("TEXT"); + + b.Property("RandomSeed") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("TotalWagered") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Gamblers"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerExclusionDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Expires") + .HasColumnType("TEXT"); + + b.Property("GamblerId") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GamblerId"); + + b.ToTable("Exclusions"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerPerkDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GamblerId") + .HasColumnType("INTEGER"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Payout") + .HasColumnType("TEXT"); + + b.Property("PerkName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PerkTier") + .HasColumnType("INTEGER"); + + b.Property("PerkType") + .HasColumnType("INTEGER"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GamblerId"); + + b.ToTable("Perks"); + }); + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.HowlggBetsDbModel", b => { b.Property("Id") @@ -273,6 +370,45 @@ namespace KfChatDotNetBot.Migrations b.ToTable("Streams"); }); + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TransactionDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Effect") + .HasColumnType("TEXT"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("FromId") + .HasColumnType("INTEGER"); + + b.Property("GamblerId") + .HasColumnType("INTEGER"); + + b.Property("NewBalance") + .HasColumnType("TEXT"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.Property("TimeUnixEpochSeconds") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FromId"); + + b.HasIndex("GamblerId"); + + b.ToTable("Transactions"); + }); + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TwitchViewCountDbModel", b => { b.Property("Id") @@ -346,6 +482,79 @@ namespace KfChatDotNetBot.Migrations b.ToTable("UsersWhoWere"); }); + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.WagerDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GamblerId") + .HasColumnType("INTEGER"); + + b.Property("Game") + .HasColumnType("INTEGER"); + + b.Property("GameMeta") + .HasColumnType("TEXT"); + + b.Property("IsComplete") + .HasColumnType("INTEGER"); + + b.Property("Multiplier") + .HasColumnType("TEXT"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.Property("TimeUnixEpochSeconds") + .HasColumnType("INTEGER"); + + b.Property("WagerAmount") + .HasColumnType("TEXT"); + + b.Property("WagerEffect") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GamblerId"); + + b.ToTable("Wagers"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerExclusionDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler") + .WithMany() + .HasForeignKey("GamblerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gambler"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerPerkDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler") + .WithMany() + .HasForeignKey("GamblerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gambler"); + }); + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b => { b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User") @@ -377,6 +586,23 @@ namespace KfChatDotNetBot.Migrations b.Navigation("User"); }); + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TransactionDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "From") + .WithMany() + .HasForeignKey("FromId"); + + b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler") + .WithMany() + .HasForeignKey("GamblerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("From"); + + b.Navigation("Gambler"); + }); + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", b => { b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User") @@ -387,6 +613,17 @@ namespace KfChatDotNetBot.Migrations b.Navigation("User"); }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.WagerDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler") + .WithMany() + .HasForeignKey("GamblerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gambler"); + }); #pragma warning restore 612, 618 } } diff --git a/KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs b/KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs index e512a73..705ef5c 100644 --- a/KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs +++ b/KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.ComponentModel.DataAnnotations; namespace KfChatDotNetBot.Models.DbModels; @@ -25,11 +26,22 @@ public class GamblerDbModel /// /// The seed value given to any instance of Random that's associated with the gambler /// + [MaxLength(256)] public required string RandomSeed { get; set; } /// /// When the gambler entity was created /// public required DateTimeOffset Created { get; set; } + /// + /// Reference value for total wagered during the entity's lifetime + /// This value is recalculated whenever the bot restarts to ensure integrity + /// + public required decimal TotalWagered { get; set; } + /// + /// Wager requirement for the next VIP level + /// If TotalWagered reaches this value, it'll trigger the calculation + /// + public required decimal NextVipLevelWagerRequirement { get; set; } } public class TransactionDbModel @@ -39,9 +51,9 @@ public class TransactionDbModel /// public int Id { get; set; } /// - /// User whose balance was affected by this transaction + /// Gambler whose balance was affected by this transaction /// - public required GamblerDbModel User { get; set; } + public required GamblerDbModel Gambler { get; set; } /// /// Source of the transaction event /// @@ -51,6 +63,13 @@ public class TransactionDbModel /// public required DateTimeOffset Time { get; set; } /// + /// Time represented as a 64-bit UNIX epoch + /// This just exists to make it far more efficient to query a range of txns + /// as then we can use native SQLite dialect to select e.g. last 24 hours + /// instead of copying thousands of rows into memory and using LINQ + /// + public required long TimeUnixEpochSeconds { get; set; } + /// /// Effect of the transaction, plus or minus /// public required decimal Effect { get; set; } @@ -62,6 +81,10 @@ public class TransactionDbModel /// Sender of the transaction in the case of a juicer, null otherwise /// public GamblerDbModel? From { get; set; } = null; + /// + /// Snapshot of the gambler's balance after this transaction's effect was applied + /// + public required decimal NewBalance { get; set; } } public class WagerDbModel @@ -71,19 +94,26 @@ public class WagerDbModel /// public int Id { get; set; } /// - /// User who wagered + /// Gambler who wagered /// - public required GamblerDbModel User { get; set; } + public required GamblerDbModel Gambler { get; set; } /// /// Time they wagered /// public required DateTimeOffset Time { get; set; } /// - /// Amount the user wagered + /// Time represented as a 64-bit UNIX epoch + /// This just exists to make it far more efficient to query a range of wagers + /// as then we can use native SQLite dialect to select e.g. last 24 hours + /// instead of copying thousands of rows into memory and using LINQ + /// + public required long TimeUnixEpochSeconds { get; set; } + /// + /// Amount the gambler wagered /// public required decimal WagerAmount { get; set; } /// - /// Effect of the wager on the user's balance + /// Effect of the wager on the gambler's balance /// public required decimal WagerEffect { get; set; } /// @@ -92,13 +122,108 @@ public class WagerDbModel /// public required WagerGame Game { get; set; } /// - /// Multiplier if applicable. 0 if it was a complete loss + /// Multiplier, e.g. 10.5x if a $1 wager paid out $10.50. 0 if it was a complete loss /// public required decimal Multiplier { get; set; } /// /// An optional field to store serialized information about the game that was played /// public string? GameMeta { get; set; } = null; + /// + /// Whether the results of the wager have been realized yet (i.e., is the game 'complete'?) + /// This is useful for wagers related to bets on the outcome of events + /// For incomplete bets: set the effect to -wager, subtract it from the user's balance, generate a txn for the wager + /// Then when the outcome of the bet is fully realized, modify the effect accordingly, generate a new txn for the + /// payout and set a multiplier based on the win (if any) + /// + public required bool IsComplete { get; set; } +} + +public class GamblerExclusionDbModel +{ + /// + /// ID fo the database row + /// + public int Id { get; set; } + /// + /// Gambler who is excluded + /// + public required GamblerDbModel Gambler { get; set; } + /// + /// When the exclusion expires + /// + public required DateTimeOffset Expires { get; set; } + /// + /// When the exclusion was created / began + /// + public required DateTimeOffset Created { get; set; } + /// + /// What triggered the exclusion + /// + public required ExclusionSource Source { get; set; } +} + +public class GamblerPerkDbModel +{ + /// + /// ID fo the database row + /// + public int Id { get; set; } + /// + /// Gambler entity the perk is associated with + /// + public required GamblerDbModel Gambler { get; set; } + /// + /// Name of the perk + /// + [MaxLength(256)] + public required string PerkName { get; set; } + /// + /// Time when the perk was attained + /// + public required DateTimeOffset Time { get; set; } + /// + /// Optional metadata associated with the perk + /// + public string? Metadata { get; set; } = null; + /// + /// What type of perk is this + /// + public required GamblerPerkType PerkType { get; set; } + /// + /// The tier the perk is at. + /// If tiers are not applicable, set to null + /// + public int? PerkTier { get; set; } + /// + /// The payout from this perk, if any. If none, set to null + /// + public decimal? Payout { get; set; } +} + +public enum GamblerPerkType +{ + /// + /// For literally anything else, though you should probably just extend this enum + /// + Other = -1, + /// + /// Used for tracking VIP levels attained + /// + [Description("VIP Level")] + VipLevel +} + +public enum ExclusionSource +{ + /// + /// Exclusion as a result of the hostess' action + /// + Hostess, + /// + /// Exclusions placed by administrators + /// + Administrative } /// @@ -123,10 +248,20 @@ public enum TransactionSourceEventType /// Administrative, /// - /// Some type of bonus, like rakeback or a reload. Do not use for hostess rewards + /// Some type of bonus, like a VIP level up. Rakeback / reloads have separate enums for this /// Bonus, /// + /// Specifically use for rakeback as we use the delta between last rakeback txn to calculate total wagered + /// to figure out what the next rakeback should be (if they've wagered enough to be eligible for one) + /// + Rakeback, + /// + /// Use specifically for daily reloads as we use the timing of the last reload txn to figure out if the most + /// recent reload has been claimed yet or not + /// + Reload, + /// /// Use this only for hostess juicers as the sum of these juicers in a given day can influence the hostess' behavior /// Hostess @@ -142,7 +277,11 @@ public enum WagerGame LambChop, Keno, [Description("Coinflip")] - CoinFlip + CoinFlip, + /// + /// This is for betting pools based on some sort of event or outcome + /// + Event } public enum GamblerState diff --git a/KfChatDotNetBot/Models/MoneyMetaModels.cs b/KfChatDotNetBot/Models/MoneyMetaModels.cs new file mode 100644 index 0000000..c73fd18 --- /dev/null +++ b/KfChatDotNetBot/Models/MoneyMetaModels.cs @@ -0,0 +1,3 @@ +namespace KfChatDotNetBot.Models; + +// Stash all the models used for perk or game metadata here \ No newline at end of file diff --git a/KfChatDotNetBot/Models/MoneyModels.cs b/KfChatDotNetBot/Models/MoneyModels.cs new file mode 100644 index 0000000..485d766 --- /dev/null +++ b/KfChatDotNetBot/Models/MoneyModels.cs @@ -0,0 +1,57 @@ +using KfChatDotNetBot.Models.DbModels; + +namespace KfChatDotNetBot.Models; + +public class MoneyVipLevel +{ + /// + /// Name of the VIP level + /// + public required string Name { get; set; } + /// + /// Number of tiers the VIP level has + /// Steps between VIP tiers are calculated by comparing with the next VIP level and dividing by number of tiers + /// e.g. (100,000 - 10,000) / 5 = 18,000 steps + /// Tier 1 = 10,000 + /// Tier 2 = 28,000 + /// Tier 3 = 46,000 + /// Tier 4 = 64,000 + /// Tier 5 = 84,000 + /// Next VIP level at 100,000 + /// What happens if they're at the last VIP level? They remain stuck at tier 1 forever regardless of this value + /// This is really just so that we have flexibility to add further tiers later without messing anything up + /// since there's no telling how easy it will be to attain the high levels at this point + /// + public required int Tiers { get; set; } + /// + /// Icon to display next to the name, like an emoji diamond or a small image embedded with bbcode [img] tags + /// + public required string Icon { get; set; } + /// + /// The wager requirement for this level. This is the requirement for the base (tier 1) level + /// Remaining tiers are calculated based on the wager requirement for the next tier + /// + public required decimal BaseWagerRequirement { get; set; } + /// + /// Payout when you attain this level. + /// Tiers (beyond 1) pay out: BonusPayout / (Tiers - 1) (e.g. 1,000 / 4 = 250 for tier 2-5 + /// + public required decimal BonusPayout { get; set; } +} + +public class NextVipLevelModel +{ + /// + /// The VIP level that's coming up next. + /// Could be the same as the existing level if it's just the next tier. + /// + public required MoneyVipLevel VipLevel { get; set; } + /// + /// What tier this is for + /// + public required int Tier { get; set; } + /// + /// The wager requirement to reach this tier that factors in the tier + /// + public required decimal WagerRequirement { get; set; } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Services/BotCommands.cs b/KfChatDotNetBot/Services/BotCommands.cs index 4c2b365..2235332 100644 --- a/KfChatDotNetBot/Services/BotCommands.cs +++ b/KfChatDotNetBot/Services/BotCommands.cs @@ -1,7 +1,9 @@ using System.Text.RegularExpressions; using Humanizer; using KfChatDotNetBot.Commands; +using KfChatDotNetBot.Extensions; using KfChatDotNetBot.Models.DbModels; +using KfChatDotNetBot.Settings; using KfChatDotNetWsClient.Models.Events; using NLog; @@ -17,9 +19,9 @@ internal class BotCommands private IEnumerable Commands; private CancellationToken _cancellationToken; - internal BotCommands(ChatBot bot, CancellationToken? ctx = null) + internal BotCommands(ChatBot bot, CancellationToken ctx = default) { - _cancellationToken = ctx ?? CancellationToken.None; + _cancellationToken = ctx; _bot = bot; var interfaceType = typeof(ICommand); Commands = @@ -60,6 +62,33 @@ internal class BotCommands if (user == null) return; if (user.Ignored) return; var continueAfterProcess = HasAttribute(command); + var kasinoCommand = HasAttribute(command); + var wagerCommand = HasAttribute(command); + if (kasinoCommand) + { + var kasinoEnabled = SettingsProvider.GetValueAsync(BuiltIn.Keys.MoneyEnabled).Result.ToBoolean(); + if (!kasinoEnabled) return; + } + + if (kasinoCommand && user.IsPermanentlyBanned(_cancellationToken).Result) + { + _bot.SendChatMessage($"@{message.Author.Username}, you've been permanently banned from the kasino. Contact support for more information.", true); + return; + } + + if (wagerCommand) + { + // GetGamblerEntity will only return null if the user is permanbanned + // and we have a check further up the chain for that hence ignoring the null + var exclusion = user.GetGamblerEntity(ct: _cancellationToken).Result + !.GetActiveExclusion(ct: _cancellationToken).Result; + if (exclusion != null) + { + _bot.SendChatMessage( + $"@{message.Author.Username}, you're self excluded from the kasino for another {(exclusion.Expires - DateTimeOffset.UtcNow).Humanize(precision: 3)}", true); + return; + } + } if (user.UserRight < command.RequiredRight) { _bot.SendChatMessage($"@{message.Author.Username}, you do not have access to use this command. Your rank: {user.UserRight.Humanize()}; Required rank: {command.RequiredRight.Humanize()}", true); @@ -89,7 +118,25 @@ internal class BotCommands { _logger.Error("Command task failed"); _logger.Error(task.Exception); + return; } + + var moneySettings = + await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.MoneyEnabled, BuiltIn.Keys.MoneySymbolSuffix]); + if (!moneySettings[BuiltIn.Keys.MoneyEnabled].ToBoolean()) return; + var wagerCommand = HasAttribute(command); + if (!wagerCommand) return; + var gambler = await user.GetGamblerEntity(ct: _cancellationToken); + if (gambler == null) return; + if (gambler.TotalWagered < gambler.NextVipLevelWagerRequirement) return; + // The reason for doing this instead of passing in TotalWagered is that otherwise VIP levels might + // get skipped if the user is a low VIP level but wagering very large amounts + var newLevel = Money.GetNextVipLevel(gambler.NextVipLevelWagerRequirement); + if (newLevel == null) return; + var payout = await gambler.UpgradeVipLevel(newLevel, _cancellationToken); + await _bot.SendChatMessageAsync( + $"🤑🤑 {user.KfUsername} has leveled up to to {newLevel.VipLevel.Icon} {newLevel.VipLevel.Name} Tier {newLevel.Tier} " + + $"and received a bonus of {payout:N2} {moneySettings[BuiltIn.Keys.MoneySymbolSuffix].Value}", true); } private static bool HasAttribute(ICommand command) where T : Attribute @@ -105,4 +152,20 @@ internal class BotCommands /// Keep in mind since commands are executed in a throwaway task and not awaited, they will run concurrently /// [AttributeUsage(AttributeTargets.Class)] -internal class AllowAdditionalMatches : Attribute; \ No newline at end of file +internal class AllowAdditionalMatches : Attribute; + +/// +/// Use this on commands where a wager is taking place. +/// This will cause the bot to check total wagered and see if the gambler has leveled up. +/// It'll also check whether the gambler is currently temp excluded before running the command. +/// +[AttributeUsage(AttributeTargets.Class)] +internal class WagerCommand : Attribute; + +/// +/// Use this on all commands that interact with the gambling / monetary system +/// When used, this will check if the system is globally enabled before running the command. +/// It'll also check whether the user is permanently banned before running the command. +/// +[AttributeUsage(AttributeTargets.Class)] +internal class KasinoCommand : Attribute; \ No newline at end of file diff --git a/KfChatDotNetBot/Services/BotServices.cs b/KfChatDotNetBot/Services/BotServices.cs index 39e8fc0..914c379 100644 --- a/KfChatDotNetBot/Services/BotServices.cs +++ b/KfChatDotNetBot/Services/BotServices.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Humanizer; +using KfChatDotNetBot.Extensions; using KfChatDotNetBot.Models; using KfChatDotNetBot.Models.DbModels; using KfChatDotNetBot.Settings; diff --git a/KfChatDotNetBot/Services/Money.cs b/KfChatDotNetBot/Services/Money.cs new file mode 100644 index 0000000..d97f67f --- /dev/null +++ b/KfChatDotNetBot/Services/Money.cs @@ -0,0 +1,199 @@ +using KfChatDotNetBot.Models; + +namespace KfChatDotNetBot.Services; + +public static class Money +{ + /// + /// This is the list of available VIP levels for gamblers to ascend + /// The order of this array is important, it begins with the loest level VIP, and ascends IN ORDER to the max level + /// + public static List VipLevels = + [ + new MoneyVipLevel + { + Name = "Rip City", + Tiers = 5, + Icon = ":ross:", + BaseWagerRequirement = 10_000, + BonusPayout = 250 + }, + new MoneyVipLevel + { + Name = "Chinesium", + Tiers = 5, + Icon = ":gold:", + BaseWagerRequirement = 100_000, + BonusPayout = 500 + }, + new MoneyVipLevel + { + Name = "Juice Fiend", + Tiers = 5, + Icon = ":juice:", + BaseWagerRequirement = 1_000_000, + BonusPayout = 1000 + }, + new MoneyVipLevel + { + Name = "Unemployment Line", + Tiers = 5, + Icon = ":tugboat:", + BaseWagerRequirement = 5_000_000, + BonusPayout = 2500 + }, + new MoneyVipLevel + { + Name = "Down Immensely", + Tiers = 5, + Icon = ":felted:", + BaseWagerRequirement = 10_000_000, + BonusPayout = 5000 + }, + new MoneyVipLevel + { + Name = "Glutton for Punishment", + Tiers = 5, + Icon = ":ow:", + BaseWagerRequirement = 25_000_000, + BonusPayout = 7500 + }, + new MoneyVipLevel + { + Name = "Targeted by Evil Eddie", + Tiers = 5, + Icon = ":bogged:", + BaseWagerRequirement = 50_000_000, + BonusPayout = 10_000 + }, + new MoneyVipLevel + { + Name = "Epic High Roller", + Tiers = 5, + Icon = ":wow:", + BaseWagerRequirement = 100_000_000, + BonusPayout = 15_000 + }, + new MoneyVipLevel + { + Name = "99% of Gamblers", + Tiers = 5, + Icon = ":wall:", + BaseWagerRequirement = 500_000_000, + BonusPayout = 25_000 + }, + new MoneyVipLevel + { + Name = "Billionaire Club", + Tiers = 5, + Icon = ":drink:", + BaseWagerRequirement = 1_000_000_000, + BonusPayout = 50_000 + }, + new MoneyVipLevel + { + Name = "Upfag", + Tiers = 5, + Icon = ":gay:", + BaseWagerRequirement = 5_000_000_000, + BonusPayout = 75_000 + }, + new MoneyVipLevel + { + Name = "No Regrets", + Tiers = 5, + Icon = ":woo:", + BaseWagerRequirement = 50_000_000_000, + BonusPayout = 100_000 + }, + new MoneyVipLevel + { + Name = "A Small Juicer of a Million Dollars", + Tiers = 5, + Icon = ":trump:", + BaseWagerRequirement = 250_000_000_000, + BonusPayout = 1_000_000 + }, + new MoneyVipLevel + { + Name = "Wannabe Bossman", + Tiers = 5, + Icon = ":lossmanjack:", + BaseWagerRequirement = 500_000_000_000, + BonusPayout = 2_000_000 + }, + new MoneyVipLevel + { + Name = "TRILLION DOLLAR COIN FLIP", + Tiers = 5, + Icon = ":winner:", + BaseWagerRequirement = 1_000_000_000_000, + BonusPayout = 4_000_000 + }, + new MoneyVipLevel + { + Name = "Madness", + Tiers = 5, + Icon = ":lunacy:", + BaseWagerRequirement = 5_000_000_000_000, + BonusPayout = 10_000_000 + }, + new MoneyVipLevel + { + Name = "Nowhere to go from here", + Tiers = 5, + Icon = ":achievement:", + BaseWagerRequirement = 50_000_000_000_000, + // Fuck you pussy + BonusPayout = 1 + } + ]; + + public static List CalculateTiers(MoneyVipLevel vipLevel) + { + // The list is in ascending order + var nextLevel = VipLevels.FirstOrDefault(v => v.BaseWagerRequirement > vipLevel.BaseWagerRequirement); + // Max level has no tiers + if (nextLevel == null) return [vipLevel.BaseWagerRequirement]; + var wagerRequirement = vipLevel.BaseWagerRequirement; + var step = (nextLevel.BaseWagerRequirement - vipLevel.BaseWagerRequirement) / vipLevel.Tiers; + var tiers = new List(); + while (wagerRequirement < nextLevel.BaseWagerRequirement) + { + tiers.Add(wagerRequirement); + wagerRequirement += step; + } + return tiers; + } + + /// + /// Get the next VIP level based on the wager amount given + /// + /// Wager amount to calculate the next level + /// null if the user is at the max level + public static NextVipLevelModel? GetNextVipLevel(decimal wagered) + { + var level = VipLevels.LastOrDefault(v => v.BaseWagerRequirement < wagered); + if (level == null) return null; + var tiers = CalculateTiers(level); + var nextTier = tiers.FirstOrDefault(t => wagered < t); + // default(decimal) is 0 + // This happens if the user is between tier 5 and their next level + if (nextTier == 0) + { + var nextLevel = VipLevels[VipLevels.IndexOf(level) + 1]; + return new NextVipLevelModel + { + VipLevel = nextLevel, + Tier = 1, + WagerRequirement = nextLevel.BaseWagerRequirement + }; + } + return new NextVipLevelModel + { + VipLevel = level, + Tier = tiers.IndexOf(nextTier) + 1, + WagerRequirement = nextTier + }; + } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs index 7c314e0..9916962 100644 --- a/KfChatDotNetBot/Settings/BuiltIn.cs +++ b/KfChatDotNetBot/Settings/BuiltIn.cs @@ -936,9 +936,38 @@ public static class BuiltIn Description = "Whether to respond to Bossman impersonations", Default = "true", ValueType = SettingValueType.Boolean, - Regex = "(true|false)" Regex = BooleanRegex }, + new BuiltInSettingsModel + { + Key = Keys.MoneySymbolSuffix, + Description = "What is the symbol of the bot's currency when used as a suffix for an amount", + Default = "KKK", + ValueType = SettingValueType.Text + }, + new BuiltInSettingsModel + { + Key = Keys.MoneySymbolPrefix, + Description = "What is the symbol of the bot's currency when used as a prefix for an amount", + Default = "KKK$", + ValueType = SettingValueType.Text + }, + new BuiltInSettingsModel + { + Key = Keys.MoneyEnabled, + Description = "Whether the monetary system is enabled at all. " + + "If disabled, the bot won't answer any commands related to balance, transactions, gambling, etc.", + Default = "false", + ValueType = SettingValueType.Boolean, + Regex = BooleanRegex + }, + new BuiltInSettingsModel + { + Key = Keys.MoneyInitialBalance, + Description = "Gambler's initial balance on creation", + Default = "100", + ValueType = SettingValueType.Text, + Regex = WholeNumberRegex } ]; @@ -1048,5 +1077,6 @@ public static class BuiltIn public static string MoneySymbolSuffix = "Money.SymbolSuffix"; public static string MoneySymbolPrefix = "Money.SymbolPrefix"; public static string MoneyEnabled = "Money.Enabled"; + public static string MoneyInitialBalance = "Money.InitialBalance"; } } \ No newline at end of file