From eb17820a50424ef3ad08300ae1e7f98e4ef6ddce Mon Sep 17 00:00:00 2001
From: Toastie <toastie@toastiet0ast.com>
Date: Sat, 21 Sep 2024 00:44:21 +1200
Subject: [PATCH] Added Gambling module

---
 .../Gambling/AnimalRacing/AnimalRace.cs       | 153 +++
 .../AnimalRacing/AnimalRaceService.cs         |   9 +
 .../AnimalRacing/AnimalRacingCommands.cs      | 197 ++++
 .../Gambling/AnimalRacing/AnimalRacingUser.cs |  26 +
 .../Exceptions/AlreadyJoinedException.cs      |  19 +
 .../Exceptions/AlreadyStartedException.cs     |  19 +
 .../Exceptions/AnimalRaceFullException.cs     |  19 +
 .../Exceptions/NotEnoughFundsException.cs     |  19 +
 .../Gambling/AnimalRacing/RaceOptions.cs      |  16 +
 .../Modules/Gambling/Bank/BankCommands.cs     | 139 +++
 .../Modules/Gambling/Bank/BankService.cs      | 115 +++
 .../Gambling/BlackJack/BlackJackCommands.cs   | 183 ++++
 .../Gambling/BlackJack/BlackJackService.cs    |   9 +
 .../Modules/Gambling/BlackJack/Blackjack.cs   | 329 +++++++
 .../Modules/Gambling/BlackJack/Player.cs      |  57 ++
 .../Modules/Gambling/Connect4/Connect4.cs     | 390 ++++++++
 .../Gambling/Connect4/Connect4Commands.cs     | 187 ++++
 .../Modules/Gambling/CurrencyProvider.cs      |  16 +
 .../Gambling/DiceRoll/DiceRollCommands.cs     | 224 +++++
 .../Modules/Gambling/Draw/DrawCommands.cs     | 246 +++++
 .../Modules/Gambling/EconomyResult.cs         |  12 +
 .../Gambling/Events/CurrencyEventsCommands.cs |  60 ++
 .../Gambling/Events/CurrencyEventsService.cs  |  70 ++
 .../Modules/Gambling/Events/EventOptions.cs   |  39 +
 .../Gambling/Events/GameStatusEvent.cs        | 195 ++++
 .../Modules/Gambling/Events/ICurrencyEvent.cs |   9 +
 .../Modules/Gambling/Events/ReactionEvent.cs  | 197 ++++
 .../Gambling/FlipCoin/FlipCoinCommands.cs     | 140 +++
 .../Modules/Gambling/FlipCoin/FlipResult.cs   |   7 +
 src/EllieBot/Modules/Gambling/Gambling.cs     | 903 ++++++++++++++++++
 .../Modules/Gambling/GamblingConfig.cs        | 411 ++++++++
 .../Modules/Gambling/GamblingConfigService.cs | 203 ++++
 .../Modules/Gambling/GamblingService.cs       | 187 ++++
 .../Gambling/GamblingTopLevelModule.cs        |  68 ++
 src/EllieBot/Modules/Gambling/InputRpsPick.cs |   3 +
 .../PlantPick/PlantAndPickCommands.cs         | 114 +++
 .../Gambling/PlantPick/PlantPickService.cs    | 390 ++++++++
 .../Modules/Gambling/Shop/IShopService.cs     |  46 +
 .../Modules/Gambling/Shop/ShopCommands.cs     | 597 ++++++++++++
 .../Modules/Gambling/Shop/ShopService.cs      | 126 +++
 .../Modules/Gambling/Slot/SlotCommands.cs     | 223 +++++
 .../Modules/Gambling/VoteRewardService.cs     | 106 ++
 .../Gambling/Waifus/WaifuClaimCommands.cs     | 406 ++++++++
 .../Modules/Gambling/Waifus/WaifuService.cs   | 633 ++++++++++++
 .../Gambling/Waifus/_common/AffinityTitle.cs  |  16 +
 .../Gambling/Waifus/_common/ClaimTitle.cs     |  18 +
 .../Gambling/Waifus/_common/DivorceResult.cs  |  10 +
 .../Gambling/Waifus/_common/Extensions.cs     |   6 +
 .../Waifus/_common/MultipleWaifuItems.cs      |   6 +
 .../_common/MultipleWaifuItemsTypeReader.cs   |  47 +
 .../Waifus/_common/WaifuClaimResult.cs        |   9 +
 .../Modules/Gambling/Waifus/db/Waifu.cs       |  17 +
 .../Gambling/Waifus/db/WaifuExtensions.cs     | 134 +++
 .../Gambling/Waifus/db/WaifuInfoStats.cs      |  14 +
 .../Modules/Gambling/Waifus/db/WaifuItem.cs   |  10 +
 .../Gambling/Waifus/db/WaifuLbResult.cs       |  16 +
 .../Modules/Gambling/Waifus/db/WaifuUpdate.cs |  15 +
 .../Gambling/Waifus/db/WaifuUpdateType.cs     |   8 +
 .../Gambling/_common/Decks/QuadDeck.cs        |  19 +
 .../_common/GamblingCleanupService.cs         |  56 ++
 .../_common/IGamblingCleanupService.cs        |   8 +
 .../Gambling/_common/IGamblingService.cs      |  17 +
 .../Gambling/_common/NewGamblingService.cs    | 268 ++++++
 .../Modules/Gambling/_common/RollDuelGame.cs  | 139 +++
 .../BaseShmartInputAmountReader.cs            |  94 ++
 .../ShmartBankInputAmountReader.cs            |  21 +
 .../TypeReaders/ShmartNumberTypeReader.cs     |  57 ++
 67 files changed, 8522 insertions(+)
 create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs
 create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRaceService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingCommands.cs
 create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingUser.cs
 create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs
 create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs
 create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs
 create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs
 create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Bank/BankCommands.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Bank/BankService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs
 create mode 100644 src/EllieBot/Modules/Gambling/BlackJack/BlackJackService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs
 create mode 100644 src/EllieBot/Modules/Gambling/BlackJack/Player.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Connect4/Connect4.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs
 create mode 100644 src/EllieBot/Modules/Gambling/CurrencyProvider.cs
 create mode 100644 src/EllieBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs
 create mode 100644 src/EllieBot/Modules/Gambling/EconomyResult.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Events/CurrencyEventsCommands.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Events/CurrencyEventsService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Events/EventOptions.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs
 create mode 100644 src/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs
 create mode 100644 src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Gambling.cs
 create mode 100644 src/EllieBot/Modules/Gambling/GamblingConfig.cs
 create mode 100644 src/EllieBot/Modules/Gambling/GamblingConfigService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/GamblingService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs
 create mode 100644 src/EllieBot/Modules/Gambling/InputRpsPick.cs
 create mode 100644 src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs
 create mode 100644 src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Shop/IShopService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Shop/ShopService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs
 create mode 100644 src/EllieBot/Modules/Gambling/VoteRewardService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/_common/AffinityTitle.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/_common/DivorceResult.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/_common/Extensions.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/_common/MultipleWaifuItems.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/_common/MultipleWaifuItemsTypeReader.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/_common/WaifuClaimResult.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs
 create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdateType.cs
 create mode 100644 src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs
 create mode 100644 src/EllieBot/Modules/Gambling/_common/GamblingCleanupService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/_common/IGamblingCleanupService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/_common/IGamblingService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs
 create mode 100644 src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs
 create mode 100644 src/EllieBot/Modules/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs
 create mode 100644 src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs
 create mode 100644 src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs

diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs
new file mode 100644
index 0000000..88f5eac
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs
@@ -0,0 +1,153 @@
+#nullable disable
+using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
+using EllieBot.Modules.Games.Common;
+
+namespace EllieBot.Modules.Gambling.Common.AnimalRacing;
+
+public sealed class AnimalRace : IDisposable
+{
+    public enum Phase
+    {
+        WaitingForPlayers,
+        Running,
+        Ended
+    }
+
+    public event Func<AnimalRace, Task> OnStarted = delegate { return Task.CompletedTask; };
+    public event Func<AnimalRace, Task> OnStartingFailed = delegate { return Task.CompletedTask; };
+    public event Func<AnimalRace, Task> OnStateUpdate = delegate { return Task.CompletedTask; };
+    public event Func<AnimalRace, Task> OnEnded = delegate { return Task.CompletedTask; };
+
+    public Phase CurrentPhase { get; private set; } = Phase.WaitingForPlayers;
+
+    public IReadOnlyCollection<AnimalRacingUser> Users
+        => _users.ToList();
+
+    public List<AnimalRacingUser> FinishedUsers { get; } = new();
+    public int MaxUsers { get; }
+
+    private readonly SemaphoreSlim _locker = new(1, 1);
+    private readonly HashSet<AnimalRacingUser> _users = new();
+    private readonly ICurrencyService _currency;
+    private readonly RaceOptions _options;
+    private readonly Queue<RaceAnimal> _animalsQueue;
+
+    public AnimalRace(RaceOptions options, ICurrencyService currency, IEnumerable<RaceAnimal> 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<AnimalRacingUser> JoinRace(ulong userId, string userName, long bet = 0)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(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 NadekoRandom();
+            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/EllieBot/Modules/Gambling/AnimalRacing/AnimalRaceService.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRaceService.cs
new file mode 100644
index 0000000..f4c99a8
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRaceService.cs
@@ -0,0 +1,9 @@
+#nullable disable
+using EllieBot.Modules.Gambling.Common.AnimalRacing;
+
+namespace EllieBot.Modules.Gambling.Services;
+
+public class AnimalRaceService : IEService
+{
+    public ConcurrentDictionary<ulong, AnimalRace> AnimalRaces { get; } = new();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingCommands.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingCommands.cs
new file mode 100644
index 0000000..1f23cf6
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingCommands.cs
@@ -0,0 +1,197 @@
+#nullable disable
+using EllieBot.Common.TypeReaders;
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Common.AnimalRacing;
+using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
+using EllieBot.Modules.Gambling.Services;
+using EllieBot.Modules.Games.Services;
+
+namespace EllieBot.Modules.Gambling;
+
+// wth is this, needs full rewrite
+public partial class Gambling
+{
+    [Group]
+    public partial class AnimalRacingCommands : GamblingSubmodule<AnimalRaceService>
+    {
+        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<RaceOptions>]
+        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 Response()
+                       .Error(GetText(strs.animal_race), GetText(strs.animal_race_already_started))
+                       .SendAsync();
+
+            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 Response()
+                           .Confirm(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)))
+                           .SendAsync();
+                }
+
+                ar.Dispose();
+                return Response()
+                       .Confirm(GetText(strs.animal_race),
+                           GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon)))
+                       .SendAsync();
+            }
+
+            ar.OnStartingFailed += Ar_OnStartingFailed;
+            ar.OnStateUpdate += Ar_OnStateUpdate;
+            ar.OnEnded += ArOnEnded;
+            ar.OnStarted += Ar_OnStarted;
+            _client.MessageReceived += ClientMessageReceived;
+
+            return Response()
+                   .Confirm(GetText(strs.animal_race),
+                       GetText(strs.animal_race_starting(options.StartTime)),
+                       footer: GetText(strs.animal_race_join_instr(prefix)))
+                   .SendAsync();
+        }
+
+        private Task Ar_OnStarted(AnimalRace race)
+        {
+            if (race.Users.Count == race.MaxUsers)
+                return Response().Confirm(GetText(strs.animal_race), GetText(strs.animal_race_full)).SendAsync();
+            return Response()
+                   .Confirm(GetText(strs.animal_race),
+                       GetText(strs.animal_race_starting_with_x(race.Users.Count)))
+                   .SendAsync();
+        }
+
+        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 Response().Confirm(text).SendAsync();
+            else
+            {
+                await msg.ModifyAsync(x => x.Embed = _sender.CreateEmbed()
+                                                        .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 Response().Error(strs.animal_race_failed).SendAsync();
+        }
+
+        [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 Response().Error(strs.race_not_exist).SendAsync();
+                return;
+            }
+
+            try
+            {
+                var user = await ar.JoinRace(ctx.User.Id, ctx.User.ToString(), amount);
+                if (amount > 0)
+                {
+                    await Response()
+                          .Confirm(GetText(strs.animal_race_join_bet(ctx.User.Mention,
+                              user.Animal.Icon,
+                              amount + CurrencySign)))
+                          .SendAsync();
+                }
+                else
+                    await Response()
+                          .Confirm(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon))
+                          .SendAsync();
+            }
+            catch (ArgumentOutOfRangeException)
+            {
+                //ignore if user inputed an invalid amount
+            }
+            catch (AlreadyJoinedException)
+            {
+                // just ignore this
+            }
+            catch (AlreadyStartedException)
+            {
+                //ignore
+            }
+            catch (AnimalRaceFullException)
+            {
+                await Response().Confirm(GetText(strs.animal_race), GetText(strs.animal_race_full)).SendAsync();
+            }
+            catch (NotEnoughFundsException)
+            {
+                await Response().Error(GetText(strs.not_enough(CurrencySign))).SendAsync();
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingUser.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingUser.cs
new file mode 100644
index 0000000..814b475
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingUser.cs
@@ -0,0 +1,26 @@
+#nullable disable
+using EllieBot.Modules.Games.Common;
+
+namespace EllieBot.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/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs
new file mode 100644
index 0000000..914b6a4
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs
@@ -0,0 +1,19 @@
+#nullable disable
+namespace EllieBot.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/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs
new file mode 100644
index 0000000..e662785
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs
@@ -0,0 +1,19 @@
+#nullable disable
+namespace EllieBot.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/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs
new file mode 100644
index 0000000..9a76b5b
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs
@@ -0,0 +1,19 @@
+#nullable disable
+namespace EllieBot.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/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs
new file mode 100644
index 0000000..b827761
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs
@@ -0,0 +1,19 @@
+#nullable disable
+namespace EllieBot.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/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs
new file mode 100644
index 0000000..fb0f8c9
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs
@@ -0,0 +1,16 @@
+#nullable disable
+using CommandLine;
+
+namespace EllieBot.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/EllieBot/Modules/Gambling/Bank/BankCommands.cs b/src/EllieBot/Modules/Gambling/Bank/BankCommands.cs
new file mode 100644
index 0000000..1005763
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Bank/BankCommands.cs
@@ -0,0 +1,139 @@
+using EllieBot.Common.TypeReaders;
+using EllieBot.Modules.Gambling.Bank;
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Services;
+
+namespace EllieBot.Modules.Gambling;
+
+public partial class Gambling
+{
+    [Name("Bank")]
+    [Group("bank")]
+    public partial class BankCommands : GamblingModule<IBankService>
+    {
+        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 Response().Confirm(strs.bank_deposited(N(amount))).SendAsync();
+            }
+            else
+            {
+                await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+            }
+        }
+        
+        [Cmd]
+        public async Task BankWithdraw([OverrideTypeReader(typeof(BankBalanceTypeReader))] long amount)
+        {
+            if (amount <= 0)
+                return;
+            
+            if (await _bank.WithdrawAsync(ctx.User.Id, amount))
+            {
+                await Response().Confirm(strs.bank_withdrew(N(amount))).SendAsync();
+            }
+            else
+            {
+                await Response().Error(strs.bank_withdraw_insuff(CurrencySign)).SendAsync();
+            }
+        }
+        
+        [Cmd]
+        public async Task BankBalance()
+        {
+            var bal = await _bank.GetBalanceAsync(ctx.User.Id);
+
+            var eb = _sender.CreateEmbed()
+                        .WithOkColor()
+                        .WithDescription(GetText(strs.bank_balance(N(bal))));
+
+            try
+            {
+                await Response().User(ctx.User).Embed(eb).SendAsync();
+                await ctx.OkAsync();
+            }
+            catch
+            {
+                await Response().Error(strs.cant_dm).SendAsync();
+            }
+        }
+        
+        [Cmd]
+        [OwnerOnly]
+        public async Task BankBalance([Leftover] IUser user)
+        {
+            var bal = await _bank.GetBalanceAsync(user.Id);
+
+            var eb = _sender.CreateEmbed()
+                            .WithOkColor()
+                            .WithDescription(GetText(strs.bank_balance_other(user.ToString(), N(bal))));
+
+            try
+            {
+                await Response().User(ctx.User).Embed(eb).SendAsync();
+                await ctx.OkAsync();
+            }
+            catch
+            {
+                await Response().Error(strs.cant_dm).SendAsync();
+            }
+        }
+
+        private async Task BankTakeInternalAsync(long amount, ulong userId)
+        {
+            if (await _bank.TakeAsync(userId, amount))
+            {
+                await ctx.OkAsync();
+                return;
+            }
+
+            await Response().Error(strs.take_fail(N(amount),
+                _client.GetUser(userId)?.ToString()
+                ?? userId.ToString(),
+                CurrencySign)).SendAsync();
+        }
+        
+        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/EllieBot/Modules/Gambling/Bank/BankService.cs b/src/EllieBot/Modules/Gambling/Bank/BankService.cs
new file mode 100644
index 0000000..0d75607
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Bank/BankService.cs
@@ -0,0 +1,115 @@
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using EllieBot.Db.Models;
+
+namespace EllieBot.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<bool> AwardAsync(ulong userId, long amount)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
+
+        await using var ctx = _db.GetDbContext();
+        await ctx.GetTable<BankUser>()
+            .InsertOrUpdateAsync(() => new()
+                {
+                    UserId = userId,
+                    Balance = amount
+                },
+                (old) => new()
+                {
+                    Balance = old.Balance + amount
+                },
+                () => new()
+                {
+                    UserId = userId
+                });
+
+        return true;
+    }
+    
+    public async Task<bool> TakeAsync(ulong userId, long amount)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
+
+        await using var ctx = _db.GetDbContext();
+        var rows = await ctx.Set<BankUser>()
+            .ToLinqToDBTable()
+            .Where(x => x.UserId == userId && x.Balance >= amount)
+            .UpdateAsync((old) => new()
+            {
+                Balance = old.Balance - amount
+            });
+        
+        return rows > 0;
+    }
+
+    public async Task<bool> DepositAsync(ulong userId, long amount)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
+
+        if (!await _cur.RemoveAsync(userId, amount, new("bank", "deposit")))
+            return false;
+
+        await using var ctx = _db.GetDbContext();
+        await ctx.Set<BankUser>()
+                 .ToLinqToDBTable()
+                 .InsertOrUpdateAsync(() => new()
+                     {
+                         UserId = userId,
+                         Balance = amount
+                     },
+                     (old) => new()
+                     {
+                         Balance = old.Balance + amount
+                     },
+                     () => new()
+                     {
+                         UserId = userId
+                     });
+
+        return true;
+    }
+
+    public async Task<bool> WithdrawAsync(ulong userId, long amount)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
+
+        await using var ctx = _db.GetDbContext();
+        var rows = await ctx.Set<BankUser>()
+                 .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<long> GetBalanceAsync(ulong userId)
+    {
+        await using var ctx = _db.GetDbContext();
+        return (await ctx.Set<BankUser>()
+                         .ToLinqToDBTable()
+                         .FirstOrDefaultAsync(x => x.UserId == userId))
+               ?.Balance
+               ?? 0;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs b/src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs
new file mode 100644
index 0000000..772cb4f
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs
@@ -0,0 +1,183 @@
+#nullable disable
+using EllieBot.Common.TypeReaders;
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Common.Blackjack;
+using EllieBot.Modules.Gambling.Services;
+
+namespace EllieBot.Modules.Gambling;
+
+public partial class Gambling
+{
+    public partial class BlackJackCommands : GamblingSubmodule<BlackJackService>
+    {
+        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 Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+                    return;
+                }
+
+                bj.StateUpdated += Bj_StateUpdated;
+                bj.GameEnded += Bj_GameEnded;
+                bj.Start();
+
+                await Response().NoReply().Confirm(strs.bj_created(ctx.User.ToString())).SendAsync();
+            }
+            else
+            {
+                if (await bj.Join(ctx.User, amount))
+                    await Response().NoReply().Confirm(strs.bj_joined(ctx.User.ToString())).SendAsync();
+                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 = _sender.CreateEmbed()
+                               .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 Response().Embed(embed).SendAsync();
+            }
+            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 Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+            }
+
+            await ctx.Message.DeleteAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/BlackJack/BlackJackService.cs b/src/EllieBot/Modules/Gambling/BlackJack/BlackJackService.cs
new file mode 100644
index 0000000..3bfb87c
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/BlackJack/BlackJackService.cs
@@ -0,0 +1,9 @@
+#nullable disable
+using EllieBot.Modules.Gambling.Common.Blackjack;
+
+namespace EllieBot.Modules.Gambling.Services;
+
+public class BlackJackService : IEService
+{
+    public ConcurrentDictionary<ulong, Blackjack> Games { get; } = new();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs b/src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs
new file mode 100644
index 0000000..e21d2cd
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs
@@ -0,0 +1,329 @@
+#nullable disable
+using Ellie.Econ;
+
+namespace EllieBot.Modules.Gambling.Common.Blackjack;
+
+public class Blackjack
+{
+    public enum GameState
+    {
+        Starting,
+        Playing,
+        Ended
+    }
+
+    public event Func<Blackjack, Task> StateUpdated;
+    public event Func<Blackjack, Task> GameEnded;
+
+    private Deck Deck { get; } = new QuadDeck();
+    public Dealer Dealer { get; set; }
+
+
+    public List<User> Players { get; set; } = new();
+    public GameState State { get; set; } = GameState.Starting;
+    public User CurrentUser { get; private set; }
+
+    private TaskCompletionSource<bool> 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 Ellie's Home 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<bool> 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<bool> Stand(IUser u)
+    {
+        var cu = CurrentUser;
+
+        if (cu is not null && cu.DiscordUser == u)
+            return await Stand(cu);
+
+        return false;
+    }
+
+    public async Task<bool> 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<bool> Double(IUser u)
+    {
+        var cu = CurrentUser;
+
+        if (cu is not null && cu.DiscordUser == u)
+            return await Double(cu);
+
+        return false;
+    }
+
+    public async Task<bool> 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<bool> Hit(IUser u)
+    {
+        var cu = CurrentUser;
+
+        if (cu is not null && cu.DiscordUser == u)
+            return await Hit(cu);
+
+        return false;
+    }
+
+    public async Task<bool> 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/EllieBot/Modules/Gambling/BlackJack/Player.cs b/src/EllieBot/Modules/Gambling/BlackJack/Player.cs
new file mode 100644
index 0000000..fb238c1
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/BlackJack/Player.cs
@@ -0,0 +1,57 @@
+#nullable disable
+using Ellie.Econ;
+
+namespace EllieBot.Modules.Gambling.Common.Blackjack;
+
+public abstract class Player
+{
+    public List<Deck.Card> 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)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bet);
+
+        Bet = bet;
+        DiscordUser = user;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Connect4/Connect4.cs b/src/EllieBot/Modules/Gambling/Connect4/Connect4.cs
new file mode 100644
index 0000000..0824a68
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Connect4/Connect4.cs
@@ -0,0 +1,390 @@
+#nullable disable
+using CommandLine;
+using System.Collections.Immutable;
+
+namespace EllieBot.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<Connect4Game, Task> OnGameStarted;
+    public event Func<Connect4Game, Task> OnGameStateUpdated;
+    public event Func<Connect4Game, Task> OnGameFailedToStart;
+    public event Func<Connect4Game, Result, Task> OnGameEnded;
+
+    public Phase CurrentPhase { get; private set; } = Phase.Joining;
+
+    public IReadOnlyList<Field> GameState
+        => _gameState.AsReadOnly();
+
+    public IReadOnlyCollection<(ulong UserId, string Username)?> Players
+        => _players.AsReadOnly();
+
+    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 EllieRandom _rng;
+
+    private Timer playerTimeoutTimer;
+
+    /* [ ][ ][ ][ ][ ][ ]
+     * [ ][ ][ ][ ][ ][ ]
+     * [ ][ ][ ][ ][ ][ ]
+     * [ ][ ][ ][ ][ ][ ]
+     * [ ][ ][ ][ ][ ][ ]
+     * [ ][ ][ ][ ][ ][ ]
+     * [ ][ ][ ][ ][ ][ ]
+     */
+
+    public Connect4Game(
+        ulong userId,
+        string userName,
+        Options options
+    )
+    {
+        _players[0] = (userId, userName);
+        _options = options;
+
+        _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;
+                }
+            }
+            finally { _locker.Release(); }
+        });
+    }
+
+    public async Task<bool> Join(ulong userId, string userName)
+    {
+        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 (_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<bool> 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)
+        {
+            return;
+        }
+    }
+
+    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;
+
+        public void NormalizeOptions()
+        {
+            if (TurnTimer is < 5 or > 60)
+                TurnTimer = 15;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs b/src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs
new file mode 100644
index 0000000..d9a245d
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs
@@ -0,0 +1,187 @@
+#nullable disable
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Common.Connect4;
+using EllieBot.Modules.Gambling.Services;
+using System.Text;
+
+namespace EllieBot.Modules.Gambling;
+
+public partial class Gambling
+{
+    [Group]
+    public partial class Connect4Commands : GamblingSubmodule<GamblingService>
+    {
+        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 IUserMessage msg;
+
+        private int repostCounter;
+
+        public Connect4Commands(DiscordSocketClient client, GamblingConfigService gamb)
+            : base(gamb)
+        {
+            _client = client;
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [EllieOptions<Connect4Game.Options>]
+        public async Task Connect4(params string[] args)
+        {
+            var (options, _) = OptionsParser.ParseFrom(new Connect4Game.Options(), args);
+
+            var newGame = new Connect4Game(ctx.User.Id, ctx.User.ToString(), options);
+            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());
+                return;
+            }
+
+            game.OnGameStateUpdated += Game_OnGameStateUpdated;
+            game.OnGameFailedToStart += GameOnGameFailedToStart;
+            game.OnGameEnded += GameOnGameEnded;
+            _client.MessageReceived += ClientMessageReceived;
+
+            game.Initialize();
+            await Response().Confirm(strs.connect4_created).SendAsync();
+
+            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 Response().Embed(msg.Embeds.First().ToEmbedBuilder()).SendAsync(); }
+                            catch { }
+                        }
+                    }
+                });
+                return Task.CompletedTask;
+            }
+
+            Task GameOnGameFailedToStart(Connect4Game arg)
+            {
+                if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose))
+                {
+                    _client.MessageReceived -= ClientMessageReceived;
+                    toDispose.Dispose();
+                }
+
+                return Response().Error(strs.connect4_failed_to_start).SendAsync();
+            }
+
+            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 = _sender.CreateEmbed()
+                                                             .WithTitle(title)
+                                                             .WithDescription(GetGameStateText(game))
+                                                             .WithOkColor()
+                                                             .Build());
+            }
+        }
+
+        private async Task Game_OnGameStateUpdated(Connect4Game game)
+        {
+            var embed = _sender.CreateEmbed()
+                               .WithTitle($"{game.CurrentPlayer.Username} vs {game.OtherPlayer.Username}")
+                               .WithDescription(GetGameStateText(game))
+                               .WithOkColor();
+
+
+            if (msg is null)
+                msg = await Response().Embed(embed).SendAsync();
+            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/EllieBot/Modules/Gambling/CurrencyProvider.cs b/src/EllieBot/Modules/Gambling/CurrencyProvider.cs
new file mode 100644
index 0000000..e4f4bc1
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/CurrencyProvider.cs
@@ -0,0 +1,16 @@
+using EllieBot.Modules.Gambling.Services;
+
+namespace EllieBot.Modules.Gambling;
+
+public sealed class CurrencyProvider : ICurrencyProvider, IEService
+{
+    private readonly GamblingConfigService _cs;
+
+    public CurrencyProvider(GamblingConfigService cs)
+    {
+        _cs = cs;
+    }
+
+    public string GetCurrencySign()
+        => _cs.Data.Currency.Sign;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs b/src/EllieBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs
new file mode 100644
index 0000000..15bf7ff
--- /dev/null
+++ b/src/EllieBot/Modules/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 EllieBot.Modules.Gambling;
+
+public partial class Gambling
+{
+    [Group]
+    public partial class DiceRollCommands : EllieModule
+    {
+        private static readonly Regex _dndRegex = new(@"^(?<n1>\d+)d(?<n2>\d+)(?:\+(?<add>\d+))?(?:\-(?<sub>\d+))?$",
+            RegexOptions.Compiled);
+
+        private static readonly Regex _fudgeRegex = new(@"^(?<n1>\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 = _sender.CreateEmbed()
+                .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 Response().Error(strs.dice_invalid_number(1, 30)).SendAsync();
+                return;
+            }
+
+            var rng = new EllieRandom();
+
+            var dice = new List<Image<Rgba32>>(num);
+            var values = new List<int>(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 = _sender.CreateEmbed()
+                .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<char>();
+
+                for (var i = 0; i < n1; i++)
+                    rolls.Add(_fateRolls[rng.Next(0, _fateRolls.Length)]);
+                var embed = _sender.CreateEmbed()
+                               .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 Response().Embed(embed).SendAsync();
+            }
+            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 = _sender.CreateEmbed()
+                                   .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 Response().Embed(embed).SendAsync();
+                }
+            }
+        }
+
+        [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 Response().Error(strs.second_larger_than_first).SendAsync();
+                    return;
+                }
+
+                rolled = new EllieRandom().Next(arr[0], arr[1] + 1);
+            }
+            else
+                rolled = new EllieRandom().Next(0, int.Parse(range) + 1);
+
+            await Response().Confirm(strs.dice_rolled(Format.Bold(rolled.ToString()))).SendAsync();
+        }
+
+        private async Task<Image<Rgba32>> GetDiceAsync(int num)
+        {
+            if (num is < 0 or > 10)
+                throw new ArgumentOutOfRangeException(nameof(num));
+
+            if (num == 10)
+            {
+                using var imgOne = Image.Load<Rgba32>(await _images.GetDiceAsync(1));
+                using var imgZero = Image.Load<Rgba32>(await _images.GetDiceAsync(0));
+                return new[] { imgOne, imgZero }.Merge();
+            }
+
+            return Image.Load<Rgba32>(await _images.GetDiceAsync(num));
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs b/src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs
new file mode 100644
index 0000000..e39003d
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs
@@ -0,0 +1,246 @@
+#nullable disable
+using Ellie.Econ;
+using EllieBot.Common.TypeReaders;
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Services;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using Image = SixLabors.ImageSharp.Image;
+
+namespace EllieBot.Modules.Gambling;
+
+public partial class Gambling
+{
+    [Group]
+    public partial class DrawCommands : GamblingSubmodule<IGamblingService>
+    {
+        private static readonly ConcurrentDictionary<IGuild, Deck> _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<Image<Rgba32>>();
+            var cardObjects = new List<Deck.Card>();
+            for (var i = 0; i < count; i++)
+            {
+                if (cards.CardPool.Count == 0 && i != 0)
+                {
+                    try
+                    {
+                        await Response().Error(strs.no_more_cards).SendAsync();
+                    }
+                    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 = _sender.CreateEmbed()
+                            .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<Image<Rgba32>> GetCardImageAsync(RegularCard currentCard)
+        {
+            var cardName = currentCard.GetName().ToLowerInvariant().Replace(' ', '_');
+            var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg");
+            return Image.Load<Rgba32>(cardBytes);
+        }
+
+        private async Task<Image<Rgba32>> GetCardImageAsync(Deck.Card currentCard)
+        {
+            var cardName = currentCard.ToString().ToLowerInvariant().Replace(' ', '_');
+            var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg");
+            return Image.Load<Rgba32>(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 Response().Confirm(strs.deck_reshuffled).SendAsync();
+        }
+
+        [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 (!await CheckBetMandatory(amount))
+            {
+                return;
+            }
+
+            var res = await _service.BetDrawAsync(ctx.User.Id,
+                amount,
+                (byte?)val,
+                (byte?)col);
+
+            if (!res.TryPickT0(out var result, out _))
+            {
+                await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+                return;
+            }
+
+            var eb = _sender.CreateEmbed()
+                            .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/EllieBot/Modules/Gambling/EconomyResult.cs b/src/EllieBot/Modules/Gambling/EconomyResult.cs
new file mode 100644
index 0000000..12a00f8
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/EconomyResult.cs
@@ -0,0 +1,12 @@
+#nullable disable
+namespace EllieBot.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; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Events/CurrencyEventsCommands.cs b/src/EllieBot/Modules/Gambling/Events/CurrencyEventsCommands.cs
new file mode 100644
index 0000000..c5e836c
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Events/CurrencyEventsCommands.cs
@@ -0,0 +1,60 @@
+#nullable disable
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Common.Events;
+using EllieBot.Modules.Gambling.Services;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Gambling;
+
+public partial class Gambling
+{
+    [Group]
+    public partial class CurrencyEventsCommands : GamblingSubmodule<CurrencyEventsService>
+    {
+        public CurrencyEventsCommands(GamblingConfigService gamblingConf)
+            : base(gamblingConf)
+        {
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [EllieOptions<EventOptions>]
+        [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 Response().Error(strs.start_event_fail).SendAsync();
+        }
+
+        private EmbedBuilder GetEmbed(CurrencyEvent.Type type, EventOptions opts, long currentPot)
+            => type switch
+            {
+                CurrencyEvent.Type.Reaction => _sender.CreateEmbed()
+                                                  .WithOkColor()
+                                                  .WithTitle(GetText(strs.event_title(type.ToString())))
+                                                  .WithDescription(GetReactionDescription(opts.Amount, currentPot))
+                                                  .WithFooter(GetText(strs.event_duration_footer(opts.Hours))),
+                CurrencyEvent.Type.GameStatus => _sender.CreateEmbed()
+                                                    .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/EllieBot/Modules/Gambling/Events/CurrencyEventsService.cs b/src/EllieBot/Modules/Gambling/Events/CurrencyEventsService.cs
new file mode 100644
index 0000000..39160ff
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Events/CurrencyEventsService.cs
@@ -0,0 +1,70 @@
+#nullable disable
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Common.Events;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Gambling.Services;
+
+public class CurrencyEventsService : IEService
+{
+    private readonly DiscordSocketClient _client;
+    private readonly ICurrencyService _cs;
+    private readonly GamblingConfigService _configService;
+
+    private readonly ConcurrentDictionary<ulong, ICurrencyEvent> _events = new();
+    private readonly IMessageSenderService _sender;
+
+    public CurrencyEventsService(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService configService,
+        IMessageSenderService sender)
+    {
+        _client = client;
+        _cs = cs;
+        _configService = configService;
+        _sender = sender;
+    }
+
+    public async Task<bool> TryCreateEventAsync(
+        ulong guildId,
+        ulong channelId,
+        CurrencyEvent.Type type,
+        EventOptions opts,
+        Func<CurrencyEvent.Type, EventOptions, long, EmbedBuilder> 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, _sender, embed);
+        else if (type == CurrencyEvent.Type.GameStatus)
+            ce = new GameStatusEvent(_client, _cs, g, ch, opts, _sender, 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/EllieBot/Modules/Gambling/Events/EventOptions.cs b/src/EllieBot/Modules/Gambling/Events/EventOptions.cs
new file mode 100644
index 0000000..3d0eb3f
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Events/EventOptions.cs
@@ -0,0 +1,39 @@
+#nullable disable
+using CommandLine;
+
+namespace EllieBot.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/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs b/src/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs
new file mode 100644
index 0000000..73b97c0
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs
@@ -0,0 +1,195 @@
+#nullable disable
+using EllieBot.Db.Models;
+using System.Collections.Concurrent;
+
+namespace EllieBot.Modules.Gambling.Common.Events;
+
+public class GameStatusEvent : ICurrencyEvent
+{
+    public event Func<ulong, Task> 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<CurrencyEvent.Type, EventOptions, long, EmbedBuilder> _embedFunc;
+    private readonly bool _isPotLimited;
+    private readonly ITextChannel _channel;
+    private readonly ConcurrentHashSet<ulong> _awardedUsers = new();
+    private readonly ConcurrentQueue<ulong> _toAward = new();
+    private readonly Timer _t;
+    private readonly Timer _timeout;
+    private readonly EventOptions _opts;
+
+    private readonly string _code;
+
+    private readonly object _stopLock = new();
+
+    private readonly object _potLock = new();
+    private readonly IMessageSenderService _sender;
+
+    private static readonly EllieRandom _rng = new EllieRandom();
+
+    public GameStatusEvent(
+        DiscordSocketClient client,
+        ICurrencyService cs,
+        SocketGuild g,
+        ITextChannel ch,
+        EventOptions opt,
+        IMessageSenderService sender,
+        Func<CurrencyEvent.Type, EventOptions, long, EmbedBuilder> embedFunc)
+    {
+        _client = client;
+        _guild = g;
+        _cs = cs;
+        _amount = opt.Amount;
+        PotSize = opt.PotSize;
+        _embedFunc = embedFunc;
+        _isPotLimited = PotSize > 0;
+        _channel = ch;
+        _opts = opt;
+        _sender = sender;
+        // generate code
+
+        _code = new kwum(_rng.Next(1_000_000, 10_000_000)).ToString();
+
+        _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<ulong>();
+        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 _sender.Response(_channel).Embed(GetEmbed(_opts.PotSize)).SendAsync();
+        await _client.SetGameAsync(_code);
+        _client.MessageDeleted += OnMessageDeleted;
+        _client.MessageReceived += HandleMessage;
+        _t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
+    }
+
+    private EmbedBuilder GetEmbed(long pot)
+        => _embedFunc(CurrencyEvent.Type.GameStatus, _opts, pot);
+
+    private async Task OnMessageDeleted(Cacheable<IMessage, ulong> message, Cacheable<IMessageChannel, ulong> 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/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs b/src/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs
new file mode 100644
index 0000000..57b96d9
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace EllieBot.Modules.Gambling.Common;
+
+public interface ICurrencyEvent
+{
+    event Func<ulong, Task> OnEnded;
+    Task StopEvent();
+    Task StartEvent();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs b/src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs
new file mode 100644
index 0000000..6f02747
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs
@@ -0,0 +1,197 @@
+#nullable disable
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Gambling.Common.Events;
+
+public class ReactionEvent : ICurrencyEvent
+{
+    public event Func<ulong, Task> 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<CurrencyEvent.Type, EventOptions, long, EmbedBuilder> _embedFunc;
+    private readonly bool _isPotLimited;
+    private readonly ITextChannel _channel;
+    private readonly ConcurrentHashSet<ulong> _awardedUsers = new();
+    private readonly System.Collections.Concurrent.ConcurrentQueue<ulong> _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();
+    private readonly IMessageSenderService _sender;
+
+    public ReactionEvent(
+        DiscordSocketClient client,
+        ICurrencyService cs,
+        SocketGuild g,
+        ITextChannel ch,
+        EventOptions opt,
+        GamblingConfig config,
+        IMessageSenderService sender,
+        Func<CurrencyEvent.Type, EventOptions, long, EmbedBuilder> 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;
+        _sender = sender;
+
+        _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<ulong>();
+        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 _sender.Response(_channel).Embed(GetEmbed(_opts.PotSize)).SendAsync();
+        await msg.AddReactionAsync(emote);
+        _client.MessageDeleted += OnMessageDeleted;
+        _client.ReactionAdded += HandleReaction;
+        _t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
+    }
+
+    private EmbedBuilder GetEmbed(long pot)
+        => _embedFunc(CurrencyEvent.Type.Reaction, _opts, pot);
+
+    private async Task OnMessageDeleted(Cacheable<IMessage, ulong> message, Cacheable<IMessageChannel, ulong> 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<IUserMessage, ulong> message,
+        Cacheable<IMessageChannel, ulong> 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/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs b/src/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs
new file mode 100644
index 0000000..2704495
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs
@@ -0,0 +1,140 @@
+#nullable disable
+using EllieBot.Common.TypeReaders;
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Services;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using Image = SixLabors.ImageSharp.Image;
+
+namespace EllieBot.Modules.Gambling;
+
+public partial class Gambling
+{
+    [Group]
+    public partial class FlipCoinCommands : GamblingSubmodule<IGamblingService>
+    {
+        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 Response().Error(strs.flip_invalid(10)).SendAsync();
+                return;
+            }
+
+            var headCount = 0;
+            var tailCount = 0;
+            var imgs = new Image<Rgba32>[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<Rgba32>(headsArr);
+                    headCount++;
+                }
+                else
+                {
+                    imgs[i] = Image.Load<Rgba32>(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 = _sender.CreateEmbed()
+                .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 Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+                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 Response().Embed(_sender.CreateEmbed()
+                .WithAuthor(ctx.User)
+                                            .WithDescription(str)
+                                            .WithOkColor()
+                                            .WithImageUrl(imageToSend.ToString())).SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs b/src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs
new file mode 100644
index 0000000..6c16b9f
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.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/EllieBot/Modules/Gambling/Gambling.cs b/src/EllieBot/Modules/Gambling/Gambling.cs
new file mode 100644
index 0000000..64fedf5
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Gambling.cs
@@ -0,0 +1,903 @@
+#nullable disable
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using EllieBot.Db.Models;
+using EllieBot.Modules.Gambling.Bank;
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Services;
+using EllieBot.Modules.Utility.Services;
+using EllieBot.Services.Currency;
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Text;
+using EllieBot.Modules.Gambling.Rps;
+using EllieBot.Common.TypeReaders;
+using EllieBot.Modules.Patronage;
+
+namespace EllieBot.Modules.Gambling;
+
+public partial class Gambling : GamblingModule<GamblingService>
+{
+    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 IRemindService _remind;
+    private readonly GamblingTxTracker _gamblingTxTracker;
+    private readonly IPatronageService _ps;
+
+    public Gambling(
+        IGamblingService gs,
+        DbService db,
+        ICurrencyService currency,
+        DiscordSocketClient client,
+        DownloadTracker tracker,
+        GamblingConfigService configService,
+        IBankService bank,
+        IRemindService remind,
+        IPatronageService patronage,
+        GamblingTxTracker gamblingTxTracker)
+        : base(configService)
+    {
+        _gs = gs;
+        _db = db;
+        _cs = currency;
+        _client = client;
+        _bank = bank;
+        _remind = remind;
+        _gamblingTxTracker = gamblingTxTracker;
+        _ps = patronage;
+
+        _enUsCulture = new CultureInfo("en-US", false).NumberFormat;
+        _enUsCulture.NumberDecimalDigits = 0;
+        _enUsCulture.NumberGroupSeparator = " ";
+        _tracker = tracker;
+        _configService = configService;
+    }
+
+    public async Task<string> GetBalanceStringAsync(ulong userId)
+    {
+        var bal = await _cs.GetBalanceAsync(userId);
+        return N(bal);
+    }
+
+    [Cmd]
+    public async Task BetStats()
+    {
+        var stats = await _gamblingTxTracker.GetAllAsync();
+
+        var eb = _sender.CreateEmbed()
+                        .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 Response().Embed(eb).SendAsync();
+    }
+
+    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),
+            ReminderType.Timely);
+
+        await smc.RespondConfirmAsync(_sender, GetText(strs.remind_timely(tt)), ephemeral: true);
+    }
+
+    // Creates timely reminder button, parameter in hours.
+    private EllieInteractionBase CreateRemindMeInteraction(int period)
+        => _inter
+            .Create(ctx.User.Id,
+                new ButtonBuilder(
+                    label: "Remind me",
+                    emote: Emoji.Parse("⏰"),
+                    customId: "timely:remind_me"),
+                (smc) => RemindTimelyAction(smc, DateTime.UtcNow.Add(TimeSpan.FromHours(period)))
+            );
+            
+    // Creates timely reminder button, parameter in milliseconds.
+    private EllieInteractionBase CreateRemindMeInteraction(double ms)
+        => _inter
+            .Create(ctx.User.Id,
+                new ButtonBuilder(
+                    label: "Remind me",
+                    emote: Emoji.Parse("⏰"),
+                    customId: "timely:remind_me"),
+                (smc) => RemindTimelyAction(smc, DateTime.UtcNow.Add(TimeSpan.FromMilliseconds(ms)))
+            );
+
+    [Cmd]
+    public async Task Timely()
+    {
+        var val = Config.Timely.Amount;
+        var period = Config.Timely.Cooldown;
+        if (val <= 0 || period <= 0)
+        {
+            await Response().Error(strs.timely_none).SendAsync();
+            return;
+        }
+
+        if (await _service.ClaimTimelyAsync(ctx.User.Id, period) is { } remainder)
+        {
+            // Get correct time form remainder
+            var interaction = CreateRemindMeInteraction(remainder.TotalMilliseconds);
+
+            // Removes timely button if there is a timely reminder in DB
+            if (_service.UserHasTimelyReminder(ctx.User.Id))
+            {
+                interaction = null;
+            }
+
+            var now = DateTime.UtcNow;
+            var relativeTag = TimestampTag.FromDateTime(now.Add(remainder), TimestampTagStyles.Relative);
+            await Response().Pending(strs.timely_already_claimed(relativeTag)).Interaction(interaction).SendAsync();
+            return;
+        }
+        
+
+        var patron = await _ps.GetPatronAsync(ctx.User.Id);
+
+        var percentBonus = (_ps.PercentBonus(patron) / 100f);
+
+        val += (int)(val * percentBonus);
+
+        var inter = CreateRemindMeInteraction(period);
+
+        await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim"));
+
+        await Response().Confirm(strs.timely(N(val), period)).Interaction(inter).SendAsync();
+    }
+
+    [Cmd]
+    [OwnerOnly]
+    public async Task TimelyReset()
+    {
+        await _service.RemoveAllTimelyClaimsAsync();
+        await Response().Confirm(strs.timely_reset).SendAsync();
+    }
+
+    [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 Response().Confirm(strs.timely_set_none).SendAsync();
+        }
+        else
+        {
+            await Response()
+                  .Confirm(strs.timely_set(Format.Bold(N(amount)), Format.Bold(period.ToString())))
+                  .SendAsync();
+        }
+    }
+
+    [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 Response()
+              .Confirm("🎟 " + GetText(strs.raffled_user),
+                  $"**{usr.Username}**",
+                  footer: $"ID: {usr.Id}")
+              .SendAsync();
+    }
+
+    [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 Response()
+              .Confirm("🎟 " + GetText(strs.raffled_user),
+                  $"**{usr.Username}**",
+                  footer: $"ID: {usr.Id}")
+              .SendAsync();
+    }
+
+    [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<CurrencyTransaction> trs;
+        await using (var uow = _db.GetDbContext())
+        {
+            trs = await uow.Set<CurrencyTransaction>().GetPageFor(userId, page);
+        }
+
+        var embed = _sender.CreateEmbed()
+                           .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 Response().Embed(embed).SendAsync();
+    }
+
+    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<CurrencyTransaction>()
+                          .ToLinqToDBTable()
+                          .Where(x => x.Id == intId && x.UserId == ctx.User.Id)
+                          .FirstOrDefaultAsync();
+
+        if (tr is null)
+        {
+            await Response().Error(strs.not_found).SendAsync();
+            return;
+        }
+
+        var eb = _sender.CreateEmbed().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 Response().Embed(eb).SendAsync();
+    }
+
+    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} - {subType} | [{userId}]",
+            _ => $"{type} - {subType}"
+        };
+
+    [Cmd]
+    [Priority(0)]
+    public async Task Cash(ulong userId)
+    {
+        var cur = await GetBalanceStringAsync(userId);
+        await Response().Confirm(strs.has(Format.Code(userId.ToString()), cur)).SendAsync();
+    }
+
+    private async Task BankAction(SocketMessageComponent smc)
+    {
+        var balance = await _bank.GetBalanceAsync(ctx.User.Id);
+
+        await N(balance)
+              .Pipe(strs.bank_balance)
+              .Pipe(GetText)
+              .Pipe(text => smc.RespondConfirmAsync(_sender, text, ephemeral: true));
+    }
+
+    private EllieInteractionBase CreateCashInteraction()
+        => _inter.Create(ctx.User.Id,
+            new ButtonBuilder(
+                customId: "cash:bank_show_balance",
+                emote: new Emoji("🏦")),
+            BankAction);
+
+    [Cmd]
+    [Priority(1)]
+    public async Task Cash([Leftover] IUser user = null)
+    {
+        user ??= ctx.User;
+        var cur = await GetBalanceStringAsync(user.Id);
+
+        var inter = user == ctx.User
+            ? CreateCashInteraction()
+            : null;
+
+        await Response()
+              .Confirm(
+                  user.ToString()
+                      .Pipe(Format.Bold)
+                      .With(cur)
+                      .Pipe(strs.has))
+              .Interaction(inter)
+              .SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [Priority(0)]
+    public async Task Give(
+        [OverrideTypeReader(typeof(BalanceTypeReader))]
+        long amount,
+        IGuildUser receiver,
+        [Leftover] string msg)
+    {
+        if (amount <= 0 || ctx.User.Id == receiver.Id || receiver.IsBot)
+        {
+            return;
+        }
+
+        if (!await _cs.TransferAsync(_sender, ctx.User, receiver, amount, msg, N(amount)))
+        {
+            await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+            return;
+        }
+
+        await Response().Confirm(strs.gifted(N(amount), Format.Bold(receiver.ToString()), ctx.User)).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [Priority(1)]
+    public Task Give([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, [Leftover] IGuildUser receiver)
+        => Give(amount, receiver, null);
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [OwnerOnly]
+    [Priority(0)]
+    public Task Award(long amount, IGuildUser usr, [Leftover] string msg)
+        => Award(amount, usr.Id, msg);
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [OwnerOnly]
+    [Priority(1)]
+    public Task Award(long amount, [Leftover] IGuildUser usr)
+        => Award(amount, usr.Id);
+
+    [Cmd]
+    [OwnerOnly]
+    [Priority(2)]
+    public async Task Award(long amount, ulong usrId, [Leftover] string msg = null)
+    {
+        if (amount <= 0)
+        {
+            return;
+        }
+
+        var usr = await ((DiscordSocketClient)Context.Client).Rest.GetUserAsync(usrId);
+
+        if (usr is null)
+        {
+            await Response().Error(strs.user_not_found).SendAsync();
+            return;
+        }
+
+        await _cs.AddAsync(usr.Id, amount, new("award", ctx.User.ToString()!, msg, ctx.User.Id));
+        await Response().Confirm(strs.awarded(N(amount), $"<@{usrId}>", ctx.User)).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [OwnerOnly]
+    [Priority(3)]
+    public async Task Award(long amount, [Leftover] IRole role)
+    {
+        var users = (await ctx.Guild.GetUsersAsync()).Where(u => u.GetRoles().Contains(role)).ToList();
+
+        await _cs.AddBulkAsync(users.Select(x => x.Id).ToList(),
+            amount,
+            new("award", ctx.User.ToString()!, role.Name, ctx.User.Id));
+
+        await Response()
+              .Confirm(strs.mass_award(N(amount),
+                  Format.Bold(users.Count.ToString()),
+                  Format.Bold(role.Name)))
+              .SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [OwnerOnly]
+    [Priority(0)]
+    public async Task Take(long amount, [Leftover] IRole role)
+    {
+        var users = (await role.GetMembersAsync()).ToList();
+
+        await _cs.RemoveBulkAsync(users.Select(x => x.Id).ToList(),
+            amount,
+            new("take", ctx.User.ToString()!, null, ctx.User.Id));
+
+        await Response()
+              .Confirm(strs.mass_take(N(amount),
+                  Format.Bold(users.Count.ToString()),
+                  Format.Bold(role.Name)))
+              .SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [OwnerOnly]
+    [Priority(1)]
+    public async Task Take(long amount, [Leftover] IGuildUser user)
+    {
+        if (amount <= 0)
+        {
+            return;
+        }
+
+        var extra = new TxData("take", ctx.User.ToString()!, null, ctx.User.Id);
+
+        if (await _cs.RemoveAsync(user.Id, amount, extra))
+        {
+            await Response().Confirm(strs.take(N(amount), Format.Bold(user.ToString()))).SendAsync();
+        }
+        else
+        {
+            await Response().Error(strs.take_fail(N(amount), Format.Bold(user.ToString()), CurrencySign)).SendAsync();
+        }
+    }
+
+    [Cmd]
+    [OwnerOnly]
+    public async Task Take(long amount, [Leftover] ulong usrId)
+    {
+        if (amount <= 0)
+        {
+            return;
+        }
+
+        var extra = new TxData("take", ctx.User.ToString()!, null, ctx.User.Id);
+
+        if (await _cs.RemoveAsync(usrId, amount, extra))
+        {
+            await Response().Confirm(strs.take(N(amount), $"<@{usrId}>")).SendAsync();
+        }
+        else
+        {
+            await Response().Error(strs.take_fail(N(amount), Format.Code(usrId.ToString()), CurrencySign)).SendAsync();
+        }
+    }
+
+    [Cmd]
+    public async Task BetRoll([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
+    {
+        if (!await CheckBetMandatory(amount))
+        {
+            return;
+        }
+
+        var maybeResult = await _gs.BetRollAsync(ctx.User.Id, amount);
+        if (!maybeResult.TryPickT0(out var result, out _))
+        {
+            await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+            return;
+        }
+
+
+        var win = (long)result.Won;
+        string str;
+        if (win > 0)
+        {
+            str = GetText(strs.br_win(N(win), result.Threshold + (result.Roll == 100 ? " 👑" : "")));
+        }
+        else
+        {
+            str = GetText(strs.better_luck);
+        }
+
+        var eb = _sender.CreateEmbed()
+                        .WithAuthor(ctx.User)
+                        .WithDescription(Format.Bold(str))
+                        .AddField(GetText(strs.roll2), result.Roll.ToString(CultureInfo.InvariantCulture))
+                        .WithOkColor();
+
+        await Response().Embed(eb).SendAsync();
+    }
+
+    [Cmd]
+    [EllieOptions<LbOpts>]
+    [Priority(0)]
+    public Task Leaderboard(params string[] args)
+        => Leaderboard(1, args);
+
+    [Cmd]
+    [EllieOptions<LbOpts>]
+    [Priority(1)]
+    public async Task Leaderboard(int page = 1, params string[] args)
+    {
+        if (--page < 0)
+        {
+            return;
+        }
+
+        var (opts, _) = OptionsParser.ParseFrom(new LbOpts(), args);
+
+        // List<DiscordUser> cleanRichest;
+        // it's pointless to have clean on dm context
+        if (ctx.Guild is null)
+        {
+            opts.Clean = false;
+        }
+
+
+        async Task<IReadOnlyCollection<DiscordUser>> GetTopRichest(int curPage)
+        {
+            if (opts.Clean)
+            {
+                await ctx.Channel.TriggerTypingAsync();
+                await _tracker.EnsureUsersDownloadedAsync(ctx.Guild);
+
+                await using var uow = _db.GetDbContext();
+
+                var cleanRichest = await uow.Set<DiscordUser>()
+                                            .GetTopRichest(_client.CurrentUser.Id, 0, 1000);
+
+                var sg = (SocketGuild)ctx.Guild!;
+                return cleanRichest.Where(x => sg.GetUser(x.UserId) is not null).ToList();
+            }
+            else
+            {
+                await using var uow = _db.GetDbContext();
+                return await uow.Set<DiscordUser>().GetTopRichest(_client.CurrentUser.Id, curPage);
+            }
+        }
+
+        var res = Response()
+            .Paginated();
+
+        await Response()
+              .Paginated()
+              .PageItems(GetTopRichest)
+              .TotalElements(900)
+              .PageSize(9)
+              .CurrentPage(page)
+              .Page((toSend, curPage) =>
+              {
+                  var embed = _sender.CreateEmbed()
+                                     .WithOkColor()
+                                     .WithTitle(CurrencySign + " " + GetText(strs.leaderboard));
+
+                  if (!toSend.Any())
+                  {
+                      embed.WithDescription(GetText(strs.no_user_on_this_page));
+                      return Task.FromResult(embed);
+                  }
+
+                  for (var i = 0; i < toSend.Count; i++)
+                  {
+                      var x = toSend[i];
+                      var usrStr = x.ToString().TrimTo(20, true);
+
+                      var j = i;
+                      embed.AddField("#" + ((9 * curPage) + j + 1) + " " + usrStr, N(x.CurrencyAmount), true);
+                  }
+
+                  return Task.FromResult(embed);
+              })
+              .SendAsync();
+    }
+
+    public enum InputRpsPick : byte
+    {
+        R = 0,
+        Rock = 0,
+        Rocket = 0,
+        P = 1,
+        Paper = 1,
+        Paperclip = 1,
+        S = 2,
+        Scissors = 2
+    }
+
+    [Cmd]
+    public async Task Rps(InputRpsPick pick, [OverrideTypeReader(typeof(BalanceTypeReader))] long amount = default)
+    {
+        static string GetRpsPick(InputRpsPick p)
+        {
+            switch (p)
+            {
+                case InputRpsPick.R:
+                    return "🚀";
+                case InputRpsPick.P:
+                    return "📎";
+                default:
+                    return "✂️";
+            }
+        }
+
+        if (!await CheckBetOptional(amount) || amount == 1)
+            return;
+
+        var res = await _gs.RpsAsync(ctx.User.Id, amount, (byte)pick);
+
+        if (!res.TryPickT0(out var result, out _))
+        {
+            await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+            return;
+        }
+
+        var embed = _sender.CreateEmbed();
+
+        string msg;
+        if (result.Result == RpsResultType.Draw)
+        {
+            msg = GetText(strs.rps_draw(GetRpsPick(pick)));
+        }
+        else if (result.Result == RpsResultType.Win)
+        {
+            if ((long)result.Won > 0)
+                embed.AddField(GetText(strs.won), N((long)result.Won));
+
+            msg = GetText(strs.rps_win(ctx.User.Mention,
+                GetRpsPick(pick),
+                GetRpsPick((InputRpsPick)result.ComputerPick)));
+        }
+        else
+        {
+            msg = GetText(strs.rps_win(ctx.Client.CurrentUser.Mention,
+                GetRpsPick((InputRpsPick)result.ComputerPick),
+                GetRpsPick(pick)));
+        }
+
+        embed
+            .WithOkColor()
+            .WithDescription(msg);
+
+        await Response().Embed(embed).SendAsync();
+    }
+
+    private static readonly ImmutableArray<string> _emojis =
+        new[] { "⬆", "↖", "⬅", "↙", "⬇", "↘", "➡", "↗" }.ToImmutableArray();
+
+
+    [Cmd]
+    public async Task LuckyLadder([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
+    {
+        if (!await CheckBetMandatory(amount))
+            return;
+
+        var res = await _gs.LulaAsync(ctx.User.Id, amount);
+        if (!res.TryPickT0(out var result, out _))
+        {
+            await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+            return;
+        }
+
+        var multis = result.Multipliers;
+
+        var sb = new StringBuilder();
+        foreach (var multi in multis)
+        {
+            sb.Append($"╠══╣");
+
+            if (multi == result.Multiplier)
+                sb.Append($"{Format.Bold($"x{multi:0.##}")} ⬅️");
+            else
+                sb.Append($"||x{multi:0.##}||");
+
+            sb.AppendLine();
+        }
+
+        var eb = _sender.CreateEmbed()
+                        .WithOkColor()
+                        .WithDescription(sb.ToString())
+                        .AddField(GetText(strs.multiplier), $"{result.Multiplier:0.##}x", true)
+                        .AddField(GetText(strs.won), $"{(long)result.Won}", true)
+                        .WithAuthor(ctx.User);
+
+
+        await Response().Embed(eb).SendAsync();
+    }
+
+
+    public enum GambleTestTarget
+    {
+        Slot,
+        Betroll,
+        Betflip,
+        BetflipT,
+        BetDraw,
+        BetDrawHL,
+        BetDrawRB,
+        Lula,
+        Rps,
+    }
+
+    [Cmd]
+    [OwnerOnly]
+    public async Task BetTest()
+    {
+        var values = Enum.GetValues<GambleTestTarget>()
+                         .Select(x => $"`{x}`")
+                         .Join(", ");
+
+        await Response().Confirm(GetText(strs.available_tests), values).SendAsync();
+    }
+
+    [Cmd]
+    [OwnerOnly]
+    public async Task BetTest(GambleTestTarget target, int tests = 1000)
+    {
+        if (tests <= 0)
+            return;
+
+        await ctx.Channel.TriggerTypingAsync();
+
+        var streak = 0;
+        var maxW = 0;
+        var maxL = 0;
+
+        var dict = new Dictionary<decimal, int>();
+        for (var i = 0; i < tests; i++)
+        {
+            var multi = target switch
+            {
+                GambleTestTarget.BetDraw => (await _gs.BetDrawAsync(ctx.User.Id, 0, 1, 0)).AsT0.Multiplier,
+                GambleTestTarget.BetDrawRB => (await _gs.BetDrawAsync(ctx.User.Id, 0, null, 1)).AsT0.Multiplier,
+                GambleTestTarget.BetDrawHL => (await _gs.BetDrawAsync(ctx.User.Id, 0, 0, null)).AsT0.Multiplier,
+                GambleTestTarget.Slot => (await _gs.SlotAsync(ctx.User.Id, 0)).AsT0.Multiplier,
+                GambleTestTarget.Betflip => (await _gs.BetFlipAsync(ctx.User.Id, 0, 0)).AsT0.Multiplier,
+                GambleTestTarget.BetflipT => (await _gs.BetFlipAsync(ctx.User.Id, 0, 1)).AsT0.Multiplier,
+                GambleTestTarget.Lula => (await _gs.LulaAsync(ctx.User.Id, 0)).AsT0.Multiplier,
+                GambleTestTarget.Rps => (await _gs.RpsAsync(ctx.User.Id, 0, (byte)(i % 3))).AsT0.Multiplier,
+                GambleTestTarget.Betroll => (await _gs.BetRollAsync(ctx.User.Id, 0)).AsT0.Multiplier,
+                _ => throw new ArgumentOutOfRangeException(nameof(target))
+            };
+
+            if (dict.ContainsKey(multi))
+                dict[multi] += 1;
+            else
+                dict.Add(multi, 1);
+
+            if (multi < 1)
+            {
+                if (streak <= 0)
+                    --streak;
+                else
+                    streak = -1;
+
+                maxL = Math.Max(maxL, -streak);
+            }
+            else if (multi > 1)
+            {
+                if (streak >= 0)
+                    ++streak;
+                else
+                    streak = 1;
+
+                maxW = Math.Max(maxW, streak);
+            }
+        }
+
+        var sb = new StringBuilder();
+        decimal payout = 0;
+        foreach (var key in dict.Keys.OrderByDescending(x => x))
+        {
+            sb.AppendLine($"x**{key}** occured `{dict[key]}` times. {dict[key] * 1.0f / tests * 100}%");
+            payout += key * dict[key];
+        }
+
+        sb.AppendLine();
+        sb.AppendLine($"Longest win streak: `{maxW}`");
+        sb.AppendLine($"Longest lose streak: `{maxL}`");
+
+        await Response()
+              .Confirm(GetText(strs.test_results_for(target)),
+                  sb.ToString(),
+                  footer: $"Total Bet: {tests} | Payout: {payout:F0} | {payout * 1.0M / tests * 100}%")
+              .SendAsync();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/GamblingConfig.cs b/src/EllieBot/Modules/Gambling/GamblingConfig.cs
new file mode 100644
index 0000000..48f8729
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/GamblingConfig.cs
@@ -0,0 +1,411 @@
+#nullable disable
+using Cloneable;
+using EllieBot.Common.Yml;
+using SixLabors.ImageSharp.PixelFormats;
+using YamlDotNet.Serialization;
+using Color = SixLabors.ImageSharp.Color;
+
+namespace EllieBot.Modules.Gambling.Common;
+
+[Cloneable]
+public sealed partial class GamblingConfig : ICloneable<GamblingConfig>
+{
+    [Comment("""DO NOT CHANGE""")]
+    public int Version { get; set; } = 8;
+
+    [Comment("""Currency settings""")]
+    public CurrencyConfig Currency { get; set; }
+
+    [Comment("""Minimum amount users can bet (>=0)""")]
+    public int MinBet { get; set; } = 0;
+
+    [Comment("""
+             Maximum amount users can bet
+             Set 0 for unlimited
+             """)]
+    public int MaxBet { get; set; } = 0;
+
+    [Comment("""Settings for betflip command""")]
+    public BetFlipConfig BetFlip { get; set; }
+
+    [Comment("""Settings for betroll command""")]
+    public BetRollConfig BetRoll { get; set; }
+
+    [Comment("""Automatic currency generation settings.""")]
+    public GenerationConfig Generation { get; set; }
+
+    [Comment("""
+             Settings for timely command 
+             (letting people claim X amount of currency every Y hours)
+             """)]
+    public TimelyConfig Timely { get; set; }
+
+    [Comment("""How much will each user's owned currency decay over time.""")]
+    public DecayConfig Decay { get; set; }
+
+    [Comment("""What is the bot's cut on some transactions""")]
+    public BotCutConfig BotCuts { get; set; }
+
+    [Comment("""Settings for LuckyLadder command""")]
+    public LuckyLadderSettings LuckyLadder { get; set; }
+
+    [Comment("""Settings related to waifus""")]
+    public WaifuConfig Waifu { get; set; }
+
+    [Comment("""
+             Amount of currency selfhosters will get PER pledged dollar CENT.
+             1 = 100 currency per $. Used almost exclusively on public nadeko.
+             """)]
+    public decimal PatreonCurrencyPerCent { get; set; } = 1;
+
+    [Comment("""
+             Currency reward per vote.
+             This will work only if you've set up VotesApi and correct credentials for topgg and/or discords voting
+             """)]
+    public long VoteReward { get; set; } = 100;
+
+    [Comment("""Slot config""")]
+    public SlotsConfig Slots { get; set; }
+
+    public GamblingConfig()
+    {
+        BetRoll = new();
+        Waifu = new();
+        Currency = new();
+        BetFlip = new();
+        Generation = new();
+        Timely = new();
+        Decay = new();
+        Slots = new();
+        LuckyLadder = new();
+        BotCuts = new();
+    }
+}
+
+public class CurrencyConfig
+{
+    [Comment("""What is the emoji/character which represents the currency""")]
+    public string Sign { get; set; } = "💵";
+
+    [Comment("""What is the name of the currency""")]
+    public string Name { get; set; } = "Ellie Money";
+
+    [Comment("""
+             For how long (in days) will the transactions be kept in the database (curtrs)
+             Set 0 to disable cleanup (keep transactions forever)
+             """)]
+    public int TransactionsLifetime { get; set; } = 0;
+}
+
+[Cloneable]
+public partial class TimelyConfig
+{
+    [Comment("""
+             How much currency will the users get every time they run .timely command
+             setting to 0 or less will disable this feature
+             """)]
+    public int Amount { get; set; } = 0;
+
+    [Comment("""
+             How often (in hours) can users claim currency with .timely command
+             setting to 0 or less will disable this feature
+             """)]
+    public int Cooldown { get; set; } = 24;
+}
+
+[Cloneable]
+public partial class BetFlipConfig
+{
+    [Comment("""Bet multiplier if user guesses correctly""")]
+    public decimal Multiplier { get; set; } = 1.95M;
+}
+
+[Cloneable]
+public partial class BetRollConfig
+{
+    [Comment("""
+             When betroll is played, user will roll a number 0-100.
+             This setting will describe which multiplier is used for when the roll is higher than the given number.
+             Doesn't have to be ordered.
+             """)]
+    public BetRollPair[] Pairs { get; set; } = Array.Empty<BetRollPair>();
+
+    public BetRollConfig()
+        => Pairs =
+        [
+            new()
+            {
+                WhenAbove = 99,
+                MultiplyBy = 10
+            },
+            new()
+            {
+                WhenAbove = 90,
+                MultiplyBy = 4
+            },
+            new()
+            {
+                WhenAbove = 66,
+                MultiplyBy = 2
+            }
+        ];
+}
+
+[Cloneable]
+public partial class GenerationConfig
+{
+    [Comment("""
+             when currency is generated, should it also have a random password
+             associated with it which users have to type after the .pick command
+             in order to get it
+             """)]
+    public bool HasPassword { get; set; } = true;
+
+    [Comment("""
+             Every message sent has a certain % chance to generate the currency
+             specify the percentage here (1 being 100%, 0 being 0% - for example
+             default is 0.02, which is 2%
+             """)]
+    public decimal Chance { get; set; } = 0.02M;
+
+    [Comment("""How many seconds have to pass for the next message to have a chance to spawn currency""")]
+    public int GenCooldown { get; set; } = 10;
+
+    [Comment("""Minimum amount of currency that can spawn""")]
+    public int MinAmount { get; set; } = 1;
+
+    [Comment("""
+             Maximum amount of currency that can spawn.
+              Set to the same value as MinAmount to always spawn the same amount
+             """)]
+    public int MaxAmount { get; set; } = 1;
+}
+
+[Cloneable]
+public partial class DecayConfig
+{
+    [Comment("""
+             Percentage of user's current currency which will be deducted every 24h. 
+             0 - 1 (1 is 100%, 0.5 50%, 0 disabled)
+             """)]
+    public decimal Percent { get; set; } = 0;
+
+    [Comment("""Maximum amount of user's currency that can decay at each interval. 0 for unlimited.""")]
+    public int MaxDecay { get; set; } = 0;
+
+    [Comment("""Only users who have more than this amount will have their currency decay.""")]
+    public int MinThreshold { get; set; } = 99;
+
+    [Comment("""How often, in hours, does the decay run. Default is 24 hours""")]
+    public int HourInterval { get; set; } = 24;
+}
+
+[Cloneable]
+public partial class LuckyLadderSettings
+{
+    [Comment("""Self-Explanatory. Has to have 8 values, otherwise the command won't work.""")]
+    public decimal[] Multipliers { get; set; }
+
+    public LuckyLadderSettings()
+        => Multipliers = [2.4M, 1.7M, 1.5M, 1.2M, 0.5M, 0.3M, 0.2M, 0.1M];
+}
+
+[Cloneable]
+public sealed partial class WaifuConfig
+{
+    [Comment("""Minimum price a waifu can have""")]
+    public long MinPrice { get; set; } = 50;
+
+    public MultipliersData Multipliers { get; set; } = new();
+
+    [Comment("""
+             Settings for periodic waifu price decay.
+             Waifu price decays only if the waifu has no claimer.
+             """)]
+    public WaifuDecayConfig Decay { get; set; } = new();
+
+    [Comment("""
+             List of items available for gifting.
+             If negative is true, gift will instead reduce waifu value.
+             """)]
+    public List<WaifuItemModel> Items { get; set; } = [];
+
+    public WaifuConfig()
+        => Items =
+        [
+            new("🥔", 5, "Potato"),
+            new("🍪", 10, "Cookie"),
+            new("🥖", 20, "Bread"),
+            new("🍭", 30, "Lollipop"),
+            new("🌹", 50, "Rose"),
+            new("🍺", 70, "Beer"),
+            new("🌮", 85, "Taco"),
+            new("💌", 100, "LoveLetter"),
+            new("🥛", 125, "Milk"),
+            new("🍕", 150, "Pizza"),
+            new("🍫", 200, "Chocolate"),
+            new("🍦", 250, "Icecream"),
+            new("🍣", 300, "Sushi"),
+            new("🍚", 400, "Rice"),
+            new("🍉", 500, "Watermelon"),
+            new("🍱", 600, "Bento"),
+            new("🎟", 800, "MovieTicket"),
+            new("🍰", 1000, "Cake"),
+            new("📔", 1500, "Book"),
+            new("🐱", 2000, "Cat"),
+            new("🐶", 2001, "Dog"),
+            new("🐼", 2500, "Panda"),
+            new("💄", 3000, "Lipstick"),
+            new("👛", 3500, "Purse"),
+            new("📱", 4000, "iPhone"),
+            new("👗", 4500, "Dress"),
+            new("💻", 5000, "Laptop"),
+            new("🎻", 7500, "Violin"),
+            new("🎹", 8000, "Piano"),
+            new("🚗", 9000, "Car"),
+            new("💍", 10000, "Ring"),
+            new("🛳", 12000, "Ship"),
+            new("🏠", 15000, "House"),
+            new("🚁", 20000, "Helicopter"),
+            new("🚀", 30000, "Spaceship"),
+            new("🌕", 50000, "Moon")
+        ];
+
+    public class WaifuDecayConfig
+    {
+        [Comment("""
+                 Unclaimed waifus will decay by this percentage (0 - 100).
+                 Default is 0 (disabled)
+                 For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$)
+                 """)]
+        public int UnclaimedDecayPercent { get; set; } = 0;
+
+        [Comment("""
+                 Claimed waifus will decay by this percentage (0 - 100).
+                 Default is 0 (disabled)
+                 For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$)
+                 """)]
+        public int ClaimedDecayPercent { get; set; } = 0;
+
+        [Comment("""How often to decay waifu values, in hours""")]
+        public int HourInterval { get; set; } = 24;
+
+        [Comment("""
+                 Minimum waifu price required for the decay to be applied.
+                 For example if this value is set to 300, any waifu with the price 300 or less will not experience decay.
+                 """)]
+        public long MinPrice { get; set; } = 300;
+    }
+}
+
+[Cloneable]
+public sealed partial class MultipliersData
+{
+    [Comment("""
+             Multiplier for waifureset. Default 150.
+             Formula (at the time of writing this): 
+             price = (waifu_price * 1.25f) + ((number_of_divorces + changes_of_heart + 2) * WaifuReset) rounded up
+             """)]
+    public int WaifuReset { get; set; } = 150;
+
+    [Comment("""
+             The minimum amount of currency that you have to pay 
+             in order to buy a waifu who doesn't have a crush on you.
+             Default is 1.1
+             Example: If a waifu is worth 100, you will have to pay at least 100 * NormalClaim currency to claim her.
+             (100 * 1.1 = 110)
+             """)]
+    public decimal NormalClaim { get; set; } = 1.1m;
+
+    [Comment("""
+             The minimum amount of currency that you have to pay 
+             in order to buy a waifu that has a crush on you.
+             Default is 0.88
+             Example: If a waifu is worth 100, you will have to pay at least 100 * CrushClaim currency to claim her.
+             (100 * 0.88 = 88)
+             """)]
+    public decimal CrushClaim { get; set; } = 0.88M;
+
+    [Comment("""
+             When divorcing a waifu, her new value will be her current value multiplied by this number.
+             Default 0.75 (meaning will lose 25% of her value)
+             """)]
+    public decimal DivorceNewValue { get; set; } = 0.75M;
+
+    [Comment("""
+             All gift prices will be multiplied by this number.
+             Default 1 (meaning no effect)
+             """)]
+    public decimal AllGiftPrices { get; set; } = 1.0M;
+
+    [Comment("""
+             What percentage of the value of the gift will a waifu gain when she's gifted.
+             Default 0.95 (meaning 95%)
+             Example: If a waifu is worth 1000, and she receives a gift worth 100, her new value will be 1095)
+             """)]
+    public decimal GiftEffect { get; set; } = 0.95M;
+
+    [Comment("""
+             What percentage of the value of the gift will a waifu lose when she's gifted a gift marked as 'negative'.
+             Default 0.5 (meaning 50%)
+             Example: If a waifu is worth 1000, and she receives a negative gift worth 100, her new value will be 950)
+             """)]
+    public decimal NegativeGiftEffect { get; set; } = 0.50M;
+}
+
+public sealed class SlotsConfig
+{
+    [Comment("""Hex value of the color which the numbers on the slot image will have.""")]
+    public Rgba32 CurrencyFontColor { get; set; } = Color.Red;
+}
+
+[Cloneable]
+public sealed partial class WaifuItemModel
+{
+    public string ItemEmoji { get; set; }
+    public long Price { get; set; }
+    public string Name { get; set; }
+
+    [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)]
+    public bool Negative { get; set; }
+
+    public WaifuItemModel()
+    {
+    }
+
+    public WaifuItemModel(
+        string itemEmoji,
+        long price,
+        string name,
+        bool negative = false)
+    {
+        ItemEmoji = itemEmoji;
+        Price = price;
+        Name = name;
+        Negative = negative;
+    }
+
+
+    public override string ToString()
+        => Name;
+}
+
+[Cloneable]
+public sealed partial class BetRollPair
+{
+    public int WhenAbove { get; set; }
+    public float MultiplyBy { get; set; }
+}
+
+[Cloneable]
+public sealed partial class BotCutConfig
+{
+    [Comment("""
+             Shop sale cut percentage.
+             Whenever a user buys something from the shop, bot will take a cut equal to this percentage.
+             The rest goes to the user who posted the item/role/whatever to the shop.
+             This is a good way to reduce the amount of currency in circulation therefore keeping the inflation in check.
+             Default 0.1 (10%).
+             """)]
+    public decimal ShopSaleCut { get; set; } = 0.1m;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/GamblingConfigService.cs b/src/EllieBot/Modules/Gambling/GamblingConfigService.cs
new file mode 100644
index 0000000..c1ad447
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/GamblingConfigService.cs
@@ -0,0 +1,203 @@
+#nullable disable
+using EllieBot.Common.Configs;
+using EllieBot.Modules.Gambling.Common;
+
+namespace EllieBot.Modules.Gambling.Services;
+
+public sealed class GamblingConfigService : ConfigServiceBase<GamblingConfig>
+{
+    private const string FILE_PATH = "data/gambling.yml";
+    private static readonly TypedKey<GamblingConfig> _changeKey = new("config.gambling.updated");
+
+    public override string Name
+        => "gambling";
+
+    private readonly IEnumerable<WaifuItemModel> _antiGiftSeed = new[]
+    {
+        new WaifuItemModel("🥀", 100, "WiltedRose", true), new WaifuItemModel("✂️", 1000, "Haircut", true),
+        new WaifuItemModel("🧻", 10000, "ToiletPaper", true)
+    };
+
+    public GamblingConfigService(IConfigSeria serializer, IPubSub pubSub)
+        : base(FILE_PATH, serializer, pubSub, _changeKey)
+    {
+        AddParsedProp("currency.name",
+            gs => gs.Currency.Name,
+            ConfigParsers.String,
+            ConfigPrinters.ToString);
+
+        AddParsedProp("currency.sign",
+            gs => gs.Currency.Sign,
+            ConfigParsers.String,
+            ConfigPrinters.ToString);
+
+        AddParsedProp("minbet",
+            gs => gs.MinBet,
+            int.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 0);
+
+        AddParsedProp("maxbet",
+            gs => gs.MaxBet,
+            int.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 0);
+
+        AddParsedProp("gen.min",
+            gs => gs.Generation.MinAmount,
+            int.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 1);
+
+        AddParsedProp("gen.max",
+            gs => gs.Generation.MaxAmount,
+            int.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 1);
+
+        AddParsedProp("gen.cd",
+            gs => gs.Generation.GenCooldown,
+            int.TryParse,
+            ConfigPrinters.ToString,
+            val => val > 0);
+
+        AddParsedProp("gen.chance",
+            gs => gs.Generation.Chance,
+            decimal.TryParse,
+            ConfigPrinters.ToString,
+            val => val is >= 0 and <= 1);
+
+        AddParsedProp("gen.has_pw",
+            gs => gs.Generation.HasPassword,
+            bool.TryParse,
+            ConfigPrinters.ToString);
+
+        AddParsedProp("bf.multi",
+            gs => gs.BetFlip.Multiplier,
+            decimal.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 1);
+
+        AddParsedProp("waifu.min_price",
+            gs => gs.Waifu.MinPrice,
+            long.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 0);
+
+        AddParsedProp("waifu.multi.reset",
+            gs => gs.Waifu.Multipliers.WaifuReset,
+            int.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 0);
+
+        AddParsedProp("waifu.multi.crush_claim",
+            gs => gs.Waifu.Multipliers.CrushClaim,
+            decimal.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 0);
+
+        AddParsedProp("waifu.multi.normal_claim",
+            gs => gs.Waifu.Multipliers.NormalClaim,
+            decimal.TryParse,
+            ConfigPrinters.ToString,
+            val => val > 0);
+
+        AddParsedProp("waifu.multi.divorce_value",
+            gs => gs.Waifu.Multipliers.DivorceNewValue,
+            decimal.TryParse,
+            ConfigPrinters.ToString,
+            val => val > 0);
+
+        AddParsedProp("waifu.multi.all_gifts",
+            gs => gs.Waifu.Multipliers.AllGiftPrices,
+            decimal.TryParse,
+            ConfigPrinters.ToString,
+            val => val > 0);
+
+        AddParsedProp("waifu.multi.gift_effect",
+            gs => gs.Waifu.Multipliers.GiftEffect,
+            decimal.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 0);
+
+        AddParsedProp("waifu.multi.negative_gift_effect",
+            gs => gs.Waifu.Multipliers.NegativeGiftEffect,
+            decimal.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 0);
+
+        AddParsedProp("decay.percent",
+            gs => gs.Decay.Percent,
+            decimal.TryParse,
+            ConfigPrinters.ToString,
+            val => val is >= 0 and <= 1);
+
+        AddParsedProp("decay.maxdecay",
+            gs => gs.Decay.MaxDecay,
+            int.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 0);
+
+        AddParsedProp("decay.threshold",
+            gs => gs.Decay.MinThreshold,
+            int.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 0);
+
+        Migrate();
+    }
+
+    public void Migrate()
+    {
+        if (data.Version < 2)
+        {
+            ModifyConfig(c =>
+            {
+                c.Waifu.Items = c.Waifu.Items.Concat(_antiGiftSeed).ToList();
+                c.Version = 2;
+            });
+        }
+
+        if (data.Version < 3)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 3;
+                c.VoteReward = 100;
+            });
+        }
+
+        if (data.Version < 5)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 5;
+            });
+        }
+
+        if (data.Version < 6)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 6;
+            });
+        }
+
+        if (data.Version < 7)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 7;
+            });
+        }
+
+        if (data.Version < 8)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 8;
+                c.Waifu.Decay.UnclaimedDecayPercent = 0;
+            });
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/GamblingService.cs b/src/EllieBot/Modules/Gambling/GamblingService.cs
new file mode 100644
index 0000000..6b3bffa
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/GamblingService.cs
@@ -0,0 +1,187 @@
+#nullable disable
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using EllieBot.Common.ModuleBehaviors;
+using EllieBot.Db.Models;
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Common.Connect4;
+
+namespace EllieBot.Modules.Gambling.Services;
+
+public class GamblingService : IEService, IReadyExecutor
+{
+    public ConcurrentDictionary<(ulong, ulong), RollDuelGame> Duels { get; } = new();
+    public ConcurrentDictionary<ulong, Connect4Game> Connect4Games { get; } = new();
+    private readonly DbService _db;
+    private readonly DiscordSocketClient _client;
+    private readonly IBotCache _cache;
+    private readonly GamblingConfigService _gss;
+
+    private static readonly TypedKey<long> _curDecayKey = new("currency:last_decay");
+
+    public GamblingService(
+        DbService db,
+        DiscordSocketClient client,
+        IBotCache cache,
+        GamblingConfigService gss)
+    {
+        _db = db;
+        _client = client;
+        _cache = cache;
+        _gss = gss;
+    }
+
+    public Task OnReadyAsync()
+        => Task.WhenAll(CurrencyDecayLoopAsync(), TransactionClearLoopAsync());
+
+    private async Task TransactionClearLoopAsync()
+    {
+        if (_client.ShardId != 0)
+            return;
+
+        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
+        while (await timer.WaitForNextTickAsync())
+        {
+            try
+            {
+                var lifetime = _gss.Data.Currency.TransactionsLifetime;
+                if (lifetime <= 0)
+                    continue;
+
+                var now = DateTime.UtcNow;
+                var days = TimeSpan.FromDays(lifetime);
+                await using var uow = _db.GetDbContext();
+                await uow.Set<CurrencyTransaction>()
+                    .DeleteAsync(ct => ct.DateAdded == null || now - ct.DateAdded < days);
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex,
+                    "An unexpected error occurred in transactions cleanup loop: {ErrorMessage}",
+                    ex.Message);
+            }
+        }
+    }
+
+    private async Task CurrencyDecayLoopAsync()
+    {
+        if (_client.ShardId != 0)
+            return;
+
+        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
+        while (await timer.WaitForNextTickAsync())
+        {
+            try
+            {
+                var config = _gss.Data;
+                var maxDecay = config.Decay.MaxDecay;
+                if (config.Decay.Percent is <= 0 or > 1 || maxDecay < 0)
+                    continue;
+
+                var now = DateTime.UtcNow;
+
+                await using var uow = _db.GetDbContext();
+                var result = await _cache.GetAsync(_curDecayKey);
+
+                if (result.TryPickT0(out var bin, out _)
+                    && (now - DateTime.FromBinary(bin) < TimeSpan.FromHours(config.Decay.HourInterval)))
+                {
+                    continue;
+                }
+
+                Log.Information("""
+                    --- Decaying users' currency ---
+                    | decay: {ConfigDecayPercent}% 
+                    | max: {MaxDecay} 
+                    | threshold: {DecayMinTreshold}
+                    """,
+                    config.Decay.Percent * 100,
+                    maxDecay,
+                    config.Decay.MinThreshold);
+
+                if (maxDecay == 0)
+                    maxDecay = int.MaxValue;
+
+                var decay = (double)config.Decay.Percent;
+                await uow.Set<DiscordUser>()
+                    .Where(x => x.CurrencyAmount > config.Decay.MinThreshold && x.UserId != _client.CurrentUser.Id)
+                    .UpdateAsync(old => new()
+                    {
+                        CurrencyAmount =
+                            maxDecay > Sql.Round((old.CurrencyAmount * decay) - 0.5)
+                                ? (long)(old.CurrencyAmount - Sql.Round((old.CurrencyAmount * decay) - 0.5))
+                                : old.CurrencyAmount - maxDecay
+                    });
+
+                await uow.SaveChangesAsync();
+
+                await _cache.AddAsync(_curDecayKey, now.ToBinary());
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex,
+                    "An unexpected error occurred in currency decay loop: {ErrorMessage}",
+                    ex.Message);
+            }
+        }
+    }
+
+    private static readonly TypedKey<EconomyResult> _ecoKey = new("nadeko:economy");
+
+    private static readonly SemaphoreSlim _timelyLock = new(1, 1);
+
+    private static TypedKey<Dictionary<ulong, long>> _timelyKey
+        = new("timely:claims");
+
+    public async Task<TimeSpan?> ClaimTimelyAsync(ulong userId, int period)
+    {
+        if (period == 0)
+            return null;
+
+        await _timelyLock.WaitAsync();
+        try
+        {
+            // get the dictionary from the cache or get a new one
+            var dict = (await _cache.GetOrAddAsync(_timelyKey,
+                () => Task.FromResult(new Dictionary<ulong, long>())))!;
+
+            var now = DateTime.UtcNow;
+            var nowB = now.ToBinary();
+
+            // try to get users last claim
+            if (!dict.TryGetValue(userId, out var lastB))
+                lastB = dict[userId] = now.ToBinary();
+
+            var diff = now - DateTime.FromBinary(lastB);
+
+            // if its now, or too long ago => success
+            if (lastB == nowB || diff > period.Hours())
+            {
+                // update the cache
+                dict[userId] = nowB;
+                await _cache.AddAsync(_timelyKey, dict);
+
+                return null;
+            }
+            else
+            {
+                // otherwise return the remaining time
+                return period.Hours() - diff;
+            }
+        }
+        finally
+        {
+            _timelyLock.Release();
+        }
+    }
+
+    public bool UserHasTimelyReminder(ulong userId)
+    {
+        var db = _db.GetDbContext();
+        return db.GetTable<Reminder>().Any(x => x.UserId == userId
+                                         && x.Type == ReminderType.Timely);
+    }   
+
+    public async Task RemoveAllTimelyClaimsAsync()
+        => await _cache.RemoveAsync(_timelyKey);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs b/src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs
new file mode 100644
index 0000000..25cbb73
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs
@@ -0,0 +1,68 @@
+#nullable disable
+using EllieBot.Modules.Gambling.Services;
+using System.Numerics;
+
+namespace EllieBot.Modules.Gambling.Common;
+
+public abstract class GamblingModule<TService> : EllieModule<TService>
+{
+    protected GamblingConfig Config
+        => _lazyConfig.Value;
+
+    protected string CurrencySign
+        => Config.Currency.Sign;
+
+    protected string CurrencyName
+        => Config.Currency.Name;
+
+    private readonly Lazy<GamblingConfig> _lazyConfig;
+
+    protected GamblingModule(GamblingConfigService gambService)
+        => _lazyConfig = new(() => gambService.Data);
+
+    private async Task<bool> InternalCheckBet(long amount)
+    {
+        if (amount < 1)
+            return false;
+        
+        if (amount < Config.MinBet)
+        {
+            await Response().Error(strs.min_bet_limit(Format.Bold(Config.MinBet.ToString()) + CurrencySign)).SendAsync();
+            return false;
+        }
+
+        if (Config.MaxBet > 0 && amount > Config.MaxBet)
+        {
+            await Response().Error(strs.max_bet_limit(Format.Bold(Config.MaxBet.ToString()) + CurrencySign)).SendAsync();
+            return false;
+        }
+
+        return true;
+    }
+
+    protected string N<T>(T cur)
+        where T : INumber<T>
+        => CurrencyHelper.N(cur, Culture, CurrencySign);
+
+    protected Task<bool> CheckBetMandatory(long amount)
+    {
+        if (amount < 1)
+            return Task.FromResult(false);
+        return InternalCheckBet(amount);
+    }
+
+    protected Task<bool> CheckBetOptional(long amount)
+    {
+        if (amount == 0)
+            return Task.FromResult(true);
+        return InternalCheckBet(amount);
+    }
+}
+
+public abstract class GamblingSubmodule<TService> : GamblingModule<TService>
+{
+    protected GamblingSubmodule(GamblingConfigService gamblingConfService)
+        : base(gamblingConfService)
+    {
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/InputRpsPick.cs b/src/EllieBot/Modules/Gambling/InputRpsPick.cs
new file mode 100644
index 0000000..d0c76f0
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/InputRpsPick.cs
@@ -0,0 +1,3 @@
+#nullable disable
+namespace EllieBot.Modules.Gambling;
+
diff --git a/src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs b/src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs
new file mode 100644
index 0000000..ab6a0bb
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs
@@ -0,0 +1,114 @@
+#nullable disable
+using EllieBot.Common.TypeReaders;
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Services;
+
+namespace EllieBot.Modules.Gambling;
+
+public partial class Gambling
+{
+    [Group]
+    public partial class PlantPickCommands : GamblingSubmodule<PlantPickService>
+    {
+        private readonly ILogCommandService _logService;
+
+        public PlantPickCommands(ILogCommandService logService, GamblingConfigService gss)
+            : base(gss)
+            => _logService = logService;
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Pick(string pass = null)
+        {
+            if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric())
+                return;
+
+            var picked = await _service.PickAsync(ctx.Guild.Id, (ITextChannel)ctx.Channel, ctx.User.Id, pass);
+
+            if (picked > 0)
+            {
+                var msg = await Response().NoReply().Confirm(strs.picked(N(picked), ctx.User)).SendAsync();
+                msg.DeleteAfter(10);
+            }
+
+            if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages)
+            {
+                try
+                {
+                    _logService.AddDeleteIgnore(ctx.Message.Id);
+                    await ctx.Message.DeleteAsync();
+                }
+                catch { }
+            }
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Plant([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, string pass = null)
+        {
+            if (amount < 1)
+                return;
+
+            if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric())
+                return;
+
+            if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages)
+            {
+                _logService.AddDeleteIgnore(ctx.Message.Id);
+                await ctx.Message.DeleteAsync();
+            }
+
+            var success = await _service.PlantAsync(ctx.Guild.Id,
+                ctx.Channel,
+                ctx.User.Id,
+                ctx.User.ToString(),
+                amount,
+                pass);
+
+            if (!success)
+                await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+#if GLOBAL_NADEKO
+            [OwnerOnly]
+#endif
+        public async Task GenCurrency()
+        {
+            var enabled = _service.ToggleCurrencyGeneration(ctx.Guild.Id, ctx.Channel.Id);
+            if (enabled)
+                await Response().Confirm(strs.curgen_enabled).SendAsync();
+            else
+                await Response().Confirm(strs.curgen_disabled).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        [OwnerOnly]
+        public Task GenCurList(int page = 1)
+        {
+            if (--page < 0)
+                return Task.CompletedTask;
+
+            var enabledIn = _service.GetAllGeneratingChannels();
+
+            return Response()
+                   .Paginated()
+                   .Items(enabledIn.ToList())
+                   .PageSize(9)
+                   .CurrentPage(page)
+                   .Page((items, _) =>
+                   {
+                       if (!items.Any())
+                           return _sender.CreateEmbed().WithErrorColor().WithDescription("-");
+
+                       return items.Aggregate(_sender.CreateEmbed().WithOkColor(),
+                           (eb, i) => eb.AddField(i.GuildId.ToString(), i.ChannelId));
+                   })
+                   .SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs b/src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs
new file mode 100644
index 0000000..80019ba
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs
@@ -0,0 +1,390 @@
+#nullable disable
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Common.ModuleBehaviors;
+using EllieBot.Db.Models;
+using SixLabors.Fonts;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+using Color = SixLabors.ImageSharp.Color;
+using Image = SixLabors.ImageSharp.Image;
+
+namespace EllieBot.Modules.Gambling.Services;
+
+public class PlantPickService : IEService, IExecNoCommand
+{
+    //channelId/last generation
+    public ConcurrentDictionary<ulong, long> LastGenerations { get; } = new();
+    private readonly DbService _db;
+    private readonly IBotStrings _strings;
+    private readonly IImageCache _images;
+    private readonly FontProvider _fonts;
+    private readonly ICurrencyService _cs;
+    private readonly CommandHandler _cmdHandler;
+    private readonly EllieRandom _rng;
+    private readonly DiscordSocketClient _client;
+    private readonly GamblingConfigService _gss;
+
+    private readonly ConcurrentHashSet<ulong> _generationChannels;
+    private readonly SemaphoreSlim _pickLock = new(1, 1);
+
+    public PlantPickService(
+        DbService db,
+        IBotStrings strings,
+        IImageCache images,
+        FontProvider fonts,
+        ICurrencyService cs,
+        CommandHandler cmdHandler,
+        DiscordSocketClient client,
+        GamblingConfigService gss)
+    {
+        _db = db;
+        _strings = strings;
+        _images = images;
+        _fonts = fonts;
+        _cs = cs;
+        _cmdHandler = cmdHandler;
+        _rng = new();
+        _client = client;
+        _gss = gss;
+
+        using var uow = db.GetDbContext();
+        var guildIds = client.Guilds.Select(x => x.Id).ToList();
+        var configs = uow.Set<GuildConfig>()
+                         .AsQueryable()
+                         .Include(x => x.GenerateCurrencyChannelIds)
+                         .Where(x => guildIds.Contains(x.GuildId))
+                         .ToList();
+
+        _generationChannels = new(configs.SelectMany(c => c.GenerateCurrencyChannelIds.Select(obj => obj.ChannelId)));
+    }
+
+    public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
+        => PotentialFlowerGeneration(msg);
+
+    private string GetText(ulong gid, LocStr str)
+        => _strings.GetText(str, gid);
+
+    public bool ToggleCurrencyGeneration(ulong gid, ulong cid)
+    {
+        bool enabled;
+        using var uow = _db.GetDbContext();
+        var guildConfig = uow.GuildConfigsForId(gid, set => set.Include(gc => gc.GenerateCurrencyChannelIds));
+
+        var toAdd = new GCChannelId
+        {
+            ChannelId = cid
+        };
+        if (!guildConfig.GenerateCurrencyChannelIds.Contains(toAdd))
+        {
+            guildConfig.GenerateCurrencyChannelIds.Add(toAdd);
+            _generationChannels.Add(cid);
+            enabled = true;
+        }
+        else
+        {
+            var toDelete = guildConfig.GenerateCurrencyChannelIds.FirstOrDefault(x => x.Equals(toAdd));
+            if (toDelete is not null)
+                uow.Remove(toDelete);
+            _generationChannels.TryRemove(cid);
+            enabled = false;
+        }
+
+        uow.SaveChanges();
+        return enabled;
+    }
+
+    public IEnumerable<GuildConfigExtensions.GeneratingChannel> GetAllGeneratingChannels()
+    {
+        using var uow = _db.GetDbContext();
+        var chs = uow.Set<GuildConfig>().GetGeneratingChannels();
+        return chs;
+    }
+
+    /// <summary>
+    ///     Get a random currency image stream, with an optional password sticked onto it.
+    /// </summary>
+    /// <param name="pass">Optional password to add to top left corner.</param>
+    /// <returns>Stream of the currency image</returns>
+    public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass)
+    {
+        var curImg = await _images.GetCurrencyImageAsync();
+
+        if (curImg is null)
+            return (new MemoryStream(), null);
+
+        if (string.IsNullOrWhiteSpace(pass))
+        {
+            // determine the extension
+            using var load = Image.Load(curImg);
+
+            var format = load.Metadata.DecodedImageFormat;
+            // return the image
+            return (curImg.ToStream(), format?.FileExtensions.FirstOrDefault() ?? "png");
+        }
+
+        // get the image stream and extension
+        return AddPassword(curImg, pass);
+    }
+
+    /// <summary>
+    ///     Add a password to the image.
+    /// </summary>
+    /// <param name="curImg">Image to add password to.</param>
+    /// <param name="pass">Password to add to top left corner.</param>
+    /// <returns>Image with the password in the top left corner.</returns>
+    private (Stream, string) AddPassword(byte[] curImg, string pass)
+    {
+        // draw lower, it looks better
+        pass = pass.TrimTo(10, true).ToLowerInvariant();
+        using var img = Image.Load<Rgba32>(curImg);
+        // choose font size based on the image height, so that it's visible
+        var font = _fonts.NotoSans.CreateFont(img.Height / 12.0f, FontStyle.Bold);
+        img.Mutate(x =>
+        {
+            // measure the size of the text to be drawing
+            var size = TextMeasurer.MeasureSize(pass,
+                new RichTextOptions(font)
+                {
+                    Origin = new PointF(0, 0)
+                });
+
+            // fill the background with black, add 5 pixels on each side to make it look better
+            x.FillPolygon(Color.ParseHex("00000080"),
+                new PointF(0, 0),
+                new PointF(size.Width + 5, 0),
+                new PointF(size.Width + 5, size.Height + 10),
+                new PointF(0, size.Height + 10));
+
+            // draw the password over the background
+            x.DrawText(pass, font, Color.White, new(0, 0));
+        });
+        // return image as a stream for easy sending
+        var format = img.Metadata.DecodedImageFormat;
+        return (img.ToStream(format), format?.FileExtensions.FirstOrDefault() ?? "png");
+    }
+
+    private Task PotentialFlowerGeneration(IUserMessage imsg)
+    {
+        if (imsg is not SocketUserMessage msg || msg.Author.IsBot)
+            return Task.CompletedTask;
+
+        if (imsg.Channel is not ITextChannel channel)
+            return Task.CompletedTask;
+
+        if (!_generationChannels.Contains(channel.Id))
+            return Task.CompletedTask;
+
+        _ = Task.Run(async () =>
+        {
+            try
+            {
+                var config = _gss.Data;
+                var lastGeneration = LastGenerations.GetOrAdd(channel.Id, DateTime.MinValue.ToBinary());
+                var rng = new EllieRandom();
+
+                if (DateTime.UtcNow - TimeSpan.FromSeconds(config.Generation.GenCooldown)
+                    < DateTime.FromBinary(lastGeneration)) //recently generated in this channel, don't generate again
+                    return;
+
+                var num = rng.Next(1, 101) + (config.Generation.Chance * 100);
+                if (num > 100 && LastGenerations.TryUpdate(channel.Id, DateTime.UtcNow.ToBinary(), lastGeneration))
+                {
+                    var dropAmount = config.Generation.MinAmount;
+                    var dropAmountMax = config.Generation.MaxAmount;
+
+                    if (dropAmountMax > dropAmount)
+                        dropAmount = new EllieRandom().Next(dropAmount, dropAmountMax + 1);
+
+                    if (dropAmount > 0)
+                    {
+                        var prefix = _cmdHandler.GetPrefix(channel.Guild.Id);
+                        var toSend = dropAmount == 1
+                            ? GetText(channel.GuildId, strs.curgen_sn(config.Currency.Sign))
+                              + " "
+                              + GetText(channel.GuildId, strs.pick_sn(prefix))
+                            : GetText(channel.GuildId, strs.curgen_pl(dropAmount, config.Currency.Sign))
+                              + " "
+                              + GetText(channel.GuildId, strs.pick_pl(prefix));
+
+                        var pw = config.Generation.HasPassword ? GenerateCurrencyPassword().ToUpperInvariant() : null;
+
+                        IUserMessage sent;
+                        var (stream, ext) = await GetRandomCurrencyImageAsync(pw);
+
+                        await using (stream)
+                            sent = await channel.SendFileAsync(stream, $"currency_image.{ext}", toSend);
+
+                        await AddPlantToDatabase(channel.GuildId,
+                            channel.Id,
+                            _client.CurrentUser.Id,
+                            sent.Id,
+                            dropAmount,
+                            pw);
+                    }
+                }
+            }
+            catch
+            {
+            }
+        });
+        return Task.CompletedTask;
+    }
+
+    /// <summary>
+    ///     Generate a hexadecimal string from 1000 to ffff.
+    /// </summary>
+    /// <returns>A hexadecimal string from 1000 to ffff</returns>
+    private string GenerateCurrencyPassword()
+    {
+        // generate a number from 1000 to ffff
+        var num = _rng.Next(4096, 65536);
+        // convert it to hexadecimal
+        return num.ToString("x4");
+    }
+
+    public async Task<long> PickAsync(
+        ulong gid,
+        ITextChannel ch,
+        ulong uid,
+        string pass)
+    {
+        await _pickLock.WaitAsync();
+        try
+        {
+            long amount;
+            ulong[] ids;
+            await using (var uow = _db.GetDbContext())
+            {
+                // this method will sum all plants with that password,
+                // remove them, and get messageids of the removed plants
+
+                pass = pass?.Trim().TrimTo(10, true).ToUpperInvariant();
+                // gets all plants in this channel with the same password
+                var entries = uow.Set<PlantedCurrency>()
+                                 .AsQueryable()
+                                 .Where(x => x.ChannelId == ch.Id && pass == x.Password)
+                                 .ToList();
+                // sum how much currency that is, and get all of the message ids (so that i can delete them)
+                amount = entries.Sum(x => x.Amount);
+                ids = entries.Select(x => x.MessageId).ToArray();
+                // remove them from the database
+                uow.RemoveRange(entries);
+
+
+                if (amount > 0)
+                    // give the picked currency to the user
+                    await _cs.AddAsync(uid, amount, new("currency", "collect"));
+                await uow.SaveChangesAsync();
+            }
+
+            try
+            {
+                // delete all of the plant messages which have just been picked
+                _ = ch.DeleteMessagesAsync(ids);
+            }
+            catch { }
+
+            // return the amount of currency the user picked
+            return amount;
+        }
+        finally
+        {
+            _pickLock.Release();
+        }
+    }
+
+    public async Task<ulong?> SendPlantMessageAsync(
+        ulong gid,
+        IMessageChannel ch,
+        string user,
+        long amount,
+        string pass)
+    {
+        try
+        {
+            // get the text
+            var prefix = _cmdHandler.GetPrefix(gid);
+            var msgToSend = GetText(gid, strs.planted(Format.Bold(user), amount + _gss.Data.Currency.Sign));
+
+            if (amount > 1)
+                msgToSend += " " + GetText(gid, strs.pick_pl(prefix));
+            else
+                msgToSend += " " + GetText(gid, strs.pick_sn(prefix));
+
+            //get the image
+            var (stream, ext) = await GetRandomCurrencyImageAsync(pass);
+            // send it
+            await using (stream)
+            {
+                var msg = await ch.SendFileAsync(stream, $"img.{ext}", msgToSend);
+                // return sent message's id (in order to be able to delete it when it's picked)
+                return msg.Id;
+            }
+        }
+        catch (Exception ex)
+        {
+            // if sending fails, return null as message id
+            Log.Warning(ex, "Sending plant message failed: {Message}", ex.Message);
+            return null;
+        }
+    }
+
+    public async Task<bool> PlantAsync(
+        ulong gid,
+        IMessageChannel ch,
+        ulong uid,
+        string user,
+        long amount,
+        string pass)
+    {
+        // normalize it - no more than 10 chars, uppercase
+        pass = pass?.Trim().TrimTo(10, true).ToUpperInvariant();
+        // has to be either null or alphanumeric
+        if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric())
+            return false;
+
+        // remove currency from the user who's planting
+        if (await _cs.RemoveAsync(uid, amount, new("put/collect", "put")))
+        {
+            // try to send the message with the currency image
+            var msgId = await SendPlantMessageAsync(gid, ch, user, amount, pass);
+            if (msgId is null)
+            {
+                // if it fails it will return null, if it returns null, refund
+                await _cs.AddAsync(uid, amount, new("put/collect", "refund"));
+                return false;
+            }
+
+            // if it doesn't fail, put the plant in the database for other people to pick
+            await AddPlantToDatabase(gid, ch.Id, uid, msgId.Value, amount, pass);
+            return true;
+        }
+
+        // if user doesn't have enough currency, fail
+        return false;
+    }
+
+    private async Task AddPlantToDatabase(
+        ulong gid,
+        ulong cid,
+        ulong uid,
+        ulong mid,
+        long amount,
+        string pass)
+    {
+        await using var uow = _db.GetDbContext();
+        uow.Set<PlantedCurrency>()
+           .Add(new()
+           {
+               Amount = amount,
+               GuildId = gid,
+               ChannelId = cid,
+               Password = pass,
+               UserId = uid,
+               MessageId = mid
+           });
+        await uow.SaveChangesAsync();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Shop/IShopService.cs b/src/EllieBot/Modules/Gambling/Shop/IShopService.cs
new file mode 100644
index 0000000..f8d19f8
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Shop/IShopService.cs
@@ -0,0 +1,46 @@
+#nullable disable
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Gambling.Services;
+
+public interface IShopService
+{
+    /// <summary>
+    ///     Changes the price of a shop item
+    /// </summary>
+    /// <param name="guildId">Id of the guild in which the shop is</param>
+    /// <param name="index">Index of the item</param>
+    /// <param name="newPrice">New item price</param>
+    /// <returns>Success status</returns>
+    Task<bool> ChangeEntryPriceAsync(ulong guildId, int index, int newPrice);
+
+    /// <summary>
+    ///     Changes the name of a shop item
+    /// </summary>
+    /// <param name="guildId">Id of the guild in which the shop is</param>
+    /// <param name="index">Index of the item</param>
+    /// <param name="newName">New item name</param>
+    /// <returns>Success status</returns>
+    Task<bool> ChangeEntryNameAsync(ulong guildId, int index, string newName);
+
+    /// <summary>
+    ///     Swaps indexes of 2 items in the shop
+    /// </summary>
+    /// <param name="guildId">Id of the guild in which the shop is</param>
+    /// <param name="index1">First entry's index</param>
+    /// <param name="index2">Second entry's index</param>
+    /// <returns>Whether swap was successful</returns>
+    Task<bool> SwapEntriesAsync(ulong guildId, int index1, int index2);
+
+    /// <summary>
+    ///     Swaps indexes of 2 items in the shop
+    /// </summary>
+    /// <param name="guildId">Id of the guild in which the shop is</param>
+    /// <param name="fromIndex">Current index of the entry to move</param>
+    /// <param name="toIndex">Destination index of the entry</param>
+    /// <returns>Whether swap was successful</returns>
+    Task<bool> MoveEntryAsync(ulong guildId, int fromIndex, int toIndex);
+
+    Task<bool> SetItemRoleRequirementAsync(ulong guildId, int index, ulong? roleId);
+    Task<ShopEntry> AddShopCommandAsync(ulong guildId, ulong userId, int price, string command);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs b/src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs
new file mode 100644
index 0000000..2d0749e
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs
@@ -0,0 +1,597 @@
+#nullable disable
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Services;
+using EllieBot.Db.Models;
+using EllieBot.Modules.Administration;
+
+namespace EllieBot.Modules.Gambling;
+
+public partial class Gambling
+{
+    [Group]
+    public partial class ShopCommands : GamblingSubmodule<IShopService>
+    {
+        public enum List
+        {
+            List
+        }
+
+        public enum Role
+        {
+            Role
+        }
+
+        public enum Command
+        {
+            Command,
+            Cmd
+        }
+
+        private readonly DbService _db;
+        private readonly ICurrencyService _cs;
+
+        public ShopCommands(DbService db, ICurrencyService cs, GamblingConfigService gamblingConf)
+            : base(gamblingConf)
+        {
+            _db = db;
+            _cs = cs;
+        }
+
+        private Task ShopInternalAsync(int page = 0)
+        {
+            if (page < 0)
+                throw new ArgumentOutOfRangeException(nameof(page));
+
+            using var uow = _db.GetDbContext();
+            var entries = uow.GuildConfigsForId(ctx.Guild.Id,
+                                 set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items))
+                             .ShopEntries.ToIndexed();
+
+            return Response()
+                   .Paginated()
+                   .Items(entries.ToList())
+                   .PageSize(9)
+                   .CurrentPage(page)
+                   .Page((items, curPage) =>
+                   {
+                       if (!items.Any())
+                           return _sender.CreateEmbed().WithErrorColor().WithDescription(GetText(strs.shop_none));
+                       var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.shop));
+
+                       for (var i = 0; i < items.Count; i++)
+                       {
+                           var entry = items[i];
+                           embed.AddField($"#{(curPage * 9) + i + 1} - {N(entry.Price)}",
+                               EntryToString(entry),
+                               true);
+                       }
+
+                       return embed;
+                   })
+                   .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public Task Shop(int page = 1)
+        {
+            if (--page < 0)
+                return Task.CompletedTask;
+
+            return ShopInternalAsync(page);
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Buy(int index)
+        {
+            index -= 1;
+            if (index < 0)
+                return;
+            ShopEntry entry;
+            await using (var uow = _db.GetDbContext())
+            {
+                var config = uow.GuildConfigsForId(ctx.Guild.Id,
+                    set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items));
+                var entries = new IndexedCollection<ShopEntry>(config.ShopEntries);
+                entry = entries.ElementAtOrDefault(index);
+                uow.SaveChanges();
+            }
+
+            if (entry is null)
+            {
+                await Response().Error(strs.shop_item_not_found).SendAsync();
+                return;
+            }
+
+            if (entry.RoleRequirement is ulong reqRoleId)
+            {
+                var role = ctx.Guild.GetRole(reqRoleId);
+                if (role is null)
+                {
+                    await Response().Error(strs.shop_item_req_role_not_found).SendAsync();
+                    return;
+                }
+
+                var guser = (IGuildUser)ctx.User;
+                if (!guser.RoleIds.Contains(reqRoleId))
+                {
+                    await Response()
+                          .Error(strs.shop_item_req_role_unfulfilled(Format.Bold(role.ToString())))
+                          .SendAsync();
+                    return;
+                }
+            }
+
+            if (entry.Type == ShopEntryType.Role)
+            {
+                var guser = (IGuildUser)ctx.User;
+                var role = ctx.Guild.GetRole(entry.RoleId);
+
+                if (role is null)
+                {
+                    await Response().Error(strs.shop_role_not_found).SendAsync();
+                    return;
+                }
+
+                if (guser.RoleIds.Any(id => id == role.Id))
+                {
+                    await Response().Error(strs.shop_role_already_bought).SendAsync();
+                    return;
+                }
+
+                if (await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString())))
+                {
+                    try
+                    {
+                        await guser.AddRoleAsync(role);
+                    }
+                    catch (Exception ex)
+                    {
+                        Log.Warning(ex, "Error adding shop role");
+                        await _cs.AddAsync(ctx.User.Id, entry.Price, new("shop", "error-refund"));
+                        await Response().Error(strs.shop_role_purchase_error).SendAsync();
+                        return;
+                    }
+
+                    var profit = GetProfitAmount(entry.Price);
+                    await _cs.AddAsync(entry.AuthorId, profit, new("shop", "sell", $"Shop sell item - {entry.Type}"));
+                    await _cs.AddAsync(ctx.Client.CurrentUser.Id, entry.Price - profit, new("shop", "cut"));
+                    await Response().Confirm(strs.shop_role_purchase(Format.Bold(role.Name))).SendAsync();
+                    return;
+                }
+
+                await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+                return;
+            }
+
+            else if (entry.Type == ShopEntryType.List)
+            {
+                if (entry.Items.Count == 0)
+                {
+                    await Response().Error(strs.out_of_stock).SendAsync();
+                    return;
+                }
+
+                var item = entry.Items.ToArray()[new EllieRandom().Next(0, entry.Items.Count)];
+
+                if (await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString())))
+                {
+                    await using (var uow = _db.GetDbContext())
+                    {
+                        uow.Set<ShopEntryItem>().Remove(item);
+                        await uow.SaveChangesAsync();
+                    }
+
+                    try
+                    {
+                        await Response()
+                              .User(ctx.User)
+                              .Embed(_sender.CreateEmbed()
+                                     .WithOkColor()
+                                     .WithTitle(GetText(strs.shop_purchase(ctx.Guild.Name)))
+                                     .AddField(GetText(strs.item), item.Text)
+                                     .AddField(GetText(strs.price), entry.Price.ToString(), true)
+                                     .AddField(GetText(strs.name), entry.Name, true))
+                              .SendAsync();
+
+                        await _cs.AddAsync(entry.AuthorId,
+                            GetProfitAmount(entry.Price),
+                            new("shop", "sell", entry.Name));
+                    }
+                    catch
+                    {
+                        await _cs.AddAsync(ctx.User.Id, entry.Price, new("shop", "error-refund", entry.Name));
+                        await using (var uow = _db.GetDbContext())
+                        {
+                            var entries = new IndexedCollection<ShopEntry>(uow.GuildConfigsForId(ctx.Guild.Id,
+                                                                                  set => set.Include(x => x.ShopEntries)
+                                                                                      .ThenInclude(x => x.Items))
+                                                                              .ShopEntries);
+                            entry = entries.ElementAtOrDefault(index);
+                            if (entry is not null)
+                            {
+                                if (entry.Items.Add(item))
+                                    uow.SaveChanges();
+                            }
+                        }
+
+                        await Response().Error(strs.shop_buy_error).SendAsync();
+                        return;
+                    }
+
+                    await Response().Confirm(strs.shop_item_purchase).SendAsync();
+                }
+                else
+                    await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+            }
+            else if (entry.Type == ShopEntryType.Command)
+            {
+                var guild = ctx.Guild as SocketGuild;
+                var channel = ctx.Channel as ISocketMessageChannel;
+                var msg = ctx.Message as SocketUserMessage;
+                var user = await ctx.Guild.GetUserAsync(entry.AuthorId);
+
+                if (guild is null || channel is null || msg is null || user is null)
+                {
+                    await Response().Error(strs.shop_command_invalid_context).SendAsync();
+                    return;
+                }
+
+                if (!await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString())))
+                {
+                    await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+                    return;
+                }
+                else
+                {
+                    var buyer = (IGuildUser)ctx.User;
+                    var cmd = entry.Command
+                                   .Replace("%you%", buyer.Mention)
+                                   .Replace("%you.mention%", buyer.Mention)
+                                   .Replace("%you.username%", buyer.Username)
+                                   .Replace("%you.name%", buyer.GlobalName ?? buyer.Username)
+                                   .Replace("%you.nick%", buyer.DisplayName);
+                    
+                    var eb = _sender.CreateEmbed()
+                             .WithPendingColor()
+                             .WithTitle("Executing shop command")
+                             .WithDescription(cmd);
+
+                    var msgTask = Response().Embed(eb).SendAsync();
+
+                    await _cs.AddAsync(entry.AuthorId,
+                        GetProfitAmount(entry.Price),
+                        new("shop", "sell", entry.Name));
+
+                    await Task.Delay(250);
+                    await _cmdHandler.TryRunCommand(guild,
+                        channel,
+                        new DoAsUserMessage(
+                            msg,
+                            user,
+                            cmd
+                        ));
+
+                    try
+                    {
+                        var pendingMsg = await msgTask;
+                        await pendingMsg.EditAsync(
+                            SmartEmbedText.FromEmbed(eb
+                                                     .WithOkColor()
+                                                     .WithTitle("Shop command executed")
+                                                     .Build()));
+                    }
+                    catch
+                    {
+                    }
+                }
+            }
+        }
+
+        private long GetProfitAmount(int price)
+            => (int)Math.Ceiling((1.0m - Config.BotCuts.ShopSaleCut) * price);
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.Administrator)]
+        [BotPerm(GuildPerm.ManageRoles)]
+        public async Task ShopAdd(Command _, int price, [Leftover] string command)
+        {
+            if (price < 1)
+                return;
+
+
+            var entry = await _service.AddShopCommandAsync(ctx.Guild.Id, ctx.User.Id, price, command);
+
+            await Response().Embed(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.Administrator)]
+        [BotPerm(GuildPerm.ManageRoles)]
+        public async Task ShopAdd(Role _, int price, [Leftover] IRole role)
+        {
+            if (price < 1)
+                return;
+
+            var entry = new ShopEntry
+            {
+                Name = "-",
+                Price = price,
+                Type = ShopEntryType.Role,
+                AuthorId = ctx.User.Id,
+                RoleId = role.Id,
+                RoleName = role.Name
+            };
+            await using (var uow = _db.GetDbContext())
+            {
+                var entries = new IndexedCollection<ShopEntry>(uow.GuildConfigsForId(ctx.Guild.Id,
+                                                                      set => set.Include(x => x.ShopEntries)
+                                                                          .ThenInclude(x => x.Items))
+                                                                  .ShopEntries)
+                {
+                    entry
+                };
+                uow.GuildConfigsForId(ctx.Guild.Id, set => set).ShopEntries = entries;
+                uow.SaveChanges();
+            }
+
+            await Response().Embed(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.Administrator)]
+        public async Task ShopAdd(List _, int price, [Leftover] string name)
+        {
+            if (price < 1)
+                return;
+
+            var entry = new ShopEntry
+            {
+                Name = name.TrimTo(100),
+                Price = price,
+                Type = ShopEntryType.List,
+                AuthorId = ctx.User.Id,
+                Items = new()
+            };
+            await using (var uow = _db.GetDbContext())
+            {
+                var entries = new IndexedCollection<ShopEntry>(uow.GuildConfigsForId(ctx.Guild.Id,
+                                                                      set => set.Include(x => x.ShopEntries)
+                                                                          .ThenInclude(x => x.Items))
+                                                                  .ShopEntries)
+                {
+                    entry
+                };
+                uow.GuildConfigsForId(ctx.Guild.Id, set => set).ShopEntries = entries;
+                uow.SaveChanges();
+            }
+
+            await Response().Embed(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.Administrator)]
+        public async Task ShopListAdd(int index, [Leftover] string itemText)
+        {
+            index -= 1;
+            if (index < 0)
+                return;
+            var item = new ShopEntryItem
+            {
+                Text = itemText
+            };
+            ShopEntry entry;
+            var rightType = false;
+            var added = false;
+            await using (var uow = _db.GetDbContext())
+            {
+                var entries = new IndexedCollection<ShopEntry>(uow.GuildConfigsForId(ctx.Guild.Id,
+                                                                      set => set.Include(x => x.ShopEntries)
+                                                                          .ThenInclude(x => x.Items))
+                                                                  .ShopEntries);
+                entry = entries.ElementAtOrDefault(index);
+                if (entry is not null && (rightType = entry.Type == ShopEntryType.List))
+                {
+                    if (entry.Items.Add(item))
+                    {
+                        added = true;
+                        uow.SaveChanges();
+                    }
+                }
+            }
+
+            if (entry is null)
+                await Response().Error(strs.shop_item_not_found).SendAsync();
+            else if (!rightType)
+                await Response().Error(strs.shop_item_wrong_type).SendAsync();
+            else if (added == false)
+                await Response().Error(strs.shop_list_item_not_unique).SendAsync();
+            else
+                await Response().Confirm(strs.shop_list_item_added).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.Administrator)]
+        public async Task ShopRemove(int index)
+        {
+            index -= 1;
+            if (index < 0)
+                return;
+            ShopEntry removed;
+            await using (var uow = _db.GetDbContext())
+            {
+                var config = uow.GuildConfigsForId(ctx.Guild.Id,
+                    set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items));
+
+                var entries = new IndexedCollection<ShopEntry>(config.ShopEntries);
+                removed = entries.ElementAtOrDefault(index);
+                if (removed is not null)
+                {
+                    uow.RemoveRange(removed.Items);
+                    uow.Remove(removed);
+                    uow.SaveChanges();
+                }
+            }
+
+            if (removed is null)
+                await Response().Error(strs.shop_item_not_found).SendAsync();
+            else
+                await Response().Embed(EntryToEmbed(removed).WithTitle(GetText(strs.shop_item_rm))).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.Administrator)]
+        public async Task ShopChangePrice(int index, int price)
+        {
+            if (--index < 0 || price <= 0)
+                return;
+
+            var succ = await _service.ChangeEntryPriceAsync(ctx.Guild.Id, index, price);
+            if (succ)
+            {
+                await ShopInternalAsync(index / 9);
+                await ctx.OkAsync();
+            }
+            else
+                await ctx.ErrorAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.Administrator)]
+        public async Task ShopChangeName(int index, [Leftover] string newName)
+        {
+            if (--index < 0 || string.IsNullOrWhiteSpace(newName))
+                return;
+
+            var succ = await _service.ChangeEntryNameAsync(ctx.Guild.Id, index, newName);
+            if (succ)
+            {
+                await ShopInternalAsync(index / 9);
+                await ctx.OkAsync();
+            }
+            else
+                await ctx.ErrorAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.Administrator)]
+        public async Task ShopSwap(int index1, int index2)
+        {
+            if (--index1 < 0 || --index2 < 0 || index1 == index2)
+                return;
+
+            var succ = await _service.SwapEntriesAsync(ctx.Guild.Id, index1, index2);
+            if (succ)
+            {
+                await ShopInternalAsync(index1 / 9);
+                await ctx.OkAsync();
+            }
+            else
+                await ctx.ErrorAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.Administrator)]
+        public async Task ShopMove(int fromIndex, int toIndex)
+        {
+            if (--fromIndex < 0 || --toIndex < 0 || fromIndex == toIndex)
+                return;
+
+            var succ = await _service.MoveEntryAsync(ctx.Guild.Id, fromIndex, toIndex);
+            if (succ)
+            {
+                await ShopInternalAsync(toIndex / 9);
+                await ctx.OkAsync();
+            }
+            else
+                await ctx.ErrorAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.Administrator)]
+        public async Task ShopReq(int itemIndex, [Leftover] IRole role = null)
+        {
+            if (--itemIndex < 0)
+                return;
+
+            var succ = await _service.SetItemRoleRequirementAsync(ctx.Guild.Id, itemIndex, role?.Id);
+            if (!succ)
+            {
+                await Response().Error(strs.shop_item_not_found).SendAsync();
+                return;
+            }
+
+            if (role is null)
+                await Response().Confirm(strs.shop_item_role_no_req(itemIndex)).SendAsync();
+            else
+                await Response().Confirm(strs.shop_item_role_req(itemIndex + 1, role)).SendAsync();
+        }
+
+        public EmbedBuilder EntryToEmbed(ShopEntry entry)
+        {
+            var embed = _sender.CreateEmbed().WithOkColor();
+
+            if (entry.Type == ShopEntryType.Role)
+            {
+                return embed
+                       .AddField(GetText(strs.name),
+                           GetText(strs.shop_role(Format.Bold(ctx.Guild.GetRole(entry.RoleId)?.Name
+                                                              ?? "MISSING_ROLE"))),
+                           true)
+                       .AddField(GetText(strs.price), N(entry.Price), true)
+                       .AddField(GetText(strs.type), entry.Type.ToString(), true);
+            }
+
+            if (entry.Type == ShopEntryType.List)
+            {
+                return embed.AddField(GetText(strs.name), entry.Name, true)
+                            .AddField(GetText(strs.price), N(entry.Price), true)
+                            .AddField(GetText(strs.type), GetText(strs.random_unique_item), true);
+            }
+
+            else if (entry.Type == ShopEntryType.Command)
+            {
+                return embed
+                       .AddField(GetText(strs.name), Format.Code(entry.Command), true)
+                       .AddField(GetText(strs.price), N(entry.Price), true)
+                       .AddField(GetText(strs.type), entry.Type.ToString(), true);
+            }
+
+            //else if (entry.Type == ShopEntryType.Infinite_List)
+            //    return embed.AddField(GetText(strs.name), GetText(strs.shop_role(Format.Bold(entry.RoleName)), true))
+            //            .AddField(GetText(strs.price), entry.Price.ToString(), true)
+            //            .AddField(GetText(strs.type), entry.Type.ToString(), true);
+            return null;
+        }
+
+        public string EntryToString(ShopEntry entry)
+        {
+            var prepend = string.Empty;
+            if (entry.RoleRequirement is not null)
+                prepend = Format.Italics(GetText(strs.shop_item_requires_role($"<@&{entry.RoleRequirement}>")))
+                          + Environment.NewLine;
+
+            if (entry.Type == ShopEntryType.Role)
+                return prepend
+                       + GetText(strs.shop_role(Format.Bold(ctx.Guild.GetRole(entry.RoleId)?.Name ?? "MISSING_ROLE")));
+            if (entry.Type == ShopEntryType.List)
+                return prepend + GetText(strs.unique_items_left(entry.Items.Count)) + "\n" + entry.Name;
+
+            if (entry.Type == ShopEntryType.Command)
+                return prepend + Format.Code(entry.Command);
+            return prepend;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Shop/ShopService.cs b/src/EllieBot/Modules/Gambling/Shop/ShopService.cs
new file mode 100644
index 0000000..9e46aa0
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Shop/ShopService.cs
@@ -0,0 +1,126 @@
+#nullable disable
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Gambling.Services;
+
+public class ShopService : IShopService, IEService
+{
+    private readonly DbService _db;
+
+    public ShopService(DbService db)
+        => _db = db;
+
+    private IndexedCollection<ShopEntry> GetEntriesInternal(DbContext uow, ulong guildId)
+        => uow.GuildConfigsForId(guildId,
+                set => set.Include(x => x.ShopEntries)
+                    .ThenInclude(x => x.Items))
+            .ShopEntries.ToIndexed();
+
+    public async Task<bool> ChangeEntryPriceAsync(ulong guildId, int index, int newPrice)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(index);
+        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(newPrice);
+
+        await using var uow = _db.GetDbContext();
+        var entries = GetEntriesInternal(uow, guildId);
+
+        if (index >= entries.Count)
+            return false;
+
+        entries[index].Price = newPrice;
+        await uow.SaveChangesAsync();
+        return true;
+    }
+
+    public async Task<bool> ChangeEntryNameAsync(ulong guildId, int index, string newName)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(index);
+        
+        if (string.IsNullOrWhiteSpace(newName))
+            throw new ArgumentNullException(nameof(newName));
+
+        await using var uow = _db.GetDbContext();
+        var entries = GetEntriesInternal(uow, guildId);
+
+        if (index >= entries.Count)
+            return false;
+
+        entries[index].Name = newName.TrimTo(100);
+        await uow.SaveChangesAsync();
+        return true;
+    }
+
+    public async Task<bool> SwapEntriesAsync(ulong guildId, int index1, int index2)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(index1);
+        ArgumentOutOfRangeException.ThrowIfNegative(index2);
+
+        await using var uow = _db.GetDbContext();
+        var entries = GetEntriesInternal(uow, guildId);
+
+        if (index1 >= entries.Count || index2 >= entries.Count || index1 == index2)
+            return false;
+
+        entries[index1].Index = index2;
+        entries[index2].Index = index1;
+
+        await uow.SaveChangesAsync();
+        return true;
+    }
+
+    public async Task<bool> MoveEntryAsync(ulong guildId, int fromIndex, int toIndex)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(fromIndex);
+        ArgumentOutOfRangeException.ThrowIfNegative(toIndex);
+
+        await using var uow = _db.GetDbContext();
+        var entries = GetEntriesInternal(uow, guildId);
+
+        if (fromIndex >= entries.Count || toIndex >= entries.Count || fromIndex == toIndex)
+            return false;
+
+        var entry = entries[fromIndex];
+        entries.RemoveAt(fromIndex);
+        entries.Insert(toIndex, entry);
+
+        await uow.SaveChangesAsync();
+        return true;
+    }
+
+    public async Task<bool> SetItemRoleRequirementAsync(ulong guildId, int index, ulong? roleId)
+    {
+        await using var uow = _db.GetDbContext();
+        var entries = GetEntriesInternal(uow, guildId);
+
+        if (index >= entries.Count)
+            return false;
+
+        var entry = entries[index];
+
+        entry.RoleRequirement = roleId;
+
+        await uow.SaveChangesAsync();
+        return true;
+    }
+
+    public async Task<ShopEntry> AddShopCommandAsync(ulong guildId, ulong userId, int price, string command)
+    {
+        await using var uow = _db.GetDbContext();
+
+        var entries = GetEntriesInternal(uow, guildId);
+        var entry = new ShopEntry()
+        {
+            AuthorId = userId,
+            Command = command,
+            Type = ShopEntryType.Command,
+            Price = price,
+        };
+        entries.Add(entry);
+        uow.GuildConfigsForId(guildId, set => set).ShopEntries = entries;
+
+        await uow.SaveChangesAsync();
+
+        return entry;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs b/src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs
new file mode 100644
index 0000000..4e8d045
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs
@@ -0,0 +1,223 @@
+#nullable disable warnings
+using EllieBot.Db.Models;
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Services;
+using SixLabors.Fonts;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+using EllieBot.Common.TypeReaders;
+using Color = SixLabors.ImageSharp.Color;
+using Image = SixLabors.ImageSharp.Image;
+
+namespace EllieBot.Modules.Gambling;
+
+public enum GamblingError
+{
+    InsufficientFunds,
+}
+
+public partial class Gambling
+{
+    [Group]
+    public partial class SlotCommands : GamblingSubmodule<IGamblingService>
+    {
+        private readonly IImageCache _images;
+        private readonly FontProvider _fonts;
+        private readonly DbService _db;
+        private object _slotStatsLock = new();
+
+        public SlotCommands(
+            IImageCache images,
+            FontProvider fonts,
+            DbService db,
+            GamblingConfigService gamb)
+            : base(gamb)
+        {
+            _images = images;
+            _fonts = fonts;
+            _db = db;
+        }
+
+        public Task Test()
+            => Task.CompletedTask;
+
+        [Cmd]
+        public async Task Slot([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
+        {
+            if (!await CheckBetMandatory(amount))
+                return;
+
+            // var slotInteraction = CreateSlotInteractionIntenal(amount);
+
+            await ctx.Channel.TriggerTypingAsync();
+
+            if (await InternalSlotAsync(amount) is not SlotResult result)
+            {
+                await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+                return;
+            }
+
+            var text = GetSlotMessageTextInternal(result);
+
+            using var image = await GenerateSlotImageAsync(amount, result);
+            await using var imgStream = await image.ToStreamAsync();
+
+
+            var eb = _sender.CreateEmbed()
+                            .WithAuthor(ctx.User)
+                            .WithDescription(Format.Bold(text))
+                            .WithImageUrl($"attachment://result.png")
+                            .WithOkColor();
+
+            var bb = new ButtonBuilder(emote: Emoji.Parse("🔁"), customId: "slot:again", label: "Pull Again");
+            var inter = _inter.Create(ctx.User.Id,
+                bb,
+                smc =>
+                {
+                    smc.DeferAsync();
+                    return Slot(amount);
+                });
+
+            var msg = await ctx.Channel.SendFileAsync(imgStream,
+                "result.png",
+                embed: eb.Build(),
+                components: inter.CreateComponent()
+            );
+            await inter.RunAsync(msg);
+        }
+
+        // private SlotInteraction CreateSlotInteractionIntenal(long amount)
+        // {
+        //     return new SlotInteraction((DiscordSocketClient)ctx.Client,
+        //         ctx.User.Id,
+        //         async (smc) =>
+        //         {
+        //             try
+        //             {
+        //                 if (await InternalSlotAsync(amount) is not SlotResult result)
+        //                 {
+        //                     await smc.RespondErrorAsync(_eb, GetText(strs.not_enough(CurrencySign)), true);
+        //                     return;
+        //                 }
+        //
+        //                 var msg = GetSlotMessageInternal(result);
+        //
+        //                 using var image = await GenerateSlotImageAsync(amount, result);
+        //                 await using var imgStream = await image.ToStreamAsync();
+        //
+        //                 var guid = Guid.NewGuid();
+        //                 var imgName = $"result_{guid}.png";
+        //                 
+        //                 var slotInteraction = CreateSlotInteractionIntenal(amount).GetInteraction();
+        //                 
+        //                 await smc.Message.ModifyAsync(m =>
+        //                 {
+        //                     m.Content = msg;
+        //                     m.Attachments = new[]
+        //                     {
+        //                         new FileAttachment(imgStream, imgName)
+        //                     };
+        //                     m.Components = slotInteraction.CreateComponent();
+        //                 });
+        //                 
+        //                 _ = slotInteraction.RunAsync(smc.Message);
+        //             }
+        //             catch (Exception ex)
+        //             {
+        //                 Log.Error(ex, "Error pulling slot again");
+        //             }
+        //             // finally
+        //             // {
+        //             //     await Task.Delay(1000);
+        //             //     _runningUsers.TryRemove(ctx.User.Id);
+        //             // }
+        //         });
+        // }
+
+        private string GetSlotMessageTextInternal(SlotResult result)
+        {
+            var multi = result.Multiplier.ToString("0.##");
+            var msg = result.WinType switch
+            {
+                SlotWinType.SingleJoker => GetText(strs.slot_single(CurrencySign, multi)),
+                SlotWinType.DoubleJoker => GetText(strs.slot_two(CurrencySign, multi)),
+                SlotWinType.TrippleNormal => GetText(strs.slot_three(multi)),
+                SlotWinType.TrippleJoker => GetText(strs.slot_jackpot(multi)),
+                _ => GetText(strs.better_luck),
+            };
+            return msg;
+        }
+
+        private async Task<SlotResult?> InternalSlotAsync(long amount)
+        {
+            var maybeResult = await _service.SlotAsync(ctx.User.Id, amount);
+
+            if (!maybeResult.TryPickT0(out var result, out var error))
+            {
+                return null;
+            }
+
+            return result;
+        }
+
+        private async Task<Image<Rgba32>> GenerateSlotImageAsync(long amount, SlotResult result)
+        {
+            long ownedAmount;
+            await using (var uow = _db.GetDbContext())
+            {
+                ownedAmount = uow.Set<DiscordUser>()
+                                 .FirstOrDefault(x => x.UserId == ctx.User.Id)?.CurrencyAmount
+                              ?? 0;
+            }
+
+            var slotBg = await _images.GetSlotBgAsync();
+            var bgImage = Image.Load<Rgba32>(slotBg);
+            var numbers = new int[3];
+            result.Rolls.CopyTo(numbers, 0);
+
+            Color fontColor = Config.Slots.CurrencyFontColor;
+
+            bgImage.Mutate<Rgba32>(x => x.DrawText(new RichTextOptions(_fonts.DottyFont.CreateFont(65))
+                {
+                    HorizontalAlignment = HorizontalAlignment.Center,
+                    VerticalAlignment = VerticalAlignment.Center,
+                    WrappingLength = 140,
+                    Origin = new(298, 100)
+                },
+                ((long)result.Won).ToString(),
+                fontColor));
+
+            var bottomFont = _fonts.DottyFont.CreateFont(50);
+
+            bgImage.Mutate(x => x.DrawText(new RichTextOptions(bottomFont)
+                {
+                    HorizontalAlignment = HorizontalAlignment.Center,
+                    VerticalAlignment = VerticalAlignment.Center,
+                    WrappingLength = 135,
+                    Origin = new(196, 480)
+                },
+                amount.ToString(),
+                fontColor));
+
+            bgImage.Mutate(x => x.DrawText(new(bottomFont)
+                {
+                    HorizontalAlignment = HorizontalAlignment.Center,
+                    VerticalAlignment = VerticalAlignment.Center,
+                    Origin = new(393, 480)
+                },
+                ownedAmount.ToString(),
+                fontColor));
+            //sw.PrintLap("drew red text");
+
+            for (var i = 0; i < 3; i++)
+            {
+                using var img = Image.Load(await _images.GetSlotEmojiAsync(numbers[i]));
+                bgImage.Mutate(x => x.DrawImage(img, new Point(148 + (105 * i), 217), 1f));
+            }
+
+            return bgImage;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/VoteRewardService.cs b/src/EllieBot/Modules/Gambling/VoteRewardService.cs
new file mode 100644
index 0000000..62d861b
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/VoteRewardService.cs
@@ -0,0 +1,106 @@
+#nullable disable
+using EllieBot.Common.ModuleBehaviors;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Gambling.Services;
+
+public class VoteModel
+{
+    [JsonPropertyName("userId")]
+    public ulong UserId { get; set; }
+}
+
+public class VoteRewardService : IEService, IReadyExecutor
+{
+    private readonly DiscordSocketClient _client;
+    private readonly IBotCredentials _creds;
+    private readonly ICurrencyService _currencyService;
+    private readonly GamblingConfigService _gamb;
+
+    public VoteRewardService(
+        DiscordSocketClient client,
+        IBotCredentials creds,
+        ICurrencyService currencyService,
+        GamblingConfigService gamb)
+    {
+        _client = client;
+        _creds = creds;
+        _currencyService = currencyService;
+        _gamb = gamb;
+    }
+
+    public async Task OnReadyAsync()
+    {
+        if (_client.ShardId != 0)
+            return;
+
+        using var http = new HttpClient(new HttpClientHandler
+        {
+            AllowAutoRedirect = false,
+            ServerCertificateCustomValidationCallback = delegate { return true; }
+        });
+
+        while (true)
+        {
+            await Task.Delay(30000);
+
+            var topggKey = _creds.Votes?.TopggKey;
+            var topggServiceUrl = _creds.Votes?.TopggServiceUrl;
+
+            try
+            {
+                if (!string.IsNullOrWhiteSpace(topggKey) && !string.IsNullOrWhiteSpace(topggServiceUrl))
+                {
+                    http.DefaultRequestHeaders.Authorization = new(topggKey);
+                    var uri = new Uri(new(topggServiceUrl), "topgg/new");
+                    var res = await http.GetStringAsync(uri);
+                    var data = JsonSerializer.Deserialize<List<VoteModel>>(res);
+
+                    if (data is { Count: > 0 })
+                    {
+                        var ids = data.Select(x => x.UserId).ToList();
+
+                        await _currencyService.AddBulkAsync(ids,
+                            _gamb.Data.VoteReward,
+                            new("vote", "top.gg", "top.gg vote reward"));
+
+                        Log.Information("Rewarding {Count} top.gg voters", ids.Count());
+                    }
+                }
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "Critical error loading top.gg vote rewards");
+            }
+
+            var discordsKey = _creds.Votes?.DiscordsKey;
+            var discordsServiceUrl = _creds.Votes?.DiscordsServiceUrl;
+
+            try
+            {
+                if (!string.IsNullOrWhiteSpace(discordsKey) && !string.IsNullOrWhiteSpace(discordsServiceUrl))
+                {
+                    http.DefaultRequestHeaders.Authorization = new(discordsKey);
+                    var res = await http.GetStringAsync(new Uri(new(discordsServiceUrl), "discords/new"));
+                    var data = JsonSerializer.Deserialize<List<VoteModel>>(res);
+
+                    if (data is { Count: > 0 })
+                    {
+                        var ids = data.Select(x => x.UserId).ToList();
+
+                        await _currencyService.AddBulkAsync(ids,
+                            _gamb.Data.VoteReward,
+                            new("vote", "discords", "discords.com vote reward"));
+
+                        Log.Information("Rewarding {Count} discords.com voters", ids.Count());
+                    }
+                }
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "Critical error loading discords.com vote rewards");
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs b/src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs
new file mode 100644
index 0000000..a25bdd9
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs
@@ -0,0 +1,406 @@
+#nullable disable
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Common.Waifu;
+using EllieBot.Modules.Gambling.Services;
+using EllieBot.Db.Models;
+using System.Globalization;
+
+namespace EllieBot.Modules.Gambling;
+
+public partial class Gambling
+{
+    [Group]
+    public partial class WaifuClaimCommands : GamblingSubmodule<WaifuService>
+    {
+        public WaifuClaimCommands(GamblingConfigService gamblingConfService)
+            : base(gamblingConfService)
+        {
+        }
+
+        [Cmd]
+        public async Task WaifuReset()
+        {
+            var price = _service.GetResetPrice(ctx.User);
+            var embed = _sender.CreateEmbed()
+                               .WithTitle(GetText(strs.waifu_reset_confirm))
+                               .WithDescription(GetText(strs.waifu_reset_price(Format.Bold(N(price)))));
+
+            if (!await PromptUserConfirmAsync(embed))
+                return;
+
+            if (await _service.TryReset(ctx.User))
+            {
+                await Response().Confirm(strs.waifu_reset).SendAsync();
+                return;
+            }
+
+            await Response().Error(strs.waifu_reset_fail).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task WaifuClaim(long amount, [Leftover] IUser target)
+        {
+            if (amount < Config.Waifu.MinPrice)
+            {
+                await Response().Error(strs.waifu_isnt_cheap(Config.Waifu.MinPrice + CurrencySign)).SendAsync();
+                return;
+            }
+
+            if (target.Id == ctx.User.Id)
+            {
+                await Response().Error(strs.waifu_not_yourself).SendAsync();
+                return;
+            }
+
+            var (w, isAffinity, result) = await _service.ClaimWaifuAsync(ctx.User, target, amount);
+
+            if (result == WaifuClaimResult.InsufficientAmount)
+            {
+                await Response()
+                      .Error(
+                          strs.waifu_not_enough(N((long)Math.Ceiling(w.Price * (isAffinity ? 0.88f : 1.1f)))))
+                      .SendAsync();
+                return;
+            }
+
+            if (result == WaifuClaimResult.NotEnoughFunds)
+            {
+                await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+                return;
+            }
+
+            var msg = GetText(strs.waifu_claimed(
+                Format.Bold(ctx.User.ToString()),
+                Format.Bold(target.ToString()),
+                N(amount)));
+            
+            if (w.Affinity?.UserId == ctx.User.Id)
+                msg += "\n" + GetText(strs.waifu_fulfilled(target, N(w.Price)));
+            else
+                msg = " " + msg;
+            await Response().Confirm(ctx.User.Mention + msg).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [Priority(0)]
+        public async Task WaifuTransfer(ulong waifuId, IUser newOwner)
+        {
+            if (!await _service.WaifuTransfer(ctx.User, waifuId, newOwner))
+            {
+                await Response().Error(strs.waifu_transfer_fail).SendAsync();
+                return;
+            }
+
+            await Response()
+                  .Confirm(strs.waifu_transfer_success(Format.Bold(waifuId.ToString()),
+                      Format.Bold(ctx.User.ToString()),
+                      Format.Bold(newOwner.ToString())))
+                  .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [Priority(1)]
+        public async Task WaifuTransfer(IUser waifu, IUser newOwner)
+        {
+            if (!await _service.WaifuTransfer(ctx.User, waifu.Id, newOwner))
+            {
+                await Response().Error(strs.waifu_transfer_fail).SendAsync();
+                return;
+            }
+
+            await Response()
+                  .Confirm(strs.waifu_transfer_success(Format.Bold(waifu.ToString()),
+                      Format.Bold(ctx.User.ToString()),
+                      Format.Bold(newOwner.ToString())))
+                  .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [Priority(-1)]
+        public Task Divorce([Leftover] string target)
+        {
+            var waifuUserId = _service.GetWaifuUserId(ctx.User.Id, target);
+            if (waifuUserId == default)
+                return Response().Error(strs.waifu_not_yours).SendAsync();
+
+            return Divorce(waifuUserId);
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [Priority(0)]
+        public Task Divorce([Leftover] IGuildUser target)
+            => Divorce(target.Id);
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [Priority(1)]
+        public async Task Divorce([Leftover] ulong targetId)
+        {
+            if (targetId == ctx.User.Id)
+                return;
+
+            var (w, result, amount, remaining) = await _service.DivorceWaifuAsync(ctx.User, targetId);
+
+            if (result == DivorceResult.SucessWithPenalty)
+            {
+                await Response()
+                      .Confirm(strs.waifu_divorced_like(Format.Bold(w.Waifu.ToString()),
+                          N(amount)))
+                      .SendAsync();
+            }
+            else if (result == DivorceResult.Success)
+                await Response().Confirm(strs.waifu_divorced_notlike(N(amount))).SendAsync();
+            else if (result == DivorceResult.NotYourWife)
+                await Response().Error(strs.waifu_not_yours).SendAsync();
+            else if (remaining is { } rem)
+            {
+                await Response()
+                      .Error(strs.waifu_recent_divorce(
+                          Format.Bold(((int)rem.TotalHours).ToString()),
+                          Format.Bold(rem.Minutes.ToString())))
+                      .SendAsync();
+            }
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Affinity([Leftover] IGuildUser user = null)
+        {
+            if (user?.Id == ctx.User.Id)
+            {
+                await Response().Error(strs.waifu_egomaniac).SendAsync();
+                return;
+            }
+
+            var (oldAff, sucess, remaining) = await _service.ChangeAffinityAsync(ctx.User, user);
+            if (!sucess)
+            {
+                if (remaining is not null)
+                {
+                    await Response()
+                          .Error(strs.waifu_affinity_cooldown(
+                              Format.Bold(((int)remaining?.TotalHours).ToString()),
+                              Format.Bold(remaining?.Minutes.ToString())))
+                          .SendAsync();
+                }
+                else
+                    await Response().Error(strs.waifu_affinity_already).SendAsync();
+
+                return;
+            }
+
+            if (user is null)
+            {
+                await Response().Confirm(strs.waifu_affinity_reset).SendAsync();
+            }
+            else if (oldAff is null)
+            {
+                await Response()
+                      .Confirm(strs.waifu_affinity_set(Format.Bold(ctx.User.ToString()), Format.Bold(user.ToString())))
+                      .SendAsync();
+            }
+            else
+            {
+                await Response()
+                      .Confirm(strs.waifu_affinity_changed(
+                          Format.Bold(ctx.User.ToString()),
+                          Format.Bold(oldAff.ToString()),
+                          Format.Bold(user.ToString())))
+                      .SendAsync();
+            }
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task WaifuLb(int page = 1)
+        {
+            page--;
+
+            if (page < 0)
+                return;
+
+            if (page > 100)
+                page = 100;
+
+            var waifus = _service.GetTopWaifusAtPage(page).ToList();
+
+            if (waifus.Count == 0)
+            {
+                await Response().Confirm(strs.waifus_none).SendAsync();
+                return;
+            }
+
+            var embed = _sender.CreateEmbed().WithTitle(GetText(strs.waifus_top_waifus)).WithOkColor();
+
+            var i = 0;
+            foreach (var w in waifus)
+            {
+                var j = i++;
+                embed.AddField("#" + ((page * 9) + j + 1) + " - " + N(w.Price), GetLbString(w));
+            }
+
+            await Response().Embed(embed).SendAsync();
+        }
+
+        private string GetLbString(WaifuLbResult w)
+        {
+            var claimer = "no one";
+            string status;
+
+            var waifuUsername = w.Username.TrimTo(20);
+            var claimerUsername = w.Claimer?.TrimTo(20);
+
+            if (w.Claimer is not null)
+                claimer = $"{claimerUsername}#{w.ClaimerDiscrim}";
+            if (w.Affinity is null)
+                status = $"... but {waifuUsername}'s heart is empty";
+            else if (w.Affinity + w.AffinityDiscrim == w.Claimer + w.ClaimerDiscrim)
+                status = $"... and {waifuUsername} likes {claimerUsername} too <3";
+            else
+                status = $"... but {waifuUsername}'s heart belongs to {w.Affinity.TrimTo(20)}#{w.AffinityDiscrim}";
+            return $"**{waifuUsername}#{w.Discrim}** - claimed by **{claimer}**\n\t{status}";
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [Priority(1)]
+        public Task WaifuInfo([Leftover] IUser target = null)
+        {
+            if (target is null)
+                target = ctx.User;
+
+            return InternalWaifuInfo(target.Id, target.ToString());
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [Priority(0)]
+        public Task WaifuInfo(ulong targetId)
+            => InternalWaifuInfo(targetId);
+
+        private async Task InternalWaifuInfo(ulong targetId, string name = null)
+        {
+            var wi = await _service.GetFullWaifuInfoAsync(targetId);
+            var affInfo = _service.GetAffinityTitle(wi.AffinityCount);
+
+            var waifuItems = _service.GetWaifuItems().ToDictionary(x => x.ItemEmoji, x => x);
+
+            var nobody = GetText(strs.nobody);
+            var itemList = await _service.GetItems(wi.WaifuId);
+            var itemsStr = !itemList.Any()
+                ? "-"
+                : string.Join("\n",
+                    itemList.Where(x => waifuItems.TryGetValue(x.ItemEmoji, out _))
+                            .OrderByDescending(x => waifuItems[x.ItemEmoji].Price)
+                            .GroupBy(x => x.ItemEmoji)
+                            .Take(60)
+                            .Select(x => $"{x.Key} x{x.Count(),-3}")
+                            .Chunk(2)
+                            .Select(x => string.Join(" ", x)));
+
+            var claimsNames = (await _service.GetClaimNames(wi.WaifuId));
+            var claimsStr = claimsNames
+                            .Shuffle()
+                            .Take(30)
+                            .Join('\n');
+
+            var fansList = await _service.GetFansNames(wi.WaifuId);
+            var fansStr = fansList
+                          .Shuffle()
+                          .Take(30)
+                          .Select((x) => claimsNames.Contains(x) ? $"{x} 💞" : x)
+                          .Join('\n');
+
+            if (string.IsNullOrWhiteSpace(fansStr))
+                fansStr = "-";
+
+            var embed = _sender.CreateEmbed()
+                               .WithOkColor()
+                               .WithTitle(GetText(strs.waifu)
+                                          + " "
+                                          + (wi.FullName ?? name ?? targetId.ToString())
+                                          + " - \"the "
+                                          + _service.GetClaimTitle(wi.ClaimCount)
+                                          + "\"")
+                               .AddField(GetText(strs.price), N(wi.Price), true)
+                               .AddField(GetText(strs.claimed_by), wi.ClaimerName ?? nobody, true)
+                               .AddField(GetText(strs.likes), wi.AffinityName ?? nobody, true)
+                               .AddField(GetText(strs.changes_of_heart),
+                                   $"{wi.AffinityCount} - \"the {affInfo}\"",
+                                   true)
+                               .AddField(GetText(strs.divorces), wi.DivorceCount.ToString(), true)
+                               .AddField("\u200B", "\u200B", true)
+                               .AddField(GetText(strs.fans(fansList.Count)), fansStr, true)
+                               .AddField($"Waifus ({wi.ClaimCount})",
+                                   wi.ClaimCount == 0 ? nobody : claimsStr,
+                                   true)
+                               .AddField(GetText(strs.gifts), itemsStr, true);
+
+            await Response().Embed(embed).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [Priority(1)]
+        public async Task WaifuGift(int page = 1)
+        {
+            if (--page < 0 || page > (Config.Waifu.Items.Count - 1) / 9)
+                return;
+
+            var waifuItems = _service.GetWaifuItems();
+            await Response()
+                  .Paginated()
+                  .Items(waifuItems.OrderBy(x => x.Negative)
+                                   .ThenBy(x => x.Price)
+                                   .ToList())
+                  .PageSize(9)
+                  .CurrentPage(page)
+                  .Page((items, _) =>
+                  {
+                      var embed = _sender.CreateEmbed().WithTitle(GetText(strs.waifu_gift_shop)).WithOkColor();
+
+                      items
+                          .ToList()
+                          .ForEach(x => embed.AddField(
+                              $"{(!x.Negative ? string.Empty : "\\💔")} {x.ItemEmoji} {x.Name}",
+                              Format.Bold(N(x.Price)),
+                              true));
+
+                      return embed;
+                  })
+                  .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [Priority(0)]
+        public async Task WaifuGift(MultipleWaifuItems items, [Leftover] IUser waifu)
+        {
+            if (waifu.Id == ctx.User.Id)
+                return;
+
+            var sucess = await _service.GiftWaifuAsync(ctx.User, waifu, items.Item, items.Count);
+
+            if (sucess)
+            {
+                await Response()
+                      .Confirm(strs.waifu_gift(
+                          Format.Bold($"{GetCountString(items)}{items.Item} {items.Item.ItemEmoji}"),
+                          Format.Bold(waifu.ToString())))
+                      .SendAsync();
+            }
+            else
+                await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
+        }
+
+        private static string GetCountString(MultipleWaifuItems items)
+            => items.Count > 1
+                ? $"{items.Count}x "
+                : string.Empty;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs b/src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs
new file mode 100644
index 0000000..286d9e8
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs
@@ -0,0 +1,633 @@
+#nullable disable
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Common.ModuleBehaviors;
+using EllieBot.Db.Models;
+using EllieBot.Modules.Gambling.Common;
+using EllieBot.Modules.Gambling.Common.Waifu;
+
+namespace EllieBot.Modules.Gambling.Services;
+
+public class WaifuService : IEService, IReadyExecutor
+{
+    private readonly DbService _db;
+    private readonly ICurrencyService _cs;
+    private readonly IBotCache _cache;
+    private readonly GamblingConfigService _gss;
+    private readonly IBotCredentials _creds;
+    private readonly DiscordSocketClient _client;
+
+    public WaifuService(
+        DbService db,
+        ICurrencyService cs,
+        IBotCache cache,
+        GamblingConfigService gss,
+        IBotCredentials creds,
+        DiscordSocketClient client)
+    {
+        _db = db;
+        _cs = cs;
+        _cache = cache;
+        _gss = gss;
+        _creds = creds;
+        _client = client;
+    }
+
+    public async Task<bool> WaifuTransfer(IUser owner, ulong waifuId, IUser newOwner)
+    {
+        if (owner.Id == newOwner.Id || waifuId == newOwner.Id)
+            return false;
+
+        var settings = _gss.Data;
+
+        await using var uow = _db.GetDbContext();
+        var waifu = uow.Set<WaifuInfo>().ByWaifuUserId(waifuId);
+        var ownerUser = uow.GetOrCreateUser(owner);
+
+        // owner has to be the owner of the waifu
+        if (waifu is null || waifu.ClaimerId != ownerUser.Id)
+            return false;
+
+        // if waifu likes the person, gotta pay the penalty
+        if (waifu.AffinityId == ownerUser.Id)
+        {
+            if (!await _cs.RemoveAsync(owner.Id, (long)(waifu.Price * 0.6), new("waifu", "affinity-penalty")))
+                // unable to pay 60% penalty
+                return false;
+
+            waifu.Price = (long)(waifu.Price * 0.7); // half of 60% = 30% price reduction
+            if (waifu.Price < settings.Waifu.MinPrice)
+                waifu.Price = settings.Waifu.MinPrice;
+        }
+        else // if not, pay 10% fee
+        {
+            if (!await _cs.RemoveAsync(owner.Id, waifu.Price / 10, new("waifu", "transfer")))
+                return false;
+
+            waifu.Price = (long)(waifu.Price * 0.95); // half of 10% = 5% price reduction
+            if (waifu.Price < settings.Waifu.MinPrice)
+                waifu.Price = settings.Waifu.MinPrice;
+        }
+
+        //new claimerId is the id of the new owner
+        var newOwnerUser = uow.GetOrCreateUser(newOwner);
+        waifu.ClaimerId = newOwnerUser.Id;
+
+        await uow.SaveChangesAsync();
+
+        return true;
+    }
+
+    public long GetResetPrice(IUser user)
+    {
+        var settings = _gss.Data;
+        using var uow = _db.GetDbContext();
+        var waifu = uow.Set<WaifuInfo>().ByWaifuUserId(user.Id);
+
+        if (waifu is null)
+            return settings.Waifu.MinPrice;
+
+        var divorces = uow.Set<WaifuUpdate>()
+                          .Count(x
+                              => x.Old != null
+                                 && x.Old.UserId == user.Id
+                                 && x.UpdateType == WaifuUpdateType.Claimed
+                                 && x.New == null);
+        var affs = uow.Set<WaifuUpdate>()
+                      .AsQueryable()
+                      .Where(w => w.User.UserId == user.Id
+                                  && w.UpdateType == WaifuUpdateType.AffinityChanged
+                                  && w.New != null)
+                      .ToList()
+                      .GroupBy(x => x.New)
+                      .Count();
+
+        return (long)Math.Ceiling(waifu.Price * 1.25f)
+               + ((divorces + affs + 2) * settings.Waifu.Multipliers.WaifuReset);
+    }
+
+    public async Task<bool> TryReset(IUser user)
+    {
+        await using var uow = _db.GetDbContext();
+        var price = GetResetPrice(user);
+        if (!await _cs.RemoveAsync(user.Id, price, new("waifu", "reset")))
+            return false;
+
+        var affs = uow.Set<WaifuUpdate>()
+                      .AsQueryable()
+                      .Where(w => w.User.UserId == user.Id
+                                  && w.UpdateType == WaifuUpdateType.AffinityChanged
+                                  && w.New != null);
+
+        var divorces = uow.Set<WaifuUpdate>()
+                          .AsQueryable()
+                          .Where(x => x.Old != null
+                                      && x.Old.UserId == user.Id
+                                      && x.UpdateType == WaifuUpdateType.Claimed
+                                      && x.New == null);
+
+        //reset changes of heart to 0
+        uow.Set<WaifuUpdate>().RemoveRange(affs);
+        //reset divorces to 0
+        uow.Set<WaifuUpdate>().RemoveRange(divorces);
+        var waifu = uow.Set<WaifuInfo>().ByWaifuUserId(user.Id);
+        //reset price, remove items
+        //remove owner, remove affinity
+        waifu.Price = 50;
+        waifu.Items.Clear();
+        waifu.ClaimerId = null;
+        waifu.AffinityId = null;
+
+        //wives stay though
+
+        await uow.SaveChangesAsync();
+
+        return true;
+    }
+
+    public async Task<(WaifuInfo, bool, WaifuClaimResult)> ClaimWaifuAsync(IUser user, IUser target, long amount)
+    {
+        var settings = _gss.Data;
+        WaifuClaimResult result;
+        WaifuInfo w;
+        bool isAffinity;
+        await using (var uow = _db.GetDbContext())
+        {
+            w = uow.Set<WaifuInfo>().ByWaifuUserId(target.Id);
+            isAffinity = w?.Affinity?.UserId == user.Id;
+            if (w is null)
+            {
+                var claimer = uow.GetOrCreateUser(user);
+                var waifu = uow.GetOrCreateUser(target);
+                if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim")))
+                    result = WaifuClaimResult.NotEnoughFunds;
+                else
+                {
+                    uow.Set<WaifuInfo>()
+                       .Add(w = new()
+                       {
+                           Waifu = waifu,
+                           Claimer = claimer,
+                           Affinity = null,
+                           Price = amount
+                       });
+                    uow.Set<WaifuUpdate>()
+                       .Add(new()
+                       {
+                           User = waifu,
+                           Old = null,
+                           New = claimer,
+                           UpdateType = WaifuUpdateType.Claimed
+                       });
+                    result = WaifuClaimResult.Success;
+                }
+            }
+            else if (isAffinity && amount > w.Price * settings.Waifu.Multipliers.CrushClaim)
+            {
+                if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim")))
+                    result = WaifuClaimResult.NotEnoughFunds;
+                else
+                {
+                    var oldClaimer = w.Claimer;
+                    w.Claimer = uow.GetOrCreateUser(user);
+                    w.Price = amount + (amount / 4);
+                    result = WaifuClaimResult.Success;
+
+                    uow.Set<WaifuUpdate>()
+                       .Add(new()
+                       {
+                           User = w.Waifu,
+                           Old = oldClaimer,
+                           New = w.Claimer,
+                           UpdateType = WaifuUpdateType.Claimed
+                       });
+                }
+            }
+            else if (amount >= w.Price * settings.Waifu.Multipliers.NormalClaim) // if no affinity
+            {
+                if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim")))
+                    result = WaifuClaimResult.NotEnoughFunds;
+                else
+                {
+                    var oldClaimer = w.Claimer;
+                    w.Claimer = uow.GetOrCreateUser(user);
+                    w.Price = amount;
+                    result = WaifuClaimResult.Success;
+
+                    uow.Set<WaifuUpdate>()
+                       .Add(new()
+                       {
+                           User = w.Waifu,
+                           Old = oldClaimer,
+                           New = w.Claimer,
+                           UpdateType = WaifuUpdateType.Claimed
+                       });
+                }
+            }
+            else
+                result = WaifuClaimResult.InsufficientAmount;
+
+
+            await uow.SaveChangesAsync();
+        }
+
+        return (w, isAffinity, result);
+    }
+
+    public async Task<(DiscordUser, bool, TimeSpan?)> ChangeAffinityAsync(IUser user, IGuildUser target)
+    {
+        DiscordUser oldAff = null;
+        var success = false;
+        TimeSpan? remaining = null;
+        await using (var uow = _db.GetDbContext())
+        {
+            var w = uow.Set<WaifuInfo>().ByWaifuUserId(user.Id);
+            var newAff = target is null ? null : uow.GetOrCreateUser(target);
+            if (w?.Affinity?.UserId == target?.Id)
+            {
+                return (null, false, null);
+            }
+
+            remaining = await _cache.GetRatelimitAsync(GetAffinityKey(user.Id),
+                30.Minutes());
+
+            if (remaining is not null)
+            {
+            }
+            else if (w is null)
+            {
+                var thisUser = uow.GetOrCreateUser(user);
+                uow.Set<WaifuInfo>()
+                   .Add(new()
+                   {
+                       Affinity = newAff,
+                       Waifu = thisUser,
+                       Price = 1,
+                       Claimer = null
+                   });
+                success = true;
+
+                uow.Set<WaifuUpdate>()
+                   .Add(new()
+                   {
+                       User = thisUser,
+                       Old = null,
+                       New = newAff,
+                       UpdateType = WaifuUpdateType.AffinityChanged
+                   });
+            }
+            else
+            {
+                if (w.Affinity is not null)
+                    oldAff = w.Affinity;
+                w.Affinity = newAff;
+                success = true;
+
+                uow.Set<WaifuUpdate>()
+                   .Add(new()
+                   {
+                       User = w.Waifu,
+                       Old = oldAff,
+                       New = newAff,
+                       UpdateType = WaifuUpdateType.AffinityChanged
+                   });
+            }
+
+            await uow.SaveChangesAsync();
+        }
+
+        return (oldAff, success, remaining);
+    }
+
+    public IEnumerable<WaifuLbResult> GetTopWaifusAtPage(int page, int perPage = 9)
+    {
+        using var uow = _db.GetDbContext();
+        return uow.Set<WaifuInfo>().GetTop(perPage, page * perPage);
+    }
+
+    public ulong GetWaifuUserId(ulong ownerId, string name)
+    {
+        using var uow = _db.GetDbContext();
+        return uow.Set<WaifuInfo>().GetWaifuUserId(ownerId, name);
+    }
+
+    private static TypedKey<long> GetDivorceKey(ulong userId)
+        => new($"waifu:divorce_cd:{userId}");
+
+    private static TypedKey<long> GetAffinityKey(ulong userId)
+        => new($"waifu:affinity:{userId}");
+
+    public async Task<(WaifuInfo, DivorceResult, long, TimeSpan?)> DivorceWaifuAsync(IUser user, ulong targetId)
+    {
+        DivorceResult result;
+        TimeSpan? remaining = null;
+        long amount = 0;
+        WaifuInfo w;
+        await using (var uow = _db.GetDbContext())
+        {
+            w = uow.Set<WaifuInfo>().ByWaifuUserId(targetId);
+            if (w?.Claimer is null || w.Claimer.UserId != user.Id)
+                result = DivorceResult.NotYourWife;
+            else
+            {
+                remaining = await _cache.GetRatelimitAsync(GetDivorceKey(user.Id), 6.Hours());
+                if (remaining is TimeSpan rem)
+                {
+                    result = DivorceResult.Cooldown;
+                    return (w, result, amount, rem);
+                }
+
+                amount = w.Price / 2;
+
+                if (w.Affinity?.UserId == user.Id)
+                {
+                    await _cs.AddAsync(w.Waifu.UserId, amount, new("waifu", "compensation"));
+                    w.Price = (long)Math.Floor(w.Price * _gss.Data.Waifu.Multipliers.DivorceNewValue);
+                    result = DivorceResult.SucessWithPenalty;
+                }
+                else
+                {
+                    await _cs.AddAsync(user.Id, amount, new("waifu", "refund"));
+
+                    result = DivorceResult.Success;
+                }
+
+                var oldClaimer = w.Claimer;
+                w.Claimer = null;
+
+                uow.Set<WaifuUpdate>()
+                   .Add(new()
+                   {
+                       User = w.Waifu,
+                       Old = oldClaimer,
+                       New = null,
+                       UpdateType = WaifuUpdateType.Claimed
+                   });
+            }
+
+            await uow.SaveChangesAsync();
+        }
+
+        return (w, result, amount, remaining);
+    }
+
+    public async Task<bool> GiftWaifuAsync(
+        IUser from,
+        IUser giftedWaifu,
+        WaifuItemModel itemObj,
+        int count)
+    {
+        ArgumentOutOfRangeException.ThrowIfLessThan(count, 1, nameof(count));
+
+        if (!await _cs.RemoveAsync(from, itemObj.Price * count, new("waifu", "item")))
+            return false;
+
+        var totalValue = itemObj.Price * count;
+
+        await using var uow = _db.GetDbContext();
+        var w = uow.Set<WaifuInfo>()
+                   .ByWaifuUserId(giftedWaifu.Id,
+                       set => set
+                              .Include(x => x.Items)
+                              .Include(x => x.Claimer));
+        if (w is null)
+        {
+            uow.Set<WaifuInfo>()
+               .Add(w = new()
+               {
+                   Affinity = null,
+                   Claimer = null,
+                   Price = 1,
+                   Waifu = uow.GetOrCreateUser(giftedWaifu)
+               });
+        }
+
+        if (!itemObj.Negative)
+        {
+            w.Items.AddRange(Enumerable.Range(0, count)
+                                       .Select((_) => new WaifuItem()
+                                       {
+                                           Name = itemObj.Name.ToLowerInvariant(),
+                                           ItemEmoji = itemObj.ItemEmoji
+                                       }));
+
+            if (w.Claimer?.UserId == from.Id)
+                w.Price += (long)(totalValue * _gss.Data.Waifu.Multipliers.GiftEffect);
+            else
+                w.Price += totalValue / 2;
+        }
+        else
+        {
+            w.Price -= (long)(totalValue * _gss.Data.Waifu.Multipliers.NegativeGiftEffect);
+            if (w.Price < 1)
+                w.Price = 1;
+        }
+
+        await uow.SaveChangesAsync();
+
+        return true;
+    }
+
+    public async Task<WaifuInfoStats> GetFullWaifuInfoAsync(ulong targetId)
+    {
+        await using var uow = _db.GetDbContext();
+        var wi = await uow.GetWaifuInfoAsync(targetId);
+        if (wi is null)
+        {
+            wi = new()
+            {
+                AffinityCount = 0,
+                AffinityName = null,
+                ClaimCount = 0,
+                ClaimerName = null,
+                DivorceCount = 0,
+                FullName = null,
+                Price = 1
+            };
+        }
+
+        return wi;
+    }
+
+    public string GetClaimTitle(int count)
+    {
+        ClaimTitle title;
+        if (count == 0)
+            title = ClaimTitle.Lonely;
+        else if (count == 1)
+            title = ClaimTitle.Devoted;
+        else if (count < 3)
+            title = ClaimTitle.Rookie;
+        else if (count < 6)
+            title = ClaimTitle.Schemer;
+        else if (count < 10)
+            title = ClaimTitle.Dilettante;
+        else if (count < 17)
+            title = ClaimTitle.Intermediate;
+        else if (count < 25)
+            title = ClaimTitle.Seducer;
+        else if (count < 35)
+            title = ClaimTitle.Expert;
+        else if (count < 50)
+            title = ClaimTitle.Veteran;
+        else if (count < 75)
+            title = ClaimTitle.Incubis;
+        else if (count < 100)
+            title = ClaimTitle.Harem_King;
+        else
+            title = ClaimTitle.Harem_God;
+
+        return title.ToString().Replace('_', ' ');
+    }
+
+    public string GetAffinityTitle(int count)
+    {
+        AffinityTitle title;
+        if (count < 1)
+            title = AffinityTitle.Pure;
+        else if (count < 2)
+            title = AffinityTitle.Faithful;
+        else if (count < 4)
+            title = AffinityTitle.Playful;
+        else if (count < 8)
+            title = AffinityTitle.Cheater;
+        else if (count < 11)
+            title = AffinityTitle.Tainted;
+        else if (count < 15)
+            title = AffinityTitle.Corrupted;
+        else if (count < 20)
+            title = AffinityTitle.Lewd;
+        else if (count < 25)
+            title = AffinityTitle.Sloot;
+        else if (count < 35)
+            title = AffinityTitle.Depraved;
+        else
+            title = AffinityTitle.Harlot;
+
+        return title.ToString().Replace('_', ' ');
+    }
+
+    public IReadOnlyList<WaifuItemModel> GetWaifuItems()
+    {
+        var conf = _gss.Data;
+        return conf.Waifu.Items.Select(x
+                       => new WaifuItemModel(x.ItemEmoji,
+                           (long)(x.Price * conf.Waifu.Multipliers.AllGiftPrices),
+                           x.Name,
+                           x.Negative))
+                   .ToList();
+    }
+
+    private static readonly TypedKey<long> _waifuDecayKey = $"waifu:last_decay";
+
+    public async Task OnReadyAsync()
+    {
+        // only decay waifu values from shard 0
+        if (_client.ShardId != 0)
+            return;
+
+        while (true)
+        {
+            try
+            {
+                var decay = _gss.Data.Waifu.Decay;
+
+                var unclaimedMulti = 1 - (decay.UnclaimedDecayPercent / 100f);
+                var claimedMulti = 1 - (decay.ClaimedDecayPercent / 100f);
+
+                var minPrice = decay.MinPrice;
+                var decayInterval = decay.HourInterval;
+
+                if (decayInterval <= 0)
+                    continue;
+
+                if ((unclaimedMulti < 0 || unclaimedMulti > 1) && (claimedMulti < 0 || claimedMulti > 1))
+                    continue;
+
+                var now = DateTime.UtcNow;
+                var nowB = now.ToBinary();
+
+                var result = await _cache.GetAsync(_waifuDecayKey);
+
+                if (result.TryGetValue(out var val))
+                {
+                    var lastDecay = DateTime.FromBinary(val);
+                    var toWait = decayInterval.Hours() - (DateTime.UtcNow - lastDecay);
+
+                    if (toWait > 0.Hours())
+                        continue;
+                }
+
+                await _cache.AddAsync(_waifuDecayKey, nowB);
+
+                if (unclaimedMulti is > 0 and <= 1)
+                {
+                    await using var uow = _db.GetDbContext();
+
+                    await uow.GetTable<WaifuInfo>()
+                             .Where(x => x.Price > minPrice && x.ClaimerId == null)
+                             .UpdateAsync(old => new()
+                             {
+                                 Price = (long)(old.Price * unclaimedMulti)
+                             });
+                }
+
+                if (claimedMulti is > 0 and <= 1)
+                {
+                    await using var uow = _db.GetDbContext();
+                    await uow.GetTable<WaifuInfo>()
+                             .Where(x => x.Price > minPrice && x.ClaimerId == null)
+                             .UpdateAsync(old => new()
+                             {
+                                 Price = (long)(old.Price * claimedMulti)
+                             });
+                }
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "Unexpected error occured in waifu decay loop: {ErrorMessage}", ex.Message);
+            }
+            finally
+            {
+                await Task.Delay(1.Hours());
+            }
+        }
+    }
+
+    public async Task<IReadOnlyCollection<string>> GetClaimNames(int waifuId)
+    {
+        await using var ctx = _db.GetDbContext();
+        return await ctx.GetTable<DiscordUser>()
+                        .Where(x => ctx.GetTable<WaifuInfo>()
+                                       .Where(wi => wi.ClaimerId == waifuId)
+                                       .Select(wi => wi.WaifuId)
+                                       .Contains(x.Id))
+                        .Select(x => $"{x.Username}#{x.Discriminator}")
+                        .ToListAsyncEF();
+    }
+
+    public async Task<IReadOnlyCollection<string>> GetFansNames(int waifuId)
+    {
+        await using var ctx = _db.GetDbContext();
+        return await ctx.GetTable<DiscordUser>()
+                        .Where(x => ctx.GetTable<WaifuInfo>()
+                                       .Where(wi => wi.AffinityId == waifuId)
+                                       .Select(wi => wi.WaifuId)
+                                       .Contains(x.Id))
+                        .Select(x => $"{x.Username}#{x.Discriminator}")
+                        .ToListAsyncEF();
+    }
+
+    public async Task<IReadOnlyCollection<WaifuItem>> GetItems(int waifuId)
+    {
+        await using var ctx = _db.GetDbContext();
+        return await ctx.GetTable<WaifuItem>()
+                        .Where(x => x.WaifuInfoId
+                                    == ctx.GetTable<WaifuInfo>()
+                                          .Where(x => x.WaifuId == waifuId)
+                                          .Select(x => x.Id)
+                                          .FirstOrDefault())
+                        .ToListAsyncEF();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/AffinityTitle.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/AffinityTitle.cs
new file mode 100644
index 0000000..64cf443
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/_common/AffinityTitle.cs
@@ -0,0 +1,16 @@
+#nullable disable
+namespace EllieBot.Modules.Gambling.Common.Waifu;
+
+public enum AffinityTitle
+{
+    Pure,
+    Faithful,
+    Playful,
+    Cheater,
+    Tainted,
+    Corrupted,
+    Lewd,
+    Sloot,
+    Depraved,
+    Harlot
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs
new file mode 100644
index 0000000..4b7628f
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs
@@ -0,0 +1,18 @@
+#nullable disable
+namespace EllieBot.Modules.Gambling.Common.Waifu;
+
+public enum ClaimTitle
+{
+    Lonely,
+    Devoted,
+    Rookie,
+    Schemer,
+    Dilettante,
+    Intermediate,
+    Seducer,
+    Expert,
+    Veteran,
+    Incubis,
+    Harem_King,
+    Harem_God
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/DivorceResult.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/DivorceResult.cs
new file mode 100644
index 0000000..650bc91
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/_common/DivorceResult.cs
@@ -0,0 +1,10 @@
+#nullable disable
+namespace EllieBot.Modules.Gambling.Common.Waifu;
+
+public enum DivorceResult
+{
+    Success,
+    SucessWithPenalty,
+    NotYourWife,
+    Cooldown
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/Extensions.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/Extensions.cs
new file mode 100644
index 0000000..44c6396
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/_common/Extensions.cs
@@ -0,0 +1,6 @@
+namespace EllieBot.Modules.Gambling.Common.Waifu;
+
+public class Extensions
+{
+    
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/MultipleWaifuItems.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/MultipleWaifuItems.cs
new file mode 100644
index 0000000..63b5742
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/_common/MultipleWaifuItems.cs
@@ -0,0 +1,6 @@
+#nullable disable
+using EllieBot.Modules.Gambling.Common;
+
+namespace EllieBot.Modules.Gambling;
+
+public record class MultipleWaifuItems(int Count, WaifuItemModel Item);
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/MultipleWaifuItemsTypeReader.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/MultipleWaifuItemsTypeReader.cs
new file mode 100644
index 0000000..21b4c60
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/_common/MultipleWaifuItemsTypeReader.cs
@@ -0,0 +1,47 @@
+#nullable disable
+using EllieBot.Common.TypeReaders;
+using EllieBot.Modules.Gambling.Services;
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Modules.Gambling;
+
+public partial class MultipleWaifuItemsTypeReader : EllieTypeReader<MultipleWaifuItems>
+{
+    private readonly WaifuService _service;
+    
+    [GeneratedRegex(@"(?:(?<count>\d+)[x*])?(?<item>.+)")]
+    private static partial Regex ItemRegex();
+
+    public MultipleWaifuItemsTypeReader(WaifuService service)
+    {
+        _service = service;
+    }
+    public override ValueTask<TypeReaderResult<MultipleWaifuItems>> ReadAsync(ICommandContext ctx, string input)
+    {
+        input = input.ToLowerInvariant();
+        var match = ItemRegex().Match(input);
+        if (!match.Success)
+        {
+            return new(Discord.Commands.TypeReaderResult.FromError(CommandError.ParseFailed, "Invalid input."));
+        }
+
+        var count = 1;
+        if (match.Groups["count"].Success)
+        {
+            if (!int.TryParse(match.Groups["count"].Value, out count) || count < 1)
+            {
+                return new(Discord.Commands.TypeReaderResult.FromError(CommandError.ParseFailed, "Invalid count."));
+            }
+        }
+        
+        var itemName = match.Groups["item"].Value?.ToLowerInvariant();
+        var allItems = _service.GetWaifuItems();
+        var item = allItems.FirstOrDefault(x => x.Name.ToLowerInvariant() == itemName);
+        if (item is null)
+        {
+            return new(Discord.Commands.TypeReaderResult.FromError(CommandError.ParseFailed, "Waifu gift does not exist."));
+        }
+        
+        return new(Discord.Commands.TypeReaderResult.FromSuccess(new MultipleWaifuItems(count, item)));
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/WaifuClaimResult.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/WaifuClaimResult.cs
new file mode 100644
index 0000000..d68eafb
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/_common/WaifuClaimResult.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace EllieBot.Modules.Gambling.Common.Waifu;
+
+public enum WaifuClaimResult
+{
+    Success,
+    NotEnoughFunds,
+    InsufficientAmount
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs b/src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs
new file mode 100644
index 0000000..559f8ba
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs
@@ -0,0 +1,17 @@
+#nullable disable
+namespace EllieBot.Db.Models;
+
+public class WaifuInfo : DbEntity
+{
+    public int WaifuId { get; set; }
+    public DiscordUser Waifu { get; set; }
+    
+    public int? ClaimerId { get; set; }
+    public DiscordUser Claimer { get; set; }
+
+    public int? AffinityId { get; set; }
+    public DiscordUser Affinity { get; set; }
+
+    public long Price { get; set; }
+    public List<WaifuItem> Items { get; set; } = new();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs
new file mode 100644
index 0000000..126b760
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs
@@ -0,0 +1,134 @@
+#nullable disable
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Db;
+
+public static class WaifuExtensions
+{
+    public static WaifuInfo ByWaifuUserId(
+        this DbSet<WaifuInfo> waifus,
+        ulong userId,
+        Func<DbSet<WaifuInfo>, IQueryable<WaifuInfo>> includes = null)
+    {
+        if (includes is null)
+        {
+            return waifus.Include(wi => wi.Waifu)
+                         .Include(wi => wi.Affinity)
+                         .Include(wi => wi.Claimer)
+                         .Include(wi => wi.Items)
+                         .FirstOrDefault(wi => wi.Waifu.UserId == userId);
+        }
+
+        return includes(waifus).AsQueryable().FirstOrDefault(wi => wi.Waifu.UserId == userId);
+    }
+
+    public static IEnumerable<WaifuLbResult> GetTop(this DbSet<WaifuInfo> waifus, int count, int skip = 0)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(count);
+
+        if (count == 0)
+            return [];
+
+        return waifus.Include(wi => wi.Waifu)
+                     .Include(wi => wi.Affinity)
+                     .Include(wi => wi.Claimer)
+                     .OrderByDescending(wi => wi.Price)
+                     .Skip(skip)
+                     .Take(count)
+                     .Select(x => new WaifuLbResult
+                     {
+                         Affinity = x.Affinity == null ? null : x.Affinity.Username,
+                         AffinityDiscrim = x.Affinity == null ? null : x.Affinity.Discriminator,
+                         Claimer = x.Claimer == null ? null : x.Claimer.Username,
+                         ClaimerDiscrim = x.Claimer == null ? null : x.Claimer.Discriminator,
+                         Username = x.Waifu.Username,
+                         Discrim = x.Waifu.Discriminator,
+                         Price = x.Price
+                     })
+                     .ToList();
+    }
+
+    public static decimal GetTotalValue(this DbSet<WaifuInfo> waifus)
+        => waifus.AsQueryable().Where(x => x.ClaimerId != null).Sum(x => x.Price);
+
+    public static ulong GetWaifuUserId(this DbSet<WaifuInfo> waifus, ulong ownerId, string name)
+        => waifus.AsQueryable()
+                 .AsNoTracking()
+                 .Where(x => x.Claimer.UserId == ownerId && x.Waifu.Username + "#" + x.Waifu.Discriminator == name)
+                 .Select(x => x.Waifu.UserId)
+                 .FirstOrDefault();
+
+    public static async Task<WaifuInfoStats> GetWaifuInfoAsync(this DbContext ctx, ulong userId)
+    {
+        await ctx.EnsureUserCreatedAsync(userId);
+        
+        await ctx.Set<WaifuInfo>()
+                 .ToLinqToDBTable()
+                 .InsertOrUpdateAsync(() => new()
+                     {
+                         AffinityId = null,
+                         ClaimerId = null,
+                         Price = 1,
+                         WaifuId = ctx.Set<DiscordUser>().Where(x => x.UserId == userId).Select(x => x.Id).First()
+                     },
+                     _ => new(),
+                     () => new()
+                     {
+                         WaifuId = ctx.Set<DiscordUser>().Where(x => x.UserId == userId).Select(x => x.Id).First()
+                     });
+
+        var toReturn = ctx.Set<WaifuInfo>()
+                          .AsQueryable()
+                          .Where(w => w.WaifuId
+                                      == ctx.Set<DiscordUser>()
+                                            .AsQueryable()
+                                            .Where(u => u.UserId == userId)
+                                            .Select(u => u.Id)
+                                            .FirstOrDefault())
+                          .Select(w => new WaifuInfoStats
+                          {
+                              WaifuId = w.WaifuId,
+                              FullName =
+                                  ctx.Set<DiscordUser>()
+                                     .AsQueryable()
+                                     .Where(u => u.UserId == userId)
+                                     .Select(u => u.Username + "#" + u.Discriminator)
+                                     .FirstOrDefault(),
+                              AffinityCount =
+                                  ctx.Set<WaifuUpdate>()
+                                     .AsQueryable()
+                                     .Count(x => x.UserId == w.WaifuId
+                                                 && x.UpdateType == WaifuUpdateType.AffinityChanged
+                                                 && x.NewId != null),
+                              AffinityName =
+                                  ctx.Set<DiscordUser>()
+                                     .AsQueryable()
+                                     .Where(u => u.Id == w.AffinityId)
+                                     .Select(u => u.Username + "#" + u.Discriminator)
+                                     .FirstOrDefault(),
+                              ClaimCount = ctx.Set<WaifuInfo>().AsQueryable().Count(x => x.ClaimerId == w.WaifuId),
+                              ClaimerName =
+                                  ctx.Set<DiscordUser>()
+                                     .AsQueryable()
+                                     .Where(u => u.Id == w.ClaimerId)
+                                     .Select(u => u.Username + "#" + u.Discriminator)
+                                     .FirstOrDefault(),
+                              DivorceCount =
+                                  ctx.Set<WaifuUpdate>()
+                                     .AsQueryable()
+                                     .Count(x => x.OldId == w.WaifuId
+                                                 && x.NewId == null
+                                                 && x.UpdateType == WaifuUpdateType.Claimed),
+                              Price = w.Price,
+                          })
+                          .FirstOrDefault();
+
+        if (toReturn is null)
+            return null;
+
+        return toReturn;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs
new file mode 100644
index 0000000..8add339
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs
@@ -0,0 +1,14 @@
+#nullable disable
+namespace EllieBot.Db;
+
+public class WaifuInfoStats
+{
+    public int WaifuId { get; init; }
+    public string FullName { get; init; }
+    public long Price { get; init; }
+    public string ClaimerName { get; init; }
+    public string AffinityName { get; init; }
+    public int AffinityCount { get; init; }
+    public int DivorceCount { get; init; }
+    public int ClaimCount { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs
new file mode 100644
index 0000000..8012b02
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs
@@ -0,0 +1,10 @@
+#nullable disable
+namespace EllieBot.Db.Models;
+
+public class WaifuItem : DbEntity
+{
+    public WaifuInfo WaifuInfo { get; set; }
+    public int? WaifuInfoId { get; set; }
+    public string ItemEmoji { get; set; }
+    public string Name { get; set; }
+}
diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs
new file mode 100644
index 0000000..f83af4f
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs
@@ -0,0 +1,16 @@
+#nullable disable
+namespace EllieBot.Db.Models;
+
+public class WaifuLbResult
+{
+    public string Username { get; set; }
+    public string Discrim { get; set; }
+
+    public string Claimer { get; set; }
+    public string ClaimerDiscrim { get; set; }
+
+    public string Affinity { get; set; }
+    public string AffinityDiscrim { get; set; }
+
+    public long Price { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs
new file mode 100644
index 0000000..64608c2
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs
@@ -0,0 +1,15 @@
+#nullable disable
+namespace EllieBot.Db.Models;
+
+public class WaifuUpdate : DbEntity
+{
+    public int UserId { get; set; }
+    public DiscordUser User { get; set; }
+    public WaifuUpdateType UpdateType { get; set; }
+
+    public int? OldId { get; set; }
+    public DiscordUser Old { get; set; }
+
+    public int? NewId { get; set; }
+    public DiscordUser New { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdateType.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdateType.cs
new file mode 100644
index 0000000..626bb4c
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdateType.cs
@@ -0,0 +1,8 @@
+#nullable disable
+namespace EllieBot.Db.Models;
+
+public enum WaifuUpdateType
+{
+    AffinityChanged,
+    Claimed
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs b/src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs
new file mode 100644
index 0000000..04b8e76
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs
@@ -0,0 +1,19 @@
+using Ellie.Econ;
+
+namespace EllieBot.Modules.Gambling.Common;
+
+public class QuadDeck : Deck
+{
+    protected override void RefillPool()
+    {
+        CardPool = new(52 * 4);
+        for (var j = 1; j < 14; j++)
+        for (var i = 1; i < 5; i++)
+        {
+            CardPool.Add(new((CardSuit)i, j));
+            CardPool.Add(new((CardSuit)i, j));
+            CardPool.Add(new((CardSuit)i, j));
+            CardPool.Add(new((CardSuit)i, j));
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/_common/GamblingCleanupService.cs b/src/EllieBot/Modules/Gambling/_common/GamblingCleanupService.cs
new file mode 100644
index 0000000..45c1c7e
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/_common/GamblingCleanupService.cs
@@ -0,0 +1,56 @@
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Gambling;
+
+public class GamblingCleanupService : IGamblingCleanupService, IEService
+{
+    private readonly DbService _db;
+
+    public GamblingCleanupService(DbService db)
+    {
+        _db = db;
+    }
+    
+    public async Task DeleteWaifus()
+    {
+        await using var ctx = _db.GetDbContext();
+        await ctx.GetTable<WaifuInfo>().DeleteAsync();
+        await ctx.GetTable<WaifuItem>().DeleteAsync();
+        await ctx.GetTable<WaifuUpdate>().DeleteAsync();
+    }
+
+    public async Task DeleteWaifu(ulong userId)
+    {
+        await using var ctx = _db.GetDbContext();
+        await ctx.GetTable<WaifuUpdate>()
+            .Where(x => x.User.UserId == userId)
+            .DeleteAsync();
+        await ctx.GetTable<WaifuItem>()
+            .Where(x => x.WaifuInfo.Waifu.UserId == userId)
+            .DeleteAsync();
+        await ctx.GetTable<WaifuInfo>()
+            .Where(x => x.Claimer.UserId == userId)
+            .UpdateAsync(old => new WaifuInfo()
+            {
+                ClaimerId = null,
+            });
+        await ctx.GetTable<WaifuInfo>()
+            .Where(x => x.Waifu.UserId == userId)
+            .DeleteAsync();
+    }
+    
+    public async Task DeleteCurrency()
+    {
+        await using var ctx = _db.GetDbContext();
+        await ctx.GetTable<DiscordUser>().UpdateAsync(_ => new DiscordUser()
+        {
+            CurrencyAmount = 0
+        });
+
+        await ctx.GetTable<CurrencyTransaction>().DeleteAsync();
+        await ctx.GetTable<PlantedCurrency>().DeleteAsync();
+        await ctx.GetTable<BankUser>().DeleteAsync();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/_common/IGamblingCleanupService.cs b/src/EllieBot/Modules/Gambling/_common/IGamblingCleanupService.cs
new file mode 100644
index 0000000..8e266b4
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/_common/IGamblingCleanupService.cs
@@ -0,0 +1,8 @@
+namespace EllieBot.Modules.Gambling;
+
+public interface IGamblingCleanupService
+{
+    Task DeleteWaifus();
+    Task DeleteWaifu(ulong userId);
+    Task DeleteCurrency();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/_common/IGamblingService.cs b/src/EllieBot/Modules/Gambling/_common/IGamblingService.cs
new file mode 100644
index 0000000..77cc2d7
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/_common/IGamblingService.cs
@@ -0,0 +1,17 @@
+#nullable disable
+using EllieBot.Modules.Gambling.Betdraw;
+using EllieBot.Modules.Gambling.Rps;
+using OneOf;
+
+namespace EllieBot.Modules.Gambling;
+
+public interface IGamblingService
+{
+    Task<OneOf<LuLaResult, GamblingError>> LulaAsync(ulong userId, long amount);
+    Task<OneOf<BetrollResult, GamblingError>> BetRollAsync(ulong userId, long amount);
+    Task<OneOf<BetflipResult, GamblingError>> BetFlipAsync(ulong userId, long amount, byte guess);
+    Task<OneOf<SlotResult, GamblingError>> SlotAsync(ulong userId, long amount);
+    Task<FlipResult[]> FlipAsync(int count);
+    Task<OneOf<RpsResult, GamblingError>> RpsAsync(ulong userId, long amount, byte pick);
+    Task<OneOf<BetdrawResult, GamblingError>> BetDrawAsync(ulong userId, long amount, byte? maybeGuessValue, byte? maybeGuessColor);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs b/src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs
new file mode 100644
index 0000000..85fa703
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs
@@ -0,0 +1,268 @@
+#nullable disable
+using EllieBot.Modules.Gambling.Betdraw;
+using EllieBot.Modules.Gambling.Rps;
+using EllieBot.Modules.Gambling.Services;
+using OneOf;
+
+namespace EllieBot.Modules.Gambling;
+
+public sealed class NewGamblingService : IGamblingService, IEService
+{
+    private readonly GamblingConfigService _bcs;
+    private readonly ICurrencyService _cs;
+
+    public NewGamblingService(GamblingConfigService bcs, ICurrencyService cs)
+    {
+        _bcs = bcs;
+        _cs = cs;
+    }
+    
+    public async Task<OneOf<LuLaResult, GamblingError>> LulaAsync(ulong userId, long amount)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(amount);
+
+        if (amount > 0)
+        {
+            var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("lula", "bet"));
+
+            if (!isTakeSuccess)
+            {
+                return GamblingError.InsufficientFunds;
+            }
+        }
+
+        var game = new LulaGame(_bcs.Data.LuckyLadder.Multipliers);
+        var result = game.Spin(amount);
+        
+        var won = (long)result.Won;
+        if (won > 0)
+        {
+            await _cs.AddAsync(userId, won, new("lula", "win"));
+        }
+
+        return result;
+    }
+
+    public async Task<OneOf<BetrollResult, GamblingError>> BetRollAsync(ulong userId, long amount)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(amount);
+
+        if (amount > 0)
+        {
+            var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betroll", "bet"));
+
+            if (!isTakeSuccess)
+            {
+                return GamblingError.InsufficientFunds;
+            }
+        }
+
+        var game = new BetrollGame(_bcs.Data.BetRoll.Pairs
+            .Select(x => (x.WhenAbove, (decimal)x.MultiplyBy))
+            .ToList());
+
+        var result = game.Roll(amount);
+
+        var won = (long)result.Won;
+        if (won > 0)
+        {
+            await _cs.AddAsync(userId, won, new("betroll", "win"));
+        }
+
+        return result;
+    }
+
+    public async Task<OneOf<BetflipResult, GamblingError>> BetFlipAsync(ulong userId, long amount, byte guess)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(amount);
+
+        ArgumentOutOfRangeException.ThrowIfGreaterThan(guess, 1);
+
+        if (amount > 0)
+        {
+            var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betflip", "bet"));
+
+            if (!isTakeSuccess)
+            {
+                return GamblingError.InsufficientFunds;
+            }
+        }
+
+        var game = new BetflipGame(_bcs.Data.BetFlip.Multiplier);
+        var result = game.Flip(guess, amount);
+        
+        var won = (long)result.Won;
+        if (won > 0)
+        {
+            await _cs.AddAsync(userId, won, new("betflip", "win"));
+        }
+        
+        return result;
+    }
+    
+    public async Task<OneOf<BetdrawResult, GamblingError>> BetDrawAsync(ulong userId, long amount, byte? maybeGuessValue, byte? maybeGuessColor)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(amount);
+
+        if (maybeGuessColor is null && maybeGuessValue is null)
+            throw new ArgumentNullException();
+
+        if (maybeGuessColor > 1)
+            throw new ArgumentOutOfRangeException(nameof(maybeGuessColor));
+        
+        if (maybeGuessValue > 1)
+            throw new ArgumentOutOfRangeException(nameof(maybeGuessValue));
+
+        if (amount > 0)
+        {
+            var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betdraw", "bet"));
+
+            if (!isTakeSuccess)
+            {
+                return GamblingError.InsufficientFunds;
+            }
+        }
+
+        var game = new BetdrawGame();
+        var result = game.Draw((BetdrawValueGuess?)maybeGuessValue, (BetdrawColorGuess?)maybeGuessColor, amount);
+        
+        var won = (long)result.Won;
+        if (won > 0)
+        {
+            await _cs.AddAsync(userId, won, new("betdraw", "win"));
+        }
+        
+        return result;
+    }
+
+    public async Task<OneOf<SlotResult, GamblingError>> SlotAsync(ulong userId, long amount)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(amount);
+
+        if (amount > 0)
+        {
+            var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("slot", "bet"));
+
+            if (!isTakeSuccess)
+            {
+                return GamblingError.InsufficientFunds;
+            }
+        }
+
+        var game = new SlotGame();
+        var result = game.Spin(amount);
+
+        var won = (long)result.Won;
+        if (won > 0)
+        {
+            await _cs.AddAsync(userId, won, new("slot", "won"));
+        }
+
+        return result;
+    }
+
+    public Task<FlipResult[]> FlipAsync(int count)
+    {
+        ArgumentOutOfRangeException.ThrowIfLessThan(count, 1);
+
+        var game = new BetflipGame(0);
+
+        var results = new FlipResult[count];
+        for (var i = 0; i < count; i++)
+        {
+            results[i] = new()
+            {
+                Side = game.Flip(0, 0).Side
+            };
+        }
+
+        return Task.FromResult(results);
+    }
+    
+    //
+    //
+    // private readonly ConcurrentDictionary<ulong, Deck> _decks = new ConcurrentDictionary<ulong, Deck>();
+    //
+    // public override Task<DeckShuffleReply> DeckShuffle(DeckShuffleRequest request, ServerCallContext context)
+    // {
+    //  _decks.AddOrUpdate(request.Id, new Deck(), (key, old) => new Deck());
+    //  return Task.FromResult(new DeckShuffleReply { });
+    // }
+    //
+    // public override Task<DeckDrawReply> DeckDraw(DeckDrawRequest request, ServerCallContext context)
+    // {
+    //  if (request.Count < 1 || request.Count > 10)
+    //      throw new ArgumentOutOfRangeException(nameof(request.Id));
+    //
+    //  var deck = request.UseNew
+    //      ? new Deck()
+    //      : _decks.GetOrAdd(request.Id, new Deck());
+    //
+    //  var list = new List<Deck.Card>(request.Count);
+    //  for (int i = 0; i < request.Count; i++)
+    //  {
+    //      var card = deck.DrawNoRestart();
+    //      if (card is null)
+    //      {
+    //          if (i == 0)
+    //          {
+    //              deck.Restart();
+    //              list.Add(deck.DrawNoRestart());
+    //              continue;
+    //          }
+    //
+    //          break;
+    //      }
+    //
+    //      list.Add(card);
+    //  }
+    //
+    //  var cards = list
+    //      .Select(x => new Card
+    //      {
+    //          Name = x.ToString().ToLowerInvariant().Replace(' ', '_'),
+    //          Number = x.Number,
+    //          Suit = (CardSuit) x.Suit
+    //      });
+    //
+    //  var toReturn = new DeckDrawReply();
+    //  toReturn.Cards.AddRange(cards);
+    //
+    //  return Task.FromResult(toReturn);
+    // }
+    //
+
+    public async Task<OneOf<RpsResult, GamblingError>> RpsAsync(ulong userId, long amount, byte pick)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(amount);
+        ArgumentOutOfRangeException.ThrowIfGreaterThan(pick, 2);
+        
+        if (amount > 0)
+        {
+            var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("rps", "bet"));
+
+            if (!isTakeSuccess)
+            {
+                return GamblingError.InsufficientFunds;
+            }
+        }
+
+        var rps = new RpsGame();
+        var result = rps.Play((RpsPick)pick, amount);
+        
+        var won = (long)result.Won;
+        if (won > 0)
+        {
+            var extra = result.Result switch
+            {
+                RpsResultType.Draw => "draw",
+                RpsResultType.Win => "win",
+                _ => "lose"
+            };
+
+            await _cs.AddAsync(userId, won, new("rps", extra));
+        }
+
+        return result;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs b/src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs
new file mode 100644
index 0000000..1244252
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs
@@ -0,0 +1,139 @@
+#nullable disable
+namespace EllieBot.Modules.Gambling.Common;
+
+public class RollDuelGame
+{
+    public enum Reason
+    {
+        Normal,
+        NoFunds,
+        Timeout
+    }
+
+    public enum State
+    {
+        Waiting,
+        Running,
+        Ended
+    }
+
+    public event Func<RollDuelGame, Task> OnGameTick;
+    public event Func<RollDuelGame, Reason, Task> OnEnded;
+
+    public ulong P1 { get; }
+    public ulong P2 { get; }
+
+    public long Amount { get; }
+
+    public List<(int, int)> Rolls { get; } = new();
+    public State CurrentState { get; private set; }
+    public ulong Winner { get; private set; }
+
+    private readonly ulong _botId;
+
+    private readonly ICurrencyService _cs;
+
+    private readonly Timer _timeoutTimer;
+    private readonly NadekoRandom _rng = new();
+    private readonly SemaphoreSlim _locker = new(1, 1);
+
+    public RollDuelGame(
+        ICurrencyService cs,
+        ulong botId,
+        ulong p1,
+        ulong p2,
+        long amount)
+    {
+        P1 = p1;
+        P2 = p2;
+        _botId = botId;
+        Amount = amount;
+        _cs = cs;
+
+        _timeoutTimer = new(async delegate
+            {
+                await _locker.WaitAsync();
+                try
+                {
+                    if (CurrentState != State.Waiting)
+                        return;
+                    CurrentState = State.Ended;
+                    await OnEnded?.Invoke(this, Reason.Timeout);
+                }
+                catch { }
+                finally
+                {
+                    _locker.Release();
+                }
+            },
+            null,
+            TimeSpan.FromSeconds(15),
+            TimeSpan.FromMilliseconds(-1));
+    }
+
+    public async Task StartGame()
+    {
+        await _locker.WaitAsync();
+        try
+        {
+            if (CurrentState != State.Waiting)
+                return;
+            _timeoutTimer.Change(Timeout.Infinite, Timeout.Infinite);
+            CurrentState = State.Running;
+        }
+        finally
+        {
+            _locker.Release();
+        }
+
+        if (!await _cs.RemoveAsync(P1, Amount, new("rollduel", "bet")))
+        {
+            await OnEnded?.Invoke(this, Reason.NoFunds);
+            CurrentState = State.Ended;
+            return;
+        }
+
+        if (!await _cs.RemoveAsync(P2, Amount, new("rollduel", "bet")))
+        {
+            await _cs.AddAsync(P1, Amount, new("rollduel", "refund"));
+            await OnEnded?.Invoke(this, Reason.NoFunds);
+            CurrentState = State.Ended;
+            return;
+        }
+
+        int n1, n2;
+        do
+        {
+            n1 = _rng.Next(0, 5);
+            n2 = _rng.Next(0, 5);
+            Rolls.Add((n1, n2));
+            if (n1 != n2)
+            {
+                if (n1 > n2)
+                    Winner = P1;
+                else
+                    Winner = P2;
+                var won = (long)(Amount * 2 * 0.98f);
+                await _cs.AddAsync(Winner, won, new("rollduel", "win"));
+
+                await _cs.AddAsync(_botId, (Amount * 2) - won, new("rollduel", "fee"));
+            }
+
+            try { await OnGameTick?.Invoke(this); }
+            catch { }
+
+            await Task.Delay(2500);
+            if (n1 != n2)
+                break;
+        } while (true);
+
+        CurrentState = State.Ended;
+        await OnEnded?.Invoke(this, Reason.Normal);
+    }
+}
+
+public struct RollDuelChallenge
+{
+    public ulong Player1 { get; set; }
+    public ulong Player2 { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs b/src/EllieBot/Modules/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs
new file mode 100644
index 0000000..6773b78
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs
@@ -0,0 +1,94 @@
+using System.Text.RegularExpressions;
+using EllieBot.Db.Models;
+using EllieBot.Modules.Gambling.Services;
+using NCalc;
+using OneOf;
+
+namespace EllieBot.Common.TypeReaders;
+
+public class BaseShmartInputAmountReader
+{
+    private static readonly Regex _percentRegex = new(@"^((?<num>100|\d{1,2})%)$", RegexOptions.Compiled);
+    protected readonly DbService _db;
+    protected readonly GamblingConfigService _gambling;
+
+    public BaseShmartInputAmountReader(DbService db, GamblingConfigService gambling)
+    {
+        _db = db;
+        _gambling = gambling;
+    }
+
+    public async ValueTask<OneOf<long, OneOf.Types.Error<string>>> ReadAsync(ICommandContext context, string input)
+    {
+        var i = input.Trim().ToUpperInvariant();
+
+        i = i.Replace("K", "000");
+
+        //can't add m because it will conflict with max atm
+
+        if (await TryHandlePercentage(context, i) is long num)
+        {
+            return num;
+        }
+
+        try
+        {
+            var expr = new Expression(i, EvaluateOptions.IgnoreCase);
+            expr.EvaluateParameter += (str, ev) => EvaluateParam(str, ev, context).GetAwaiter().GetResult();
+            return (long)decimal.Parse(expr.Evaluate().ToString()!);
+        }
+        catch (Exception)
+        {
+            return new OneOf.Types.Error<string>($"Invalid input: {input}");
+        }
+    }
+
+    private async Task EvaluateParam(string name, ParameterArgs args, ICommandContext ctx)
+    {
+        switch (name.ToUpperInvariant())
+        {
+            case "PI":
+                args.Result = Math.PI;
+                break;
+            case "E":
+                args.Result = Math.E;
+                break;
+            case "ALL":
+            case "ALLIN":
+                args.Result = await Cur(ctx);
+                break;
+            case "HALF":
+                args.Result = await Cur(ctx) / 2;
+                break;
+            case "MAX":
+                args.Result = await Max(ctx);
+                break;
+        }
+    }
+
+    protected virtual async Task<long> Cur(ICommandContext ctx)
+    {
+        await using var uow = _db.GetDbContext();
+        return await uow.Set<DiscordUser>().GetUserCurrencyAsync(ctx.User.Id);
+    }
+
+    protected virtual async Task<long> Max(ICommandContext ctx)
+    {
+        var settings = _gambling.Data;
+        var max = settings.MaxBet;
+        return max == 0 ? await Cur(ctx) : max;
+    }
+
+    private async Task<long?> TryHandlePercentage(ICommandContext ctx, string input)
+    {
+        var m = _percentRegex.Match(input);
+        
+        if (m.Captures.Count == 0)
+            return null;
+        
+        if (!long.TryParse(m.Groups["num"].ToString(), out var percent))
+            return null;
+
+        return (long)(await Cur(ctx) * (percent / 100.0f));
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs b/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs
new file mode 100644
index 0000000..bcb7c20
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs
@@ -0,0 +1,21 @@
+using EllieBot.Modules.Gambling.Bank;
+using EllieBot.Modules.Gambling.Services;
+
+namespace EllieBot.Common.TypeReaders;
+
+public sealed class ShmartBankInputAmountReader : BaseShmartInputAmountReader
+{
+    private readonly IBankService _bank;
+
+    public ShmartBankInputAmountReader(IBankService bank, DbService db, GamblingConfigService gambling)
+        : base(db, gambling)
+    {
+        _bank = bank;
+    }
+
+    protected override Task<long> Cur(ICommandContext ctx)
+        => _bank.GetBalanceAsync(ctx.User.Id);
+
+    protected override Task<long> Max(ICommandContext ctx)
+        => Cur(ctx);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs b/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs
new file mode 100644
index 0000000..cd94058
--- /dev/null
+++ b/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs
@@ -0,0 +1,57 @@
+#nullable disable
+using EllieBot.Modules.Gambling.Bank;
+using EllieBot.Modules.Gambling.Services;
+
+namespace EllieBot.Common.TypeReaders;
+
+public sealed class BalanceTypeReader : TypeReader
+{
+    private readonly BaseShmartInputAmountReader _tr;
+
+    public BalanceTypeReader(DbService db, GamblingConfigService gambling)
+    {
+        _tr = new BaseShmartInputAmountReader(db, gambling); 
+    }
+    
+    public override async Task<Discord.Commands.TypeReaderResult> ReadAsync(
+        ICommandContext context,
+        string input,
+        IServiceProvider services)
+    {
+
+        var result = await _tr.ReadAsync(context, input);
+
+        if (result.TryPickT0(out var val, out var err))
+        {
+            return Discord.Commands.TypeReaderResult.FromSuccess(val);
+        }
+        
+        return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, err.Value);
+    }
+}
+
+public sealed class BankBalanceTypeReader : TypeReader
+{
+    private readonly ShmartBankInputAmountReader _tr;
+
+    public BankBalanceTypeReader(IBankService bank, DbService db, GamblingConfigService gambling)
+    {
+        _tr = new ShmartBankInputAmountReader(bank, db, gambling);
+    }
+    
+    public override async Task<Discord.Commands.TypeReaderResult> ReadAsync(
+        ICommandContext context,
+        string input,
+        IServiceProvider services)
+    {
+
+        var result = await _tr.ReadAsync(context, input);
+
+        if (result.TryPickT0(out var val, out var err))
+        {
+            return Discord.Commands.TypeReaderResult.FromSuccess(val);
+        }
+        
+        return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, err.Value);
+    }
+}
\ No newline at end of file