mirror of
https://github.com/barelyprofessional/KfChatDotNet.git
synced 2026-04-30 03:22:04 -04:00
189 lines
7.4 KiB
C#
189 lines
7.4 KiB
C#
using System.ComponentModel;
|
|
using System.Reflection;
|
|
using System.Text.RegularExpressions;
|
|
using Humanizer;
|
|
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),
|
|
new Regex(@"^legitcheck (?<user_id>\d+) all$", RegexOptions.IgnoreCase),
|
|
new Regex(@"^legitcheck @(?<username>.+) all$", RegexOptions.IgnoreCase),
|
|
new Regex(@"^legitcheck @(?<username>.+)$", RegexOptions.IgnoreCase),
|
|
new Regex(@"^legitcheck$", RegexOptions.IgnoreCase),
|
|
new Regex(@"^legitcheck all$", 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)
|
|
};
|
|
public bool WhisperCanInvoke => false;
|
|
|
|
// 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, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
|
|
CancellationToken ctx)
|
|
{
|
|
await using var db = new ApplicationDbContext();
|
|
|
|
var isAll = message.MessageRaw.EndsWith(" all");
|
|
|
|
UserDbModel? targetUser;
|
|
if (arguments["username"].Success)
|
|
{
|
|
var chatUser = botInstance.FindUserByName(arguments["username"].Value);
|
|
if (chatUser == null)
|
|
{
|
|
await botInstance.SendChatMessageAsync(
|
|
$"{user.FormatUsername()}, couldn't find that user in chat. They must be present in chat to look up by username.",
|
|
true);
|
|
return;
|
|
}
|
|
targetUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == chatUser.Id, ctx);
|
|
}
|
|
else if (arguments["user_id"].Success)
|
|
{
|
|
var targetUserId = int.Parse(arguments["user_id"].Value);
|
|
targetUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == targetUserId, ctx);
|
|
}
|
|
else
|
|
{
|
|
targetUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == user.KfId, ctx);
|
|
}
|
|
|
|
if (targetUser == null)
|
|
{
|
|
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, that user 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.
|
|
List<int> gamblerIds = [];
|
|
if (isAll)
|
|
{
|
|
gamblerIds = await db.Gamblers
|
|
.Where(g => g.User.Id == targetUser.Id)
|
|
.Select(g => g.Id)
|
|
.ToListAsync(ctx);
|
|
}
|
|
else
|
|
{
|
|
var gambler = await Money.GetGamblerEntityAsync(targetUser.Id, ct: ctx);
|
|
if (gambler == null)
|
|
{
|
|
await botInstance.SendChatMessageAsync(
|
|
$"{user.FormatUsername()}, {targetUser.KfUsername} has never played at the kasino.", true);
|
|
return;
|
|
}
|
|
gamblerIds.Add(gambler.Id);
|
|
}
|
|
|
|
|
|
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 = luckiestGame.Game.Humanize();
|
|
response +=
|
|
$" | Luckiest: {gameName} ({luckiestGame.Rtp:F2}% RTP, {luckiestGame.WagerCount:N0} wagers, {await luckiestGame.TotalWagered.FormatKasinoCurrencyAsync()} wagered)";
|
|
}
|
|
|
|
await botInstance.SendChatMessageAsync(response, true);
|
|
}
|
|
|
|
/// <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; }
|
|
}
|
|
}
|