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";
}
}