using System.Text.Json; 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 RandN.Compat; using RandN; namespace KfChatDotNetBot.Commands.Kasino; [KasinoCommand] [WagerCommand] public class BlackjackCommand : ICommand { private static readonly TimeSpan GameTimeout = TimeSpan.FromMinutes(5); public List Patterns => [ new Regex(@"^blackjack (?\d+)$", RegexOptions.IgnoreCase), new Regex(@"^blackjack (?\d+\.\d+)$", RegexOptions.IgnoreCase), new Regex(@"^bj (?\d+)$", RegexOptions.IgnoreCase), new Regex(@"^bj (?\d+\.\d+)$", RegexOptions.IgnoreCase), new Regex(@"^blackjack (?hit|stand|double)$", RegexOptions.IgnoreCase), new Regex(@"^bj (?hit|stand|double)$", RegexOptions.IgnoreCase) ]; public string? HelpText => "!blackjack or !bj to start, then !bj hit/stand/double"; public UserRight RequiredRight => UserRight.Loser; public TimeSpan Timeout => TimeSpan.FromSeconds(15); public RateLimitOptionsModel? RateLimitOptions => new() { MaxInvocations = 5, Window = TimeSpan.FromSeconds(20), Flags = RateLimitFlags.NoAutoDeleteCooldownResponse }; public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx) { var cleanupDelay = TimeSpan.FromMilliseconds( (await SettingsProvider.GetValueAsync(BuiltIn.Keys.KasinoBlackjackCleanupDelay)).ToType()); // Check if this is a new game or continuing existing game if (arguments.TryGetValue("amount", out var amountGroup)) { await StartNewGame(botInstance, user, amountGroup.Value, cleanupDelay, ctx); } else if (arguments.TryGetValue("action", out var actionGroup)) { await ContinueGame(botInstance, user, actionGroup.Value.ToLower(), cleanupDelay, ctx); } } private async Task StartNewGame(ChatBot botInstance, UserDbModel user, string amountStr, TimeSpan cleanupDelay, CancellationToken ctx) { 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: cleanupDelay); return; } // Check for existing incomplete blackjack game await using var db = new ApplicationDbContext(); var existingGame = await db.Wagers .Where(w => w.Gambler.Id == gambler.Id && w.Game == WagerGame.Blackjack && !w.IsComplete) .OrderByDescending(w => w.Id) .FirstOrDefaultAsync(ctx); if (existingGame != null) { if (existingGame.GameMeta == null) { // Mark as complete with loss and continue db.Attach(existingGame); existingGame.IsComplete = true; existingGame.WagerEffect = -existingGame.WagerAmount; existingGame.Multiplier = 0m; await db.SaveChangesAsync(ctx); } else { var existingGameState = JsonSerializer.Deserialize(existingGame.GameMeta); if (existingGameState == null) { db.Attach(existingGame); existingGame.IsComplete = true; existingGame.WagerEffect = -existingGame.WagerAmount; existingGame.Multiplier = 0m; await db.SaveChangesAsync(ctx); } else { // Check if game has timed out var timeSinceStart = DateTimeOffset.UtcNow - existingGameState.GameStarted; if (timeSinceStart > GameTimeout) { await ForfeitGame(botInstance, user, gambler, existingGame, cleanupDelay, ctx); } else { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, you already have an active blackjack game. Use !bj hit or !bj stand to continue.", true, autoDeleteAfter: cleanupDelay); return; } } } } // Create deck and deal initial hands var rng = StandardRng.Create(); var random = RandomShim.Create(rng); var deck = BlackjackHelper.CreateDeck(random); var playerHand = new List { deck[0], deck[2] }; var dealerHand = new List { deck[1], deck[3] }; deck.RemoveRange(0, 4); // Create game state var newGameState = new BlackjackGameMetaModel { WagerId = 0, // Will be set after wager is created PlayerHand = playerHand, DealerHand = dealerHand, Deck = deck, GameStarted = DateTimeOffset.UtcNow, HasDoubledDown = false }; // Create incomplete wager var newBalance = await Money.NewWagerAsync( gambler.Id, wager, -wager, // This will be the effect for incomplete wagers WagerGame.Blackjack, autoModifyBalance: true, gameMeta: newGameState, isComplete: false, ct: ctx ); // Update wager ID in game state var createdWager = await db.Wagers .Where(w => w.Gambler.Id == gambler.Id && w.Game == WagerGame.Blackjack && !w.IsComplete) .OrderByDescending(w => w.Id) .FirstAsync(ctx); newGameState.WagerId = createdWager.Id; db.Attach(createdWager); createdWager.GameMeta = JsonSerializer.Serialize(newGameState); await db.SaveChangesAsync(ctx); // Check for immediate blackjacks var playerValue = BlackjackHelper.CalculateHandValue(playerHand); var dealerValue = BlackjackHelper.CalculateHandValue(dealerHand); var playerBlackjack = BlackjackHelper.IsBlackjack(playerHand); var dealerBlackjack = BlackjackHelper.IsBlackjack(dealerHand); if (playerBlackjack || dealerBlackjack) { await ResolveGame(botInstance, user, gambler, createdWager, newGameState, true, cleanupDelay, ctx); return; } // Display initial game state var colors = await SettingsProvider.GetMultipleValuesAsync([ BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor ]); await botInstance.SendChatMessageAsync( $"🃏 {user.FormatUsername()} started blackjack with {await wager.FormatKasinoCurrencyAsync()}[br]" + $"[B]Your hand:[/B] {BlackjackHelper.FormatHand(playerHand)} = {playerValue}[br]" + $"[B]Dealer:[/B] {BlackjackHelper.FormatHand(dealerHand, hideFirstCard: true)}[br]" + $"Use [B]!bj hit[/B] or [B]!bj stand[/B] to continue", true, autoDeleteAfter: cleanupDelay); } private async Task ContinueGame(ChatBot botInstance, UserDbModel user, string action, TimeSpan cleanupDelay, CancellationToken ctx) { 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 active game await using var db = new ApplicationDbContext(); var activeWager = await db.Wagers .Where(w => w.Gambler.Id == gambler.Id && w.Game == WagerGame.Blackjack && !w.IsComplete) .OrderByDescending(w => w.Id) .FirstOrDefaultAsync(ctx); if (activeWager == null) { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, you don't have an active blackjack game. Start one with !bj ", true, autoDeleteAfter: cleanupDelay); return; } if (activeWager.GameMeta == null) { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, your game data is corrupted. Please start a new game.", true, autoDeleteAfter: cleanupDelay); return; } var currentGameState = JsonSerializer.Deserialize(activeWager.GameMeta); if (currentGameState == null) { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, your game data is corrupted. Please start a new game.", true, autoDeleteAfter: cleanupDelay); return; } // Check timeout var timeSinceStart = DateTimeOffset.UtcNow - currentGameState.GameStarted; if (timeSinceStart > GameTimeout) { await ForfeitGame(botInstance, user, gambler, activeWager, cleanupDelay, ctx); return; } var rng = StandardRng.Create(); var random = RandomShim.Create(rng); switch (action) { case "hit": await HandleHit(botInstance, user, gambler, activeWager, currentGameState, random, cleanupDelay, ctx); break; case "stand": await HandleStand(botInstance, user, gambler, activeWager, currentGameState, cleanupDelay, ctx); break; case "double": await HandleDouble(botInstance, user, gambler, activeWager, currentGameState, random, cleanupDelay, ctx); break; } } private async Task HandleHit(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler, WagerDbModel wager, BlackjackGameMetaModel gameState, Random random, TimeSpan cleanupDelay, CancellationToken ctx) { if (gameState.Deck.Count == 0) { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, game error: no cards left in deck. Game forfeited.", true, autoDeleteAfter: cleanupDelay); await ForfeitGame(botInstance, user, gambler, wager, cleanupDelay, ctx); return; } // Draw card var card = gameState.Deck[0]; gameState.Deck.RemoveAt(0); gameState.PlayerHand.Add(card); var playerValue = BlackjackHelper.CalculateHandValue(gameState.PlayerHand); if (playerValue > 21) { // Bust - player loses await botInstance.SendChatMessageAsync( $"{user.FormatUsername()} hit and drew {card}[br]" + $"[B]Your hand:[/B] {BlackjackHelper.FormatHand(gameState.PlayerHand)} = {playerValue}[br]" + $"[B][COLOR=red]BUST![/COLOR][/B]", true, autoDeleteAfter: cleanupDelay); await ResolveGame(botInstance, user, gambler, wager, gameState, false, cleanupDelay, ctx); } else if (gameState.HasDoubledDown) { // Auto-stand after double down hit await botInstance.SendChatMessageAsync( $"{user.FormatUsername()} hit and drew {card}[br]" + $"[B]Your hand:[/B] {BlackjackHelper.FormatHand(gameState.PlayerHand)} = {playerValue}", true, autoDeleteAfter: cleanupDelay); await HandleStand(botInstance, user, gambler, wager, gameState, cleanupDelay, ctx); } else { // Continue game await using var db = new ApplicationDbContext(); db.Attach(wager); wager.GameMeta = JsonSerializer.Serialize(gameState); await db.SaveChangesAsync(ctx); await botInstance.SendChatMessageAsync( $"{user.FormatUsername()} hit and drew {card}[br]" + $"[B]Your hand:[/B] {BlackjackHelper.FormatHand(gameState.PlayerHand)} = {playerValue}[br]" + $"Use [B]!bj hit[/B] or [B]!bj stand[/B] to continue", true, autoDeleteAfter: cleanupDelay); } } private async Task HandleStand(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler, WagerDbModel wager, BlackjackGameMetaModel gameState, TimeSpan cleanupDelay, CancellationToken ctx) { // Dealer plays var dealerValue = BlackjackHelper.CalculateHandValue(gameState.DealerHand); while (dealerValue < 17) { if (gameState.Deck.Count == 0) { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, game error: dealer ran out of cards. Game forfeited.", true, autoDeleteAfter: cleanupDelay); await ForfeitGame(botInstance, user, gambler, wager, cleanupDelay, ctx); return; } var card = gameState.Deck[0]; gameState.Deck.RemoveAt(0); gameState.DealerHand.Add(card); dealerValue = BlackjackHelper.CalculateHandValue(gameState.DealerHand); } await ResolveGame(botInstance, user, gambler, wager, gameState, false, cleanupDelay, ctx); } private async Task HandleDouble(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler, WagerDbModel wager, BlackjackGameMetaModel gameState, Random random, TimeSpan cleanupDelay, CancellationToken ctx) { // Check if player can double (only on first action with 2 cards) if (gameState.PlayerHand.Count != 2) { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, you can only double down on your first action.", true, autoDeleteAfter: cleanupDelay); return; } // Check if player has enough balance for double if (gambler.Balance < wager.WagerAmount) { await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, you don't have enough balance to double down.", true, autoDeleteAfter: cleanupDelay); return; } // Double the wager await using var db = new ApplicationDbContext(); db.Attach(wager); db.Attach(gambler); var additionalWager = wager.WagerAmount; gambler.Balance -= additionalWager; wager.WagerAmount *= 2; wager.WagerEffect -= additionalWager; // Subtract the additional wager gameState.HasDoubledDown = true; await db.SaveChangesAsync(ctx); await botInstance.SendChatMessageAsync( $"{user.FormatUsername()} doubled down! Wager is now {await wager.WagerAmount.FormatKasinoCurrencyAsync()}", true, autoDeleteAfter: cleanupDelay); // Draw one card and auto-stand await HandleHit(botInstance, user, gambler, wager, gameState, random, cleanupDelay, ctx); } private async Task ResolveGame(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler, WagerDbModel wager, BlackjackGameMetaModel gameState, bool immediateResolution, TimeSpan cleanupDelay, CancellationToken ctx) { var playerValue = BlackjackHelper.CalculateHandValue(gameState.PlayerHand); var dealerValue = BlackjackHelper.CalculateHandValue(gameState.DealerHand); var playerBlackjack = BlackjackHelper.IsBlackjack(gameState.PlayerHand); var dealerBlackjack = BlackjackHelper.IsBlackjack(gameState.DealerHand); decimal finalEffect; decimal multiplier; string result; var colors = await SettingsProvider.GetMultipleValuesAsync([ BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor ]); // Determine outcome if (playerBlackjack && dealerBlackjack) { finalEffect = 0; multiplier = 1m; result = $"[B][COLOR=orange]PUSH![/COLOR][/B] Both have blackjack"; } else if (playerBlackjack) { finalEffect = wager.WagerAmount * 1.5m; multiplier = 2.5m; result = $"[B][COLOR={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]BLACKJACK![/COLOR][/B]"; } else if (dealerBlackjack) { finalEffect = -wager.WagerAmount; multiplier = 0m; result = $"[B][COLOR={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]DEALER BLACKJACK![/COLOR][/B]"; } else if (playerValue > 21) { finalEffect = -wager.WagerAmount; multiplier = 0m; result = $"[B][COLOR={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]BUST![/COLOR][/B]"; } else if (dealerValue > 21) { finalEffect = wager.WagerAmount; multiplier = 2m; result = $"[B][COLOR={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]DEALER BUST - YOU WIN![/COLOR][/B]"; } else if (playerValue > dealerValue) { finalEffect = wager.WagerAmount; multiplier = 2m; result = $"[B][COLOR={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]YOU WIN![/COLOR][/B]"; } else if (playerValue < dealerValue) { finalEffect = -wager.WagerAmount; multiplier = 0m; result = $"[B][COLOR={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]DEALER WINS![/COLOR][/B]"; } else { finalEffect = 0; multiplier = 1m; result = $"[B][COLOR=orange]PUSH![/COLOR][/B]"; } // Update wager to complete await using var db = new ApplicationDbContext(); db.Attach(wager); db.Attach(gambler); wager.IsComplete = true; wager.WagerEffect = finalEffect; wager.Multiplier = multiplier; var balanceAdjustment = finalEffect + wager.WagerAmount; gambler.Balance += balanceAdjustment; // Create transaction for the outcome await db.Transactions.AddAsync(new TransactionDbModel { Gambler = gambler, EventSource = TransactionSourceEventType.Gambling, Time = DateTimeOffset.UtcNow, Effect = balanceAdjustment, Comment = $"Blackjack outcome from wager {wager.Id}", NewBalance = gambler.Balance, TimeUnixEpochSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }, ctx); await db.SaveChangesAsync(ctx); // Display result var message = $"🃏 {user.FormatUsername()}'s blackjack game:[br]" + $"[B]Your hand:[/B] {BlackjackHelper.FormatHand(gameState.PlayerHand)} = {playerValue}[br]" + $"[B]Dealer:[/B] {BlackjackHelper.FormatHand(gameState.DealerHand)} = {dealerValue}[br]" + $"{result}[br]"; if (finalEffect > 0) { message += $"You won {await finalEffect.FormatKasinoCurrencyAsync()}! "; } else if (finalEffect < 0) { message += $"You lost {await Math.Abs(finalEffect).FormatKasinoCurrencyAsync()}! "; } message += $"Balance: {await gambler.Balance.FormatKasinoCurrencyAsync()}"; await botInstance.SendChatMessageAsync(message, true, autoDeleteAfter: cleanupDelay); } private async Task ForfeitGame(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler, WagerDbModel wager, TimeSpan cleanupDelay, CancellationToken ctx) { await using var db = new ApplicationDbContext(); db.Attach(wager); wager.IsComplete = true; wager.WagerEffect = -wager.WagerAmount; wager.Multiplier = 0m; await db.SaveChangesAsync(ctx); await botInstance.SendChatMessageAsync( $"{user.FormatUsername()}, your blackjack game timed out and you forfeited {await wager.WagerAmount.FormatKasinoCurrencyAsync()}", true, autoDeleteAfter: cleanupDelay); } }