mirror of
https://github.com/barelyprofessional/KfChatDotNet.git
synced 2026-05-02 04:22:04 -04:00
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:
committed by
GitHub
parent
fa9cbff738
commit
79a1b7a224
155
KfChatDotNetBot/Commands/Kasino/LegitCheckCommand.cs
Normal file
155
KfChatDotNetBot/Commands/Kasino/LegitCheckCommand.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user