Add !legitcheck command for user RTP statistics (#41)

New command that calculates Return-to-Player statistics for any user
by aggregating all their kasino wagers across all gambler entities.
Shows overall RTP, total wagered/returned, wager count, and luckiest
game (highest RTP with minimum 10 wagers to qualify).
This commit is contained in:
ClaudetteTheGreat
2026-01-09 19:49:59 -05:00
committed by GitHub
parent fa9cbff738
commit 79a1b7a224

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
[KasinoCommand]
public class LegitCheckCommand : ICommand
{
public List<Regex> Patterns =>
[
new Regex(@"^legitcheck (?<user_id>\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);
}
/// <summary>
/// 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.
/// </summary>
private static string GetGameDisplayName(WagerGame game)
{
var memberInfo = typeof(WagerGame).GetMember(game.ToString()).FirstOrDefault();
var descriptionAttribute = memberInfo?.GetCustomAttribute<DescriptionAttribute>();
return descriptionAttribute?.Description ?? game.ToString();
}
/// <summary>
/// Helper class to hold per-game statistics during calculation.
/// </summary>
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; }
}
}