Experimental convoluted rain refactor to use Redis instead of semaphores

This commit is contained in:
barelyprofessional
2026-01-28 00:40:01 -06:00
parent 9a7762a933
commit 65b7b19b8a
5 changed files with 245 additions and 199 deletions

View File

@@ -529,6 +529,31 @@ public class ChatBot
return message;
}
/// <summary>
/// Wait for a chat message to be successfully delivered or not
/// </summary>
/// <param name="message">Reference to the message you're waiting for</param>
/// <param name="patience">How long to wait</param>
/// <param name="ct">Cancellation token</param>
/// <returns>True if the message was echoed, false otherwise</returns>
public async Task<bool> 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<UserModel> users, UsersJsonModel jsonPayload)

View File

@@ -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<int,Rain> 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<bool> 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<bool> 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<GamblerDbModel> participants = new List<GamblerDbModel>();
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<Regex> Patterns => [
new Regex(@"^rain (?<amount>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^rain (?<amount>\d+\.\d+)$", RegexOptions.IgnoreCase),
@@ -169,17 +18,21 @@ public class RainCommand : ICommand
public string? HelpText => "!rain <amount> 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 <amount> to start a new rain lobby",
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, there's no rain currently running. !rain <amount> 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 <amount> 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
}
}
}

View File

@@ -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()
{

View File

@@ -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<KasinoRainModel?> 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<KasinoRainModel>(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<string> 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<int> 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; }
}

View File

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