From eb17820a50424ef3ad08300ae1e7f98e4ef6ddce Mon Sep 17 00:00:00 2001 From: Toastie 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 OnStarted = delegate { return Task.CompletedTask; }; + public event Func OnStartingFailed = delegate { return Task.CompletedTask; }; + public event Func OnStateUpdate = delegate { return Task.CompletedTask; }; + public event Func OnEnded = delegate { return Task.CompletedTask; }; + + public Phase CurrentPhase { get; private set; } = Phase.WaitingForPlayers; + + public IReadOnlyCollection Users + => _users.ToList(); + + public List FinishedUsers { get; } = new(); + public int MaxUsers { get; } + + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly HashSet _users = new(); + private readonly ICurrencyService _currency; + private readonly RaceOptions _options; + private readonly Queue _animalsQueue; + + public AnimalRace(RaceOptions options, ICurrencyService currency, IEnumerable availableAnimals) + { + _currency = currency; + _options = options; + _animalsQueue = new(availableAnimals); + MaxUsers = _animalsQueue.Count; + + if (_animalsQueue.Count == 0) + CurrentPhase = Phase.Ended; + } + + public void Initialize() //lame name + => _ = Task.Run(async () => + { + await Task.Delay(_options.StartTime * 1000); + + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.WaitingForPlayers) + return; + + await Start(); + } + finally { _locker.Release(); } + }); + + public async Task JoinRace(ulong userId, string userName, long bet = 0) + { + 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 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 + { + private readonly ICurrencyService _cs; + private readonly DiscordSocketClient _client; + private readonly GamesConfigService _gamesConf; + + private IUserMessage raceMessage; + + public AnimalRacingCommands( + ICurrencyService cs, + DiscordSocketClient client, + GamblingConfigService gamblingConf, + GamesConfigService gamesConf) + : base(gamblingConf) + { + _cs = cs; + _client = client; + _gamesConf = gamesConf; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public Task Race(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new RaceOptions(), args); + + var ar = new AnimalRace(options, _cs, _gamesConf.Data.RaceAnimals.Shuffle()); + if (!_service.AnimalRaces.TryAdd(ctx.Guild.Id, ar)) + return 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 + { + 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 AwardAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + UserId = userId, + Balance = amount + }, + (old) => new() + { + Balance = old.Balance + amount + }, + () => new() + { + UserId = userId + }); + + return true; + } + + public async Task TakeAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + await using var ctx = _db.GetDbContext(); + var rows = await ctx.Set() + .ToLinqToDBTable() + .Where(x => x.UserId == userId && x.Balance >= amount) + .UpdateAsync((old) => new() + { + Balance = old.Balance - amount + }); + + return rows > 0; + } + + public async Task DepositAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + if (!await _cur.RemoveAsync(userId, amount, new("bank", "deposit"))) + return false; + + await using var ctx = _db.GetDbContext(); + await ctx.Set() + .ToLinqToDBTable() + .InsertOrUpdateAsync(() => new() + { + UserId = userId, + Balance = amount + }, + (old) => new() + { + Balance = old.Balance + amount + }, + () => new() + { + UserId = userId + }); + + return true; + } + + public async Task WithdrawAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + await using var ctx = _db.GetDbContext(); + var rows = await ctx.Set() + .ToLinqToDBTable() + .Where(x => x.UserId == userId && x.Balance >= amount) + .UpdateAsync((old) => new() + { + Balance = old.Balance - amount + }); + + if (rows > 0) + { + await _cur.AddAsync(userId, amount, new("bank", "withdraw")); + return true; + } + + return false; + } + + public async Task GetBalanceAsync(ulong userId) + { + await using var ctx = _db.GetDbContext(); + return (await ctx.Set() + .ToLinqToDBTable() + .FirstOrDefaultAsync(x => x.UserId == userId)) + ?.Balance + ?? 0; + } +} \ No newline at end of file diff --git a/src/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 + { + 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 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 StateUpdated; + public event Func GameEnded; + + private Deck Deck { get; } = new QuadDeck(); + public Dealer Dealer { get; set; } + + + public List Players { get; set; } = new(); + public GameState State { get; set; } = GameState.Starting; + public User CurrentUser { get; private set; } + + private TaskCompletionSource currentUserMove; + private readonly ICurrencyService _cs; + + private readonly SemaphoreSlim _locker = new(1, 1); + + public Blackjack(ICurrencyService cs) + { + _cs = cs; + Dealer = new(); + } + + public void Start() + => _ = GameLoop(); + + public async Task GameLoop() + { + try + { + //wait for players to join + await Task.Delay(20000); + await _locker.WaitAsync(); + try + { + State = GameState.Playing; + } + finally + { + _locker.Release(); + } + + await PrintState(); + //if no users joined the game, end it + if (!Players.Any()) + { + State = GameState.Ended; + _ = GameEnded?.Invoke(this); + return; + } + + //give 1 card to the dealer and 2 to each player + Dealer.Cards.Add(Deck.Draw()); + foreach (var usr in Players) + { + usr.Cards.Add(Deck.Draw()); + usr.Cards.Add(Deck.Draw()); + + if (usr.GetHandValue() == 21) + usr.State = User.UserState.Blackjack; + } + + //go through all users and ask them what they want to do + foreach (var usr in Players.Where(x => !x.Done)) + { + while (!usr.Done) + { + Log.Information("Waiting for {DiscordUser}'s move", usr.DiscordUser); + await PromptUserMove(usr); + } + } + + await PrintState(); + State = GameState.Ended; + await Task.Delay(2500); + Log.Information("Dealer moves"); + await DealerMoves(); + await PrintState(); + _ = GameEnded?.Invoke(this); + } + catch (Exception ex) + { + Log.Error(ex, "REPORT THE MESSAGE BELOW IN 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 Join(IUser user, long bet) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Starting) + return false; + + if (Players.Count >= 5) + return false; + + if (!await _cs.RemoveAsync(user, bet, new("blackjack", "gamble"))) + return false; + + Players.Add(new(user, bet)); + _ = PrintState(); + return true; + } + finally + { + _locker.Release(); + } + } + + public async Task Stand(IUser u) + { + var cu = CurrentUser; + + if (cu is not null && cu.DiscordUser == u) + return await Stand(cu); + + return false; + } + + public async Task Stand(User u) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Playing) + return false; + + if (CurrentUser != u) + return false; + + u.State = User.UserState.Stand; + currentUserMove.TrySetResult(true); + return true; + } + finally + { + _locker.Release(); + } + } + + private async Task DealerMoves() + { + var hw = Dealer.GetHandValue(); + while (hw < 17 + || (hw == 17 + && Dealer.Cards.Count(x => x.Number == 1) > (Dealer.GetRawHandValue() - 17) / 10)) // hit on soft 17 + { + /* Dealer has + A 6 + That's 17, soft + hw == 17 => true + number of aces = 1 + 1 > 17-17 /10 => true + + AA 5 + That's 17, again soft, since one ace is worth 11, even though another one is 1 + hw == 17 => true + number of aces = 2 + 2 > 27 - 17 / 10 => true + + AA Q 5 + That's 17, but not soft, since both aces are worth 1 + hw == 17 => true + number of aces = 2 + 2 > 37 - 17 / 10 => false + * */ + Dealer.Cards.Add(Deck.Draw()); + hw = Dealer.GetHandValue(); + } + + if (hw > 21) + { + foreach (var usr in Players) + { + if (usr.State is User.UserState.Stand or User.UserState.Blackjack) + usr.State = User.UserState.Won; + else + usr.State = User.UserState.Lost; + } + } + else + { + foreach (var usr in Players) + { + if (usr.State == User.UserState.Blackjack) + usr.State = User.UserState.Won; + else if (usr.State == User.UserState.Stand) + usr.State = hw < usr.GetHandValue() ? User.UserState.Won : User.UserState.Lost; + else + usr.State = User.UserState.Lost; + } + } + + foreach (var usr in Players) + { + if (usr.State is User.UserState.Won or User.UserState.Blackjack) + await _cs.AddAsync(usr.DiscordUser.Id, usr.Bet * 2, new("blackjack", "win")); + } + } + + public async Task Double(IUser u) + { + var cu = CurrentUser; + + if (cu is not null && cu.DiscordUser == u) + return await Double(cu); + + return false; + } + + public async Task Double(User u) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Playing) + return false; + + if (CurrentUser != u) + return false; + + if (!await _cs.RemoveAsync(u.DiscordUser.Id, u.Bet, new("blackjack", "double"))) + return false; + + u.Bet *= 2; + + u.Cards.Add(Deck.Draw()); + + if (u.GetHandValue() == 21) + //blackjack + u.State = User.UserState.Blackjack; + else if (u.GetHandValue() > 21) + // user busted + u.State = User.UserState.Bust; + else + //with double you just get one card, and then you're done + u.State = User.UserState.Stand; + currentUserMove.TrySetResult(true); + + return true; + } + finally + { + _locker.Release(); + } + } + + public async Task Hit(IUser u) + { + var cu = CurrentUser; + + if (cu is not null && cu.DiscordUser == u) + return await Hit(cu); + + return false; + } + + public async Task Hit(User u) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Playing) + return false; + + if (CurrentUser != u) + return false; + + u.Cards.Add(Deck.Draw()); + + if (u.GetHandValue() == 21) + //blackjack + u.State = User.UserState.Blackjack; + else if (u.GetHandValue() > 21) + // user busted + u.State = User.UserState.Bust; + + currentUserMove.TrySetResult(true); + + return true; + } + finally + { + _locker.Release(); + } + } + + public Task PrintState() + { + if (StateUpdated is null) + return Task.CompletedTask; + return StateUpdated.Invoke(this); + } +} \ No newline at end of file diff --git a/src/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 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 OnGameStarted; + public event Func OnGameStateUpdated; + public event Func OnGameFailedToStart; + public event Func OnGameEnded; + + public Phase CurrentPhase { get; private set; } = Phase.Joining; + + public IReadOnlyList 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 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 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 + { + 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] + 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(@"^(?\d+)d(?\d+)(?:\+(?\d+))?(?:\-(?\d+))?$", + RegexOptions.Compiled); + + private static readonly Regex _fudgeRegex = new(@"^(?\d+)d(?:F|f)$", RegexOptions.Compiled); + + private static readonly char[] _fateRolls = ['-', ' ', '+']; + private readonly IImageCache _images; + + public DiceRollCommands(IImageCache images) + => _images = images; + + [Cmd] + public async Task Roll() + { + var rng = new EllieRandom(); + var gen = rng.Next(1, 101); + + var num1 = gen / 10; + var num2 = gen % 10; + + using var img1 = await GetDiceAsync(num1); + using var img2 = await GetDiceAsync(num2); + using var img = new[] { img1, img2 }.Merge(out var format); + await using var ms = await img.ToStreamAsync(format); + + var fileName = $"dice.{format.FileExtensions.First()}"; + + var eb = _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>(num); + var values = new List(num); + for (var i = 0; i < num; i++) + { + var randomNumber = rng.Next(1, 7); + var toInsert = dice.Count; + if (ordered) + { + if (randomNumber == 6 || dice.Count == 0) + toInsert = 0; + else if (randomNumber != 1) + { + for (var j = 0; j < dice.Count; j++) + { + if (values[j] < randomNumber) + { + toInsert = j; + break; + } + } + } + } + else + toInsert = dice.Count; + + dice.Insert(toInsert, await GetDiceAsync(randomNumber)); + values.Insert(toInsert, randomNumber); + } + + using var bitmap = dice.Merge(out var format); + await using var ms = bitmap.ToStream(format); + foreach (var d in dice) + d.Dispose(); + + var imageName = $"dice.{format.FileExtensions.First()}"; + var eb = _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(); + + 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> GetDiceAsync(int num) + { + if (num is < 0 or > 10) + throw new ArgumentOutOfRangeException(nameof(num)); + + if (num == 10) + { + using var imgOne = Image.Load(await _images.GetDiceAsync(1)); + using var imgZero = Image.Load(await _images.GetDiceAsync(0)); + return new[] { imgOne, imgZero }.Merge(); + } + + return Image.Load(await _images.GetDiceAsync(num)); + } + } +} \ No newline at end of file diff --git a/src/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 + { + private static readonly ConcurrentDictionary _allDecks = new(); + private readonly IImageCache _images; + + public DrawCommands(IImageCache images, GamblingConfigService gcs) + : base(gcs) + => _images = images; + + private async Task InternalDraw(int count, ulong? guildId = null) + { + if (count is < 1 or > 10) + throw new ArgumentOutOfRangeException(nameof(count)); + + var cards = guildId is null ? new() : _allDecks.GetOrAdd(ctx.Guild, _ => new()); + var images = new List>(); + var cardObjects = new List(); + for (var i = 0; i < count; i++) + { + if (cards.CardPool.Count == 0 && i != 0) + { + try + { + await 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> GetCardImageAsync(RegularCard currentCard) + { + var cardName = currentCard.GetName().ToLowerInvariant().Replace(' ', '_'); + var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg"); + return Image.Load(cardBytes); + } + + private async Task> GetCardImageAsync(Deck.Card currentCard) + { + var cardName = currentCard.ToString().ToLowerInvariant().Replace(' ', '_'); + var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg"); + return Image.Load(cardBytes); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Draw(int num = 1) + { + if (num < 1) + return; + + if (num > 10) + num = 10; + + await InternalDraw(num, ctx.Guild.Id); + } + + [Cmd] + public async Task DrawNew(int num = 1) + { + if (num < 1) + return; + + if (num > 10) + num = 10; + + await InternalDraw(num); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task DeckShuffle() + { + //var channel = (ITextChannel)ctx.Channel; + + _allDecks.AddOrUpdate(ctx.Guild, + _ => new(), + (_, c) => + { + c.Restart(); + return c; + }); + + await 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 + { + public CurrencyEventsCommands(GamblingConfigService gamblingConf) + : base(gamblingConf) + { + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + [OwnerOnly] + public async Task EventStart(CurrencyEvent.Type ev, params string[] options) + { + var (opts, _) = OptionsParser.ParseFrom(new EventOptions(), options); + if (!await _service.TryCreateEventAsync(ctx.Guild.Id, ctx.Channel.Id, ev, opts, GetEmbed)) + await 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 _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 TryCreateEventAsync( + ulong guildId, + ulong channelId, + CurrencyEvent.Type type, + EventOptions opts, + Func embed) + { + var g = _client.GetGuild(guildId); + if (g?.GetChannel(channelId) is not ITextChannel ch) + return false; + + ICurrencyEvent ce; + + if (type == CurrencyEvent.Type.Reaction) + ce = new ReactionEvent(_client, _cs, g, ch, opts, _configService.Data, _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 OnEnded; + private long PotSize { get; set; } + public bool Stopped { get; private set; } + public bool PotEmptied { get; private set; } + private readonly DiscordSocketClient _client; + private readonly IGuild _guild; + private IUserMessage msg; + private readonly ICurrencyService _cs; + private readonly long _amount; + + private readonly Func _embedFunc; + private readonly bool _isPotLimited; + private readonly ITextChannel _channel; + private readonly ConcurrentHashSet _awardedUsers = new(); + private readonly ConcurrentQueue _toAward = new(); + private readonly Timer _t; + private readonly Timer _timeout; + private readonly EventOptions _opts; + + private readonly string _code; + + private readonly 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 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(); + 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 message, Cacheable cacheable) + { + if (message.Id == msg.Id) + await StopEvent(); + } + + public Task StopEvent() + { + lock (_stopLock) + { + if (Stopped) + return Task.CompletedTask; + Stopped = true; + _client.MessageDeleted -= OnMessageDeleted; + _client.MessageReceived -= HandleMessage; + _t.Change(Timeout.Infinite, Timeout.Infinite); + _timeout?.Change(Timeout.Infinite, Timeout.Infinite); + _ = _client.SetGameAsync(null); + try + { + _ = msg.DeleteAsync(); + } + catch { } + + _ = OnEnded?.Invoke(_guild.Id); + } + + return Task.CompletedTask; + } + + private Task HandleMessage(SocketMessage message) + { + _ = Task.Run(async () => + { + if (message.Author is not IGuildUser gu // no unknown users, as they could be bots, or alts + || gu.IsBot // no bots + || message.Content != _code // code has to be the same + || (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5) // no recently created accounts + return; + // there has to be money left in the pot + // and the user wasn't rewarded + if (_awardedUsers.Add(message.Author.Id) && TryTakeFromPot()) + { + _toAward.Enqueue(message.Author.Id); + if (_isPotLimited && PotSize < _amount) + PotEmptied = true; + } + + try + { + await message.DeleteAsync(new() + { + RetryMode = RetryMode.AlwaysFail + }); + } + catch { } + }); + return Task.CompletedTask; + } + + private bool TryTakeFromPot() + { + if (_isPotLimited) + { + lock (_potLock) + { + if (PotSize < _amount) + return false; + + PotSize -= _amount; + return true; + } + } + + return true; + } +} \ No newline at end of file diff --git a/src/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 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 OnEnded; + private long PotSize { get; set; } + public bool Stopped { get; private set; } + public bool PotEmptied { get; private set; } + private readonly DiscordSocketClient _client; + private readonly IGuild _guild; + private IUserMessage msg; + private IEmote emote; + private readonly ICurrencyService _cs; + private readonly long _amount; + + private readonly Func _embedFunc; + private readonly bool _isPotLimited; + private readonly ITextChannel _channel; + private readonly ConcurrentHashSet _awardedUsers = new(); + private readonly System.Collections.Concurrent.ConcurrentQueue _toAward = new(); + private readonly Timer _t; + private readonly Timer _timeout; + private readonly bool _noRecentlyJoinedServer; + private readonly EventOptions _opts; + private readonly GamblingConfig _config; + + private readonly object _stopLock = new(); + + private readonly object _potLock = new(); + private readonly IMessageSenderService _sender; + + public ReactionEvent( + DiscordSocketClient client, + ICurrencyService cs, + SocketGuild g, + ITextChannel ch, + EventOptions opt, + GamblingConfig config, + IMessageSenderService sender, + Func embedFunc) + { + _client = client; + _guild = g; + _cs = cs; + _amount = opt.Amount; + PotSize = opt.PotSize; + _embedFunc = embedFunc; + _isPotLimited = PotSize > 0; + _channel = ch; + _noRecentlyJoinedServer = false; + _opts = opt; + _config = config; + _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(); + 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 message, Cacheable cacheable) + { + if (message.Id == msg.Id) + await StopEvent(); + } + + public Task StopEvent() + { + lock (_stopLock) + { + if (Stopped) + return Task.CompletedTask; + + Stopped = true; + _client.MessageDeleted -= OnMessageDeleted; + _client.ReactionAdded -= HandleReaction; + _t.Change(Timeout.Infinite, Timeout.Infinite); + _timeout?.Change(Timeout.Infinite, Timeout.Infinite); + try + { + _ = msg.DeleteAsync(); + } + catch { } + + _ = OnEnded?.Invoke(_guild.Id); + } + + return Task.CompletedTask; + } + + private Task HandleReaction( + Cacheable message, + Cacheable cacheable, + SocketReaction r) + { + _ = Task.Run(() => + { + if (emote.Name != r.Emote.Name) + return; + if ((r.User.IsSpecified + ? r.User.Value + : null) is not IGuildUser gu // no unknown users, as they could be bots, or alts + || message.Id != msg.Id // same message + || gu.IsBot // no bots + || (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5 // no recently created accounts + || (_noRecentlyJoinedServer + && // if specified, no users who joined the server in the last 24h + (gu.JoinedAt is null + || (DateTime.UtcNow - gu.JoinedAt.Value).TotalDays + < 1))) // and no users for who we don't know when they joined + return; + // there has to be money left in the pot + // and the user wasn't rewarded + if (_awardedUsers.Add(r.UserId) && TryTakeFromPot()) + { + _toAward.Enqueue(r.UserId); + if (_isPotLimited && PotSize < _amount) + PotEmptied = true; + } + }); + return Task.CompletedTask; + } + + private bool TryTakeFromPot() + { + if (_isPotLimited) + { + lock (_potLock) + { + if (PotSize < _amount) + return false; + + PotSize -= _amount; + return true; + } + } + + return true; + } +} \ No newline at end of file diff --git a/src/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 + { + 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[count]; + var headsArr = await _images.GetHeadsImageAsync(); + var tailsArr = await _images.GetTailsImageAsync(); + + var result = await _service.FlipAsync(count); + + for (var i = 0; i < result.Length; i++) + { + if (result[i].Side == 0) + { + imgs[i] = Image.Load(headsArr); + headCount++; + } + else + { + imgs[i] = Image.Load(tailsArr); + tailCount++; + } + } + + using var img = imgs.Merge(out var format); + await using var stream = await img.ToStreamAsync(format); + foreach (var i in imgs) + i.Dispose(); + + var imgName = $"coins.{format.FileExtensions.First()}"; + + var msg = count != 1 + ? Format.Bold(GetText(strs.flip_results(count, headCount, tailCount))) + : GetText(strs.flipped(headCount > 0 + ? Format.Bold(GetText(strs.heads)) + : Format.Bold(GetText(strs.tails)))); + + var eb = _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 +{ + 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 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 trs; + await using (var uow = _db.GetDbContext()) + { + trs = await uow.Set().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() + .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] + [Priority(0)] + public Task Leaderboard(params string[] args) + => Leaderboard(1, args); + + [Cmd] + [EllieOptions] + [Priority(1)] + public async Task Leaderboard(int page = 1, params string[] args) + { + if (--page < 0) + { + return; + } + + var (opts, _) = OptionsParser.ParseFrom(new LbOpts(), args); + + // List cleanRichest; + // it's pointless to have clean on dm context + if (ctx.Guild is null) + { + opts.Clean = false; + } + + + async Task> 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() + .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().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 _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() + .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(); + 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 +{ + [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(); + + 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 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 +{ + private const string FILE_PATH = "data/gambling.yml"; + private static readonly TypedKey _changeKey = new("config.gambling.updated"); + + public override string Name + => "gambling"; + + private readonly IEnumerable _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 Connect4Games { get; } = new(); + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly IBotCache _cache; + private readonly GamblingConfigService _gss; + + private static readonly TypedKey _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() + .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() + .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 _ecoKey = new("nadeko:economy"); + + private static readonly SemaphoreSlim _timelyLock = new(1, 1); + + private static TypedKey> _timelyKey + = new("timely:claims"); + + public async Task 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())))!; + + 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().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 : EllieModule +{ + protected GamblingConfig Config + => _lazyConfig.Value; + + protected string CurrencySign + => Config.Currency.Sign; + + protected string CurrencyName + => Config.Currency.Name; + + private readonly Lazy _lazyConfig; + + protected GamblingModule(GamblingConfigService gambService) + => _lazyConfig = new(() => gambService.Data); + + private async Task 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 cur) + where T : INumber + => CurrencyHelper.N(cur, Culture, CurrencySign); + + protected Task CheckBetMandatory(long amount) + { + if (amount < 1) + return Task.FromResult(false); + return InternalCheckBet(amount); + } + + protected Task CheckBetOptional(long amount) + { + if (amount == 0) + return Task.FromResult(true); + return InternalCheckBet(amount); + } +} + +public abstract class GamblingSubmodule : GamblingModule +{ + 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 + { + 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 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 _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() + .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 GetAllGeneratingChannels() + { + using var uow = _db.GetDbContext(); + var chs = uow.Set().GetGeneratingChannels(); + return chs; + } + + /// + /// Get a random currency image stream, with an optional password sticked onto it. + /// + /// Optional password to add to top left corner. + /// Stream of the currency image + 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); + } + + /// + /// Add a password to the image. + /// + /// Image to add password to. + /// Password to add to top left corner. + /// Image with the password in the top left corner. + private (Stream, string) AddPassword(byte[] curImg, string pass) + { + // draw lower, it looks better + pass = pass.TrimTo(10, true).ToLowerInvariant(); + using var img = Image.Load(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; + } + + /// + /// Generate a hexadecimal string from 1000 to ffff. + /// + /// A hexadecimal string from 1000 to ffff + 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 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() + .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 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 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() + .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 +{ + /// + /// Changes the price of a shop item + /// + /// Id of the guild in which the shop is + /// Index of the item + /// New item price + /// Success status + Task ChangeEntryPriceAsync(ulong guildId, int index, int newPrice); + + /// + /// Changes the name of a shop item + /// + /// Id of the guild in which the shop is + /// Index of the item + /// New item name + /// Success status + Task ChangeEntryNameAsync(ulong guildId, int index, string newName); + + /// + /// Swaps indexes of 2 items in the shop + /// + /// Id of the guild in which the shop is + /// First entry's index + /// Second entry's index + /// Whether swap was successful + Task SwapEntriesAsync(ulong guildId, int index1, int index2); + + /// + /// Swaps indexes of 2 items in the shop + /// + /// Id of the guild in which the shop is + /// Current index of the entry to move + /// Destination index of the entry + /// Whether swap was successful + Task MoveEntryAsync(ulong guildId, int fromIndex, int toIndex); + + Task SetItemRoleRequirementAsync(ulong guildId, int index, ulong? roleId); + Task 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 + { + 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(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().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(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(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(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(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(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 GetEntriesInternal(DbContext uow, ulong guildId) + => uow.GuildConfigsForId(guildId, + set => set.Include(x => x.ShopEntries) + .ThenInclude(x => x.Items)) + .ShopEntries.ToIndexed(); + + public async Task 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 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 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 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 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 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 + { + 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 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> GenerateSlotImageAsync(long amount, SlotResult result) + { + long ownedAmount; + await using (var uow = _db.GetDbContext()) + { + ownedAmount = uow.Set() + .FirstOrDefault(x => x.UserId == ctx.User.Id)?.CurrencyAmount + ?? 0; + } + + var slotBg = await _images.GetSlotBgAsync(); + var bgImage = Image.Load(slotBg); + var numbers = new int[3]; + result.Rolls.CopyTo(numbers, 0); + + Color fontColor = Config.Slots.CurrencyFontColor; + + bgImage.Mutate(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>(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>(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 + { + 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 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().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().ByWaifuUserId(user.Id); + + if (waifu is null) + return settings.Waifu.MinPrice; + + var divorces = uow.Set() + .Count(x + => x.Old != null + && x.Old.UserId == user.Id + && x.UpdateType == WaifuUpdateType.Claimed + && x.New == null); + var affs = uow.Set() + .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 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() + .AsQueryable() + .Where(w => w.User.UserId == user.Id + && w.UpdateType == WaifuUpdateType.AffinityChanged + && w.New != null); + + var divorces = uow.Set() + .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().RemoveRange(affs); + //reset divorces to 0 + uow.Set().RemoveRange(divorces); + var waifu = uow.Set().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().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() + .Add(w = new() + { + Waifu = waifu, + Claimer = claimer, + Affinity = null, + Price = amount + }); + uow.Set() + .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() + .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() + .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().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() + .Add(new() + { + Affinity = newAff, + Waifu = thisUser, + Price = 1, + Claimer = null + }); + success = true; + + uow.Set() + .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() + .Add(new() + { + User = w.Waifu, + Old = oldAff, + New = newAff, + UpdateType = WaifuUpdateType.AffinityChanged + }); + } + + await uow.SaveChangesAsync(); + } + + return (oldAff, success, remaining); + } + + public IEnumerable GetTopWaifusAtPage(int page, int perPage = 9) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetTop(perPage, page * perPage); + } + + public ulong GetWaifuUserId(ulong ownerId, string name) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetWaifuUserId(ownerId, name); + } + + private static TypedKey GetDivorceKey(ulong userId) + => new($"waifu:divorce_cd:{userId}"); + + private static TypedKey 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().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() + .Add(new() + { + User = w.Waifu, + Old = oldClaimer, + New = null, + UpdateType = WaifuUpdateType.Claimed + }); + } + + await uow.SaveChangesAsync(); + } + + return (w, result, amount, remaining); + } + + public async Task 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() + .ByWaifuUserId(giftedWaifu.Id, + set => set + .Include(x => x.Items) + .Include(x => x.Claimer)); + if (w is null) + { + uow.Set() + .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 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 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 _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() + .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() + .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> GetClaimNames(int waifuId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => ctx.GetTable() + .Where(wi => wi.ClaimerId == waifuId) + .Select(wi => wi.WaifuId) + .Contains(x.Id)) + .Select(x => $"{x.Username}#{x.Discriminator}") + .ToListAsyncEF(); + } + + public async Task> GetFansNames(int waifuId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => ctx.GetTable() + .Where(wi => wi.AffinityId == waifuId) + .Select(wi => wi.WaifuId) + .Contains(x.Id)) + .Select(x => $"{x.Username}#{x.Discriminator}") + .ToListAsyncEF(); + } + + public async Task> GetItems(int waifuId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => x.WaifuInfoId + == ctx.GetTable() + .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 +{ + private readonly WaifuService _service; + + [GeneratedRegex(@"(?:(?\d+)[x*])?(?.+)")] + private static partial Regex ItemRegex(); + + public MultipleWaifuItemsTypeReader(WaifuService service) + { + _service = service; + } + public override ValueTask> 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 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 waifus, + ulong userId, + Func, IQueryable> 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 GetTop(this DbSet 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 waifus) + => waifus.AsQueryable().Where(x => x.ClaimerId != null).Sum(x => x.Price); + + public static ulong GetWaifuUserId(this DbSet 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 GetWaifuInfoAsync(this DbContext ctx, ulong userId) + { + await ctx.EnsureUserCreatedAsync(userId); + + await ctx.Set() + .ToLinqToDBTable() + .InsertOrUpdateAsync(() => new() + { + AffinityId = null, + ClaimerId = null, + Price = 1, + WaifuId = ctx.Set().Where(x => x.UserId == userId).Select(x => x.Id).First() + }, + _ => new(), + () => new() + { + WaifuId = ctx.Set().Where(x => x.UserId == userId).Select(x => x.Id).First() + }); + + var toReturn = ctx.Set() + .AsQueryable() + .Where(w => w.WaifuId + == ctx.Set() + .AsQueryable() + .Where(u => u.UserId == userId) + .Select(u => u.Id) + .FirstOrDefault()) + .Select(w => new WaifuInfoStats + { + WaifuId = w.WaifuId, + FullName = + ctx.Set() + .AsQueryable() + .Where(u => u.UserId == userId) + .Select(u => u.Username + "#" + u.Discriminator) + .FirstOrDefault(), + AffinityCount = + ctx.Set() + .AsQueryable() + .Count(x => x.UserId == w.WaifuId + && x.UpdateType == WaifuUpdateType.AffinityChanged + && x.NewId != null), + AffinityName = + ctx.Set() + .AsQueryable() + .Where(u => u.Id == w.AffinityId) + .Select(u => u.Username + "#" + u.Discriminator) + .FirstOrDefault(), + ClaimCount = ctx.Set().AsQueryable().Count(x => x.ClaimerId == w.WaifuId), + ClaimerName = + ctx.Set() + .AsQueryable() + .Where(u => u.Id == w.ClaimerId) + .Select(u => u.Username + "#" + u.Discriminator) + .FirstOrDefault(), + DivorceCount = + ctx.Set() + .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().DeleteAsync(); + await ctx.GetTable().DeleteAsync(); + await ctx.GetTable().DeleteAsync(); + } + + public async Task DeleteWaifu(ulong userId) + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .Where(x => x.User.UserId == userId) + .DeleteAsync(); + await ctx.GetTable() + .Where(x => x.WaifuInfo.Waifu.UserId == userId) + .DeleteAsync(); + await ctx.GetTable() + .Where(x => x.Claimer.UserId == userId) + .UpdateAsync(old => new WaifuInfo() + { + ClaimerId = null, + }); + await ctx.GetTable() + .Where(x => x.Waifu.UserId == userId) + .DeleteAsync(); + } + + public async Task DeleteCurrency() + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable().UpdateAsync(_ => new DiscordUser() + { + CurrencyAmount = 0 + }); + + await ctx.GetTable().DeleteAsync(); + await ctx.GetTable().DeleteAsync(); + await ctx.GetTable().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> LulaAsync(ulong userId, long amount); + Task> BetRollAsync(ulong userId, long amount); + Task> BetFlipAsync(ulong userId, long amount, byte guess); + Task> SlotAsync(ulong userId, long amount); + Task FlipAsync(int count); + Task> RpsAsync(ulong userId, long amount, byte pick); + Task> 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> 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> 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> 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> 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> 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 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 _decks = new ConcurrentDictionary(); + // + // public override Task DeckShuffle(DeckShuffleRequest request, ServerCallContext context) + // { + // _decks.AddOrUpdate(request.Id, new Deck(), (key, old) => new Deck()); + // return Task.FromResult(new DeckShuffleReply { }); + // } + // + // public override Task 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(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> 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 OnGameTick; + public event Func 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(@"^((?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>> 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($"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 Cur(ICommandContext ctx) + { + await using var uow = _db.GetDbContext(); + return await uow.Set().GetUserCurrencyAsync(ctx.User.Id); + } + + protected virtual async Task Max(ICommandContext ctx) + { + var settings = _gambling.Data; + var max = settings.MaxBet; + return max == 0 ? await Cur(ctx) : max; + } + + private async Task 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 Cur(ICommandContext ctx) + => _bank.GetBalanceAsync(ctx.User.Id); + + protected override Task 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 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 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