From 65b7b19b8ad621e7934c70daefe0a5d574fe1894 Mon Sep 17 00:00:00 2001 From: barelyprofessional <150058423+barelyprofessional@users.noreply.github.com> Date: Wed, 28 Jan 2026 00:40:01 -0600 Subject: [PATCH] Experimental convoluted rain refactor to use Redis instead of semaphores --- KfChatDotNetBot/ChatBot.cs | 25 ++ .../Commands/Kasino/RainCommand.cs | 262 +++++------------- KfChatDotNetBot/Services/BotServices.cs | 10 +- KfChatDotNetBot/Services/KasinoRain.cs | 145 ++++++++++ KfChatDotNetBot/Settings/BuiltIn.cs | 2 + 5 files changed, 245 insertions(+), 199 deletions(-) create mode 100644 KfChatDotNetBot/Services/KasinoRain.cs diff --git a/KfChatDotNetBot/ChatBot.cs b/KfChatDotNetBot/ChatBot.cs index 80b173f..f79c553 100644 --- a/KfChatDotNetBot/ChatBot.cs +++ b/KfChatDotNetBot/ChatBot.cs @@ -529,6 +529,31 @@ public class ChatBot return message; } + /// + /// Wait for a chat message to be successfully delivered or not + /// + /// Reference to the message you're waiting for + /// How long to wait + /// Cancellation token + /// True if the message was echoed, false otherwise + public async Task WaitForChatMessageAsync(SentMessageTrackerModel message, TimeSpan? patience = null, CancellationToken ct = default) + { + if (patience == null) + { + patience = TimeSpan.FromSeconds(60); + } + + var patienceEnds = DateTimeOffset.UtcNow.Add(patience.Value); + while (message.ChatMessageId == null) + { + if (DateTimeOffset.UtcNow > patienceEnds) return false; + if (message.Status is SentMessageTrackerStatus.Lost or SentMessageTrackerStatus.NotSending) return false; + await Task.Delay(100, ct); + } + + return true; + } + public class SentMessageNotFoundException : Exception; private void OnUsersJoined(object sender, List users, UsersJsonModel jsonPayload) diff --git a/KfChatDotNetBot/Commands/Kasino/RainCommand.cs b/KfChatDotNetBot/Commands/Kasino/RainCommand.cs index 13449fc..e9b885d 100644 --- a/KfChatDotNetBot/Commands/Kasino/RainCommand.cs +++ b/KfChatDotNetBot/Commands/Kasino/RainCommand.cs @@ -1,166 +1,15 @@ -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; namespace KfChatDotNetBot.Commands.Kasino; +[KasinoCommand] +[WagerCommand] public class RainCommand : ICommand { - public static class RainManager - { - public static Dictionary rainLobbies { get; } = new(); //dictionary of lobbies with the creators id and the lobby - public static readonly SemaphoreSlim _lock = new(1, 1); - public static ChatBot botInstance; - - - public static async Task AddParticipant(GamblerDbModel gambler, UserDbModel user) - { - await _lock.WaitAsync(); - bool openLobby = false; - try - { - foreach (var lobby in rainLobbies.Values) - { - if (lobby.open) - { - lobby.AddParticipant(gambler); - openLobby = true; - } - - } - - } - finally - { - _lock.Release(); - } - return openLobby; - } - - public static async Task AddRain(int gamblerId, Rain rain) - { - await _lock.WaitAsync(); - try - { - if (!rainLobbies.TryGetValue(gamblerId, out var lobby)) - { - rainLobbies.Add(gamblerId, lobby); - } - else //can't start multiple rains at the same time - { - await botInstance.SendChatMessageAsync( - $"{lobby.creator.FormatUsername()}, you can't start multiple rains at the same time."); - return false; - } - } - finally - { - _lock.Release(); - } - - return true; - } - - public static async Task PayoutRain(Rain rain) - { - if (rain.participants.Count == 0) - { - await botInstance.SendChatMessageAsync($"{rain.creator.FormatUsername()} made it rain on nobody."); - await Money.ModifyBalanceAsync(rain.creatorGambler.Id, -rain.rainAmount, TransactionSourceEventType.Rain); - return; - } - else - { - decimal payout = rain.rainAmount / rain.participants.Count; - string rainParticipants = ""; - int counter = 0; - foreach (var participant in rain.participants) - { - rainParticipants += (rain.participants.Count > 1 && counter == rain.participants.Count - 1) ? $"and {participant.User.FormatUsername()}" : $"{participant.User.FormatUsername()}, "; - await Money.ModifyBalanceAsync(participant.Id, payout, TransactionSourceEventType.Rain); - counter++; - } - if (rain.participants.Count == 1) rainParticipants = rain.participants[0].User.FormatUsername(); - await botInstance.SendChatMessageAsync( - $"{rain.creator.FormatUsername()} made it rain {payout.FormatKasinoCurrencyAsync()} on {rainParticipants}", - true, autoDeleteAfter: TimeSpan.FromSeconds(30)); - await Money.ModifyBalanceAsync(rain.creatorGambler.Id, -rain.rainAmount, TransactionSourceEventType.Rain); - - } - } - - public static async Task RemoveRain(Rain rain) - { - await _lock.WaitAsync(); - try - { - rainLobbies.Remove(rain.creatorGambler.Id); - } - finally - { - _lock.Release(); - } - } - public class Rain - { - public List participants = new List(); - public UserDbModel creator; - public GamblerDbModel creatorGambler; - public DateTime startTime { get; } = DateTime.UtcNow; - public bool open = true; - public decimal rainAmount; - - public Rain(UserDbModel user, GamblerDbModel gambler, decimal wager) - { - creator = user; - creatorGambler = gambler; - rainAmount = wager; - _ = RunRain(); - } - - public void AddParticipant(GamblerDbModel gambler) - { - participants.Add(gambler); - } - - public async Task RunRain() - { - var chatTimer = TimeSpan.FromSeconds(5); - int rainTimer = 60; - var msgId = await botInstance.SendChatMessageAsync( - $"{creator.FormatUsername()} is making it rain with {rainAmount.FormatKasinoCurrencyAsync()}! You have {rainTimer} seconds left to join!", true, - autoDeleteAfter: TimeSpan.FromSeconds(rainTimer)); - int num = 0; - while (msgId.ChatMessageId == null) - { - num++; - if (msgId.Status is SentMessageTrackerStatus.NotSending or SentMessageTrackerStatus.Lost) return; - if (num > 100) return; - await Task.Delay(100); - } - for (int i = 0; i < rainTimer / Convert.ToInt32(chatTimer); i++) - { - await Task.Delay(chatTimer); - await botInstance.KfClient.EditMessageAsync(msgId.ChatMessageId!.Value, - $"{creator.FormatUsername()} is making it rain with {rainAmount.FormatKasinoCurrencyAsync()}! You have {rainTimer} seconds left to join!"); - } - open = false; - await PayoutRain(this); - await RemoveRain(this); - } - - - } - - } - - - public List Patterns => [ new Regex(@"^rain (?\d+)$", RegexOptions.IgnoreCase), new Regex(@"^rain (?\d+\.\d+)$", RegexOptions.IgnoreCase), @@ -169,17 +18,21 @@ public class RainCommand : ICommand public string? HelpText => "!rain to start a rain, !rain to join all active rains"; public UserRight RequiredRight => UserRight.Loser; - public TimeSpan Timeout => TimeSpan.FromSeconds(30); + public TimeSpan Timeout => TimeSpan.FromSeconds(60); public RateLimitOptionsModel? RateLimitOptions => null; public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx) { - if (!RainManager.botInstance.Equals(botInstance)) - { - RainManager.botInstance = botInstance; - } var cleanupDelay = TimeSpan.FromSeconds(30); + if (botInstance.BotServices.KasinoRain == null || !botInstance.BotServices.KasinoRain.IsInitialized()) + { + await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, rain is not available at this time", true, + autoDeleteAfter: cleanupDelay); + return; + } + + var rain = await botInstance.BotServices.KasinoRain.GetRainState(); var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx); if (gambler == null) { @@ -187,62 +40,75 @@ public class RainCommand : ICommand } if (!arguments.TryGetValue("amount", out var amount)) //if you're trying to join a rain { - if (RainManager.rainLobbies.Count == 0) //if there are no lobbies + if (rain == null) //if there are no lobbies { - botInstance.SendChatMessageAsync( - $"{user.FormatUsername()}, there are no rain lobbies currently running. !rain to start a new rain lobby", + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, there's no rain currently running. !rain to start a new rain", true, autoDeleteAfter: cleanupDelay); return; } - else + + if (rain.Participants.Contains(user.Id)) { - bool success = await RainManager.AddParticipant(gambler, user); - if (!success) - { - await botInstance.SendChatMessageAsync( - $"{user.FormatUsername()}, there are no rain lobbies currently running. !rain to start a new rain lobby", - true, autoDeleteAfter: cleanupDelay); - } - else - { - - string rainCreators = ""; - await RainManager._lock.WaitAsync(); - int lobbyCount = 0; - try - { - foreach (var lobby in RainManager.rainLobbies.Values) - { - rainCreators += (RainManager.rainLobbies.Count > 1 && lobbyCount == RainManager.rainLobbies.Count - 1) ? $"and {lobby.creator.FormatUsername()}": $"{lobby.creator.FormatUsername()}, "; - lobbyCount++; - } - - } - finally - { - RainManager._lock.Release(); - } - - string areIs = RainManager.rainLobbies.Count > 1 ? "are" : "is"; - await botInstance.SendChatMessageAsync( - $"{rainCreators} {areIs} making it rain on {user.FormatUsername()}!",true, autoDeleteAfter: cleanupDelay); - - } + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, you're already participating in this rain!", true, + autoDeleteAfter: cleanupDelay); + return; } + + await botInstance.BotServices.KasinoRain.AddParticipant(user.Id); + var pluralSuffix = string.Empty; + if (rain.Participants.Count > 0) pluralSuffix = "s"; + await botInstance.SendChatMessageAsync( + $"LFG {user.FormatUsername()} is now a participant! There's now {rain.Participants.Count} participant{pluralSuffix}", + true, autoDeleteAfter: cleanupDelay); + return; } //if you're trying to start the rain decimal decAmount = Convert.ToDecimal(amount.Value); if (decAmount <= 0) { - await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you can't make it rain with nothing.", true, autoDeleteAfter: cleanupDelay); + await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you can't make it rain with nothing.", + true, autoDeleteAfter: cleanupDelay); return; } if (gambler.Balance < decAmount) { - await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, your balance ${gambler.Balance} KKK is not enough to make it rain for ${decAmount} KKK.", true, autoDeleteAfter: cleanupDelay); + await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()}, your balance {await gambler.Balance.FormatKasinoCurrencyAsync()} is not enough to make it rain for {await decAmount.FormatKasinoCurrencyAsync()}.", + true, autoDeleteAfter: cleanupDelay); return; } - await RainManager.AddRain(gambler.Id,new RainManager.Rain(user, gambler, decAmount)); + rain = new KasinoRainModel + { + Participants = [], + Creator = user.Id, + Started = DateTimeOffset.UtcNow, + RainAmount = decAmount, + PayoutWhen = DateTimeOffset.MaxValue + }; + var timer = 60; + var msg = await botInstance.SendChatMessageAsync( + $"{user.FormatUsername()} is making it rain with {await decAmount.FormatKasinoCurrencyAsync()}! Type !rain in the next {timer} seconds to join.", + true); + var result = await botInstance.WaitForChatMessageAsync(msg, ct: ctx); + if (!result) + { + throw new InvalidOperationException("Failed to send chat message for the rain. Not going to proceed with it"); + } + + // Wait to set a real payout deadline only when chyat echoes the message out of fairness + // (and also so the timer doesn't overlap with the payout deadline) + rain.PayoutWhen = DateTimeOffset.UtcNow.AddSeconds(60); + await botInstance.BotServices.KasinoRain.SaveRainState(rain); + while (timer > 0) + { + timer--; + await botInstance.KfClient.EditMessageAsync(msg.ChatMessageId!.Value, + $"{user.FormatUsername()} is making it rain with {await decAmount.FormatKasinoCurrencyAsync()}! Type !rain in the next {timer} seconds to join."); + } + await botInstance.KfClient.DeleteMessageAsync(msg.ChatMessageId!.Value); + // At this point the timer should take care of things but truthfully it's } -} +} \ No newline at end of file diff --git a/KfChatDotNetBot/Services/BotServices.cs b/KfChatDotNetBot/Services/BotServices.cs index 02e7f8c..e31eb54 100644 --- a/KfChatDotNetBot/Services/BotServices.cs +++ b/KfChatDotNetBot/Services/BotServices.cs @@ -40,6 +40,7 @@ public class BotServices private Owncast? _owncastStatusCheck; private ShuffleDotUs? _shuffleDotUs; private YouTubePubSub? _youTubePubSub; + public KasinoRain? KasinoRain; private Task? _websocketWatchdog; private Task? _howlggGetUserTimer; @@ -91,7 +92,8 @@ public class BotServices BuildPeerTubeLiveStatusCheck(), BuildOwncastLiveStatusCheck(), BuildShuffleDotUs(), - BuildYouTubePubSub() + BuildYouTubePubSub(), + BuildKasinoRain() ]; try { @@ -107,6 +109,12 @@ public class BotServices _websocketWatchdog = WebsocketWatchdog(); _howlggGetUserTimer = HowlggGetUserTimer(); } + + private async Task BuildKasinoRain() + { + _logger.Debug("Building the Kasino Rain thingy"); + KasinoRain = new KasinoRain(_chatBot, _cancellationToken); + } private async Task BuildShuffle() { diff --git a/KfChatDotNetBot/Services/KasinoRain.cs b/KfChatDotNetBot/Services/KasinoRain.cs new file mode 100644 index 0000000..51e7867 --- /dev/null +++ b/KfChatDotNetBot/Services/KasinoRain.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using KfChatDotNetBot.Extensions; +using KfChatDotNetBot.Models.DbModels; +using KfChatDotNetBot.Settings; +using Microsoft.EntityFrameworkCore; +using NLog; +using StackExchange.Redis; + +namespace KfChatDotNetBot.Services; + +public class KasinoRain : IDisposable +{ + private readonly Logger _logger = LogManager.GetCurrentClassLogger(); + private Task? _rainTimerTask; + private IDatabase? _redisDb; + private ChatBot _kfChatBot; + private CancellationToken _ct; + private CancellationTokenSource _rainCts = new(); + + public KasinoRain(ChatBot kfChatBot, CancellationToken ct = default) + { + _kfChatBot = kfChatBot; + _ct = ct; + var connectionString = SettingsProvider.GetValueAsync(BuiltIn.Keys.BotRedisConnectionString).Result; + if (string.IsNullOrEmpty(connectionString.Value)) + { + _logger.Error($"Can't initialize the Kasino Rain service as Redis isn't configured in {BuiltIn.Keys.BotRedisConnectionString}"); + return; + } + + var redis = ConnectionMultiplexer.Connect(connectionString.Value); + _redisDb = redis.GetDatabase(); + _rainTimerTask = Task.Run(RainTimerTask, ct); + } + + public bool IsInitialized() + { + return _redisDb != null; + } + + public async Task AddParticipant(int userId) + { + var data = await GetRainState(); + if (data == null) throw new InvalidOperationException("Failed to retrieve state or no rain is in progress"); + if (data.Participants.Contains(userId)) return; + data.Participants.Add(userId); + await SaveRainState(data); + } + + public async Task RemoveRainState() + { + if (_redisDb == null) throw new InvalidOperationException("Kasino Rain service isn't initialized"); + await _redisDb.KeyDeleteAsync("Rain.State"); + } + + public async Task GetRainState() + { + if (_redisDb == null) throw new InvalidOperationException("Kasino Rain service isn't initialized"); + var json = await _redisDb.StringGetAsync("Rain.State"); + if (string.IsNullOrEmpty(json)) return null; + var data = JsonSerializer.Deserialize(json.ToString()); + return data; + } + + public async Task SaveRainState(KasinoRainModel rain) + { + if (_redisDb == null) throw new InvalidOperationException("Kasino Rain service isn't initialized"); + var json = JsonSerializer.Serialize(rain); + await _redisDb.StringSetAsync("Rain.State", json, null, When.Always); + } + + private async Task RainTimerTask() + { + var interval = TimeSpan.FromSeconds(1); + using var timer = new PeriodicTimer(interval); + while (await timer.WaitForNextTickAsync(_ct)) + { + var rain = await GetRainState(); + if (rain == null) continue; + if (DateTimeOffset.UtcNow < rain.PayoutWhen) continue; + var creator = await Money.GetGamblerEntityAsync(rain.Creator, ct: _ct); + if (creator == null) + { + _logger.Error($"Somehow this rain was created by a non-existent (or banned) gambler with user ID {rain.Creator}? Wiping this fucked up state"); + await RemoveRainState(); + continue; + } + + if (rain.Participants.Count == 0) + { + await _kfChatBot.SendChatMessageAsync( + $"Nobody participated in {creator.User.FormatUsername()}'s rain!", + true, autoDeleteAfter: TimeSpan.FromSeconds(30)); + continue; + } + + if (rain.RainAmount > creator.Balance) + { + await _kfChatBot.SendChatMessageAsync( + $"{creator.User.FormatUsername()} lost it all before he could bless everyone! The giveaway is canceled! :lossmanjack:", + true, autoDeleteAfter: TimeSpan.FromSeconds(30)); + await RemoveRainState(); + continue; + } + + List participantNames = []; + var payout = rain.RainAmount / rain.Participants.Count; + decimal failedPayoutAmount = 0; + foreach (var participant in rain.Participants) + { + var gambler = await Money.GetGamblerEntityAsync(participant, ct: _ct); + if (gambler == null) + { + _logger.Error($"Somehow this participant ({participant}) does not have a gambler entity or has been banned"); + failedPayoutAmount += payout; + continue; + } + participantNames.Add(gambler.User.FormatUsername()); + await Money.ModifyBalanceAsync(gambler.Id, payout, TransactionSourceEventType.Rain, + "Payout from rain event", creator.Id, _ct); + } + + await Money.ModifyBalanceAsync(creator.Id, -rain.RainAmount + failedPayoutAmount, TransactionSourceEventType.Rain, + $"Rained on {participantNames.Count} people", ct: _ct); + await _kfChatBot.SendChatMessageAsync( + $"{creator.User.FormatUsername()} made it rain {await payout.FormatKasinoCurrencyAsync()} on {string.Join(' ', participantNames)}", + true, autoDeleteAfter: TimeSpan.FromSeconds(30)); + await RemoveRainState(); + } + } + public void Dispose() + { + _rainTimerTask?.Dispose(); + GC.SuppressFinalize(this); + } +} + +public class KasinoRainModel +{ + public required List Participants { get; set; } = []; + public required int Creator { get; set; } + public required DateTimeOffset Started { get; set; } + public required decimal RainAmount { get; set; } + public required DateTimeOffset PayoutWhen { get; set; } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs index f6f3c62..cff3e79 100644 --- a/KfChatDotNetBot/Settings/BuiltIn.cs +++ b/KfChatDotNetBot/Settings/BuiltIn.cs @@ -504,6 +504,8 @@ public static class BuiltIn public static string KasinoPlinkoEnabled = "Kasino.Plinko.Enabled"; [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)] + public static string BotRedisConnectionString = "Bot.RedisConnectionString"; } }