diff --git a/KfChatDotNetBot/Commands/Kasino/LegitCheckCommand.cs b/KfChatDotNetBot/Commands/Kasino/LegitCheckCommand.cs new file mode 100644 index 0000000..390b90e --- /dev/null +++ b/KfChatDotNetBot/Commands/Kasino/LegitCheckCommand.cs @@ -0,0 +1,155 @@ +using System.ComponentModel; +using System.Reflection; +using System.Text.RegularExpressions; +using KfChatDotNetBot.Extensions; +using KfChatDotNetBot.Models; +using KfChatDotNetBot.Models.DbModels; +using KfChatDotNetBot.Services; +using KfChatDotNetWsClient.Models.Events; +using Microsoft.EntityFrameworkCore; + +namespace KfChatDotNetBot.Commands.Kasino; + +/// +/// Command to check a user's kasino "legitimacy" by calculating their Return-to-Player (RTP) statistics. +/// RTP represents the percentage of wagered money returned to the player over time. +/// An RTP of 100% means break-even, above 100% means profit, below means loss. +/// +[KasinoCommand] +public class LegitCheckCommand : ICommand +{ + public List Patterns => + [ + new Regex(@"^legitcheck (?\d+)$", RegexOptions.IgnoreCase), + ]; + + public string? HelpText => "Check a user's kasino RTP statistics"; + public UserRight RequiredRight => UserRight.Loser; + public TimeSpan Timeout => TimeSpan.FromSeconds(10); + + public RateLimitOptionsModel? RateLimitOptions => new() + { + MaxInvocations = 3, + Window = TimeSpan.FromSeconds(30) + }; + + // Minimum wagers required for a game to be considered for "luckiest game" + // This prevents small sample sizes from skewing results (e.g., 1 win on 1 bet = 200% RTP) + private const int MinWagersForLuckiestGame = 10; + + public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, + CancellationToken ctx) + { + await using var db = new ApplicationDbContext(); + + var targetUserId = int.Parse(arguments["user_id"].Value); + var targetUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == targetUserId, ctx); + + if (targetUser == null) + { + await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, the user ID you gave doesn't exist.", + true); + return; + } + + // A user can have multiple gambler entities (e.g., if they abandoned an account or got reset). + // We want to aggregate stats across ALL their gambler entities for a complete picture. + var gamblerIds = await db.Gamblers + .Where(g => g.User.Id == targetUser.Id) + .Select(g => g.Id) + .ToListAsync(ctx); + + if (gamblerIds.Count == 0) + { + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, {targetUser.KfUsername} has never played at the kasino.", true); + return; + } + + // Only include completed wagers - incomplete ones are pending bets (e.g., event outcomes) + var wagers = await db.Wagers + .Where(w => gamblerIds.Contains(w.Gambler.Id) && w.IsComplete) + .ToListAsync(ctx); + + if (wagers.Count == 0) + { + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, {targetUser.KfUsername} has no completed kasino wagers on record.", true); + return; + } + + // RTP Calculation: + // - WagerAmount: The amount the user bet + // - WagerEffect: The net change to balance (negative for loss, positive for profit) + // - TotalReturned = WagerAmount + WagerEffect (what they got back) + // Example: Bet 100, lost -> WagerEffect = -100, Returned = 100 + (-100) = 0 + // Example: Bet 100, won 150 total -> WagerEffect = +50 (profit), Returned = 100 + 50 = 150 + // - RTP% = (TotalReturned / TotalWagered) * 100 + var totalWagered = wagers.Sum(w => w.WagerAmount); + var totalReturned = wagers.Sum(w => w.WagerAmount + w.WagerEffect); + var overallRtp = totalWagered > 0 ? (totalReturned / totalWagered) * 100 : 0; + + // Group wagers by game type to find per-game statistics + var gameStats = wagers + .GroupBy(w => w.Game) + .Select(g => new GameStatistic + { + Game = g.Key, + WagerCount = g.Count(), + TotalWagered = g.Sum(w => w.WagerAmount), + TotalReturned = g.Sum(w => w.WagerAmount + w.WagerEffect) + }) + .ToList(); + + // Calculate RTP for each game (done separately to avoid division in the LINQ projection) + foreach (var stat in gameStats) + { + stat.Rtp = stat.TotalWagered > 0 ? (stat.TotalReturned / stat.TotalWagered) * 100 : 0; + } + + // Find the game with highest RTP, but only if they have enough wagers to be statistically meaningful + var luckiestGame = gameStats + .Where(g => g.WagerCount >= MinWagersForLuckiestGame) + .MaxBy(g => g.Rtp); + + // Build response + var response = + $"{user.FormatUsername()}, {targetUser.KfUsername} RTP: {overallRtp:F2}% | " + + $"Wagered: {await totalWagered.FormatKasinoCurrencyAsync()} | " + + $"Returned: {await totalReturned.FormatKasinoCurrencyAsync()} | " + + $"Wagers: {wagers.Count:N0}"; + + if (luckiestGame != null) + { + var gameName = GetGameDisplayName(luckiestGame.Game); + response += + $" | Luckiest: {gameName} ({luckiestGame.Rtp:F2}% RTP, {luckiestGame.WagerCount:N0} wagers, {await luckiestGame.TotalWagered.FormatKasinoCurrencyAsync()} wagered)"; + } + + await botInstance.SendChatMessageAsync(response, true); + } + + /// + /// Gets the display name for a WagerGame enum value. + /// Uses the [Description] attribute if present (e.g., LambChop -> "Lambchop"), + /// otherwise falls back to the enum name itself. + /// + private static string GetGameDisplayName(WagerGame game) + { + var memberInfo = typeof(WagerGame).GetMember(game.ToString()).FirstOrDefault(); + var descriptionAttribute = memberInfo?.GetCustomAttribute(); + return descriptionAttribute?.Description ?? game.ToString(); + } + + /// + /// Helper class to hold per-game statistics during calculation. + /// + private class GameStatistic + { + public WagerGame Game { get; init; } + public int WagerCount { get; init; } + public decimal TotalWagered { get; init; } + public decimal TotalReturned { get; init; } + public decimal Rtp { get; set; } + } +}