diff --git a/KfChatDotNetBot/Commands/Kasino/Roulette/RouletteCommand.cs b/KfChatDotNetBot/Commands/Kasino/Roulette/RouletteCommand.cs new file mode 100644 index 0000000..a0b894f --- /dev/null +++ b/KfChatDotNetBot/Commands/Kasino/Roulette/RouletteCommand.cs @@ -0,0 +1,865 @@ +using System.Net.Http.Headers; +using System.Text.RegularExpressions; +using KfChatDotNetBot.Extensions; +using KfChatDotNetBot.Models; +using KfChatDotNetBot.Models.DbModels; +using KfChatDotNetBot.Services; +using KfChatDotNetBot.Settings; +using KfChatDotNetWsClient.Models.Events; +using Microsoft.EntityFrameworkCore; +using NLog; + +namespace KfChatDotNetBot.Commands.Kasino.Roulette; + +[KasinoCommand] +[WagerCommand] +public class RouletteCommand : ICommand +{ + private static RouletteRound? _activeRound = null; + private static readonly object _roundLock = new object(); + private static int _nextRoundId = 1; + + public List Patterns => [ + new Regex(@"^roulette (?\d+(?:\.\d+)?) (?.+)$", RegexOptions.IgnoreCase), + new Regex(@"^rl (?\d+(?:\.\d+)?) (?.+)$", RegexOptions.IgnoreCase), + new Regex(@"^roulette (?refund|cancel)$", RegexOptions.IgnoreCase), + new Regex(@"^rl (?refund|cancel)$", RegexOptions.IgnoreCase) + ]; + + public string? HelpText => "!roulette - Bet types: number (0-36), red/black, odd/even, low/high, 1st12/2nd12/3rd12, col1/col2/col3"; + public UserRight RequiredRight => UserRight.Loser; + public TimeSpan Timeout => TimeSpan.FromSeconds(5); + public RateLimitOptionsModel? RateLimitOptions => new() + { + MaxInvocations = 10, + Window = TimeSpan.FromSeconds(30), + Flags = RateLimitFlags.NoAutoDeleteCooldownResponse + }; + + private ApplicationDbContext _dbContext = new(); + + // European Roulette wheel configuration + private static readonly HashSet RedNumbers = new() + { 1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36 }; + + private static readonly HashSet BlackNumbers = new() + { 2, 4, 6, 8, 10, 11, 13, 15, 17, 20, 22, 24, 26, 28, 29, 31, 33, 35 }; + + public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, + CancellationToken ctx) + { + var settings = await SettingsProvider.GetMultipleValuesAsync([ + BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, + BuiltIn.Keys.KasinoRouletteEnabled, + BuiltIn.Keys.KasinoRouletteCountdownDuration + ]); + + // Check if roulette is enabled + var rouletteEnabled = settings[BuiltIn.Keys.KasinoRouletteEnabled].ToBoolean(); + if (!rouletteEnabled) + { + var gameDisabledCleanupDelay = TimeSpan.FromMilliseconds( + settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType()); + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, roulette is currently disabled.", + true, autoDeleteAfter: gameDisabledCleanupDelay); + return; + } + + var countdownDuration = TimeSpan.FromSeconds( + settings[BuiltIn.Keys.KasinoRouletteCountdownDuration].ToType()); + + // Handle actions (refund/cancel) + if (arguments.TryGetValue("action", out var actionGroup)) + { + var action = actionGroup.Value.ToLower(); + if (action == "refund") + { + await HandleRefund(botInstance, user, ctx); + return; + } + else if (action == "cancel") + { + // Check if user has admin rights + if (user.UserRight < UserRight.TrueAndHonest) + { + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, you don't have permission to cancel the roulette round.", + true, autoDeleteAfter: TimeSpan.FromSeconds(10)); + return; + } + await HandleCancel(botInstance, user, ctx); + return; + } + } + + // Handle placing a bet + if (!arguments.TryGetValue("amount", out var amountGroup) || !arguments.TryGetValue("bet", out var betGroup)) + { + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, invalid syntax. Use: !roulette ", + true, autoDeleteAfter: TimeSpan.FromSeconds(10)); + return; + } + + await PlaceBet(botInstance, user, amountGroup.Value, betGroup.Value.Trim(), countdownDuration, ctx); + } + + private async Task PlaceBet(ChatBot botInstance, UserDbModel user, string amountStr, string betStr, + TimeSpan countdownDuration, CancellationToken ctx) + { + var logger = LogManager.GetCurrentClassLogger(); + var wager = Convert.ToDecimal(amountStr); + var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx); + + if (gambler == null) + { + throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}"); + } + + // Check if user has enough balance + if (gambler.Balance < wager) + { + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.", + true, autoDeleteAfter: TimeSpan.FromSeconds(10)); + return; + } + + // Parse and validate bet + var betInfo = ParseBet(betStr); + if (betInfo == null) + { + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, invalid bet. Valid bets: 0-36, red/black, odd/even, low/high, 1st12/2nd12/3rd12, col1/col2/col3", + true, autoDeleteAfter: TimeSpan.FromSeconds(10)); + return; + } + + int roundId; + bool isFirstBet = false; + + lock (_roundLock) + { + // Check if there's an active round + if (_activeRound == null) + { + // Start a new round + isFirstBet = true; + _activeRound = new RouletteRound + { + RoundId = _nextRoundId++, + StartTime = DateTimeOffset.UtcNow, + Bets = new List(), + CancellationTokenSource = new CancellationTokenSource() + }; + } + roundId = _activeRound.RoundId; + } + + // Create incomplete wager + var gameMeta = new RouletteWagerMetaModel + { + RoundId = roundId, + BetType = betInfo.Value.BetType, + BetValue = betInfo.Value.BetValue + }; + + await Money.NewWagerAsync( + gambler.Id, + wager, + -wager, + WagerGame.Roulette, + autoModifyBalance: true, + gameMeta: gameMeta, + isComplete: false, + ct: ctx + ); + + var newWager = await _dbContext.Wagers + .OrderBy(x => x.Id) + .LastOrDefaultAsync(w => w.Gambler.Id == gambler.Id && + w.Game == WagerGame.Roulette && + !w.IsComplete, + cancellationToken: ctx); + + if (newWager == null) + { + throw new InvalidOperationException("Failed to create roulette wager"); + } + + // Add bet to active round + lock (_roundLock) + { + if (_activeRound?.RoundId == roundId) + { + _activeRound.Bets.Add(new RouletteBetInfo + { + WagerId = newWager.Id, + GamblerId = gambler.Id, + Username = user.KfUsername, + Amount = wager, + BetType = betInfo.Value.BetType, + BetValue = betInfo.Value.BetValue + }); + } + } + + logger.Info($"User {user.KfUsername} placed roulette bet: {wager} on {betInfo.Value.BetType} {betInfo.Value.BetValue}"); + + // If this is the first bet, start the countdown + if (isFirstBet) + { + _ = Task.Run(async () => await RunCountdown(botInstance, countdownDuration, _activeRound.CancellationTokenSource.Token)); + } + } + + private async Task RunCountdown(ChatBot botInstance, TimeSpan countdownDuration, CancellationToken ctx) + { + var logger = LogManager.GetCurrentClassLogger(); + + try + { + var endTime = DateTimeOffset.UtcNow.Add(countdownDuration); + + // Send initial countdown message + var initialMessage = FormatCountdownMessage(endTime); + var countdownMessage = await botInstance.SendChatMessageAsync(initialMessage, true); + + lock (_roundLock) + { + if (_activeRound != null) + { + _activeRound.CountdownMessageId = countdownMessage.ChatMessageId; + } + } + + // Wait until message is fully sent + logger.Debug("Waiting for countdown message to be sent..."); + while (countdownMessage.Status != SentMessageTrackerStatus.ResponseReceived && !ctx.IsCancellationRequested) + { + await Task.Delay(100, ctx); + } + + if (!countdownMessage.ChatMessageId.HasValue) + { + logger.Error("Countdown message was sent but no ChatMessageId was assigned"); + return; + } + + logger.Info($"Countdown started, will end at {endTime:HH:mm:ss}"); + + // Update countdown every second for real-time display + var lastUpdate = DateTimeOffset.UtcNow; + while (DateTimeOffset.UtcNow < endTime && !ctx.IsCancellationRequested) + { + var remaining = endTime - DateTimeOffset.UtcNow; + if (remaining.TotalSeconds <= 0) break; + + // Wait 1 second between updates + await Task.Delay(TimeSpan.FromSeconds(1), ctx); + + try + { + var updatedMessage = FormatCountdownMessage(endTime); + await botInstance.KfClient.EditMessageAsync(countdownMessage.ChatMessageId.Value, updatedMessage); + + var timeSinceLastUpdate = DateTimeOffset.UtcNow - lastUpdate; + logger.Debug($"Countdown updated (elapsed: {timeSinceLastUpdate.TotalSeconds:F1}s, remaining: {remaining.TotalSeconds:F0}s)"); + lastUpdate = DateTimeOffset.UtcNow; + } + catch (Exception ex) + { + logger.Error(ex, "Error updating countdown message, will retry on next interval"); + } + } + + if (ctx.IsCancellationRequested) + { + logger.Info("Countdown was cancelled before completion"); + return; + } + + logger.Info("Countdown complete, spinning wheel..."); + + // Countdown complete, spin the wheel + await SpinWheel(botInstance, ctx); + } + catch (TaskCanceledException) + { + logger.Info("Roulette countdown cancelled"); + } + catch (Exception ex) + { + logger.Error(ex, "Error in roulette countdown"); + } + } + + private string FormatCountdownMessage(DateTimeOffset endTime) + { + var remaining = endTime - DateTimeOffset.UtcNow; + if (remaining.TotalSeconds < 0) remaining = TimeSpan.Zero; + + var minutes = (int)remaining.TotalMinutes; + var seconds = remaining.Seconds; + + var message = $"🎰 [B]ROULETTE ROUND STARTING[/B] 🎰[br]" + + $"⏱️ Time remaining: [B]{minutes:00}:{seconds:00}[/B][br][br]"; + + lock (_roundLock) + { + if (_activeRound != null && _activeRound.Bets.Count > 0) + { + message += "[B]Current Bets:[/B][br]"; + + // Group bets by user + var betsByUser = _activeRound.Bets + .GroupBy(b => b.Username) + .OrderBy(g => g.Key); + + foreach (var userGroup in betsByUser) + { + message += $"[B]{userGroup.Key}:[/B] "; + var userBets = userGroup.Select(b => $"{b.Amount:F2} on {FormatBetDisplay(b.BetType, b.BetValue)}"); + message += string.Join(", ", userBets) + "[br]"; + } + + message += $"[br][B]Total bets:[/B] {_activeRound.Bets.Count}"; + } + else + { + message += "[I]No bets placed yet. Use !roulette to join![/I]"; + } + } + + return message; + } + + private string FormatBetDisplay(string betType, string betValue) + { + // All bet types display the same way - just return the value + return betValue; + } + + private async Task SpinWheel(ChatBot botInstance, CancellationToken ctx) + { + var logger = LogManager.GetCurrentClassLogger(); + RouletteRound? round; + + lock (_roundLock) + { + round = _activeRound; + _activeRound = null; // Clear active round + } + + if (round == null || round.Bets.Count == 0) + { + logger.Info("No bets placed in roulette round, ending"); + return; + } + + try + { + // Generate winning number using first gambler's seed + var firstGambler = await _dbContext.Gamblers + .FirstOrDefaultAsync(g => g.Id == round.Bets[0].GamblerId, cancellationToken: ctx); + + if (firstGambler == null) + { + throw new InvalidOperationException("Could not find first gambler for roulette round"); + } + + var winningNumber = Money.GetRandomNumber(firstGambler, 0, 36); + logger.Info($"Roulette round {round.RoundId} winning number: {winningNumber}"); + + // Generate animation + logger.Info($"Generating roulette animation for round {round.RoundId}"); + var (animationDuration, animationBytes) = RouletteAnimationGenerator.GenerateAnimation(winningNumber); + logger.Info($"Animation generated: {animationBytes.Length} bytes, duration: {animationDuration}s"); + + // Upload animation to Zipline + logger.Info("Uploading animation to Zipline"); + using var animationStream = new MemoryStream(animationBytes); + var animationUrl = await Zipline.Upload( + animationStream, + new MediaTypeHeaderValue("image/webp"), + expiration: "1h", + ct: ctx); + + if (string.IsNullOrEmpty(animationUrl)) + { + throw new InvalidOperationException("Failed to upload animation to Zipline"); + } + + logger.Info($"Animation uploaded: {animationUrl}"); + + // Update countdown message to show it's spinning + if (round.CountdownMessageId.HasValue) + { + var spinningMessage = $"🎰 [B]SPINNING THE WHEEL...[/B] 🎰[br][br]" + + "Watch the animation below!"; + await botInstance.KfClient.EditMessageAsync( + round.CountdownMessageId.Value, + spinningMessage); + } + + // Post animation as a new message + var animationMessage = $"[img]{animationUrl}[/img]"; + await botInstance.SendChatMessageAsync(animationMessage, true); + + // Wait for animation duration before revealing results + logger.Info($"Waiting {animationDuration} seconds for animation to complete"); + await Task.Delay(TimeSpan.FromSeconds(animationDuration), ctx); + + // Process all bets and show results + await ProcessBets(botInstance, round, winningNumber, ctx); + } + catch (Exception ex) + { + logger.Error(ex, $"Error spinning roulette wheel for round {round.RoundId}"); + + // Cancel the round and refund all bets + await CancelRoundDueToError(botInstance, round, ex.Message, ctx); + } + } + + private async Task CancelRoundDueToError(ChatBot botInstance, RouletteRound round, string errorMessage, CancellationToken ctx) + { + var logger = LogManager.GetCurrentClassLogger(); + logger.Error($"Cancelling roulette round {round.RoundId} due to error: {errorMessage}"); + + // Refund all bets + decimal totalRefunded = 0; + foreach (var bet in round.Bets) + { + try + { + var wager = await _dbContext.Wagers + .Include(w => w.Gambler) + .FirstOrDefaultAsync(w => w.Id == bet.WagerId, cancellationToken: ctx); + + if (wager != null) + { + wager.IsComplete = true; + wager.WagerEffect = 0; + wager.Multiplier = 1; + + await Money.ModifyBalanceAsync( + wager.Gambler.Id, + wager.WagerAmount, + TransactionSourceEventType.Gambling, + $"Roulette round {round.RoundId} cancelled due to error, wager {wager.Id} refunded", + ct: ctx); + + totalRefunded += wager.WagerAmount; + } + } + catch (Exception ex) + { + logger.Error(ex, $"Error refunding bet {bet.WagerId} during error cancellation"); + } + } + + await _dbContext.SaveChangesAsync(ctx); + + var cancelMessage = $"🎰 [B]ROULETTE ROUND ERROR[/B] 🎰[br]" + + $"Round {round.RoundId} has been cancelled due to a technical error.[br]" + + $"All {round.Bets.Count} bet(s) have been refunded (total: {await totalRefunded.FormatKasinoCurrencyAsync()}).[br]" + + $"Please try again."; + + if (round.CountdownMessageId.HasValue) + { + await botInstance.KfClient.EditMessageAsync( + round.CountdownMessageId.Value, + cancelMessage); + } + else + { + await botInstance.SendChatMessageAsync(cancelMessage, true); + } + } + + private async Task ProcessBets(ChatBot botInstance, RouletteRound round, int winningNumber, CancellationToken ctx) + { + var logger = LogManager.GetCurrentClassLogger(); + var colors = await SettingsProvider.GetMultipleValuesAsync([ + BuiltIn.Keys.KiwiFarmsGreenColor, + BuiltIn.Keys.KiwiFarmsRedColor + ]); + + var resultMessage = $"🎰 [B]ROULETTE RESULTS - Round {round.RoundId}[/B] 🎰[br][br]"; + resultMessage += $"[B]Winning Number: {winningNumber}[/B] ({GetNumberColor(winningNumber)})[br][br]"; + + var winnersByUser = new Dictionary winningBets)>(); + + foreach (var bet in round.Bets) + { + try + { + var wager = await _dbContext.Wagers + .Include(w => w.Gambler) + .FirstOrDefaultAsync(w => w.Id == bet.WagerId, cancellationToken: ctx); + + if (wager == null) + { + logger.Error($"Could not find wager {bet.WagerId}"); + continue; + } + + var isWin = CheckWin(bet.BetType, bet.BetValue, winningNumber); + var payout = isWin ? CalculatePayout(bet.BetType, bet.Amount) : 0; + var effect = payout - bet.Amount; // Net win/loss + + // Update wager + wager.IsComplete = true; + wager.WagerEffect = effect; + wager.Multiplier = payout / bet.Amount; + + await _dbContext.SaveChangesAsync(ctx); + + // Update balance + var balanceAdjustment = payout; + await Money.ModifyBalanceAsync( + wager.Gambler.Id, + balanceAdjustment, + TransactionSourceEventType.Gambling, + $"Roulette outcome from wager {wager.Id}", + ct: ctx); + + // Track results by user + if (!winnersByUser.ContainsKey(bet.Username)) + { + winnersByUser[bet.Username] = (0, new List()); + } + + var userData = winnersByUser[bet.Username]; + userData.netWin += effect; + + if (isWin) + { + userData.winningBets.Add($"{FormatBetDisplay(bet.BetType, bet.BetValue)} (+{await effect.FormatKasinoCurrencyAsync()})"); + } + + winnersByUser[bet.Username] = userData; + + logger.Info($"Processed bet {bet.WagerId}: {bet.Username} bet {bet.Amount} on {bet.BetType} {bet.BetValue}, " + + $"win: {isWin}, payout: {payout}, effect: {effect}"); + } + catch (Exception ex) + { + logger.Error(ex, $"Error processing bet {bet.WagerId}"); + } + } + + // Format results + resultMessage += "[B]Results:[/B][br]"; + foreach (var (username, (netWin, winningBets)) in winnersByUser.OrderByDescending(x => x.Value.netWin)) + { + var winColor = netWin >= 0 + ? colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value + : colors[BuiltIn.Keys.KiwiFarmsRedColor].Value; + + resultMessage += $"[B]{username}:[/B] [COLOR={winColor}]{(netWin >= 0 ? "+" : "")}{await netWin.FormatKasinoCurrencyAsync()}[/COLOR]"; + + if (winningBets.Count > 0) + { + resultMessage += $" ({string.Join(", ", winningBets)})"; + } + + resultMessage += "[br]"; + } + + // Post results as a new message + await botInstance.SendChatMessageAsync(resultMessage, true); + } + + private async Task HandleRefund(ChatBot botInstance, UserDbModel user, CancellationToken ctx) + { + lock (_roundLock) + { + if (_activeRound == null) + { + _ = botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, there's no active roulette round.", + true, autoDeleteAfter: TimeSpan.FromSeconds(10)); + return; + } + } + + var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx); + if (gambler == null) + { + throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}"); + } + + // Find all incomplete roulette wagers for this user + var userWagers = await _dbContext.Wagers + .Where(w => w.Gambler.Id == gambler.Id && + w.Game == WagerGame.Roulette && + !w.IsComplete) + .ToListAsync(cancellationToken: ctx); + + if (userWagers.Count == 0) + { + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, you don't have any active roulette bets to refund.", + true, autoDeleteAfter: TimeSpan.FromSeconds(10)); + return; + } + + decimal totalRefund = 0; + + foreach (var wager in userWagers) + { + wager.IsComplete = true; + wager.WagerEffect = 0; // No loss + wager.Multiplier = 1; // Break even + totalRefund += wager.WagerAmount; + + // Refund the wager amount + await Money.ModifyBalanceAsync( + gambler.Id, + wager.WagerAmount, + TransactionSourceEventType.Gambling, + $"Roulette bet refund for wager {wager.Id}", + ct: ctx); + } + + await _dbContext.SaveChangesAsync(ctx); + + // Remove bets from active round + lock (_roundLock) + { + if (_activeRound != null) + { + _activeRound.Bets.RemoveAll(b => b.GamblerId == gambler.Id); + } + } + + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, refunded {userWagers.Count} bet(s) totaling {await totalRefund.FormatKasinoCurrencyAsync()}", + true, autoDeleteAfter: TimeSpan.FromSeconds(15)); + } + + private async Task HandleCancel(ChatBot botInstance, UserDbModel user, CancellationToken ctx) + { + RouletteRound? round; + + lock (_roundLock) + { + round = _activeRound; + _activeRound = null; + } + + if (round == null) + { + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, there's no active roulette round to cancel.", + true, autoDeleteAfter: TimeSpan.FromSeconds(10)); + return; + } + + // Cancel countdown + round.CancellationTokenSource?.Cancel(); + + // Refund all bets + decimal totalRefunded = 0; + foreach (var bet in round.Bets) + { + try + { + var wager = await _dbContext.Wagers + .Include(w => w.Gambler) + .FirstOrDefaultAsync(w => w.Id == bet.WagerId, cancellationToken: ctx); + + if (wager != null) + { + wager.IsComplete = true; + wager.WagerEffect = 0; + wager.Multiplier = 1; + + await Money.ModifyBalanceAsync( + wager.Gambler.Id, + wager.WagerAmount, + TransactionSourceEventType.Gambling, + $"Roulette round {round.RoundId} cancelled, wager {wager.Id} refunded", + ct: ctx); + + totalRefunded += wager.WagerAmount; + } + } + catch (Exception ex) + { + LogManager.GetCurrentClassLogger().Error(ex, $"Error refunding bet {bet.WagerId}"); + } + } + + await _dbContext.SaveChangesAsync(ctx); + + var cancelMessage = $"🎰 [B]ROULETTE ROUND CANCELLED[/B] 🎰[br]" + + $"Cancelled by {user.FormatUsername()}[br]" + + $"Refunded {round.Bets.Count} bet(s) totaling {await totalRefunded.FormatKasinoCurrencyAsync()}"; + + if (round.CountdownMessageId.HasValue) + { + await botInstance.KfClient.EditMessageAsync( + round.CountdownMessageId.Value, + cancelMessage); + } + else + { + await botInstance.SendChatMessageAsync(cancelMessage, true); + } + } + + private (string BetType, string BetValue)? ParseBet(string betStr) + { + betStr = betStr.ToLower().Trim(); + + // Try to parse as number (0-36) + if (int.TryParse(betStr, out var number) && number >= 0 && number <= 36) + { + return ("number", number.ToString()); + } + + // Color bets + if (betStr is "red" or "r") + return ("color", "RED"); + if (betStr is "black" or "b") + return ("color", "BLACK"); + + // Odd/Even + if (betStr is "odd" or "o") + return ("oddeven", "ODD"); + if (betStr is "even" or "e") + return ("oddeven", "EVEN"); + + // Low/High + if (betStr is "low" or "1-18") + return ("lowhigh", "LOW"); + if (betStr is "high" or "19-36") + return ("lowhigh", "HIGH"); + + // Dozens + if (betStr is "1st12" or "d1" or "1-12") + return ("dozen", "1ST"); + if (betStr is "2nd12" or "d2" or "13-24") + return ("dozen", "2ND"); + if (betStr is "3rd12" or "d3" or "25-36") + return ("dozen", "3RD"); + + // Columns + if (betStr is "col1" or "c1") + return ("column", "COL1"); + if (betStr is "col2" or "c2") + return ("column", "COL2"); + if (betStr is "col3" or "c3") + return ("column", "COL3"); + + return null; + } + + private bool CheckWin(string betType, string betValue, int winningNumber) + { + return betType switch + { + "number" => int.Parse(betValue) == winningNumber, + "color" => CheckColorWin(betValue, winningNumber), + "oddeven" => CheckOddEvenWin(betValue, winningNumber), + "lowhigh" => CheckLowHighWin(betValue, winningNumber), + "dozen" => CheckDozenWin(betValue, winningNumber), + "column" => CheckColumnWin(betValue, winningNumber), + _ => false + }; + } + + private bool CheckColorWin(string color, int number) + { + if (number == 0) return false; + return color == "RED" ? RedNumbers.Contains(number) : BlackNumbers.Contains(number); + } + + private bool CheckOddEvenWin(string oddEven, int number) + { + if (number == 0) return false; + return oddEven == "ODD" ? number % 2 == 1 : number % 2 == 0; + } + + private bool CheckLowHighWin(string lowHigh, int number) + { + if (number == 0) return false; + return lowHigh == "LOW" ? number <= 18 : number >= 19; + } + + private bool CheckDozenWin(string dozen, int number) + { + if (number == 0) return false; + return dozen switch + { + "1ST" => number >= 1 && number <= 12, + "2ND" => number >= 13 && number <= 24, + "3RD" => number >= 25 && number <= 36, + _ => false + }; + } + + private bool CheckColumnWin(string column, int number) + { + if (number == 0) return false; + return column switch + { + "COL1" => number % 3 == 1, + "COL2" => number % 3 == 2, + "COL3" => number % 3 == 0, + _ => false + }; + } + + private decimal CalculatePayout(string betType, decimal wagerAmount) + { + var multiplier = betType switch + { + "number" => 35m, // 35:1 + "color" => 1m, // 1:1 + "oddeven" => 1m, // 1:1 + "lowhigh" => 1m, // 1:1 + "dozen" => 2m, // 2:1 + "column" => 2m, // 2:1 + _ => 0m + }; + + return wagerAmount * (multiplier + 1); // Return original wager + winnings + } + + private string GetNumberColor(int number) + { + if (number == 0) return "GREEN"; + return RedNumbers.Contains(number) ? "RED" : "BLACK"; + } + + private class RouletteRound + { + public required int RoundId { get; init; } + public required DateTimeOffset StartTime { get; init; } + public int? CountdownMessageId { get; set; } + public required List Bets { get; init; } + public CancellationTokenSource? CancellationTokenSource { get; init; } + } + + private class RouletteBetInfo + { + public required int WagerId { get; init; } + public required int GamblerId { get; init; } + public required string Username { get; init; } + public required decimal Amount { get; init; } + public required string BetType { get; init; } + public required string BetValue { get; init; } + } +} + +public class RouletteWagerMetaModel +{ + public int RoundId { get; set; } + public string BetType { get; set; } = ""; + public string BetValue { get; set; } = ""; +} \ No newline at end of file diff --git a/KfChatDotNetBot/Commands/Kasino/Roulette/RouletteanImationGenerator.cs b/KfChatDotNetBot/Commands/Kasino/Roulette/RouletteanImationGenerator.cs new file mode 100644 index 0000000..e39e085 --- /dev/null +++ b/KfChatDotNetBot/Commands/Kasino/Roulette/RouletteanImationGenerator.cs @@ -0,0 +1,137 @@ +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.Formats.Webp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace KfChatDotNetBot.Commands.Kasino.Roulette; + +public static class RouletteAnimationGenerator +{ + // European Wheel Sequence + private static readonly int[] WheelNumbers = { + 0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5, 24, 16, 33, 1, 20, 14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26 + }; + + /// + /// Generates an animated roulette wheel that lands on the specified winning number + /// + /// The number (0-36) that the ball should land on + /// A tuple containing the animation duration in seconds and the WebP animation bytes + public static (int durationSeconds, byte[] animationBytes) GenerateAnimation(int winningNumber) + { + if (winningNumber < 0 || winningNumber > 36) + { + throw new ArgumentOutOfRangeException(nameof(winningNumber), "Winning number must be between 0 and 36"); + } + + using var board = DrawWheelBase(); + int fps = 20; + int duration = Random.Shared.Next(6, 9); + int totalFrames = fps * duration; + using var animation = new Image(500, 500); + + // Find the index of the winning number in the wheel sequence + int winningIndex = Array.IndexOf(WheelNumbers, winningNumber); + if (winningIndex == -1) + { + throw new InvalidOperationException($"Winning number {winningNumber} not found in wheel sequence"); + } + + // Set the "Journey" + float endWheelRotation = 720f + Random.Shared.Next(0, 360); + float sliceAngle = 360f / 37f; + + // The pocket index 'i' is located at (i * sliceAngle) degrees relative to wheel zero. + // Our '0' pocket was drawn at -90 degrees. + float pocketOffsetOnWheel = (winningIndex * sliceAngle) - 90; + + // This is where the ball MUST be at the end of the video + float finalBallAngle = endWheelRotation + pocketOffsetOnWheel; + + // Render frames + for (int i = 0; i < totalFrames; i++) + { + float progress = (float)i / totalFrames; + float ease = 1f - MathF.Pow(1f - progress, 3); // Smooth stop + + // Wheel rotates Clockwise (Adding degrees) + float currentWheelAngle = endWheelRotation * ease; + + // Ball rotates Counter-Clockwise (Starting high and subtracting) + // We start with 5 extra laps (1800 degrees) and "go back" to the final angle + float startBallAngle = finalBallAngle + 1800f; + float currentBallAngle = startBallAngle - ((startBallAngle - finalBallAngle) * ease); + + var frame = new Image(500, 500); + frame.Mutate(ctx => { + // Draw Wheel + using var rotatedBoard = board.Clone(b => b.Rotate(currentWheelAngle)); + int ox = 250 - (rotatedBoard.Width / 2); + int oy = 250 - (rotatedBoard.Height / 2); + ctx.DrawImage(rotatedBoard, new Point(ox, oy), 1f); + + // Ball Radius (Physics) + float dropT = MathF.Max(0, (progress - 0.7f) / 0.3f); + float radius = 230 - (45 * MathF.Pow(dropT, 2)); + + float rads = currentBallAngle * MathF.PI / 180; + float bx = 250 + (radius * MathF.Cos(rads)); + float by = 250 + (radius * MathF.Sin(rads)); + + ctx.Fill(Color.White, new EllipsePolygon(bx, by, 14)); + }); + + frame.Frames.RootFrame.Metadata.GetWebpMetadata().FrameDelay = (uint)(1000 / fps); + animation.Frames.AddFrame(frame.Frames.RootFrame); + frame.Dispose(); + } + + animation.Frames.RemoveFrame(0); + using var ms = new MemoryStream(); + animation.SaveAsWebp(ms, new WebpEncoder { FileFormat = WebpFileFormatType.Lossy, Quality = 50 }); + + return (duration, ms.ToArray()); + } + + private static Image DrawWheelBase() + { + var img = new Image(500, 500); + float centerX = 250, centerY = 250, outerRadius = 245, innerRadius = 170, step = 360f / 37f; + + img.Mutate(ctx => { + for (int i = 0; i < 37; i++) { + float startAngle = i * step - (step / 2) - 90; + var color = WheelNumbers[i] == 0 ? Color.Green : (i % 2 == 0 ? Color.DarkRed : Color.Black); + var path = new PathBuilder().AddArc(centerX, centerY, outerRadius, outerRadius, 0, startAngle, step) + .AddArc(centerX, centerY, innerRadius, innerRadius, 0, startAngle + step, -step).Build(); + ctx.Fill(color, path); + ctx.Draw(Color.Gold, 1, path); + + string text = WheelNumbers[i].ToString(); + float textAngle = (startAngle + (step / 2)) * MathF.PI / 180; + float tx = centerX + ((outerRadius + innerRadius) / 2) * MathF.Cos(textAngle); + float ty = centerY + ((outerRadius + innerRadius) / 2) * MathF.Sin(textAngle); + + try { + var font = SystemFonts.CreateFont("Arial", 14, FontStyle.Bold); + ctx.DrawText( + new DrawingOptions { + Transform = Matrix3x2Extensions.CreateRotationDegrees(startAngle + (step / 2) + 90, new PointF(tx, ty)) + }, + text, + font, + Color.White, + new PointF(tx - 6, ty - 9)); + } catch { + // Font loading failed, skip text rendering + } + } + ctx.Fill(Color.DarkSlateGray, new EllipsePolygon(centerX, centerY, innerRadius - 5)); + }); + + return img; + } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs b/KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs index c424918..a094233 100644 --- a/KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs +++ b/KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs @@ -302,7 +302,9 @@ public enum WagerGame Slots, Blackjack, [Description("Plinko")] - Plinko + Plinko, + [Description("Roulette but live")] + Roulette } public enum GamblerState diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs index cff3e79..f5790bd 100644 --- a/KfChatDotNetBot/Settings/BuiltIn.cs +++ b/KfChatDotNetBot/Settings/BuiltIn.cs @@ -502,6 +502,10 @@ public static class BuiltIn public static string KasinoSlotsEnabled = "Kasino.Slots.Enabled"; [BuiltInSetting("Whether plinko is enabled", SettingValueType.Boolean, "true", BooleanRegex)] public static string KasinoPlinkoEnabled = "Kasino.Plinko.Enabled"; + [BuiltInSetting("Enable/disable roulette game", SettingValueType.Boolean, "true")] + public static string KasinoRouletteEnabled = "Kasino.Roulette.Enabled"; + [BuiltInSetting("Roulette countdown duration in seconds", SettingValueType.Text, "120", WholeNumberRegex)] + public static string KasinoRouletteCountdownDuration = "Kasino.Roulette.CountdownDuration"; [BuiltInSetting("Whether Xeet posting is enabled", SettingValueType.Boolean, "true", BooleanRegex)] public static string XeetEnabled = "Xeet.Enabled"; [BuiltInSetting("Connection string for bot's Redis", SettingValueType.Text)]