diff --git a/src/Ellie.Bot.Modules.Gambling/Ellie.Bot.Modules.Gambling.csproj b/src/Ellie.Bot.Modules.Gambling/Ellie.Bot.Modules.Gambling.csproj
new file mode 100644
index 0000000..77f1755
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Ellie.Bot.Modules.Gambling.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net7.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRace.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRace.cs
new file mode 100644
index 0000000..4a35236
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRace.cs
@@ -0,0 +1,154 @@
+#nullable disable
+using Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions;
+using Ellie.Modules.Games.Common;
+
+namespace Ellie.Modules.Gambling.Common.AnimalRacing;
+
+public sealed class AnimalRace : IDisposable
+{
+ public enum Phase
+ {
+ WaitingForPlayers,
+ Running,
+ Ended
+ }
+
+ public event Func OnStarted = delegate { return Task.CompletedTask; };
+ public event Func OnStartingFailed = delegate { return Task.CompletedTask; };
+ public event Func OnStateUpdate = delegate { return Task.CompletedTask; };
+ public event Func OnEnded = delegate { return Task.CompletedTask; };
+
+ public Phase CurrentPhase { get; private set; } = Phase.WaitingForPlayers;
+
+ public IReadOnlyCollection Users
+ => _users.ToList();
+
+ public List FinishedUsers { get; } = new();
+ public int MaxUsers { get; }
+
+ private readonly SemaphoreSlim _locker = new(1, 1);
+ private readonly HashSet _users = new();
+ private readonly ICurrencyService _currency;
+ private readonly RaceOptions _options;
+ private readonly Queue _animalsQueue;
+
+ public AnimalRace(RaceOptions options, ICurrencyService currency, IEnumerable availableAnimals)
+ {
+ _currency = currency;
+ _options = options;
+ _animalsQueue = new(availableAnimals);
+ MaxUsers = _animalsQueue.Count;
+
+ if (_animalsQueue.Count == 0)
+ CurrentPhase = Phase.Ended;
+ }
+
+ public void Initialize() //lame name
+ => _ = Task.Run(async () =>
+ {
+ await Task.Delay(_options.StartTime * 1000);
+
+ await _locker.WaitAsync();
+ try
+ {
+ if (CurrentPhase != Phase.WaitingForPlayers)
+ return;
+
+ await Start();
+ }
+ finally { _locker.Release(); }
+ });
+
+ public async Task JoinRace(ulong userId, string userName, long bet = 0)
+ {
+ if (bet < 0)
+ throw new ArgumentOutOfRangeException(nameof(bet));
+
+ var user = new AnimalRacingUser(userName, userId, bet);
+
+ await _locker.WaitAsync();
+ try
+ {
+ if (_users.Count == MaxUsers)
+ throw new AnimalRaceFullException();
+
+ if (CurrentPhase != Phase.WaitingForPlayers)
+ throw new AlreadyStartedException();
+
+ if (!await _currency.RemoveAsync(userId, bet, new("animalrace", "bet")))
+ throw new NotEnoughFundsException();
+
+ if (_users.Contains(user))
+ throw new AlreadyJoinedException();
+
+ var animal = _animalsQueue.Dequeue();
+ user.Animal = animal;
+ _users.Add(user);
+
+ if (_animalsQueue.Count == 0) //start if no more spots left
+ await Start();
+
+ return user;
+ }
+ finally { _locker.Release(); }
+ }
+
+ private async Task Start()
+ {
+ CurrentPhase = Phase.Running;
+ if (_users.Count <= 1)
+ {
+ foreach (var user in _users)
+ {
+ if (user.Bet > 0)
+ await _currency.AddAsync(user.UserId, user.Bet, new("animalrace", "refund"));
+ }
+
+ _ = OnStartingFailed?.Invoke(this);
+ CurrentPhase = Phase.Ended;
+ return;
+ }
+
+ _ = OnStarted?.Invoke(this);
+ _ = Task.Run(async () =>
+ {
+ var rng = new EllieRandom();
+ while (!_users.All(x => x.Progress >= 60))
+ {
+ foreach (var user in _users)
+ {
+ user.Progress += rng.Next(1, 11);
+ if (user.Progress >= 60)
+ user.Progress = 60;
+ }
+
+ var finished = _users.Where(x => x.Progress >= 60 && !FinishedUsers.Contains(x)).Shuffle();
+
+ FinishedUsers.AddRange(finished);
+
+ _ = OnStateUpdate?.Invoke(this);
+ await Task.Delay(2500);
+ }
+
+ if (FinishedUsers[0].Bet > 0)
+ {
+ await _currency.AddAsync(FinishedUsers[0].UserId,
+ FinishedUsers[0].Bet * (_users.Count - 1),
+ new("animalrace", "win"));
+ }
+
+ _ = OnEnded?.Invoke(this);
+ });
+ }
+
+ public void Dispose()
+ {
+ CurrentPhase = Phase.Ended;
+ OnStarted = null;
+ OnEnded = null;
+ OnStartingFailed = null;
+ OnStateUpdate = null;
+ _locker.Dispose();
+ _users.Clear();
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRaceService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRaceService.cs
new file mode 100644
index 0000000..4a73781
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRaceService.cs
@@ -0,0 +1,9 @@
+#nullable disable
+using Ellie.Modules.Gambling.Common.AnimalRacing;
+
+namespace Ellie.Modules.Gambling.Services;
+
+public class AnimalRaceService : IEService
+{
+ public ConcurrentDictionary AnimalRaces { get; } = new();
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRacingCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRacingCommands.cs
new file mode 100644
index 0000000..3de1171
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRacingCommands.cs
@@ -0,0 +1,183 @@
+#nullable disable
+using Ellie.Common.TypeReaders;
+using Ellie.Modules.Gambling.Common;
+using Ellie.Modules.Gambling.Common.AnimalRacing;
+using Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions;
+using Ellie.Modules.Gambling.Services;
+using Ellie.Modules.Games.Services;
+
+namespace Ellie.Modules.Gambling;
+
+// wth is this, needs full rewrite
+public partial class Gambling
+{
+ [Group]
+ public partial class AnimalRacingCommands : GamblingSubmodule
+ {
+ private readonly ICurrencyService _cs;
+ private readonly DiscordSocketClient _client;
+ private readonly GamesConfigService _gamesConf;
+
+ private IUserMessage raceMessage;
+
+ public AnimalRacingCommands(
+ ICurrencyService cs,
+ DiscordSocketClient client,
+ GamblingConfigService gamblingConf,
+ GamesConfigService gamesConf)
+ : base(gamblingConf)
+ {
+ _cs = cs;
+ _client = client;
+ _gamesConf = gamesConf;
+ }
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ [EllieOptions]
+ public Task Race(params string[] args)
+ {
+ var (options, _) = OptionsParser.ParseFrom(new RaceOptions(), args);
+
+ var ar = new AnimalRace(options, _cs, _gamesConf.Data.RaceAnimals.Shuffle());
+ if (!_service.AnimalRaces.TryAdd(ctx.Guild.Id, ar))
+ return SendErrorAsync(GetText(strs.animal_race), GetText(strs.animal_race_already_started));
+
+ ar.Initialize();
+
+ var count = 0;
+
+ Task ClientMessageReceived(SocketMessage arg)
+ {
+ _ = Task.Run(() =>
+ {
+ try
+ {
+ if (arg.Channel.Id == ctx.Channel.Id)
+ {
+ if (ar.CurrentPhase == AnimalRace.Phase.Running && ++count % 9 == 0)
+ raceMessage = null;
+ }
+ }
+ catch { }
+ });
+ return Task.CompletedTask;
+ }
+
+ Task ArOnEnded(AnimalRace race)
+ {
+ _client.MessageReceived -= ClientMessageReceived;
+ _service.AnimalRaces.TryRemove(ctx.Guild.Id, out _);
+ var winner = race.FinishedUsers[0];
+ if (race.FinishedUsers[0].Bet > 0)
+ {
+ return SendConfirmAsync(GetText(strs.animal_race),
+ GetText(strs.animal_race_won_money(Format.Bold(winner.Username),
+ winner.Animal.Icon,
+ (race.FinishedUsers[0].Bet * (race.Users.Count - 1)) + CurrencySign)));
+ }
+
+ ar.Dispose();
+ return SendConfirmAsync(GetText(strs.animal_race),
+ GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon)));
+ }
+
+ ar.OnStartingFailed += Ar_OnStartingFailed;
+ ar.OnStateUpdate += Ar_OnStateUpdate;
+ ar.OnEnded += ArOnEnded;
+ ar.OnStarted += Ar_OnStarted;
+ _client.MessageReceived += ClientMessageReceived;
+
+ return SendConfirmAsync(GetText(strs.animal_race),
+ GetText(strs.animal_race_starting(options.StartTime)),
+ footer: GetText(strs.animal_race_join_instr(prefix)));
+ }
+
+ private Task Ar_OnStarted(AnimalRace race)
+ {
+ if (race.Users.Count == race.MaxUsers)
+ return SendConfirmAsync(GetText(strs.animal_race), GetText(strs.animal_race_full));
+ return SendConfirmAsync(GetText(strs.animal_race),
+ GetText(strs.animal_race_starting_with_x(race.Users.Count)));
+ }
+
+ private async Task Ar_OnStateUpdate(AnimalRace race)
+ {
+ var text = $@"|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|
+{string.Join("\n", race.Users.Select(p =>
+{
+ var index = race.FinishedUsers.IndexOf(p);
+ var extra = index == -1 ? "" : $"#{index + 1} {(index == 0 ? "🏆" : "")}";
+ return $"{(int)(p.Progress / 60f * 100),-2}%|{new string('‣', p.Progress) + p.Animal.Icon + extra}";
+}))}
+|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|";
+
+ var msg = raceMessage;
+
+ if (msg is null)
+ raceMessage = await SendConfirmAsync(text);
+ else
+ {
+ await msg.ModifyAsync(x => x.Embed = _eb.Create()
+ .WithTitle(GetText(strs.animal_race))
+ .WithDescription(text)
+ .WithOkColor()
+ .Build());
+ }
+ }
+
+ private Task Ar_OnStartingFailed(AnimalRace race)
+ {
+ _service.AnimalRaces.TryRemove(ctx.Guild.Id, out _);
+ race.Dispose();
+ return ReplyErrorLocalizedAsync(strs.animal_race_failed);
+ }
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ public async Task JoinRace([OverrideTypeReader(typeof(BalanceTypeReader))] long amount = default)
+ {
+ if (!await CheckBetOptional(amount))
+ return;
+
+ if (!_service.AnimalRaces.TryGetValue(ctx.Guild.Id, out var ar))
+ {
+ await ReplyErrorLocalizedAsync(strs.race_not_exist);
+ return;
+ }
+
+ try
+ {
+ var user = await ar.JoinRace(ctx.User.Id, ctx.User.ToString(), amount);
+ if (amount > 0)
+ {
+ await SendConfirmAsync(GetText(strs.animal_race_join_bet(ctx.User.Mention,
+ user.Animal.Icon,
+ amount + CurrencySign)));
+ }
+ else
+ await SendConfirmAsync(GetText(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon)));
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ //ignore if user inputed an invalid amount
+ }
+ catch (AlreadyJoinedException)
+ {
+ // just ignore this
+ }
+ catch (AlreadyStartedException)
+ {
+ //ignore
+ }
+ catch (AnimalRaceFullException)
+ {
+ await SendConfirmAsync(GetText(strs.animal_race), GetText(strs.animal_race_full));
+ }
+ catch (NotEnoughFundsException)
+ {
+ await SendErrorAsync(GetText(strs.not_enough(CurrencySign)));
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRacingUser.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRacingUser.cs
new file mode 100644
index 0000000..66bfd48
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRacingUser.cs
@@ -0,0 +1,26 @@
+#nullable disable
+using Ellie.Modules.Games.Common;
+
+namespace Ellie.Modules.Gambling.Common.AnimalRacing;
+
+public class AnimalRacingUser
+{
+ public long Bet { get; }
+ public string Username { get; }
+ public ulong UserId { get; }
+ public RaceAnimal Animal { get; set; }
+ public int Progress { get; set; }
+
+ public AnimalRacingUser(string username, ulong userId, long bet)
+ {
+ Bet = bet;
+ Username = username;
+ UserId = userId;
+ }
+
+ public override bool Equals(object obj)
+ => obj is AnimalRacingUser x ? x.UserId == UserId : false;
+
+ public override int GetHashCode()
+ => UserId.GetHashCode();
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs
new file mode 100644
index 0000000..9099331
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs
@@ -0,0 +1,19 @@
+#nullable disable
+namespace Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions;
+
+public class AlreadyJoinedException : Exception
+{
+ public AlreadyJoinedException()
+ {
+ }
+
+ public AlreadyJoinedException(string message)
+ : base(message)
+ {
+ }
+
+ public AlreadyJoinedException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs
new file mode 100644
index 0000000..70e08ba
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs
@@ -0,0 +1,19 @@
+#nullable disable
+namespace Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions;
+
+public class AlreadyStartedException : Exception
+{
+ public AlreadyStartedException()
+ {
+ }
+
+ public AlreadyStartedException(string message)
+ : base(message)
+ {
+ }
+
+ public AlreadyStartedException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs
new file mode 100644
index 0000000..8a32104
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs
@@ -0,0 +1,19 @@
+#nullable disable
+namespace Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions;
+
+public class AnimalRaceFullException : Exception
+{
+ public AnimalRaceFullException()
+ {
+ }
+
+ public AnimalRaceFullException(string message)
+ : base(message)
+ {
+ }
+
+ public AnimalRaceFullException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs
new file mode 100644
index 0000000..7f3d70d
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs
@@ -0,0 +1,19 @@
+#nullable disable
+namespace Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions;
+
+public class NotEnoughFundsException : Exception
+{
+ public NotEnoughFundsException()
+ {
+ }
+
+ public NotEnoughFundsException(string message)
+ : base(message)
+ {
+ }
+
+ public NotEnoughFundsException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/RaceOptions.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/RaceOptions.cs
new file mode 100644
index 0000000..21e8c3a
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/RaceOptions.cs
@@ -0,0 +1,16 @@
+#nullable disable
+using CommandLine;
+
+namespace Ellie.Modules.Gambling.Common.AnimalRacing;
+
+public class RaceOptions : IEllieCommandOptions
+{
+ [Option('s', "start-time", Default = 20, Required = false)]
+ public int StartTime { get; set; } = 20;
+
+ public void NormalizeOptions()
+ {
+ if (StartTime is < 10 or > 120)
+ StartTime = 20;
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Bank/BankCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Bank/BankCommands.cs
new file mode 100644
index 0000000..8c67afb
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Bank/BankCommands.cs
@@ -0,0 +1,118 @@
+using Ellie.Common.TypeReaders;
+using Ellie.Modules.Gambling.Bank;
+using Ellie.Modules.Gambling.Common;
+using Ellie.Modules.Gambling.Services;
+
+namespace Ellie.Modules.Gambling;
+
+public partial class Gambling
+{
+ [Name("Bank")]
+ [Group("bank")]
+ public partial class BankCommands : GamblingModule
+ {
+ private readonly IBankService _bank;
+ private readonly DiscordSocketClient _client;
+
+ public BankCommands(GamblingConfigService gcs,
+ IBankService bank,
+ DiscordSocketClient client) : base(gcs)
+ {
+ _bank = bank;
+ _client = client;
+ }
+
+ [Cmd]
+ public async Task BankDeposit([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
+ {
+ if (amount <= 0)
+ return;
+
+ if (await _bank.DepositAsync(ctx.User.Id, amount))
+ {
+ await ReplyConfirmLocalizedAsync(strs.bank_deposited(N(amount)));
+ }
+ else
+ {
+ await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
+ }
+ }
+
+ [Cmd]
+ public async Task BankWithdraw([OverrideTypeReader(typeof(BankBalanceTypeReader))] long amount)
+ {
+ if (amount <= 0)
+ return;
+
+ if (await _bank.WithdrawAsync(ctx.User.Id, amount))
+ {
+ await ReplyConfirmLocalizedAsync(strs.bank_withdrew(N(amount)));
+ }
+ else
+ {
+ await ReplyErrorLocalizedAsync(strs.bank_withdraw_insuff(CurrencySign));
+ }
+ }
+
+ [Cmd]
+ public async Task BankBalance()
+ {
+ var bal = await _bank.GetBalanceAsync(ctx.User.Id);
+
+ var eb = _eb.Create(ctx)
+ .WithOkColor()
+ .WithDescription(GetText(strs.bank_balance(N(bal))));
+
+ try
+ {
+ await ctx.User.EmbedAsync(eb);
+ await ctx.OkAsync();
+ }
+ catch
+ {
+ await ReplyErrorLocalizedAsync(strs.cant_dm);
+ }
+ }
+
+ private async Task BankTakeInternalAsync(long amount, ulong userId)
+ {
+ if (await _bank.TakeAsync(userId, amount))
+ {
+ await ctx.OkAsync();
+ return;
+ }
+
+ await ReplyErrorLocalizedAsync(strs.take_fail(N(amount),
+ _client.GetUser(userId)?.ToString()
+ ?? userId.ToString(),
+ CurrencySign));
+ }
+
+ private async Task BankAwardInternalAsync(long amount, ulong userId)
+ {
+ if (await _bank.AwardAsync(userId, amount))
+ {
+ await ctx.OkAsync();
+ return;
+ }
+
+ }
+
+ [Cmd]
+ [OwnerOnly]
+ [Priority(1)]
+ public async Task BankTake(long amount, [Leftover] IUser user)
+ => await BankTakeInternalAsync(amount, user.Id);
+
+ [Cmd]
+ [OwnerOnly]
+ [Priority(0)]
+ public async Task BankTake(long amount, ulong userId)
+ => await BankTakeInternalAsync(amount, userId);
+
+ [Cmd]
+ [OwnerOnly]
+ public async Task BankAward(long amount, [Leftover] IUser user)
+ => await BankAwardInternalAsync(amount, user.Id);
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Bank/BankService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Bank/BankService.cs
new file mode 100644
index 0000000..dba4b0b
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Bank/BankService.cs
@@ -0,0 +1,119 @@
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using Ellie.Db.Models;
+
+namespace Ellie.Modules.Gambling.Bank;
+
+public sealed class BankService : IBankService, IEService
+{
+ private readonly ICurrencyService _cur;
+ private readonly DbService _db;
+
+ public BankService(ICurrencyService cur, DbService db)
+ {
+ _cur = cur;
+ _db = db;
+ }
+
+ public async Task AwardAsync(ulong userId, long amount)
+ {
+ if (amount <= 0)
+ throw new ArgumentOutOfRangeException(nameof(amount));
+
+ await using var ctx = _db.GetDbContext();
+ await ctx.GetTable()
+ .InsertOrUpdateAsync(() => new()
+ {
+ UserId = userId,
+ Balance = amount
+ },
+ (old) => new()
+ {
+ Balance = old.Balance + amount
+ },
+ () => new()
+ {
+ UserId = userId
+ });
+
+ return true;
+ }
+
+ public async Task TakeAsync(ulong userId, long amount)
+ {
+ if (amount <= 0)
+ throw new ArgumentOutOfRangeException(nameof(amount));
+
+ await using var ctx = _db.GetDbContext();
+ var rows = await ctx.Set()
+ .ToLinqToDBTable()
+ .Where(x => x.UserId == userId && x.Balance >= amount)
+ .UpdateAsync((old) => new()
+ {
+ Balance = old.Balance - amount
+ });
+
+ return rows > 0;
+ }
+
+ public async Task DepositAsync(ulong userId, long amount)
+ {
+ if (amount <= 0)
+ throw new ArgumentOutOfRangeException(nameof(amount));
+
+ if (!await _cur.RemoveAsync(userId, amount, new("bank", "deposit")))
+ return false;
+
+ await using var ctx = _db.GetDbContext();
+ await ctx.Set()
+ .ToLinqToDBTable()
+ .InsertOrUpdateAsync(() => new()
+ {
+ UserId = userId,
+ Balance = amount
+ },
+ (old) => new()
+ {
+ Balance = old.Balance + amount
+ },
+ () => new()
+ {
+ UserId = userId
+ });
+
+ return true;
+ }
+
+ public async Task WithdrawAsync(ulong userId, long amount)
+ {
+ if (amount <= 0)
+ throw new ArgumentOutOfRangeException(nameof(amount));
+
+ await using var ctx = _db.GetDbContext();
+ var rows = await ctx.Set()
+ .ToLinqToDBTable()
+ .Where(x => x.UserId == userId && x.Balance >= amount)
+ .UpdateAsync((old) => new()
+ {
+ Balance = old.Balance - amount
+ });
+
+ if (rows > 0)
+ {
+ await _cur.AddAsync(userId, amount, new("bank", "withdraw"));
+ return true;
+ }
+
+ return false;
+ }
+
+ public async Task GetBalanceAsync(ulong userId)
+ {
+ await using var ctx = _db.GetDbContext();
+ return (await ctx.Set()
+ .ToLinqToDBTable()
+ .FirstOrDefaultAsync(x => x.UserId == userId))
+ ?.Balance
+ ?? 0;
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/BlackJackCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/BlackJackCommands.cs
new file mode 100644
index 0000000..246eba9
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/BlackJackCommands.cs
@@ -0,0 +1,183 @@
+#nullable disable
+using Ellie.Common.TypeReaders;
+using Ellie.Modules.Gambling.Common;
+using Ellie.Modules.Gambling.Common.Blackjack;
+using Ellie.Modules.Gambling.Services;
+
+namespace Ellie.Modules.Gambling;
+
+public partial class Gambling
+{
+ public partial class BlackJackCommands : GamblingSubmodule
+ {
+ public enum BjAction
+ {
+ Hit = int.MinValue,
+ Stand,
+ Double
+ }
+
+ private readonly ICurrencyService _cs;
+ private readonly DbService _db;
+ private IUserMessage msg;
+
+ public BlackJackCommands(ICurrencyService cs, DbService db, GamblingConfigService gamblingConf)
+ : base(gamblingConf)
+ {
+ _cs = cs;
+ _db = db;
+ }
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ public async Task BlackJack([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
+ {
+ if (!await CheckBetMandatory(amount))
+ return;
+
+ var newBj = new Blackjack(_cs);
+ Blackjack bj;
+ if (newBj == (bj = _service.Games.GetOrAdd(ctx.Channel.Id, newBj)))
+ {
+ if (!await bj.Join(ctx.User, amount))
+ {
+ _service.Games.TryRemove(ctx.Channel.Id, out _);
+ await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
+ return;
+ }
+
+ bj.StateUpdated += Bj_StateUpdated;
+ bj.GameEnded += Bj_GameEnded;
+ bj.Start();
+
+ await ReplyConfirmLocalizedAsync(strs.bj_created);
+ }
+ else
+ {
+ if (await bj.Join(ctx.User, amount))
+ await ReplyConfirmLocalizedAsync(strs.bj_joined);
+ else
+ {
+ Log.Information("{User} can't join a blackjack game as it's in {BlackjackState} state already",
+ ctx.User,
+ bj.State);
+ }
+ }
+
+ await ctx.Message.DeleteAsync();
+ }
+
+ private Task Bj_GameEnded(Blackjack arg)
+ {
+ _service.Games.TryRemove(ctx.Channel.Id, out _);
+ return Task.CompletedTask;
+ }
+
+ private async Task Bj_StateUpdated(Blackjack bj)
+ {
+ try
+ {
+ if (msg is not null)
+ _ = msg.DeleteAsync();
+
+ var c = bj.Dealer.Cards.Select(x => x.GetEmojiString())
+ .ToList();
+ var dealerIcon = "❔ ";
+ if (bj.State == Blackjack.GameState.Ended)
+ {
+ if (bj.Dealer.GetHandValue() == 21)
+ dealerIcon = "💰 ";
+ else if (bj.Dealer.GetHandValue() > 21)
+ dealerIcon = "💥 ";
+ else
+ dealerIcon = "🏁 ";
+ }
+
+ var cStr = string.Concat(c.Select(x => x[..^1] + " "));
+ cStr += "\n" + string.Concat(c.Select(x => x.Last() + " "));
+ var embed = _eb.Create()
+ .WithOkColor()
+ .WithTitle("BlackJack")
+ .AddField($"{dealerIcon} Dealer's Hand | Value: {bj.Dealer.GetHandValue()}", cStr);
+
+ if (bj.CurrentUser is not null)
+ embed.WithFooter($"Player to make a choice: {bj.CurrentUser.DiscordUser}");
+
+ foreach (var p in bj.Players)
+ {
+ c = p.Cards.Select(x => x.GetEmojiString()).ToList();
+ cStr = "-\t" + string.Concat(c.Select(x => x[..^1] + " "));
+ cStr += "\n-\t" + string.Concat(c.Select(x => x.Last() + " "));
+ var full = $"{p.DiscordUser.ToString().TrimTo(20)} | Bet: {N(p.Bet)} | Value: {p.GetHandValue()}";
+ if (bj.State == Blackjack.GameState.Ended)
+ {
+ if (p.State == User.UserState.Lost)
+ full = "❌ " + full;
+ else
+ full = "✅ " + full;
+ }
+ else if (p == bj.CurrentUser)
+ full = "▶ " + full;
+ else if (p.State == User.UserState.Stand)
+ full = "⏹ " + full;
+ else if (p.State == User.UserState.Bust)
+ full = "💥 " + full;
+ else if (p.State == User.UserState.Blackjack)
+ full = "💰 " + full;
+
+ embed.AddField(full, cStr);
+ }
+
+ msg = await ctx.Channel.EmbedAsync(embed);
+ }
+ catch
+ {
+ }
+ }
+
+ private string UserToString(User x)
+ {
+ var playerName = x.State == User.UserState.Bust
+ ? Format.Strikethrough(x.DiscordUser.ToString().TrimTo(30))
+ : x.DiscordUser.ToString();
+
+ // var hand = $"{string.Concat(x.Cards.Select(y => "〖" + y.GetEmojiString() + "〗"))}";
+
+
+ return $"{playerName} | Bet: {x.Bet}\n";
+ }
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ public Task Hit()
+ => InternalBlackJack(BjAction.Hit);
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ public Task Stand()
+ => InternalBlackJack(BjAction.Stand);
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ public Task Double()
+ => InternalBlackJack(BjAction.Double);
+
+ private async Task InternalBlackJack(BjAction a)
+ {
+ if (!_service.Games.TryGetValue(ctx.Channel.Id, out var bj))
+ return;
+
+ if (a == BjAction.Hit)
+ await bj.Hit(ctx.User);
+ else if (a == BjAction.Stand)
+ await bj.Stand(ctx.User);
+ else if (a == BjAction.Double)
+ {
+ if (!await bj.Double(ctx.User))
+ await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
+ }
+
+ await ctx.Message.DeleteAsync();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/BlackJackService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/BlackJackService.cs
new file mode 100644
index 0000000..216cc2f
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/BlackJackService.cs
@@ -0,0 +1,9 @@
+#nullable disable
+using Ellie.Modules.Gambling.Common.Blackjack;
+
+namespace Ellie.Modules.Gambling.Services;
+
+public class BlackJackService : IEService
+{
+ public ConcurrentDictionary Games { get; } = new();
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/Blackjack.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/Blackjack.cs
new file mode 100644
index 0000000..95f545a
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/Blackjack.cs
@@ -0,0 +1,329 @@
+#nullable disable
+using Ellie.Econ;
+
+namespace Ellie.Modules.Gambling.Common.Blackjack;
+
+public class Blackjack
+{
+ public enum GameState
+ {
+ Starting,
+ Playing,
+ Ended
+ }
+
+ public event Func StateUpdated;
+ public event Func GameEnded;
+
+ private Deck Deck { get; } = new QuadDeck();
+ public Dealer Dealer { get; set; }
+
+
+ public List Players { get; set; } = new();
+ public GameState State { get; set; } = GameState.Starting;
+ public User CurrentUser { get; private set; }
+
+ private TaskCompletionSource currentUserMove;
+ private readonly ICurrencyService _cs;
+
+ private readonly SemaphoreSlim _locker = new(1, 1);
+
+ public Blackjack(ICurrencyService cs)
+ {
+ _cs = cs;
+ Dealer = new();
+ }
+
+ public void Start()
+ => _ = GameLoop();
+
+ public async Task GameLoop()
+ {
+ try
+ {
+ //wait for players to join
+ await Task.Delay(20000);
+ await _locker.WaitAsync();
+ try
+ {
+ State = GameState.Playing;
+ }
+ finally
+ {
+ _locker.Release();
+ }
+
+ await PrintState();
+ //if no users joined the game, end it
+ if (!Players.Any())
+ {
+ State = GameState.Ended;
+ _ = GameEnded?.Invoke(this);
+ return;
+ }
+
+ //give 1 card to the dealer and 2 to each player
+ Dealer.Cards.Add(Deck.Draw());
+ foreach (var usr in Players)
+ {
+ usr.Cards.Add(Deck.Draw());
+ usr.Cards.Add(Deck.Draw());
+
+ if (usr.GetHandValue() == 21)
+ usr.State = User.UserState.Blackjack;
+ }
+
+ //go through all users and ask them what they want to do
+ foreach (var usr in Players.Where(x => !x.Done))
+ {
+ while (!usr.Done)
+ {
+ Log.Information("Waiting for {DiscordUser}'s move", usr.DiscordUser);
+ await PromptUserMove(usr);
+ }
+ }
+
+ await PrintState();
+ State = GameState.Ended;
+ await Task.Delay(2500);
+ Log.Information("Dealer moves");
+ await DealerMoves();
+ await PrintState();
+ _ = GameEnded?.Invoke(this);
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "REPORT THE MESSAGE BELOW IN #NadekoLog SERVER PLEASE");
+ State = GameState.Ended;
+ _ = GameEnded?.Invoke(this);
+ }
+ }
+
+ private async Task PromptUserMove(User usr)
+ {
+ using var cts = new CancellationTokenSource();
+ var pause = Task.Delay(20000, cts.Token); //10 seconds to decide
+ CurrentUser = usr;
+ currentUserMove = new();
+ await PrintState();
+ // either wait for the user to make an action and
+ // if he doesn't - stand
+ var finished = await Task.WhenAny(pause, currentUserMove.Task);
+ if (finished == pause)
+ await Stand(usr);
+ else
+ cts.Cancel();
+
+ CurrentUser = null;
+ currentUserMove = null;
+ }
+
+ public async Task Join(IUser user, long bet)
+ {
+ await _locker.WaitAsync();
+ try
+ {
+ if (State != GameState.Starting)
+ return false;
+
+ if (Players.Count >= 5)
+ return false;
+
+ if (!await _cs.RemoveAsync(user, bet, new("blackjack", "gamble")))
+ return false;
+
+ Players.Add(new(user, bet));
+ _ = PrintState();
+ return true;
+ }
+ finally
+ {
+ _locker.Release();
+ }
+ }
+
+ public async Task Stand(IUser u)
+ {
+ var cu = CurrentUser;
+
+ if (cu is not null && cu.DiscordUser == u)
+ return await Stand(cu);
+
+ return false;
+ }
+
+ public async Task Stand(User u)
+ {
+ await _locker.WaitAsync();
+ try
+ {
+ if (State != GameState.Playing)
+ return false;
+
+ if (CurrentUser != u)
+ return false;
+
+ u.State = User.UserState.Stand;
+ currentUserMove.TrySetResult(true);
+ return true;
+ }
+ finally
+ {
+ _locker.Release();
+ }
+ }
+
+ private async Task DealerMoves()
+ {
+ var hw = Dealer.GetHandValue();
+ while (hw < 17
+ || (hw == 17
+ && Dealer.Cards.Count(x => x.Number == 1) > (Dealer.GetRawHandValue() - 17) / 10)) // hit on soft 17
+ {
+ /* Dealer has
+ A 6
+ That's 17, soft
+ hw == 17 => true
+ number of aces = 1
+ 1 > 17-17 /10 => true
+
+ AA 5
+ That's 17, again soft, since one ace is worth 11, even though another one is 1
+ hw == 17 => true
+ number of aces = 2
+ 2 > 27 - 17 / 10 => true
+
+ AA Q 5
+ That's 17, but not soft, since both aces are worth 1
+ hw == 17 => true
+ number of aces = 2
+ 2 > 37 - 17 / 10 => false
+ * */
+ Dealer.Cards.Add(Deck.Draw());
+ hw = Dealer.GetHandValue();
+ }
+
+ if (hw > 21)
+ {
+ foreach (var usr in Players)
+ {
+ if (usr.State is User.UserState.Stand or User.UserState.Blackjack)
+ usr.State = User.UserState.Won;
+ else
+ usr.State = User.UserState.Lost;
+ }
+ }
+ else
+ {
+ foreach (var usr in Players)
+ {
+ if (usr.State == User.UserState.Blackjack)
+ usr.State = User.UserState.Won;
+ else if (usr.State == User.UserState.Stand)
+ usr.State = hw < usr.GetHandValue() ? User.UserState.Won : User.UserState.Lost;
+ else
+ usr.State = User.UserState.Lost;
+ }
+ }
+
+ foreach (var usr in Players)
+ {
+ if (usr.State is User.UserState.Won or User.UserState.Blackjack)
+ await _cs.AddAsync(usr.DiscordUser.Id, usr.Bet * 2, new("blackjack", "win"));
+ }
+ }
+
+ public async Task Double(IUser u)
+ {
+ var cu = CurrentUser;
+
+ if (cu is not null && cu.DiscordUser == u)
+ return await Double(cu);
+
+ return false;
+ }
+
+ public async Task Double(User u)
+ {
+ await _locker.WaitAsync();
+ try
+ {
+ if (State != GameState.Playing)
+ return false;
+
+ if (CurrentUser != u)
+ return false;
+
+ if (!await _cs.RemoveAsync(u.DiscordUser.Id, u.Bet, new("blackjack", "double")))
+ return false;
+
+ u.Bet *= 2;
+
+ u.Cards.Add(Deck.Draw());
+
+ if (u.GetHandValue() == 21)
+ //blackjack
+ u.State = User.UserState.Blackjack;
+ else if (u.GetHandValue() > 21)
+ // user busted
+ u.State = User.UserState.Bust;
+ else
+ //with double you just get one card, and then you're done
+ u.State = User.UserState.Stand;
+ currentUserMove.TrySetResult(true);
+
+ return true;
+ }
+ finally
+ {
+ _locker.Release();
+ }
+ }
+
+ public async Task Hit(IUser u)
+ {
+ var cu = CurrentUser;
+
+ if (cu is not null && cu.DiscordUser == u)
+ return await Hit(cu);
+
+ return false;
+ }
+
+ public async Task Hit(User u)
+ {
+ await _locker.WaitAsync();
+ try
+ {
+ if (State != GameState.Playing)
+ return false;
+
+ if (CurrentUser != u)
+ return false;
+
+ u.Cards.Add(Deck.Draw());
+
+ if (u.GetHandValue() == 21)
+ //blackjack
+ u.State = User.UserState.Blackjack;
+ else if (u.GetHandValue() > 21)
+ // user busted
+ u.State = User.UserState.Bust;
+
+ currentUserMove.TrySetResult(true);
+
+ return true;
+ }
+ finally
+ {
+ _locker.Release();
+ }
+ }
+
+ public Task PrintState()
+ {
+ if (StateUpdated is null)
+ return Task.CompletedTask;
+ return StateUpdated.Invoke(this);
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/Player.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/Player.cs
new file mode 100644
index 0000000..fb6a2f8
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/Player.cs
@@ -0,0 +1,58 @@
+#nullable disable
+using Ellie.Econ;
+
+namespace Ellie.Modules.Gambling.Common.Blackjack;
+
+public abstract class Player
+{
+ public List Cards { get; } = new();
+
+ public int GetHandValue()
+ {
+ var val = GetRawHandValue();
+
+ // while the hand value is greater than 21, for each ace you have in the deck
+ // reduce the value by 10 until it drops below 22
+ // (emulating the fact that ace is either a 1 or a 11)
+ var i = Cards.Count(x => x.Number == 1);
+ while (val > 21 && i-- > 0)
+ val -= 10;
+ return val;
+ }
+
+ public int GetRawHandValue()
+ => Cards.Sum(x => x.Number == 1 ? 11 : x.Number >= 10 ? 10 : x.Number);
+}
+
+public class Dealer : Player
+{
+}
+
+public class User : Player
+{
+ public enum UserState
+ {
+ Waiting,
+ Stand,
+ Bust,
+ Blackjack,
+ Won,
+ Lost
+ }
+
+ public UserState State { get; set; } = UserState.Waiting;
+ public long Bet { get; set; }
+ public IUser DiscordUser { get; }
+
+ public bool Done
+ => State != UserState.Waiting;
+
+ public User(IUser user, long bet)
+ {
+ if (bet <= 0)
+ throw new ArgumentOutOfRangeException(nameof(bet));
+
+ Bet = bet;
+ DiscordUser = user;
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/CleanupCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/CleanupCommands.cs
new file mode 100644
index 0000000..e69de29
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Connect4/Connect4.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Connect4/Connect4.cs
new file mode 100644
index 0000000..6a608fa
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Connect4/Connect4.cs
@@ -0,0 +1,409 @@
+#nullable disable
+using CommandLine;
+using System.Collections.Immutable;
+
+namespace Ellie.Modules.Gambling.Common.Connect4;
+
+public sealed class Connect4Game : IDisposable
+{
+ public enum Field //temporary most likely
+ {
+ Empty,
+ P1,
+ P2
+ }
+
+ public enum Phase
+ {
+ Joining, // waiting for second player to join
+ P1Move,
+ P2Move,
+ Ended
+ }
+
+ public enum Result
+ {
+ Draw,
+ CurrentPlayerWon,
+ OtherPlayerWon
+ }
+
+ public const int NUMBER_OF_COLUMNS = 7;
+ public const int NUMBER_OF_ROWS = 6;
+
+ //public event Func OnGameStarted;
+ public event Func OnGameStateUpdated;
+ public event Func OnGameFailedToStart;
+ public event Func OnGameEnded;
+
+ public Phase CurrentPhase { get; private set; } = Phase.Joining;
+
+ public ImmutableArray GameState
+ => _gameState.ToImmutableArray();
+
+ public ImmutableArray<(ulong UserId, string Username)?> Players
+ => _players.ToImmutableArray();
+
+ public (ulong UserId, string Username) CurrentPlayer
+ => CurrentPhase == Phase.P1Move ? _players[0].Value : _players[1].Value;
+
+ public (ulong UserId, string Username) OtherPlayer
+ => CurrentPhase == Phase.P2Move ? _players[0].Value : _players[1].Value;
+
+ //state is bottom to top, left to right
+ private readonly Field[] _gameState = new Field[NUMBER_OF_ROWS * NUMBER_OF_COLUMNS];
+ private readonly (ulong UserId, string Username)?[] _players = new (ulong, string)?[2];
+
+ private readonly SemaphoreSlim _locker = new(1, 1);
+ private readonly Options _options;
+ private readonly ICurrencyService _cs;
+ private readonly EllieRandom _rng;
+
+ private Timer playerTimeoutTimer;
+
+ /* [ ][ ][ ][ ][ ][ ]
+ * [ ][ ][ ][ ][ ][ ]
+ * [ ][ ][ ][ ][ ][ ]
+ * [ ][ ][ ][ ][ ][ ]
+ * [ ][ ][ ][ ][ ][ ]
+ * [ ][ ][ ][ ][ ][ ]
+ * [ ][ ][ ][ ][ ][ ]
+ */
+
+ public Connect4Game(
+ ulong userId,
+ string userName,
+ Options options,
+ ICurrencyService cs)
+ {
+ _players[0] = (userId, userName);
+ _options = options;
+ _cs = cs;
+
+ _rng = new();
+ for (var i = 0; i < NUMBER_OF_COLUMNS * NUMBER_OF_ROWS; i++)
+ _gameState[i] = Field.Empty;
+ }
+
+ public void Initialize()
+ {
+ if (CurrentPhase != Phase.Joining)
+ return;
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(15000);
+ await _locker.WaitAsync();
+ try
+ {
+ if (_players[1] is null)
+ {
+ _ = OnGameFailedToStart?.Invoke(this);
+ CurrentPhase = Phase.Ended;
+ await _cs.AddAsync(_players[0].Value.UserId, _options.Bet, new("connect4", "refund"));
+ }
+ }
+ finally { _locker.Release(); }
+ });
+ }
+
+ public async Task Join(ulong userId, string userName, int bet)
+ {
+ await _locker.WaitAsync();
+ try
+ {
+ if (CurrentPhase != Phase.Joining) //can't join if its not a joining phase
+ return false;
+
+ if (_players[0].Value.UserId == userId) // same user can't join own game
+ return false;
+
+ if (bet != _options.Bet) // can't join if bet amount is not the same
+ return false;
+
+ if (!await _cs.RemoveAsync(userId, bet, new("connect4", "bet"))) // user doesn't have enough money to gamble
+ return false;
+
+ if (_rng.Next(0, 2) == 0) //rolling from 0-1, if number is 0, join as first player
+ {
+ _players[1] = _players[0];
+ _players[0] = (userId, userName);
+ }
+ else //else join as a second player
+ _players[1] = (userId, userName);
+
+ CurrentPhase = Phase.P1Move; //start the game
+ playerTimeoutTimer = new(async _ =>
+ {
+ await _locker.WaitAsync();
+ try
+ {
+ EndGame(Result.OtherPlayerWon, OtherPlayer.UserId);
+ }
+ finally { _locker.Release(); }
+ },
+ null,
+ TimeSpan.FromSeconds(_options.TurnTimer),
+ TimeSpan.FromSeconds(_options.TurnTimer));
+ _ = OnGameStateUpdated?.Invoke(this);
+
+ return true;
+ }
+ finally { _locker.Release(); }
+ }
+
+ public async Task Input(ulong userId, int inputCol)
+ {
+ await _locker.WaitAsync();
+ try
+ {
+ inputCol -= 1;
+ if (CurrentPhase is Phase.Ended or Phase.Joining)
+ return false;
+
+ if (!((_players[0].Value.UserId == userId && CurrentPhase == Phase.P1Move)
+ || (_players[1].Value.UserId == userId && CurrentPhase == Phase.P2Move)))
+ return false;
+
+ if (inputCol is < 0 or > NUMBER_OF_COLUMNS) //invalid input
+ return false;
+
+ if (IsColumnFull(inputCol)) //can't play there event?
+ return false;
+
+ var start = NUMBER_OF_ROWS * inputCol;
+ for (var i = start; i < start + NUMBER_OF_ROWS; i++)
+ {
+ if (_gameState[i] == Field.Empty)
+ {
+ _gameState[i] = GetPlayerPiece(userId);
+ break;
+ }
+ }
+
+ //check winnning condition
+ // ok, i'll go from [0-2] in rows (and through all columns) and check upward if 4 are connected
+
+ for (var i = 0; i < NUMBER_OF_ROWS - 3; i++)
+ {
+ if (CurrentPhase == Phase.Ended)
+ break;
+
+ for (var j = 0; j < NUMBER_OF_COLUMNS; j++)
+ {
+ if (CurrentPhase == Phase.Ended)
+ break;
+
+ var first = _gameState[i + (j * NUMBER_OF_ROWS)];
+ if (first != Field.Empty)
+ {
+ for (var k = 1; k < 4; k++)
+ {
+ var next = _gameState[i + k + (j * NUMBER_OF_ROWS)];
+ if (next == first)
+ {
+ if (k == 3)
+ EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId);
+ else
+ continue;
+ }
+ else
+ break;
+ }
+ }
+ }
+ }
+
+ // i'll go [0-1] in columns (and through all rows) and check to the right if 4 are connected
+ for (var i = 0; i < NUMBER_OF_COLUMNS - 3; i++)
+ {
+ if (CurrentPhase == Phase.Ended)
+ break;
+
+ for (var j = 0; j < NUMBER_OF_ROWS; j++)
+ {
+ if (CurrentPhase == Phase.Ended)
+ break;
+
+ var first = _gameState[j + (i * NUMBER_OF_ROWS)];
+ if (first != Field.Empty)
+ {
+ for (var k = 1; k < 4; k++)
+ {
+ var next = _gameState[j + ((i + k) * NUMBER_OF_ROWS)];
+ if (next == first)
+ {
+ if (k == 3)
+ EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId);
+ else
+ continue;
+ }
+ else
+ break;
+ }
+ }
+ }
+ }
+
+ //need to check diagonal now
+ for (var col = 0; col < NUMBER_OF_COLUMNS; col++)
+ {
+ if (CurrentPhase == Phase.Ended)
+ break;
+
+ for (var row = 0; row < NUMBER_OF_ROWS; row++)
+ {
+ if (CurrentPhase == Phase.Ended)
+ break;
+
+ var first = _gameState[row + (col * NUMBER_OF_ROWS)];
+
+ if (first != Field.Empty)
+ {
+ var same = 1;
+
+ //top left
+ for (var i = 1; i < 4; i++)
+ {
+ //while going top left, rows are increasing, columns are decreasing
+ var curRow = row + i;
+ var curCol = col - i;
+
+ //check if current values are in range
+ if (curRow is >= NUMBER_OF_ROWS or < 0)
+ break;
+ if (curCol is < 0 or >= NUMBER_OF_COLUMNS)
+ break;
+
+ var cur = _gameState[curRow + (curCol * NUMBER_OF_ROWS)];
+ if (cur == first)
+ same++;
+ else
+ break;
+ }
+
+ if (same == 4)
+ {
+ EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId);
+ break;
+ }
+
+ same = 1;
+
+ //top right
+ for (var i = 1; i < 4; i++)
+ {
+ //while going top right, rows are increasing, columns are increasing
+ var curRow = row + i;
+ var curCol = col + i;
+
+ //check if current values are in range
+ if (curRow is >= NUMBER_OF_ROWS or < 0)
+ break;
+ if (curCol is < 0 or >= NUMBER_OF_COLUMNS)
+ break;
+
+ var cur = _gameState[curRow + (curCol * NUMBER_OF_ROWS)];
+ if (cur == first)
+ same++;
+ else
+ break;
+ }
+
+ if (same == 4)
+ {
+ EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId);
+ break;
+ }
+ }
+ }
+ }
+
+ //check draw? if it's even possible
+ if (_gameState.All(x => x != Field.Empty))
+ EndGame(Result.Draw, null);
+
+ if (CurrentPhase != Phase.Ended)
+ {
+ if (CurrentPhase == Phase.P1Move)
+ CurrentPhase = Phase.P2Move;
+ else
+ CurrentPhase = Phase.P1Move;
+
+ ResetTimer();
+ }
+
+ _ = OnGameStateUpdated?.Invoke(this);
+ return true;
+ }
+ finally { _locker.Release(); }
+ }
+
+ private void ResetTimer()
+ => playerTimeoutTimer.Change(TimeSpan.FromSeconds(_options.TurnTimer),
+ TimeSpan.FromSeconds(_options.TurnTimer));
+
+ private void EndGame(Result result, ulong? winId)
+ {
+ if (CurrentPhase == Phase.Ended)
+ return;
+ _ = OnGameEnded?.Invoke(this, result);
+ CurrentPhase = Phase.Ended;
+
+ if (result == Result.Draw)
+ {
+ _cs.AddAsync(CurrentPlayer.UserId, _options.Bet, new("connect4", "draw"));
+ _cs.AddAsync(OtherPlayer.UserId, _options.Bet, new("connect4", "draw"));
+ return;
+ }
+
+ if (winId is not null)
+ _cs.AddAsync(winId.Value, (long)(_options.Bet * 1.98), new("connect4", "win"));
+ }
+
+ private Field GetPlayerPiece(ulong userId)
+ => _players[0].Value.UserId == userId ? Field.P1 : Field.P2;
+
+ //column is full if there are no empty fields
+ private bool IsColumnFull(int column)
+ {
+ var start = NUMBER_OF_ROWS * column;
+ for (var i = start; i < start + NUMBER_OF_ROWS; i++)
+ {
+ if (_gameState[i] == Field.Empty)
+ return false;
+ }
+
+ return true;
+ }
+
+ public void Dispose()
+ {
+ OnGameFailedToStart = null;
+ OnGameStateUpdated = null;
+ OnGameEnded = null;
+ playerTimeoutTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ }
+
+
+ public class Options : IEllieCommandOptions
+ {
+ [Option('t',
+ "turn-timer",
+ Required = false,
+ Default = 15,
+ HelpText = "Turn time in seconds. It has to be between 5 and 60. Default 15.")]
+ public int TurnTimer { get; set; } = 15;
+
+ [Option('b', "bet", Required = false, Default = 0, HelpText = "Amount you bet. Default 0.")]
+ public int Bet { get; set; }
+
+ public void NormalizeOptions()
+ {
+ if (TurnTimer is < 5 or > 60)
+ TurnTimer = 15;
+
+ if (Bet < 0)
+ Bet = 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Connect4/Connect4Commands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Connect4/Connect4Commands.cs
new file mode 100644
index 0000000..57ecdf0
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Connect4/Connect4Commands.cs
@@ -0,0 +1,204 @@
+#nullable disable
+using Ellie.Modules.Gambling.Common;
+using Ellie.Modules.Gambling.Common.Connect4;
+using Ellie.Modules.Gambling.Services;
+using System.Text;
+
+namespace Ellie.Modules.Gambling;
+
+public partial class Gambling
+{
+ [Group]
+ public partial class Connect4Commands : GamblingSubmodule
+ {
+ private static readonly string[] _numbers =
+ {
+ ":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:"
+ };
+
+ private int RepostCounter
+ {
+ get => repostCounter;
+ set
+ {
+ if (value is < 0 or > 7)
+ repostCounter = 0;
+ else
+ repostCounter = value;
+ }
+ }
+
+ private readonly DiscordSocketClient _client;
+ private readonly ICurrencyService _cs;
+
+ private IUserMessage msg;
+
+ private int repostCounter;
+
+ public Connect4Commands(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService gamb)
+ : base(gamb)
+ {
+ _client = client;
+ _cs = cs;
+ }
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ [EllieOptions]
+ public async Task Connect4(params string[] args)
+ {
+ var (options, _) = OptionsParser.ParseFrom(new Connect4Game.Options(), args);
+ if (!await CheckBetOptional(options.Bet))
+ return;
+
+ var newGame = new Connect4Game(ctx.User.Id, ctx.User.ToString(), options, _cs);
+ Connect4Game game;
+ if ((game = _service.Connect4Games.GetOrAdd(ctx.Channel.Id, newGame)) != newGame)
+ {
+ if (game.CurrentPhase != Connect4Game.Phase.Joining)
+ return;
+
+ newGame.Dispose();
+ //means game already exists, try to join
+ await game.Join(ctx.User.Id, ctx.User.ToString(), options.Bet);
+ return;
+ }
+
+ if (options.Bet > 0)
+ {
+ if (!await _cs.RemoveAsync(ctx.User.Id, options.Bet, new("connect4", "bet")))
+ {
+ await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
+ _service.Connect4Games.TryRemove(ctx.Channel.Id, out _);
+ game.Dispose();
+ return;
+ }
+ }
+
+ game.OnGameStateUpdated += Game_OnGameStateUpdated;
+ game.OnGameFailedToStart += GameOnGameFailedToStart;
+ game.OnGameEnded += GameOnGameEnded;
+ _client.MessageReceived += ClientMessageReceived;
+
+ game.Initialize();
+ if (options.Bet == 0)
+ await ReplyConfirmLocalizedAsync(strs.connect4_created);
+ else
+ await ReplyErrorLocalizedAsync(strs.connect4_created_bet(N(options.Bet)));
+
+ Task ClientMessageReceived(SocketMessage arg)
+ {
+ if (ctx.Channel.Id != arg.Channel.Id)
+ return Task.CompletedTask;
+
+ _ = Task.Run(async () =>
+ {
+ var success = false;
+ if (int.TryParse(arg.Content, out var col))
+ success = await game.Input(arg.Author.Id, col);
+
+ if (success)
+ {
+ try { await arg.DeleteAsync(); }
+ catch { }
+ }
+ else
+ {
+ if (game.CurrentPhase is Connect4Game.Phase.Joining or Connect4Game.Phase.Ended)
+ return;
+ RepostCounter++;
+ if (RepostCounter == 0)
+ {
+ try { msg = await ctx.Channel.SendMessageAsync("", embed: (Embed)msg.Embeds.First()); }
+ catch { }
+ }
+ }
+ });
+ return Task.CompletedTask;
+ }
+
+ Task GameOnGameFailedToStart(Connect4Game arg)
+ {
+ if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose))
+ {
+ _client.MessageReceived -= ClientMessageReceived;
+ toDispose.Dispose();
+ }
+
+ return ErrorLocalizedAsync(strs.connect4_failed_to_start);
+ }
+
+ Task GameOnGameEnded(Connect4Game arg, Connect4Game.Result result)
+ {
+ if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose))
+ {
+ _client.MessageReceived -= ClientMessageReceived;
+ toDispose.Dispose();
+ }
+
+ string title;
+ if (result == Connect4Game.Result.CurrentPlayerWon)
+ {
+ title = GetText(strs.connect4_won(Format.Bold(arg.CurrentPlayer.Username),
+ Format.Bold(arg.OtherPlayer.Username)));
+ }
+ else if (result == Connect4Game.Result.OtherPlayerWon)
+ {
+ title = GetText(strs.connect4_won(Format.Bold(arg.OtherPlayer.Username),
+ Format.Bold(arg.CurrentPlayer.Username)));
+ }
+ else
+ title = GetText(strs.connect4_draw);
+
+ return msg.ModifyAsync(x => x.Embed = _eb.Create()
+ .WithTitle(title)
+ .WithDescription(GetGameStateText(game))
+ .WithOkColor()
+ .Build());
+ }
+ }
+
+ private async Task Game_OnGameStateUpdated(Connect4Game game)
+ {
+ var embed = _eb.Create()
+ .WithTitle($"{game.CurrentPlayer.Username} vs {game.OtherPlayer.Username}")
+ .WithDescription(GetGameStateText(game))
+ .WithOkColor();
+
+
+ if (msg is null)
+ msg = await ctx.Channel.EmbedAsync(embed);
+ else
+ await msg.ModifyAsync(x => x.Embed = embed.Build());
+ }
+
+ private string GetGameStateText(Connect4Game game)
+ {
+ var sb = new StringBuilder();
+
+ if (game.CurrentPhase is Connect4Game.Phase.P1Move or Connect4Game.Phase.P2Move)
+ sb.AppendLine(GetText(strs.connect4_player_to_move(Format.Bold(game.CurrentPlayer.Username))));
+
+ for (var i = Connect4Game.NUMBER_OF_ROWS; i > 0; i--)
+ {
+ for (var j = 0; j < Connect4Game.NUMBER_OF_COLUMNS; j++)
+ {
+ var cur = game.GameState[i + (j * Connect4Game.NUMBER_OF_ROWS) - 1];
+
+ if (cur == Connect4Game.Field.Empty)
+ sb.Append("⚫"); //black circle
+ else if (cur == Connect4Game.Field.P1)
+ sb.Append("🔴"); //red circle
+ else
+ sb.Append("🔵"); //blue circle
+ }
+
+ sb.AppendLine();
+ }
+
+ for (var i = 0; i < Connect4Game.NUMBER_OF_COLUMNS; i++)
+ sb.Append(_numbers[i]);
+ return sb.ToString();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/DiceRoll/DiceRollCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/DiceRoll/DiceRollCommands.cs
new file mode 100644
index 0000000..fe7b91e
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/DiceRoll/DiceRollCommands.cs
@@ -0,0 +1,224 @@
+#nullable disable
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using System.Text.RegularExpressions;
+using Image = SixLabors.ImageSharp.Image;
+
+namespace Ellie.Modules.Gambling;
+
+public partial class Gambling
+{
+ [Group]
+ public partial class DiceRollCommands : EllieModule
+ {
+ private static readonly Regex _dndRegex = new(@"^(?\d+)d(?\d+)(?:\+(?\d+))?(?:\-(?\d+))?$",
+ RegexOptions.Compiled);
+
+ private static readonly Regex _fudgeRegex = new(@"^(?\d+)d(?:F|f)$", RegexOptions.Compiled);
+
+ private static readonly char[] _fateRolls = { '-', ' ', '+' };
+ private readonly IImageCache _images;
+
+ public DiceRollCommands(IImageCache images)
+ => _images = images;
+
+ [Cmd]
+ public async Task Roll()
+ {
+ var rng = new EllieRandom();
+ var gen = rng.Next(1, 101);
+
+ var num1 = gen / 10;
+ var num2 = gen % 10;
+
+ using var img1 = await GetDiceAsync(num1);
+ using var img2 = await GetDiceAsync(num2);
+ using var img = new[] { img1, img2 }.Merge(out var format);
+ await using var ms = await img.ToStreamAsync(format);
+
+ var fileName = $"dice.{format.FileExtensions.First()}";
+
+ var eb = _eb.Create(ctx)
+ .WithOkColor()
+ .WithAuthor(ctx.User)
+ .AddField(GetText(strs.roll2), gen)
+ .WithImageUrl($"attachment://{fileName}");
+
+ await ctx.Channel.SendFileAsync(ms,
+ fileName,
+ embed: eb.Build());
+ }
+
+ [Cmd]
+ [Priority(1)]
+ public async Task Roll(int num)
+ => await InternalRoll(num, true);
+
+
+ [Cmd]
+ [Priority(1)]
+ public async Task Rolluo(int num = 1)
+ => await InternalRoll(num, false);
+
+ [Cmd]
+ [Priority(0)]
+ public async Task Roll(string arg)
+ => await InternallDndRoll(arg, true);
+
+ [Cmd]
+ [Priority(0)]
+ public async Task Rolluo(string arg)
+ => await InternallDndRoll(arg, false);
+
+ private async Task InternalRoll(int num, bool ordered)
+ {
+ if (num is < 1 or > 30)
+ {
+ await ReplyErrorLocalizedAsync(strs.dice_invalid_number(1, 30));
+ return;
+ }
+
+ var rng = new EllieRandom();
+
+ var dice = new List>(num);
+ var values = new List(num);
+ for (var i = 0; i < num; i++)
+ {
+ var randomNumber = rng.Next(1, 7);
+ var toInsert = dice.Count;
+ if (ordered)
+ {
+ if (randomNumber == 6 || dice.Count == 0)
+ toInsert = 0;
+ else if (randomNumber != 1)
+ {
+ for (var j = 0; j < dice.Count; j++)
+ {
+ if (values[j] < randomNumber)
+ {
+ toInsert = j;
+ break;
+ }
+ }
+ }
+ }
+ else
+ toInsert = dice.Count;
+
+ dice.Insert(toInsert, await GetDiceAsync(randomNumber));
+ values.Insert(toInsert, randomNumber);
+ }
+
+ using var bitmap = dice.Merge(out var format);
+ await using var ms = bitmap.ToStream(format);
+ foreach (var d in dice)
+ d.Dispose();
+
+ var imageName = $"dice.{format.FileExtensions.First()}";
+ var eb = _eb.Create(ctx)
+ .WithOkColor()
+ .WithAuthor(ctx.User)
+ .AddField(GetText(strs.rolls), values.Select(x => Format.Code(x.ToString())).Join(' '), true)
+ .AddField(GetText(strs.total), values.Sum(), true)
+ .WithDescription(GetText(strs.dice_rolled_num(Format.Bold(values.Count.ToString()))))
+ .WithImageUrl($"attachment://{imageName}");
+
+ await ctx.Channel.SendFileAsync(ms,
+ imageName,
+ embed: eb.Build());
+ }
+
+ private async Task InternallDndRoll(string arg, bool ordered)
+ {
+ Match match;
+ if ((match = _fudgeRegex.Match(arg)).Length != 0
+ && int.TryParse(match.Groups["n1"].ToString(), out var n1)
+ && n1 is > 0 and < 500)
+ {
+ var rng = new EllieRandom();
+
+ var rolls = new List();
+
+ for (var i = 0; i < n1; i++)
+ rolls.Add(_fateRolls[rng.Next(0, _fateRolls.Length)]);
+ var embed = _eb.Create()
+ .WithOkColor()
+ .WithAuthor(ctx.User)
+ .WithDescription(GetText(strs.dice_rolled_num(Format.Bold(n1.ToString()))))
+ .AddField(Format.Bold("Result"),
+ string.Join(" ", rolls.Select(c => Format.Code($"[{c}]"))));
+
+ await ctx.Channel.EmbedAsync(embed);
+ }
+ else if ((match = _dndRegex.Match(arg)).Length != 0)
+ {
+ var rng = new EllieRandom();
+ if (int.TryParse(match.Groups["n1"].ToString(), out n1)
+ && int.TryParse(match.Groups["n2"].ToString(), out var n2)
+ && n1 <= 50
+ && n2 <= 100000
+ && n1 > 0
+ && n2 > 0)
+ {
+ if (!int.TryParse(match.Groups["add"].Value, out var add))
+ add = 0;
+ if (!int.TryParse(match.Groups["sub"].Value, out var sub))
+ sub = 0;
+
+ var arr = new int[n1];
+ for (var i = 0; i < n1; i++)
+ arr[i] = rng.Next(1, n2 + 1);
+
+ var sum = arr.Sum();
+ var embed = _eb.Create()
+ .WithOkColor()
+ .WithAuthor(ctx.User)
+ .WithDescription(GetText(strs.dice_rolled_num(n1 + $"`1 - {n2}`")))
+ .AddField(Format.Bold(GetText(strs.rolls)),
+ string.Join(" ",
+ (ordered ? arr.OrderBy(x => x).AsEnumerable() : arr).Select(x
+ => Format.Code(x.ToString()))))
+ .AddField(Format.Bold("Sum"),
+ sum + " + " + add + " - " + sub + " = " + (sum + add - sub));
+ await ctx.Channel.EmbedAsync(embed);
+ }
+ }
+ }
+
+ [Cmd]
+ public async Task NRoll([Leftover] string range)
+ {
+ int rolled;
+ if (range.Contains("-"))
+ {
+ var arr = range.Split('-').Take(2).Select(int.Parse).ToArray();
+ if (arr[0] > arr[1])
+ {
+ await ReplyErrorLocalizedAsync(strs.second_larger_than_first);
+ return;
+ }
+
+ rolled = new EllieRandom().Next(arr[0], arr[1] + 1);
+ }
+ else
+ rolled = new EllieRandom().Next(0, int.Parse(range) + 1);
+
+ await ReplyConfirmLocalizedAsync(strs.dice_rolled(Format.Bold(rolled.ToString())));
+ }
+
+ private async Task> GetDiceAsync(int num)
+ {
+ if (num is < 0 or > 10)
+ throw new ArgumentOutOfRangeException(nameof(num));
+
+ if (num == 10)
+ {
+ using var imgOne = Image.Load(await _images.GetDiceAsync(1));
+ using var imgZero = Image.Load(await _images.GetDiceAsync(0));
+ return new[] { imgOne, imgZero }.Merge();
+ }
+
+ return Image.Load(await _images.GetDiceAsync(num));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Draw/DrawCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Draw/DrawCommands.cs
new file mode 100644
index 0000000..2b9cf8c
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Draw/DrawCommands.cs
@@ -0,0 +1,234 @@
+#nullable disable
+using Ellie.Econ;
+using Ellie.Common.TypeReaders;
+using Ellie.Modules.Gambling.Common;
+using Ellie.Modules.Gambling.Services;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using Image = SixLabors.ImageSharp.Image;
+
+namespace Ellie.Modules.Gambling;
+
+public partial class Gambling
+{
+ [Group]
+ public partial class DrawCommands : GamblingSubmodule
+ {
+ private static readonly ConcurrentDictionary _allDecks = new();
+ private readonly IImageCache _images;
+
+ public DrawCommands(IImageCache images, GamblingConfigService gcs) : base(gcs)
+ => _images = images;
+
+ private async Task InternalDraw(int count, ulong? guildId = null)
+ {
+ if (count is < 1 or > 10)
+ throw new ArgumentOutOfRangeException(nameof(count));
+
+ var cards = guildId is null ? new() : _allDecks.GetOrAdd(ctx.Guild, _ => new());
+ var images = new List>();
+ var cardObjects = new List();
+ for (var i = 0; i < count; i++)
+ {
+ if (cards.CardPool.Count == 0 && i != 0)
+ {
+ try
+ {
+ await ReplyErrorLocalizedAsync(strs.no_more_cards);
+ }
+ catch
+ {
+ // ignored
+ }
+
+ break;
+ }
+
+ var currentCard = cards.Draw();
+ cardObjects.Add(currentCard);
+ var image = await GetCardImageAsync(currentCard);
+ images.Add(image);
+ }
+
+ var imgName = "cards.jpg";
+ using var img = images.Merge();
+ foreach (var i in images)
+ i.Dispose();
+
+ var eb = _eb.Create(ctx)
+ .WithOkColor();
+
+ var toSend = string.Empty;
+ if (cardObjects.Count == 5)
+ eb.AddField(GetText(strs.hand_value), Deck.GetHandValue(cardObjects), true);
+
+ if (guildId is not null)
+ toSend += GetText(strs.cards_left(Format.Bold(cards.CardPool.Count.ToString())));
+
+ eb.WithDescription(toSend)
+ .WithAuthor(ctx.User)
+ .WithImageUrl($"attachment://{imgName}");
+
+ if (count > 1)
+ eb.AddField(GetText(strs.cards), count.ToString(), true);
+
+ await using var imageStream = await img.ToStreamAsync();
+ await ctx.Channel.SendFileAsync(imageStream,
+ imgName,
+ embed: eb.Build());
+ }
+
+ private async Task> GetCardImageAsync(RegularCard currentCard)
+ {
+ var cardName = currentCard.GetName().ToLowerInvariant().Replace(' ', '_');
+ var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg");
+ return Image.Load(cardBytes);
+ }
+
+ private async Task> GetCardImageAsync(Deck.Card currentCard)
+ {
+ var cardName = currentCard.ToString().ToLowerInvariant().Replace(' ', '_');
+ var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg");
+ return Image.Load(cardBytes);
+ }
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ public async Task Draw(int num = 1)
+ {
+ if (num < 1)
+ return;
+
+ if (num > 10)
+ num = 10;
+
+ await InternalDraw(num, ctx.Guild.Id);
+ }
+
+ [Cmd]
+ public async Task DrawNew(int num = 1)
+ {
+ if (num < 1)
+ return;
+
+ if (num > 10)
+ num = 10;
+
+ await InternalDraw(num);
+ }
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ public async Task DeckShuffle()
+ {
+ //var channel = (ITextChannel)ctx.Channel;
+
+ _allDecks.AddOrUpdate(ctx.Guild,
+ _ => new(),
+ (_, c) =>
+ {
+ c.Restart();
+ return c;
+ });
+
+ await ReplyConfirmLocalizedAsync(strs.deck_reshuffled);
+ }
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ public Task BetDraw([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, InputValueGuess val, InputColorGuess? col = null)
+ => BetDrawInternal(amount, val, col);
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ public Task BetDraw([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, InputColorGuess col, InputValueGuess? val = null)
+ => BetDrawInternal(amount, val, col);
+
+ public async Task BetDrawInternal(long amount, InputValueGuess? val, InputColorGuess? col)
+ {
+ if (amount <= 0)
+ return;
+
+ var res = await _service.BetDrawAsync(ctx.User.Id,
+ amount,
+ (byte?)val,
+ (byte?)col);
+
+ if (!res.TryPickT0(out var result, out _))
+ {
+ await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
+ return;
+ }
+
+ var eb = _eb.Create(ctx)
+ .WithOkColor()
+ .WithAuthor(ctx.User)
+ .WithDescription(result.Card.GetEmoji())
+ .AddField(GetText(strs.guess), GetGuessInfo(val, col), true)
+ .AddField(GetText(strs.card), GetCardInfo(result.Card), true)
+ .AddField(GetText(strs.won), N((long)result.Won), false)
+ .WithImageUrl("attachment://card.png");
+
+ using var img = await GetCardImageAsync(result.Card);
+ await using var imgStream = await img.ToStreamAsync();
+ await ctx.Channel.SendFileAsync(imgStream, "card.png", embed: eb.Build());
+ }
+
+ private string GetGuessInfo(InputValueGuess? valG, InputColorGuess? colG)
+ {
+ var val = valG switch
+ {
+ InputValueGuess.H => "Hi ⬆️",
+ InputValueGuess.L => "Lo ⬇️",
+ _ => "❓"
+ };
+
+ var col = colG switch
+ {
+ InputColorGuess.Red => "R 🔴",
+ InputColorGuess.Black => "B ⚫",
+ _ => "❓"
+ };
+
+ return $"{val} / {col}";
+ }
+ private string GetCardInfo(RegularCard card)
+ {
+ var val = (int)card.Value switch
+ {
+ < 7 => "Lo ⬇️",
+ > 7 => "Hi ⬆️",
+ _ => "7 💀"
+ };
+
+ var col = card.Value == RegularValue.Seven
+ ? "7 💀"
+ : card.Suit switch
+ {
+ RegularSuit.Diamonds or RegularSuit.Hearts => "R 🔴",
+ _ => "B ⚫"
+ };
+
+ return $"{val} / {col}";
+ }
+
+ public enum InputValueGuess
+ {
+ High = 0,
+ H = 0,
+ Hi = 0,
+ Low = 1,
+ L = 1,
+ Lo = 1,
+ }
+
+ public enum InputColorGuess
+ {
+ R = 0,
+ Red = 0,
+ B = 1,
+ Bl = 1,
+ Black = 1,
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/EconomyResult.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/EconomyResult.cs
new file mode 100644
index 0000000..811d1c5
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/EconomyResult.cs
@@ -0,0 +1,12 @@
+#nullable disable
+namespace Ellie.Modules.Gambling.Services;
+
+public sealed class EconomyResult
+{
+ public decimal Cash { get; init; }
+ public decimal Planted { get; init; }
+ public decimal Waifus { get; init; }
+ public decimal OnePercent { get; init; }
+ public decimal Bank { get; init; }
+ public long Bot { get; init; }
+}
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Events/CurrencyEventsCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/CurrencyEventsCommands.cs
new file mode 100644
index 0000000..fd0f655
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/CurrencyEventsCommands.cs
@@ -0,0 +1,60 @@
+#nullable disable
+using Ellie.Modules.Gambling.Common;
+using Ellie.Modules.Gambling.Common.Events;
+using Ellie.Modules.Gambling.Services;
+using Ellie.Services.Database.Models;
+
+namespace Ellie.Modules.Gambling;
+
+public partial class Gambling
+{
+ [Group]
+ public partial class CurrencyEventsCommands : GamblingSubmodule
+ {
+ public CurrencyEventsCommands(GamblingConfigService gamblingConf)
+ : base(gamblingConf)
+ {
+ }
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ [EllieOptions]
+ [OwnerOnly]
+ public async Task EventStart(CurrencyEvent.Type ev, params string[] options)
+ {
+ var (opts, _) = OptionsParser.ParseFrom(new EventOptions(), options);
+ if (!await _service.TryCreateEventAsync(ctx.Guild.Id, ctx.Channel.Id, ev, opts, GetEmbed))
+ await ReplyErrorLocalizedAsync(strs.start_event_fail);
+ }
+
+ private IEmbedBuilder GetEmbed(CurrencyEvent.Type type, EventOptions opts, long currentPot)
+ => type switch
+ {
+ CurrencyEvent.Type.Reaction => _eb.Create()
+ .WithOkColor()
+ .WithTitle(GetText(strs.event_title(type.ToString())))
+ .WithDescription(GetReactionDescription(opts.Amount, currentPot))
+ .WithFooter(GetText(strs.event_duration_footer(opts.Hours))),
+ CurrencyEvent.Type.GameStatus => _eb.Create()
+ .WithOkColor()
+ .WithTitle(GetText(strs.event_title(type.ToString())))
+ .WithDescription(GetGameStatusDescription(opts.Amount, currentPot))
+ .WithFooter(GetText(strs.event_duration_footer(opts.Hours))),
+ _ => throw new ArgumentOutOfRangeException(nameof(type))
+ };
+
+ private string GetReactionDescription(long amount, long potSize)
+ {
+ var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : N(potSize));
+
+ return GetText(strs.new_reaction_event(CurrencySign, Format.Bold(N(amount)), potSizeStr));
+ }
+
+ private string GetGameStatusDescription(long amount, long potSize)
+ {
+ var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : potSize + CurrencySign);
+
+ return GetText(strs.new_gamestatus_event(CurrencySign, Format.Bold(N(amount)), potSizeStr));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Events/CurrencyEventsService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/CurrencyEventsService.cs
new file mode 100644
index 0000000..be62459
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/CurrencyEventsService.cs
@@ -0,0 +1,67 @@
+#nullable disable
+using Ellie.Modules.Gambling.Common;
+using Ellie.Modules.Gambling.Common.Events;
+using Ellie.Services.Database.Models;
+
+namespace Ellie.Modules.Gambling.Services;
+
+public class CurrencyEventsService : IEService
+{
+ private readonly DiscordSocketClient _client;
+ private readonly ICurrencyService _cs;
+ private readonly GamblingConfigService _configService;
+
+ private readonly ConcurrentDictionary _events = new();
+
+ public CurrencyEventsService(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService configService)
+ {
+ _client = client;
+ _cs = cs;
+ _configService = configService;
+ }
+
+ public async Task TryCreateEventAsync(
+ ulong guildId,
+ ulong channelId,
+ CurrencyEvent.Type type,
+ EventOptions opts,
+ Func embed)
+ {
+ var g = _client.GetGuild(guildId);
+ if (g?.GetChannel(channelId) is not ITextChannel ch)
+ return false;
+
+ ICurrencyEvent ce;
+
+ if (type == CurrencyEvent.Type.Reaction)
+ ce = new ReactionEvent(_client, _cs, g, ch, opts, _configService.Data, embed);
+ else if (type == CurrencyEvent.Type.GameStatus)
+ ce = new GameStatusEvent(_client, _cs, g, ch, opts, embed);
+ else
+ return false;
+
+ var added = _events.TryAdd(guildId, ce);
+ if (added)
+ {
+ try
+ {
+ ce.OnEnded += OnEventEnded;
+ await ce.StartEvent();
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Error starting event");
+ _events.TryRemove(guildId, out ce);
+ return false;
+ }
+ }
+
+ return added;
+ }
+
+ private Task OnEventEnded(ulong gid)
+ {
+ _events.TryRemove(gid, out _);
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Events/EventOptions.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/EventOptions.cs
new file mode 100644
index 0000000..163f9a9
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/EventOptions.cs
@@ -0,0 +1,39 @@
+#nullable disable
+using CommandLine;
+
+namespace Ellie.Modules.Gambling.Common.Events;
+
+public class EventOptions : IEllieCommandOptions
+{
+ [Option('a', "amount", Required = false, Default = 100, HelpText = "Amount of currency each user receives.")]
+ public long Amount { get; set; } = 100;
+
+ [Option('p',
+ "pot-size",
+ Required = false,
+ Default = 0,
+ HelpText = "The maximum amount of currency that can be rewarded. 0 means no limit.")]
+ public long PotSize { get; set; }
+
+ //[Option('t', "type", Required = false, Default = "reaction", HelpText = "Type of the event. reaction, gamestatus or joinserver.")]
+ //public string TypeString { get; set; } = "reaction";
+ [Option('d',
+ "duration",
+ Required = false,
+ Default = 24,
+ HelpText = "Number of hours the event should run for. Default 24.")]
+ public int Hours { get; set; } = 24;
+
+
+ public void NormalizeOptions()
+ {
+ if (Amount < 0)
+ Amount = 100;
+ if (PotSize < 0)
+ PotSize = 0;
+ if (Hours <= 0)
+ Hours = 24;
+ if (PotSize != 0 && PotSize < Amount)
+ PotSize = 0;
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Events/GameStatusEvent.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/GameStatusEvent.cs
new file mode 100644
index 0000000..10ec416
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/GameStatusEvent.cs
@@ -0,0 +1,195 @@
+#nullable disable
+using Ellie.Services.Database.Models;
+using System.Collections.Concurrent;
+
+namespace Ellie.Modules.Gambling.Common.Events;
+
+public class GameStatusEvent : ICurrencyEvent
+{
+ public event Func OnEnded;
+ private long PotSize { get; set; }
+ public bool Stopped { get; private set; }
+ public bool PotEmptied { get; private set; }
+ private readonly DiscordSocketClient _client;
+ private readonly IGuild _guild;
+ private IUserMessage msg;
+ private readonly ICurrencyService _cs;
+ private readonly long _amount;
+
+ private readonly Func _embedFunc;
+ private readonly bool _isPotLimited;
+ private readonly ITextChannel _channel;
+ private readonly ConcurrentHashSet _awardedUsers = new();
+ private readonly ConcurrentQueue _toAward = new();
+ private readonly Timer _t;
+ private readonly Timer _timeout;
+ private readonly EventOptions _opts;
+
+ private readonly string _code;
+
+ private readonly char[] _sneakyGameStatusChars = Enumerable.Range(48, 10)
+ .Concat(Enumerable.Range(65, 26))
+ .Concat(Enumerable.Range(97, 26))
+ .Select(x => (char)x)
+ .ToArray();
+
+ private readonly object _stopLock = new();
+
+ private readonly object _potLock = new();
+
+ public GameStatusEvent(
+ DiscordSocketClient client,
+ ICurrencyService cs,
+ SocketGuild g,
+ ITextChannel ch,
+ EventOptions opt,
+ Func embedFunc)
+ {
+ _client = client;
+ _guild = g;
+ _cs = cs;
+ _amount = opt.Amount;
+ PotSize = opt.PotSize;
+ _embedFunc = embedFunc;
+ _isPotLimited = PotSize > 0;
+ _channel = ch;
+ _opts = opt;
+ // generate code
+ _code = new(_sneakyGameStatusChars.Shuffle().Take(5).ToArray());
+
+ _t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2));
+ if (_opts.Hours > 0)
+ _timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan);
+ }
+
+ private void EventTimeout(object state)
+ => _ = StopEvent();
+
+ private async void OnTimerTick(object state)
+ {
+ var potEmpty = PotEmptied;
+ var toAward = new List();
+ while (_toAward.TryDequeue(out var x))
+ toAward.Add(x);
+
+ if (!toAward.Any())
+ return;
+
+ try
+ {
+ await _cs.AddBulkAsync(toAward,
+ _amount,
+ new("event", "gamestatus")
+ );
+
+ if (_isPotLimited)
+ {
+ await msg.ModifyAsync(m =>
+ {
+ m.Embed = GetEmbed(PotSize).Build();
+ });
+ }
+
+ Log.Information("Game status event awarded {Count} users {Amount} currency.{Remaining}",
+ toAward.Count,
+ _amount,
+ _isPotLimited ? $" {PotSize} left." : "");
+
+ if (potEmpty)
+ _ = StopEvent();
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Error in OnTimerTick in gamestatusevent");
+ }
+ }
+
+ public async Task StartEvent()
+ {
+ msg = await _channel.EmbedAsync(GetEmbed(_opts.PotSize));
+ await _client.SetGameAsync(_code);
+ _client.MessageDeleted += OnMessageDeleted;
+ _client.MessageReceived += HandleMessage;
+ _t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
+ }
+
+ private IEmbedBuilder GetEmbed(long pot)
+ => _embedFunc(CurrencyEvent.Type.GameStatus, _opts, pot);
+
+ private async Task OnMessageDeleted(Cacheable message, Cacheable cacheable)
+ {
+ if (message.Id == msg.Id)
+ await StopEvent();
+ }
+
+ public Task StopEvent()
+ {
+ lock (_stopLock)
+ {
+ if (Stopped)
+ return Task.CompletedTask;
+ Stopped = true;
+ _client.MessageDeleted -= OnMessageDeleted;
+ _client.MessageReceived -= HandleMessage;
+ _t.Change(Timeout.Infinite, Timeout.Infinite);
+ _timeout?.Change(Timeout.Infinite, Timeout.Infinite);
+ _ = _client.SetGameAsync(null);
+ try
+ {
+ _ = msg.DeleteAsync();
+ }
+ catch { }
+
+ _ = OnEnded?.Invoke(_guild.Id);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private Task HandleMessage(SocketMessage message)
+ {
+ _ = Task.Run(async () =>
+ {
+ if (message.Author is not IGuildUser gu // no unknown users, as they could be bots, or alts
+ || gu.IsBot // no bots
+ || message.Content != _code // code has to be the same
+ || (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5) // no recently created accounts
+ return;
+ // there has to be money left in the pot
+ // and the user wasn't rewarded
+ if (_awardedUsers.Add(message.Author.Id) && TryTakeFromPot())
+ {
+ _toAward.Enqueue(message.Author.Id);
+ if (_isPotLimited && PotSize < _amount)
+ PotEmptied = true;
+ }
+
+ try
+ {
+ await message.DeleteAsync(new()
+ {
+ RetryMode = RetryMode.AlwaysFail
+ });
+ }
+ catch { }
+ });
+ return Task.CompletedTask;
+ }
+
+ private bool TryTakeFromPot()
+ {
+ if (_isPotLimited)
+ {
+ lock (_potLock)
+ {
+ if (PotSize < _amount)
+ return false;
+
+ PotSize -= _amount;
+ return true;
+ }
+ }
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Events/ICurrencyEvent.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/ICurrencyEvent.cs
new file mode 100644
index 0000000..fdb0431
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/ICurrencyEvent.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace Ellie.Modules.Gambling.Common;
+
+public interface ICurrencyEvent
+{
+ event Func OnEnded;
+ Task StopEvent();
+ Task StartEvent();
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Events/ReactionEvent.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/ReactionEvent.cs
new file mode 100644
index 0000000..85ecfe0
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/ReactionEvent.cs
@@ -0,0 +1,194 @@
+#nullable disable
+using Ellie.Services.Database.Models;
+
+namespace Ellie.Modules.Gambling.Common.Events;
+
+public class ReactionEvent : ICurrencyEvent
+{
+ public event Func OnEnded;
+ private long PotSize { get; set; }
+ public bool Stopped { get; private set; }
+ public bool PotEmptied { get; private set; }
+ private readonly DiscordSocketClient _client;
+ private readonly IGuild _guild;
+ private IUserMessage msg;
+ private IEmote emote;
+ private readonly ICurrencyService _cs;
+ private readonly long _amount;
+
+ private readonly Func _embedFunc;
+ private readonly bool _isPotLimited;
+ private readonly ITextChannel _channel;
+ private readonly ConcurrentHashSet _awardedUsers = new();
+ private readonly System.Collections.Concurrent.ConcurrentQueue _toAward = new();
+ private readonly Timer _t;
+ private readonly Timer _timeout;
+ private readonly bool _noRecentlyJoinedServer;
+ private readonly EventOptions _opts;
+ private readonly GamblingConfig _config;
+
+ private readonly object _stopLock = new();
+
+ private readonly object _potLock = new();
+
+ public ReactionEvent(
+ DiscordSocketClient client,
+ ICurrencyService cs,
+ SocketGuild g,
+ ITextChannel ch,
+ EventOptions opt,
+ GamblingConfig config,
+ Func embedFunc)
+ {
+ _client = client;
+ _guild = g;
+ _cs = cs;
+ _amount = opt.Amount;
+ PotSize = opt.PotSize;
+ _embedFunc = embedFunc;
+ _isPotLimited = PotSize > 0;
+ _channel = ch;
+ _noRecentlyJoinedServer = false;
+ _opts = opt;
+ _config = config;
+
+ _t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2));
+ if (_opts.Hours > 0)
+ _timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan);
+ }
+
+ private void EventTimeout(object state)
+ => _ = StopEvent();
+
+ private async void OnTimerTick(object state)
+ {
+ var potEmpty = PotEmptied;
+ var toAward = new List();
+ while (_toAward.TryDequeue(out var x))
+ toAward.Add(x);
+
+ if (!toAward.Any())
+ return;
+
+ try
+ {
+ await _cs.AddBulkAsync(toAward, _amount, new("event", "reaction"));
+
+ if (_isPotLimited)
+ {
+ await msg.ModifyAsync(m =>
+ {
+ m.Embed = GetEmbed(PotSize).Build();
+ });
+ }
+
+ Log.Information("Reaction Event awarded {Count} users {Amount} currency.{Remaining}",
+ toAward.Count,
+ _amount,
+ _isPotLimited ? $" {PotSize} left." : "");
+
+ if (potEmpty)
+ _ = StopEvent();
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Error adding bulk currency to users");
+ }
+ }
+
+ public async Task StartEvent()
+ {
+ if (Emote.TryParse(_config.Currency.Sign, out var parsedEmote))
+ emote = parsedEmote;
+ else
+ emote = new Emoji(_config.Currency.Sign);
+ msg = await _channel.EmbedAsync(GetEmbed(_opts.PotSize));
+ await msg.AddReactionAsync(emote);
+ _client.MessageDeleted += OnMessageDeleted;
+ _client.ReactionAdded += HandleReaction;
+ _t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
+ }
+
+ private IEmbedBuilder GetEmbed(long pot)
+ => _embedFunc(CurrencyEvent.Type.Reaction, _opts, pot);
+
+ private async Task OnMessageDeleted(Cacheable message, Cacheable cacheable)
+ {
+ if (message.Id == msg.Id)
+ await StopEvent();
+ }
+
+ public Task StopEvent()
+ {
+ lock (_stopLock)
+ {
+ if (Stopped)
+ return Task.CompletedTask;
+
+ Stopped = true;
+ _client.MessageDeleted -= OnMessageDeleted;
+ _client.ReactionAdded -= HandleReaction;
+ _t.Change(Timeout.Infinite, Timeout.Infinite);
+ _timeout?.Change(Timeout.Infinite, Timeout.Infinite);
+ try
+ {
+ _ = msg.DeleteAsync();
+ }
+ catch { }
+
+ _ = OnEnded?.Invoke(_guild.Id);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private Task HandleReaction(
+ Cacheable message,
+ Cacheable cacheable,
+ SocketReaction r)
+ {
+ _ = Task.Run(() =>
+ {
+ if (emote.Name != r.Emote.Name)
+ return;
+ if ((r.User.IsSpecified
+ ? r.User.Value
+ : null) is not IGuildUser gu // no unknown users, as they could be bots, or alts
+ || message.Id != msg.Id // same message
+ || gu.IsBot // no bots
+ || (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5 // no recently created accounts
+ || (_noRecentlyJoinedServer
+ && // if specified, no users who joined the server in the last 24h
+ (gu.JoinedAt is null
+ || (DateTime.UtcNow - gu.JoinedAt.Value).TotalDays
+ < 1))) // and no users for who we don't know when they joined
+ return;
+ // there has to be money left in the pot
+ // and the user wasn't rewarded
+ if (_awardedUsers.Add(r.UserId) && TryTakeFromPot())
+ {
+ _toAward.Enqueue(r.UserId);
+ if (_isPotLimited && PotSize < _amount)
+ PotEmptied = true;
+ }
+ });
+ return Task.CompletedTask;
+ }
+
+ private bool TryTakeFromPot()
+ {
+ if (_isPotLimited)
+ {
+ lock (_potLock)
+ {
+ if (PotSize < _amount)
+ return false;
+
+ PotSize -= _amount;
+ return true;
+ }
+ }
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/FlipCoin/FlipCoinCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/FlipCoin/FlipCoinCommands.cs
new file mode 100644
index 0000000..0b759b5
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/FlipCoin/FlipCoinCommands.cs
@@ -0,0 +1,140 @@
+#nullable disable
+using Ellie.Common.TypeReaders;
+using Ellie.Modules.Gambling.Common;
+using Ellie.Modules.Gambling.Services;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using Image = SixLabors.ImageSharp.Image;
+
+namespace Ellie.Modules.Gambling;
+
+public partial class Gambling
+{
+ [Group]
+ public partial class FlipCoinCommands : GamblingSubmodule
+ {
+ public enum BetFlipGuess : byte
+ {
+ H = 0,
+ Head = 0,
+ Heads = 0,
+ T = 1,
+ Tail = 1,
+ Tails = 1
+ }
+
+ private static readonly EllieRandom _rng = new();
+ private readonly IImageCache _images;
+ private readonly ICurrencyService _cs;
+ private readonly ImagesConfig _ic;
+
+ public FlipCoinCommands(
+ IImageCache images,
+ ImagesConfig ic,
+ ICurrencyService cs,
+ GamblingConfigService gss)
+ : base(gss)
+ {
+ _ic = ic;
+ _images = images;
+ _cs = cs;
+ }
+
+ [Cmd]
+ public async Task Flip(int count = 1)
+ {
+ if (count is > 10 or < 1)
+ {
+ await ReplyErrorLocalizedAsync(strs.flip_invalid(10));
+ return;
+ }
+
+ var headCount = 0;
+ var tailCount = 0;
+ var imgs = new Image[count];
+ var headsArr = await _images.GetHeadsImageAsync();
+ var tailsArr = await _images.GetTailsImageAsync();
+
+ var result = await _service.FlipAsync(count);
+
+ for (var i = 0; i < result.Length; i++)
+ {
+ if (result[i].Side == 0)
+ {
+ imgs[i] = Image.Load(headsArr);
+ headCount++;
+ }
+ else
+ {
+ imgs[i] = Image.Load(tailsArr);
+ tailCount++;
+ }
+ }
+
+ using var img = imgs.Merge(out var format);
+ await using var stream = await img.ToStreamAsync(format);
+ foreach (var i in imgs)
+ i.Dispose();
+
+ var imgName = $"coins.{format.FileExtensions.First()}";
+
+ var msg = count != 1
+ ? Format.Bold(GetText(strs.flip_results(count, headCount, tailCount)))
+ : GetText(strs.flipped(headCount > 0
+ ? Format.Bold(GetText(strs.heads))
+ : Format.Bold(GetText(strs.tails))));
+
+ var eb = _eb.Create(ctx)
+ .WithOkColor()
+ .WithAuthor(ctx.User)
+ .WithDescription(msg)
+ .WithImageUrl($"attachment://{imgName}");
+
+ await ctx.Channel.SendFileAsync(stream,
+ imgName,
+ embed: eb.Build());
+ }
+
+ [Cmd]
+ public async Task Betflip([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, BetFlipGuess guess)
+ {
+ if (!await CheckBetMandatory(amount) || amount == 1)
+ return;
+
+ var res = await _service.BetFlipAsync(ctx.User.Id, amount, (byte)guess);
+ if (!res.TryPickT0(out var result, out _))
+ {
+ await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
+ return;
+ }
+
+ Uri imageToSend;
+ var coins = _ic.Data.Coins;
+ if (result.Side == 0)
+ {
+ imageToSend = coins.Heads[_rng.Next(0, coins.Heads.Length)];
+ }
+ else
+ {
+ imageToSend = coins.Tails[_rng.Next(0, coins.Tails.Length)];
+ }
+
+ string str;
+ var won = (long)result.Won;
+ if (won > 0)
+ {
+ str = Format.Bold(GetText(strs.flip_guess(N(won))));
+ }
+ else
+ {
+ str = Format.Bold(GetText(strs.better_luck));
+ }
+
+ await ctx.Channel.EmbedAsync(_eb.Create()
+ .WithAuthor(ctx.User)
+ .WithDescription(str)
+ .WithOkColor()
+ .WithImageUrl(imageToSend.ToString()));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/FlipCoin/FlipResult.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/FlipCoin/FlipResult.cs
new file mode 100644
index 0000000..044f991
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/FlipCoin/FlipResult.cs
@@ -0,0 +1,7 @@
+namespace Ellie.Econ.Gambling;
+
+public readonly struct FlipResult
+{
+ public long Won { get; init; }
+ public int Side { get; init; }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Gambling.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Gambling.cs
new file mode 100644
index 0000000..141864a
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Gambling.cs
@@ -0,0 +1,1002 @@
+#nullable disable
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using Ellie.Db;
+using Ellie.Db.Models;
+using Ellie.Modules.Gambling.Bank;
+using Ellie.Modules.Gambling.Common;
+using Ellie.Modules.Gambling.Services;
+using Ellie.Modules.Utility.Services;
+using Ellie.Services.Currency;
+using Ellie.Services.Database.Models;
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Text;
+using Ellie.Econ.Gambling.Rps;
+using Ellie.Common.TypeReaders;
+using Ellie.Modules.Patronage;
+
+namespace Ellie.Modules.Gambling;
+
+public partial class Gambling : GamblingModule
+{
+ private readonly IGamblingService _gs;
+ private readonly DbService _db;
+ private readonly ICurrencyService _cs;
+ private readonly DiscordSocketClient _client;
+ private readonly NumberFormatInfo _enUsCulture;
+ private readonly DownloadTracker _tracker;
+ private readonly GamblingConfigService _configService;
+ private readonly IBankService _bank;
+ private readonly IPatronageService _ps;
+ private readonly IRemindService _remind;
+ private readonly GamblingTxTracker _gamblingTxTracker;
+
+ private IUserMessage rdMsg;
+
+ public Gambling(
+ IGamblingService gs,
+ DbService db,
+ ICurrencyService currency,
+ DiscordSocketClient client,
+ DownloadTracker tracker,
+ GamblingConfigService configService,
+ IBankService bank,
+ IPatronageService ps,
+ IRemindService remind,
+ GamblingTxTracker gamblingTxTracker)
+ : base(configService)
+ {
+ _gs = gs;
+ _db = db;
+ _cs = currency;
+ _client = client;
+ _bank = bank;
+ _ps = ps;
+ _remind = remind;
+ _gamblingTxTracker = gamblingTxTracker;
+
+ _enUsCulture = new CultureInfo("en-US", false).NumberFormat;
+ _enUsCulture.NumberDecimalDigits = 0;
+ _enUsCulture.NumberGroupSeparator = " ";
+ _tracker = tracker;
+ _configService = configService;
+ }
+
+ public async Task GetBalanceStringAsync(ulong userId)
+ {
+ var bal = await _cs.GetBalanceAsync(userId);
+ return N(bal);
+ }
+
+ [Cmd]
+ public async Task BetStats()
+ {
+ var stats = await _gamblingTxTracker.GetAllAsync();
+
+ var eb = _eb.Create(ctx)
+ .WithOkColor();
+
+ var str = "` Feature `|` Bet `|`Paid Out`|` RoI `\n";
+ str += "――――――――――――――――――――\n";
+ foreach (var stat in stats)
+ {
+ var perc = (stat.PaidOut / stat.Bet).ToString("P2", Culture);
+ str += $"`{stat.Feature.PadBoth(9)}`" +
+ $"|`{stat.Bet.ToString("N0").PadLeft(8, ' ')}`" +
+ $"|`{stat.PaidOut.ToString("N0").PadLeft(8, ' ')}`" +
+ $"|`{perc.PadLeft(6, ' ')}`\n";
+ }
+
+ var bet = stats.Sum(x => x.Bet);
+ var paidOut = stats.Sum(x => x.PaidOut);
+
+ if (bet == 0)
+ bet = 1;
+
+ var tPerc = (paidOut / bet).ToString("P2", Culture);
+ str += "――――――――――――――――――――\n";
+ str += $"` {("TOTAL").PadBoth(7)}` " +
+ $"|**{N(bet).PadLeft(8, ' ')}**" +
+ $"|**{N(paidOut).PadLeft(8, ' ')}**" +
+ $"|`{tPerc.PadLeft(6, ' ')}`";
+
+ eb.WithDescription(str);
+
+ await ctx.Channel.EmbedAsync(eb);
+ }
+
+ [Cmd]
+ public async Task Economy()
+ {
+ var ec = await _service.GetEconomyAsync();
+ decimal onePercent = 0;
+
+ // This stops the top 1% from owning more than 100% of the money
+ if (ec.Cash > 0)
+ {
+ onePercent = ec.OnePercent / (ec.Cash - ec.Bot);
+ }
+
+ // [21:03] Bob Page: Kinda remids me of US economy
+ var embed = _eb.Create()
+ .WithTitle(GetText(strs.economy_state))
+ .AddField(GetText(strs.currency_owned), N(ec.Cash - ec.Bot))
+ .AddField(GetText(strs.currency_one_percent), (onePercent * 100).ToString("F2") + "%")
+ .AddField(GetText(strs.currency_planted), N(ec.Planted))
+ .AddField(GetText(strs.owned_waifus_total), N(ec.Waifus))
+ .AddField(GetText(strs.bot_currency), N(ec.Bot))
+ .AddField(GetText(strs.bank_accounts), N(ec.Bank))
+ .AddField(GetText(strs.total), N(ec.Cash + ec.Planted + ec.Waifus + ec.Bank))
+ .WithOkColor();
+
+ // ec.Cash already contains ec.Bot as it's the total of all values in the CurrencyAmount column of the DiscordUser table
+ await ctx.Channel.EmbedAsync(embed);
+ }
+
+ private static readonly FeatureLimitKey _timelyKey = new FeatureLimitKey()
+ {
+ Key = "timely:extra_percent",
+ PrettyName = "Timely"
+ };
+
+ private async Task RemindTimelyAction(SocketMessageComponent smc, DateTime when)
+ {
+ var tt = TimestampTag.FromDateTime(when, TimestampTagStyles.Relative);
+
+ await _remind.AddReminderAsync(ctx.User.Id,
+ ctx.User.Id,
+ ctx.Guild?.Id,
+ true,
+ when,
+ GetText(strs.timely_time));
+
+ await smc.RespondConfirmAsync(_eb, GetText(strs.remind_timely(tt)), ephemeral: true);
+ }
+
+ [Cmd]
+ public async Task Timely()
+ {
+ var val = Config.Timely.Amount;
+ var period = Config.Timely.Cooldown;
+ if (val <= 0 || period <= 0)
+ {
+ await ReplyErrorLocalizedAsync(strs.timely_none);
+ return;
+ }
+
+ if (await _service.ClaimTimelyAsync(ctx.User.Id, period) is { } rem)
+ {
+ var now = DateTime.UtcNow;
+ var relativeTag = TimestampTag.FromDateTime(now.Add(rem), TimestampTagStyles.Relative);
+ await ReplyPendingLocalizedAsync(strs.timely_already_claimed(relativeTag));
+ return;
+ }
+
+ var result = await _ps.TryGetFeatureLimitAsync(_timelyKey, ctx.User.Id, 0);
+
+ val = (int)(val * (1 + (result.Quota! * 0.01f)));
+
+ await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim"));
+
+ var inter = _inter
+ .Create(ctx.User.Id,
+ new SimpleInteraction(
+ new ButtonBuilder(
+ label: "Remind me",
+ emote: Emoji.Parse("⏰"),
+ customId: "timely:remind_me"),
+ RemindTimelyAction,
+ DateTime.UtcNow.Add(TimeSpan.FromHours(period))));
+
+ await ReplyConfirmLocalizedAsync(strs.timely(N(val), period), inter);
+ }
+
+ [Cmd]
+ [OwnerOnly]
+ public async Task TimelyReset()
+ {
+ await _service.RemoveAllTimelyClaimsAsync();
+ await ReplyConfirmLocalizedAsync(strs.timely_reset);
+ }
+
+ [Cmd]
+ [OwnerOnly]
+ public async Task TimelySet(int amount, int period = 24)
+ {
+ if (amount < 0 || period < 0)
+ {
+ return;
+ }
+
+ _configService.ModifyConfig(gs =>
+ {
+ gs.Timely.Amount = amount;
+ gs.Timely.Cooldown = period;
+ });
+
+ if (amount == 0)
+ {
+ await ReplyConfirmLocalizedAsync(strs.timely_set_none);
+ }
+ else
+ {
+ await ReplyConfirmLocalizedAsync(strs.timely_set(Format.Bold(N(amount)), Format.Bold(period.ToString())));
+ }
+ }
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ public async Task Raffle([Leftover] IRole role = null)
+ {
+ role ??= ctx.Guild.EveryoneRole;
+
+ var members = (await role.GetMembersAsync()).Where(u => u.Status != UserStatus.Offline);
+ var membersArray = members as IUser[] ?? members.ToArray();
+ if (membersArray.Length == 0)
+ {
+ return;
+ }
+
+ var usr = membersArray[new EllieRandom().Next(0, membersArray.Length)];
+ await SendConfirmAsync("🎟 " + GetText(strs.raffled_user),
+ $"**{usr.Username}#{usr.Discriminator}**",
+ footer: $"ID: {usr.Id}");
+ }
+
+ [Cmd]
+ [RequireContext(ContextType.Guild)]
+ public async Task RaffleAny([Leftover] IRole role = null)
+ {
+ role ??= ctx.Guild.EveryoneRole;
+
+ var members = await role.GetMembersAsync();
+ var membersArray = members as IUser[] ?? members.ToArray();
+ if (membersArray.Length == 0)
+ {
+ return;
+ }
+
+ var usr = membersArray[new EllieRandom().Next(0, membersArray.Length)];
+ await SendConfirmAsync("🎟 " + GetText(strs.raffled_user),
+ $"**{usr.Username}#{usr.Discriminator}**",
+ footer: $"ID: {usr.Id}");
+ }
+
+ [Cmd]
+ [Priority(2)]
+ public Task CurrencyTransactions(int page = 1)
+ => InternalCurrencyTransactions(ctx.User.Id, page);
+
+ [Cmd]
+ [OwnerOnly]
+ [Priority(0)]
+ public Task CurrencyTransactions([Leftover] IUser usr)
+ => InternalCurrencyTransactions(usr.Id, 1);
+
+ [Cmd]
+ [OwnerOnly]
+ [Priority(1)]
+ public Task CurrencyTransactions(IUser usr, int page)
+ => InternalCurrencyTransactions(usr.Id, page);
+
+ private async Task InternalCurrencyTransactions(ulong userId, int page)
+ {
+ if (--page < 0)
+ {
+ return;
+ }
+
+ List trs;
+ await using (var uow = _db.GetDbContext())
+ {
+ trs = await uow.Set().GetPageFor(userId, page);
+ }
+
+ var embed = _eb.Create()
+ .WithTitle(GetText(strs.transactions(((SocketGuild)ctx.Guild)?.GetUser(userId)?.ToString()
+ ?? $"{userId}")))
+ .WithOkColor();
+
+ var sb = new StringBuilder();
+ foreach (var tr in trs)
+ {
+ var change = tr.Amount >= 0 ? "🔵" : "🔴";
+ var kwumId = new kwum(tr.Id).ToString();
+ var date = $"#{Format.Code(kwumId)} `〖{GetFormattedCurtrDate(tr)}〗`";
+
+ sb.AppendLine($"\\{change} {date} {Format.Bold(N(tr.Amount))}");
+ var transactionString = GetHumanReadableTransaction(tr.Type, tr.Extra, tr.OtherId);
+ if (transactionString is not null)
+ {
+ sb.AppendLine(transactionString);
+ }
+
+ if (!string.IsNullOrWhiteSpace(tr.Note))
+ {
+ sb.AppendLine($"\t`Note:` {tr.Note.TrimTo(50)}");
+ }
+ }
+
+ embed.WithDescription(sb.ToString());
+ embed.WithFooter(GetText(strs.page(page + 1)));
+ await ctx.Channel.EmbedAsync(embed);
+ }
+
+ private static string GetFormattedCurtrDate(CurrencyTransaction ct)
+ => $"{ct.DateAdded:HH:mm yyyy-MM-dd}";
+
+ [Cmd]
+ public async Task CurrencyTransaction(kwum id)
+ {
+ int intId = id;
+ await using var uow = _db.GetDbContext();
+
+ var tr = await uow.Set().ToLinqToDBTable()
+ .Where(x => x.Id == intId && x.UserId == ctx.User.Id)
+ .FirstOrDefaultAsync();
+
+ if (tr is null)
+ {
+ await ReplyErrorLocalizedAsync(strs.not_found);
+ return;
+ }
+
+ var eb = _eb.Create(ctx).WithOkColor();
+
+ eb.WithAuthor(ctx.User);
+ eb.WithTitle(GetText(strs.transaction));
+ eb.WithDescription(new kwum(tr.Id).ToString());
+ eb.AddField("Amount", N(tr.Amount));
+ eb.AddField("Type", tr.Type, true);
+ eb.AddField("Extra", tr.Extra, true);
+
+ if (tr.OtherId is ulong other)
+ {
+ eb.AddField("From Id", other);
+ }
+
+ if (!string.IsNullOrWhiteSpace(tr.Note))
+ {
+ eb.AddField("Note", tr.Note);
+ }
+
+ eb.WithFooter(GetFormattedCurtrDate(tr));
+
+ await ctx.Channel.EmbedAsync(eb);
+ }
+
+ private string GetHumanReadableTransaction(string type, string subType, ulong? maybeUserId)
+ => (type, subType, maybeUserId) switch
+ {
+ ("gift", var name, ulong userId) => GetText(strs.curtr_gift(name, userId)),
+ ("award", var name, ulong userId) => GetText(strs.curtr_award(name, userId)),
+ ("take", var name, ulong userId) => GetText(strs.curtr_take(name, userId)),
+ ("blackjack", _, _) => $"Blackjack - {subType}",
+ ("wheel", _, _) => $"Lucky Ladder - {subType}",
+ ("lula", _, _) => $"Lucky Ladder - {subType}",
+ ("rps", _, _) => $"Rock Paper Scissors - {subType}",
+ (null, _, _) => null,
+ (_, null, _) => null,
+ (_, _, ulong userId) => $"{type.Titleize()} - {subType.Titleize()} | [{userId}]",
+ _ => $"{type.Titleize()} - {subType.Titleize()}"
+ };
+
+ [Cmd]
+ [Priority(0)]
+ public async Task Cash(ulong userId)
+ {
+ var cur = await GetBalanceStringAsync(userId);
+ await ReplyConfirmLocalizedAsync(strs.has(Format.Code(userId.ToString()), cur));
+ }
+
+ private async Task BankAction(SocketMessageComponent smc, object _)
+ {
+ var balance = await _bank.GetBalanceAsync(ctx.User.Id);
+
+ await N(balance)
+ .Pipe(strs.bank_balance)
+ .Pipe(GetText)
+ .Pipe(text => smc.RespondConfirmAsync(_eb, text, ephemeral: true));
+ }
+
+ private EllieInteraction CreateCashInteraction()
+ => _inter.Create