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