diff --git a/KfChatDotNetBot/Commands/Kasino/LambchopCommand.cs b/KfChatDotNetBot/Commands/Kasino/LambchopCommand.cs index 637f882..f61d21d 100644 --- a/KfChatDotNetBot/Commands/Kasino/LambchopCommand.cs +++ b/KfChatDotNetBot/Commands/Kasino/LambchopCommand.cs @@ -207,7 +207,7 @@ public class LambchopCommand : ICommand continue; } } - if (Random.Shared.NextDouble() <= 0.15) + if (Money.GetRandomDouble(gambler) <= 0.15) { //fakeouts // forrest or desert diff --git a/KfChatDotNetBot/Commands/Kasino/WheelCommand.cs b/KfChatDotNetBot/Commands/Kasino/WheelCommand.cs new file mode 100644 index 0000000..4800c1d --- /dev/null +++ b/KfChatDotNetBot/Commands/Kasino/WheelCommand.cs @@ -0,0 +1,246 @@ +using System.Text.RegularExpressions; +using System.Globalization; +using KfChatDotNetBot.Extensions; +using KfChatDotNetBot.Models; +using KfChatDotNetBot.Models.DbModels; +using KfChatDotNetBot.Services; +using KfChatDotNetBot.Settings; +using KfChatDotNetWsClient.Models.Events; + +namespace KfChatDotNetBot.Commands.Kasino; + +[KasinoCommand] +[WagerCommand] +public class WheelCommand : ICommand +{ + public List Patterns => + [ + new Regex(@"wheel (?\d+)$", RegexOptions.IgnoreCase), + new Regex(@"wheel (?\d+\.\d+)$", RegexOptions.IgnoreCase), + new Regex(@"wheel (?\d+) (?[A-Za-z]+)$", RegexOptions.IgnoreCase), + new Regex(@"wheel (?\d+\.\d+) (?[A-Za-z]+)$", RegexOptions.IgnoreCase) + ]; + + public string? HelpText => + "Its wheel but oval shaped and shit"; + public UserRight RequiredRight => UserRight.Loser; + public TimeSpan Timeout => TimeSpan.FromSeconds(12); + public RateLimitOptionsModel? RateLimitOptions => new() + { + MaxInvocations = 3, + Window = TimeSpan.FromSeconds(15) + }; + //private static double _houseEdge = 0.015; // house edge hack? + + // game assets + private const string LOW_DIFFICULTY_WHEEL = "🟢⚪⚪⚪⚫⚪⚪⚪⚪⚫🟢⚪⚪⚪⚫⚪⚪⚪⚪⚫"; + private const string MEDIUM_DIFFICULTY_WHEEL = "🟢⚫🟡⚫🟡⚫🟡⚫🟢⚫🟣⚫⚪⚫🟡⚫🟡⚫🟡⚫"; + private const string HIGH_DIFFICULTY_WHEEL = "⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫🔴"; + private const string MIDDLE_WHEEL_FILL = "....................⮝...................."; + // game settings + private const int MIN_WHEELSPIN_DELAY = 100; + private const int MAX_WHEELSPIN_DELAY = 600; + private static readonly Dictionary LOW_DIFF_MULTIS = new() + { + { "⚫", 0.00m }, + { "⚪", 1.20m }, + { "🟢", 1.50m } + }; + private static readonly Dictionary MEDIUM_DIFF_MULTIS = new() + { + { "⚫", 0.00m }, + { "🟢", 1.50m }, + { "⚪", 1.80m }, + { "🟡", 2.00m }, + { "🟣", 3.00m } + }; + private static readonly Dictionary HIGH_DIFF_MULTIS = new() + { + { "⚫", 0.00m }, + { "🔴", 19.80m } + }; + + + public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, + CancellationToken ctx) + { + var cleanupDelay = TimeSpan.FromMilliseconds((await SettingsProvider.GetValueAsync(BuiltIn.Keys.KasinoGuessWhatNumberCleanupDelay)).ToType()); + if (!arguments.TryGetValue("amount", out var amount)) + { + await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, not enough arguments. !wheel ", true, autoDeleteAfter: cleanupDelay); + 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}"); + var difficulty = arguments["difficulty"].Success ? Convert.ToString(arguments["difficulty"].Value) : new[] {"low", "medium", "high"}[Money.GetRandomNumber(gambler, 0,3)]; + if (difficulty.ToLower() is not ("l" or "low" or "m" or "medium" or "h" or "high")) + { + await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, unrecognized difficulty selection, please choose between: low, medium, high", true, autoDeleteAfter: cleanupDelay); + return; + } + + var wager = Convert.ToDecimal(amount.Value); + 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; + } + var colors = + await SettingsProvider.GetMultipleValuesAsync([ + BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor + ]); + var wheel = difficulty.ToLower() switch + { + ("l" or "low") => new Wheel(gambler, LOW_DIFFICULTY_WHEEL, MIDDLE_WHEEL_FILL, 0), + ("m" or "medium") => new Wheel(gambler, MEDIUM_DIFFICULTY_WHEEL, MIDDLE_WHEEL_FILL, 1), + ("h" or "high") => new Wheel(gambler, HIGH_DIFFICULTY_WHEEL, MIDDLE_WHEEL_FILL, 2), + _ => null + }; + if (wheel == null) + throw new InvalidOperationException($"Something went horribly wrong, couldn't initialize wheel based on difficulty selection"); + // choose target to land on after wheelspin + var target = wheel.GetWheelElements()[Money.GetRandomNumber(gambler,0, wheel.GetWheelElements().Count)]; + var stepsToTarget = wheel.ComputeGameStepsToTarget(target); + var wheelDisplayMessage = await botInstance.SendChatMessageAsync(wheel.ConvertWheelToOvalString(), + true, autoDeleteAfter: cleanupDelay); + while (wheelDisplayMessage.Status != SentMessageTrackerStatus.ResponseReceived) + { + await Task.Delay(100, ctx); // wait until first message is fully sent + } + // main loop + for (int i = 0; i < stepsToTarget; i++) + { + double t = (double)i / (stepsToTarget - 1); + double easeOut = 1 - Math.Pow(1 - t, 3); // cubic ease-out curve for 'realistic' wheelspin animation + + int delay = (int)(MIN_WHEELSPIN_DELAY + easeOut * (MAX_WHEELSPIN_DELAY - MAX_WHEELSPIN_DELAY)); + await Task.Delay(delay, ctx); + wheel.RotateWheelOnce(); + await botInstance.KfClient.EditMessageAsync(wheelDisplayMessage.ChatMessageId!.Value, + wheel.ConvertWheelToOvalString()); + } + + // payout logic + var multi = -1.0m; + if (wheel.GetDifficulty() == 0) multi = LOW_DIFF_MULTIS[target]; + if (wheel.GetDifficulty() == 1) multi = MEDIUM_DIFF_MULTIS[target]; + if (wheel.GetDifficulty() == 2) multi = HIGH_DIFF_MULTIS[target]; + if (multi == -1.0m) + throw new InvalidOperationException($"Could not derrive multi from target: {target} on wheel diff {wheel.GetDifficulty()}"); + var win = multi == 0.00m; + + string wheelResultMessage; + decimal newBalance; + if (win) + { + var wheelPayout = Math.Round(wager * multi - wager, 2); + newBalance = await Money.NewWagerAsync(gambler.Id, wager, wheelPayout, WagerGame.Wheel, ct: ctx); + wheelResultMessage = $"{user.FormatUsername()}, you spun a {multi}x and [B][COLOR={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]WON[/COLOR][/B]" + + $" your balance is {await newBalance.FormatKasinoCurrencyAsync()}"; + } + else + { + newBalance = await Money.NewWagerAsync(gambler.Id, wager, -wager, WagerGame.Wheel, ct: ctx); + wheelResultMessage = $"{user.FormatUsername()}, you spun a {multi}x and [B][COLOR={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]LOST[/COLOR][/B]" + + $", better luck next time. Your balance is {await newBalance.FormatKasinoCurrencyAsync()}"; + + } + await botInstance.SendChatMessageAsync(wheelResultMessage, true, autoDeleteAfter: cleanupDelay); + } +} + +public class Wheel +{ + private readonly GamblerDbModel _gambler; + private List _wheelElements; + private readonly string _middleFill; + private readonly int _difficulty; // 0 = low, 1 = medium, 2 = high + + public Wheel(GamblerDbModel gambler, string wheelString, string middleFill, int difficulty) + { + _gambler = gambler; + _wheelElements = ExtractTextElements(wheelString); + if (_wheelElements.Count != 20) + throw new ArgumentException("Wheel must be exactly 20 elements."); + _middleFill = middleFill; + _difficulty = difficulty; + + RandomizeInitialState(); // start wheel in random state + } + + public List GetWheelElements() => _wheelElements; + public int GetDifficulty() => _difficulty; + + // Extract grapheme clusters, safe for emojis, stolen from AI + private static List ExtractTextElements(string rawWheel) + { + List wheelElements = new(); + TextElementEnumerator e = StringInfo.GetTextElementEnumerator(rawWheel); + while (e.MoveNext()) + wheelElements.Add((string)e.Current); + return wheelElements; + } + + private void RandomizeInitialState() + { + int shift = Money.GetRandomNumber(_gambler, 0, 20); + RotateWheel(shift); + } + + private void RotateWheel(int steps) + { + steps %= _wheelElements.Count; + if (steps <= 0) return; + int cut = _wheelElements.Count - steps; + List rotated = new(); + // Last N + first 20-N + rotated.AddRange(_wheelElements.GetRange(cut, steps)); + rotated.AddRange(_wheelElements.GetRange(0, cut)); + _wheelElements = rotated; + } + + public void RotateWheelOnce() => RotateWheel(1); + + public int ComputeGameStepsToTarget(string target) + { + // start by first doing 1-3 full rotations of the wheel + int fullRotations = Money.GetRandomNumber(_gambler, 1, 4); + int steps = fullRotations * 20; + // find how many more steps until wheel index 4 (top middle) == target + int extra = StepsUntilIndex4Match(target); + return steps + extra; + } + + private int StepsUntilIndex4Match(string target) + { + List temp = new List(_wheelElements); + for (int step = 0; step < 20; step++) + { + if (temp[4] == target) + return step; + // sim rotation + int cut = temp.Count - 1; + string last = temp[cut]; + temp.RemoveAt(cut); + temp.Insert(0, last); + } + return 0; // should always find in 20 steps; + } + + public string ConvertWheelToOvalString() + { + // top row indices 0..8 + string top = String.Concat(_wheelElements.GetRange(0, 9)); + // middle row, left index 19, right index 9 + string middle = _wheelElements[19] + _middleFill + _wheelElements[9]; + // bottom row indices 10..18 but reversed so 18..10 + var reversedBottom = new List(9); + for (int i = 19; i >= 10; i--) + reversedBottom.Add(_wheelElements[i]); + string bottom = string.Concat(reversedBottom); + return $"{top}\n{middle}\n{bottom}"; + } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs b/KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs index ae26c81..7850617 100644 --- a/KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs +++ b/KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs @@ -290,6 +290,7 @@ public enum WagerGame Event, [Description("Guess what number I'm thinking of")] GuessWhatNumber, + Wheel } public enum GamblerState