From 5b71c0a1bbc740154c3f62ae472cb21fa9fb9afb Mon Sep 17 00:00:00 2001 From: barelyprofessional <150058423+barelyprofessional@users.noreply.github.com> Date: Wed, 24 Sep 2025 00:58:45 -0500 Subject: [PATCH] Migrated away from extension methods for pretty much all the money stuff as it turns out it passes a copy of the object and not a reference. This was causing a lot of weird behavior probably due to EF change tracking. Also added a lot more logging to the API itself. --- .../inspectionProfiles/Project_Default.xml | 3 +- .../Commands/KasinoUserCommands.cs | 52 ++-- KfChatDotNetBot/Extensions/MoneyExtensions.cs | 246 --------------- KfChatDotNetBot/Services/BotCommands.cs | 10 +- KfChatDotNetBot/Services/Money.cs | 281 +++++++++++++++++- 5 files changed, 320 insertions(+), 272 deletions(-) diff --git a/.idea/.idea.KfChatDotNet/.idea/inspectionProfiles/Project_Default.xml b/.idea/.idea.KfChatDotNet/.idea/inspectionProfiles/Project_Default.xml index 1aaccf7..a862370 100644 --- a/.idea/.idea.KfChatDotNet/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/.idea.KfChatDotNet/.idea/inspectionProfiles/Project_Default.xml @@ -1,6 +1,7 @@ - + \ No newline at end of file diff --git a/KfChatDotNetBot/Commands/KasinoUserCommands.cs b/KfChatDotNetBot/Commands/KasinoUserCommands.cs index 9f4b6b1..02bca01 100644 --- a/KfChatDotNetBot/Commands/KasinoUserCommands.cs +++ b/KfChatDotNetBot/Commands/KasinoUserCommands.cs @@ -26,7 +26,7 @@ public class GetBalanceCommand : ICommand public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx) { - var gambler = await user.GetGamblerEntity(ct: ctx); + var gambler = await Money.GetGamblerEntityAsync(user, ct: ctx); await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, your balance is {await gambler!.Balance.FormatKasinoCurrencyAsync()}", true); } @@ -45,8 +45,12 @@ public class GetExclusionCommand : ICommand public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx) { - var gambler = await user.GetGamblerEntity(ct: ctx); - var exclusion = await gambler!.GetActiveExclusion(ct: ctx); + var gambler = await Money.GetGamblerEntityAsync(user, ct: ctx); + if (gambler == null) + { + throw new InvalidOperationException($"Caught a null when retrieving {user.Id}'s gambler entity"); + } + var exclusion = await Money.GetActiveExclusionAsync(gambler, ct: ctx); if (exclusion == null) { await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you are currently not excluded.", true); @@ -80,7 +84,7 @@ public class SendJuiceCommand : ICommand { var logger = LogManager.GetCurrentClassLogger(); await using var db = new ApplicationDbContext(); - var gambler = await user.GetGamblerEntity(ct: ctx); + var gambler = await Money.GetGamblerEntityAsync(user, ct: ctx); var targetUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == int.Parse(arguments["user_id"].Value), ctx); var amount = decimal.Parse(arguments["amount"].Value); if (gambler == null) @@ -100,16 +104,16 @@ public class SendJuiceCommand : ICommand return; } - var targetGambler = await targetUser.GetGamblerEntity(ct: ctx); + var targetGambler = await Money.GetGamblerEntityAsync(targetUser, ct: ctx); if (targetGambler == null) { await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you can't juice a banned user", true); return; } - await gambler.ModifyBalance(-amount, TransactionSourceEventType.Juicer, + await Money.ModifyBalanceAsync(gambler, -amount, TransactionSourceEventType.Juicer, $"Juice sent to {targetUser.KfUsername}", ct: ctx); - await targetGambler.ModifyBalance(amount, TransactionSourceEventType.Juicer, $"Juice from {user.KfUsername}", + await Money.ModifyBalanceAsync(targetGambler, amount, TransactionSourceEventType.Juicer, $"Juice from {user.KfUsername}", gambler, ctx); await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, {await amount.FormatKasinoCurrencyAsync()} has been sent to {targetUser.KfUsername}", true); } @@ -134,20 +138,23 @@ public class RakebackCommand : ICommand CancellationToken ctx) { await using var db = new ApplicationDbContext(); - var gambler = await user.GetGamblerEntity(ct: ctx); - db.Attach(gambler!); + var gambler = await Money.GetGamblerEntityAsync(user, ct: ctx); + if (gambler == null) + { + throw new InvalidOperationException($"Caught a null when retrieving {user.Id}'s gambler entity"); + } var settings = await SettingsProvider.GetMultipleValuesAsync([ BuiltIn.Keys.MoneyRakebackPercentage, BuiltIn.Keys.MoneyRakebackMinimumAmount ]); var mostRecentRakeback = await db.Transactions.OrderBy(x => x.Id).LastOrDefaultAsync(tx => - tx.EventSource == TransactionSourceEventType.Rakeback && tx.Gambler == gambler, cancellationToken: ctx); + tx.EventSource == TransactionSourceEventType.Rakeback && tx.Gambler.Id == gambler.Id, cancellationToken: ctx); long offset = 0; if (mostRecentRakeback != null) { offset = mostRecentRakeback.TimeUnixEpochSeconds; } - var wagers = await db.Wagers.Where(w => w.Gambler == gambler && w.TimeUnixEpochSeconds > offset).ToListAsync(ctx); + var wagers = await db.Wagers.Where(w => w.Gambler.Id == gambler.Id && w.TimeUnixEpochSeconds > offset).ToListAsync(ctx); if (wagers.Count == 0) { await botInstance.SendChatMessageAsync( @@ -163,7 +170,7 @@ public class RakebackCommand : ICommand await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, your rakeback payout of {await rakeback.FormatKasinoCurrencyAsync()} is below the minimum amount of {await minimumRakeback.FormatKasinoCurrencyAsync()}", true); return; } - await gambler!.ModifyBalance(rakeback, TransactionSourceEventType.Rakeback, "Rakeback claimed by gambler", + await Money.ModifyBalanceAsync(gambler, rakeback, TransactionSourceEventType.Rakeback, "Rakeback claimed by gambler", ct: ctx); await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, the hostess has given you {await rakeback.FormatKasinoCurrencyAsync()} rakeback", true); } @@ -187,20 +194,23 @@ public class LossbackCommand : ICommand CancellationToken ctx) { await using var db = new ApplicationDbContext(); - var gambler = await user.GetGamblerEntity(ct: ctx); - db.Attach(gambler!); + var gambler = await Money.GetGamblerEntityAsync(user, ct: ctx); + if (gambler == null) + { + throw new InvalidOperationException($"Caught a null when retrieving {user.Id}'s gambler entity"); + } var settings = await SettingsProvider.GetMultipleValuesAsync([ BuiltIn.Keys.MoneyLossbackPercentage, BuiltIn.Keys.MoneyLossbackMinimumAmount ]); var mostRecentLossback = await db.Transactions.OrderBy(x => x.Id).LastOrDefaultAsync(tx => - tx.EventSource == TransactionSourceEventType.Lossback && tx.Gambler == gambler, cancellationToken: ctx); + tx.EventSource == TransactionSourceEventType.Lossback && tx.Gambler.Id == gambler.Id, cancellationToken: ctx); long offset = 0; if (mostRecentLossback != null) { offset = mostRecentLossback.TimeUnixEpochSeconds; } - var wagers = await db.Wagers.Where(w => w.Gambler == gambler && w.TimeUnixEpochSeconds > offset && w.Multiplier < 1).ToListAsync(ctx); + var wagers = await db.Wagers.Where(w => w.Gambler.Id == gambler.Id && w.TimeUnixEpochSeconds > offset && w.Multiplier < 1).ToListAsync(ctx); if (wagers.Count == 0) { await botInstance.SendChatMessageAsync( @@ -216,7 +226,7 @@ public class LossbackCommand : ICommand await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, your lossback payout of {await lossback.FormatKasinoCurrencyAsync()} is below the minimum amount of {await minimumLossback.FormatKasinoCurrencyAsync()}", true); return; } - await gambler!.ModifyBalance(lossback, TransactionSourceEventType.Lossback, "Lossback claimed by gambler", + await Money.ModifyBalanceAsync(gambler, lossback, TransactionSourceEventType.Lossback, "Lossback claimed by gambler", ct: ctx); await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, the hostess has given you {await lossback.FormatKasinoCurrencyAsync()} lossback", true); } @@ -245,8 +255,12 @@ public class AbandonKasinoCommand : ICommand return; } await using var db = new ApplicationDbContext(); - var gambler = await user.GetGamblerEntity(ct: ctx); - db.Attach(gambler!); + var gambler = await Money.GetGamblerEntityAsync(user, ct: ctx); + if (gambler == null) + { + throw new InvalidOperationException($"Caught a null when retrieving {user.Id}'s gambler entity"); + } + db.Attach(gambler); gambler!.State = GamblerState.Abandoned; await db.SaveChangesAsync(ctx); await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, Kasino account with ID {gambler.Id} has been marked as abandoned.", true); diff --git a/KfChatDotNetBot/Extensions/MoneyExtensions.cs b/KfChatDotNetBot/Extensions/MoneyExtensions.cs index 7b326db..62bda0a 100644 --- a/KfChatDotNetBot/Extensions/MoneyExtensions.cs +++ b/KfChatDotNetBot/Extensions/MoneyExtensions.cs @@ -10,252 +10,6 @@ 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(); - // Refetch user as I'm fairly certain some of the buggy behavior is coming from db.Attach being weird - user = await db.Users.FirstAsync(u => u.Id == user.Id, cancellationToken: ct); - var gambler = - await db.Gamblers.OrderBy(x => x.Id).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.OrderBy(x => x.Id).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(); - return await db.Gamblers.AnyAsync(u => u.User.Id == user.Id && 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(); - gambler = await db.Gamblers.FirstAsync(g => g.Id == gambler.Id, cancellationToken: ct); - 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(); - gambler = await db.Gamblers.FirstAsync(g => g.Id == gambler.Id, cancellationToken: ct); - 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(); - return (await db.Exclusions.Where(g => g.Gambler.Id == gambler.Id).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(); - var perk = await db.Perks.OrderBy(x => x.Id).LastOrDefaultAsync( - p => p.Gambler.Id == gambler.Id && p.PerkType == GamblerPerkType.VipLevel, ct); - return perk; - } - - /// - /// Upgrade to the given VIP level. Grants a bonus as part of the level up. - /// - /// The gambler you wish to level up - /// VIP level to grant them - /// Cancellation token - /// The bonus they received - public static async Task UpgradeVipLevel(this GamblerDbModel gambler, NextVipLevelModel nextVipLevel, - CancellationToken ct = default) - { - await using var db = new ApplicationDbContext(); - gambler = await db.Gamblers.FirstAsync(g => g.Id == gambler.Id, cancellationToken: ct); - 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; - } - /// /// Format an amount of money using configured symbols /// diff --git a/KfChatDotNetBot/Services/BotCommands.cs b/KfChatDotNetBot/Services/BotCommands.cs index 543d217..59a41fb 100644 --- a/KfChatDotNetBot/Services/BotCommands.cs +++ b/KfChatDotNetBot/Services/BotCommands.cs @@ -74,7 +74,7 @@ internal class BotCommands if (!kasinoEnabled) return; } - if (kasinoCommand && user.IsPermanentlyBanned(_cancellationToken).Result) + if (kasinoCommand && Money.IsPermanentlyBannedAsync(user, _cancellationToken).Result) { _bot.SendChatMessage($"@{message.Author.Username}, you've been permanently banned from the kasino. Contact support for more information.", true); return; @@ -84,8 +84,8 @@ internal class BotCommands { // 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; + var gambler = Money.GetGamblerEntityAsync(user, ct: _cancellationToken).Result; + var exclusion = Money.GetActiveExclusionAsync(gambler!, ct: _cancellationToken).Result; if (exclusion != null) { _bot.SendChatMessage( @@ -140,14 +140,14 @@ internal class BotCommands if (!(await SettingsProvider.GetValueAsync(BuiltIn.Keys.MoneyEnabled)).ToBoolean()) return; var wagerCommand = HasAttribute(command); if (!wagerCommand) return; - var gambler = await user.GetGamblerEntity(ct: _cancellationToken); + var gambler = await Money.GetGamblerEntityAsync(user, 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); + var payout = await Money.UpgradeVipLevelAsync(gambler, newLevel, _cancellationToken); await _bot.SendChatMessageAsync( $"🤑🤑 {user.FormatUsername()} has leveled up to to {newLevel.VipLevel.Icon} {newLevel.VipLevel.Name} Tier {newLevel.Tier} " + $"and received a bonus of {await payout.FormatKasinoCurrencyAsync()}", true); diff --git a/KfChatDotNetBot/Services/Money.cs b/KfChatDotNetBot/Services/Money.cs index d97f67f..f1a565a 100644 --- a/KfChatDotNetBot/Services/Money.cs +++ b/KfChatDotNetBot/Services/Money.cs @@ -1,9 +1,16 @@ -using KfChatDotNetBot.Models; +using Humanizer; +using KfChatDotNetBot.Models; +using KfChatDotNetBot.Models.DbModels; +using KfChatDotNetBot.Settings; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using NLog; namespace KfChatDotNetBot.Services; public static class Money { + private static Logger _logger = LogManager.GetCurrentClassLogger(); /// /// 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 @@ -196,4 +203,276 @@ public static class Money WagerRequirement = nextTier }; } + + /// + /// 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 GetGamblerEntityAsync(UserDbModel user, bool createIfNoneExists = true, CancellationToken ct = default) + { + await using var db = new ApplicationDbContext(); + var gambler = + await db.Gamblers.OrderBy(x => x.Id).Include(x => x.User).LastOrDefaultAsync(g => g.User.Id == user.Id && g.State != GamblerState.PermanentlyBanned, + cancellationToken: ct); + _logger.Info($"Retrieved entity for {user.KfUsername}. Is Gambler Entity Null? => {gambler == null}"); + if (gambler != null) + { + _logger.Info($"Gambler entity details: {gambler.Id}, Created: {gambler.Created:o}"); + } + if (!createIfNoneExists) return gambler; + var permaBanned = await IsPermanentlyBannedAsync(user, ct); + _logger.Info($"permaBanned => {permaBanned}"); + 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); + var newEntity = await db.Gamblers.OrderBy(x => x.Id).Include(x => x.User) + .LastOrDefaultAsync(g => g.User == user, cancellationToken: ct); + if (newEntity == null) + { + throw new InvalidOperationException( + $"Caught a null when trying to retrieve freshly created gambler entity for user {user.KfId}"); + } + _logger.Info($"New gambler entity created for {user.KfUsername} with ID {newEntity.Id}"); + return newEntity; + } + + /// + /// 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 IsPermanentlyBannedAsync(UserDbModel user, CancellationToken ct = default) + { + await using var db = new ApplicationDbContext(); + return await db.Gamblers.AnyAsync(u => u.User.Id == user.Id && 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 ModifyBalanceAsync(GamblerDbModel gambler, decimal effect, + TransactionSourceEventType eventSource, string? comment = null, GamblerDbModel? from = null, + CancellationToken ct = default) + { + await using var db = new ApplicationDbContext(); + db.Attach(gambler); + _logger.Info($"Updating balance for {gambler.Id} with effect {effect:N}. Balance is currently {gambler.Balance:N}"); + gambler.Balance += effect; + _logger.Info($"Balance is now {gambler.Balance:N}"); + 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 NewWagerAsync(GamblerDbModel gambler, decimal wagerAmount, decimal wagerEffect, + WagerGame game, bool autoModifyBalance = true, dynamic? gameMeta = null, bool isComplete = true, + CancellationToken ct = default) + { + _logger.Info($"Adding a wager for {gambler.User.KfUsername}. wagerAmount => {wagerAmount:N}, " + + $"wagerEffect => {wagerEffect:N}, game => {game.Humanize()}, autoModifyBalance => {autoModifyBalance}, " + + $"isComplete => {isComplete}"); + await using var db = new ApplicationDbContext(); + db.Attach(gambler); + string? metaJson = null; + if (gameMeta != null) + { + metaJson = JsonConvert.SerializeObject(gameMeta, Formatting.Indented); + _logger.Info("Serialized metadata follows"); + _logger.Info(metaJson); + } + + if (!isComplete) + { + wagerEffect = -wagerAmount; + _logger.Info($"isComplete is false, set wagerEffect to {wagerEffect}"); + } + decimal multi = 0; + if (isComplete && wagerAmount > 0 && wagerEffect > 0 && wagerAmount + wagerEffect > 0) + { + multi = (wagerAmount + wagerEffect) / wagerAmount; + _logger.Info($"multi is {multi}"); + } + + gambler.TotalWagered += wagerAmount; + _logger.Info($"Updated TotalWagered to {gambler.TotalWagered}"); + 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); + _logger.Info($"Added wager row with ID {wager.Entity.Id}"); + gambler.Balance += wagerEffect; + if (!autoModifyBalance) return; + + var txn = 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); + _logger.Info($"Added transaction with ID {txn.Entity.Id}"); + } + + /// + /// 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 GetActiveExclusionAsync(GamblerDbModel gambler, CancellationToken ct = default) + { + await using var db = new ApplicationDbContext(); + return (await db.Exclusions.Where(g => g.Gambler.Id == gambler.Id).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(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); + } + _logger.Info($"Generated random number {result} with min {min} and max {max} over {iterations} iterations"); + return result; + } + + /// + /// Get the user's current VIP level + /// + /// Gambler entity whose VIP level you want to get + /// Cancellation token + /// + public static async Task GetVipLevelAsync(GamblerDbModel gambler, CancellationToken ct = default) + { + await using var db = new ApplicationDbContext(); + var perk = await db.Perks.OrderBy(x => x.Id).LastOrDefaultAsync( + p => p.Gambler.Id == gambler.Id && p.PerkType == GamblerPerkType.VipLevel, ct); + _logger.Info($"User's VIP perk is null? {perk == null}"); + if (perk != null) + { + _logger.Info($"VIP perk found for {gambler.User.Id}, {perk.PerkName} tier {perk.PerkTier}"); + } + return perk; + } + + /// + /// Upgrade to the given VIP level. Grants a bonus as part of the level up. + /// + /// The gambler you wish to level up + /// VIP level to grant them + /// Cancellation token + /// The bonus they received + public static async Task UpgradeVipLevelAsync(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); + } + _logger.Info($"Calculated a VIP payout of {payout:N} for gambler ID {gambler.Id}"); + + 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 ModifyBalanceAsync(gambler, payout, TransactionSourceEventType.Bonus, + $"VIP Level '{nextVipLevel.VipLevel.Icon} {nextVipLevel.VipLevel.Name}' Tier {nextVipLevel.Tier} level up bonus", ct: ct); + return payout; + } + } \ No newline at end of file