diff --git a/src/Ellie.Bot.Modules.Gambling/Ellie.Bot.Modules.Gambling.csproj b/src/Ellie.Bot.Modules.Gambling/Ellie.Bot.Modules.Gambling.csproj new file mode 100644 index 0000000..77f1755 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Ellie.Bot.Modules.Gambling.csproj @@ -0,0 +1,21 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRace.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRace.cs new file mode 100644 index 0000000..4a35236 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRace.cs @@ -0,0 +1,154 @@ +#nullable disable +using Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions; +using Ellie.Modules.Games.Common; + +namespace Ellie.Modules.Gambling.Common.AnimalRacing; + +public sealed class AnimalRace : IDisposable +{ + public enum Phase + { + WaitingForPlayers, + Running, + Ended + } + + public event Func OnStarted = delegate { return Task.CompletedTask; }; + public event Func OnStartingFailed = delegate { return Task.CompletedTask; }; + public event Func OnStateUpdate = delegate { return Task.CompletedTask; }; + public event Func OnEnded = delegate { return Task.CompletedTask; }; + + public Phase CurrentPhase { get; private set; } = Phase.WaitingForPlayers; + + public IReadOnlyCollection Users + => _users.ToList(); + + public List FinishedUsers { get; } = new(); + public int MaxUsers { get; } + + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly HashSet _users = new(); + private readonly ICurrencyService _currency; + private readonly RaceOptions _options; + private readonly Queue _animalsQueue; + + public AnimalRace(RaceOptions options, ICurrencyService currency, IEnumerable availableAnimals) + { + _currency = currency; + _options = options; + _animalsQueue = new(availableAnimals); + MaxUsers = _animalsQueue.Count; + + if (_animalsQueue.Count == 0) + CurrentPhase = Phase.Ended; + } + + public void Initialize() //lame name + => _ = Task.Run(async () => + { + await Task.Delay(_options.StartTime * 1000); + + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.WaitingForPlayers) + return; + + await Start(); + } + finally { _locker.Release(); } + }); + + public async Task JoinRace(ulong userId, string userName, long bet = 0) + { + if (bet < 0) + throw new ArgumentOutOfRangeException(nameof(bet)); + + var user = new AnimalRacingUser(userName, userId, bet); + + await _locker.WaitAsync(); + try + { + if (_users.Count == MaxUsers) + throw new AnimalRaceFullException(); + + if (CurrentPhase != Phase.WaitingForPlayers) + throw new AlreadyStartedException(); + + if (!await _currency.RemoveAsync(userId, bet, new("animalrace", "bet"))) + throw new NotEnoughFundsException(); + + if (_users.Contains(user)) + throw new AlreadyJoinedException(); + + var animal = _animalsQueue.Dequeue(); + user.Animal = animal; + _users.Add(user); + + if (_animalsQueue.Count == 0) //start if no more spots left + await Start(); + + return user; + } + finally { _locker.Release(); } + } + + private async Task Start() + { + CurrentPhase = Phase.Running; + if (_users.Count <= 1) + { + foreach (var user in _users) + { + if (user.Bet > 0) + await _currency.AddAsync(user.UserId, user.Bet, new("animalrace", "refund")); + } + + _ = OnStartingFailed?.Invoke(this); + CurrentPhase = Phase.Ended; + return; + } + + _ = OnStarted?.Invoke(this); + _ = Task.Run(async () => + { + var rng = new EllieRandom(); + while (!_users.All(x => x.Progress >= 60)) + { + foreach (var user in _users) + { + user.Progress += rng.Next(1, 11); + if (user.Progress >= 60) + user.Progress = 60; + } + + var finished = _users.Where(x => x.Progress >= 60 && !FinishedUsers.Contains(x)).Shuffle(); + + FinishedUsers.AddRange(finished); + + _ = OnStateUpdate?.Invoke(this); + await Task.Delay(2500); + } + + if (FinishedUsers[0].Bet > 0) + { + await _currency.AddAsync(FinishedUsers[0].UserId, + FinishedUsers[0].Bet * (_users.Count - 1), + new("animalrace", "win")); + } + + _ = OnEnded?.Invoke(this); + }); + } + + public void Dispose() + { + CurrentPhase = Phase.Ended; + OnStarted = null; + OnEnded = null; + OnStartingFailed = null; + OnStateUpdate = null; + _locker.Dispose(); + _users.Clear(); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRaceService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRaceService.cs new file mode 100644 index 0000000..4a73781 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRaceService.cs @@ -0,0 +1,9 @@ +#nullable disable +using Ellie.Modules.Gambling.Common.AnimalRacing; + +namespace Ellie.Modules.Gambling.Services; + +public class AnimalRaceService : IEService +{ + public ConcurrentDictionary AnimalRaces { get; } = new(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRacingCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRacingCommands.cs new file mode 100644 index 0000000..3de1171 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRacingCommands.cs @@ -0,0 +1,183 @@ +#nullable disable +using Ellie.Common.TypeReaders; +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Common.AnimalRacing; +using Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions; +using Ellie.Modules.Gambling.Services; +using Ellie.Modules.Games.Services; + +namespace Ellie.Modules.Gambling; + +// wth is this, needs full rewrite +public partial class Gambling +{ + [Group] + public partial class AnimalRacingCommands : GamblingSubmodule + { + private readonly ICurrencyService _cs; + private readonly DiscordSocketClient _client; + private readonly GamesConfigService _gamesConf; + + private IUserMessage raceMessage; + + public AnimalRacingCommands( + ICurrencyService cs, + DiscordSocketClient client, + GamblingConfigService gamblingConf, + GamesConfigService gamesConf) + : base(gamblingConf) + { + _cs = cs; + _client = client; + _gamesConf = gamesConf; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public Task Race(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new RaceOptions(), args); + + var ar = new AnimalRace(options, _cs, _gamesConf.Data.RaceAnimals.Shuffle()); + if (!_service.AnimalRaces.TryAdd(ctx.Guild.Id, ar)) + return SendErrorAsync(GetText(strs.animal_race), GetText(strs.animal_race_already_started)); + + ar.Initialize(); + + var count = 0; + + Task ClientMessageReceived(SocketMessage arg) + { + _ = Task.Run(() => + { + try + { + if (arg.Channel.Id == ctx.Channel.Id) + { + if (ar.CurrentPhase == AnimalRace.Phase.Running && ++count % 9 == 0) + raceMessage = null; + } + } + catch { } + }); + return Task.CompletedTask; + } + + Task ArOnEnded(AnimalRace race) + { + _client.MessageReceived -= ClientMessageReceived; + _service.AnimalRaces.TryRemove(ctx.Guild.Id, out _); + var winner = race.FinishedUsers[0]; + if (race.FinishedUsers[0].Bet > 0) + { + return SendConfirmAsync(GetText(strs.animal_race), + GetText(strs.animal_race_won_money(Format.Bold(winner.Username), + winner.Animal.Icon, + (race.FinishedUsers[0].Bet * (race.Users.Count - 1)) + CurrencySign))); + } + + ar.Dispose(); + return SendConfirmAsync(GetText(strs.animal_race), + GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon))); + } + + ar.OnStartingFailed += Ar_OnStartingFailed; + ar.OnStateUpdate += Ar_OnStateUpdate; + ar.OnEnded += ArOnEnded; + ar.OnStarted += Ar_OnStarted; + _client.MessageReceived += ClientMessageReceived; + + return SendConfirmAsync(GetText(strs.animal_race), + GetText(strs.animal_race_starting(options.StartTime)), + footer: GetText(strs.animal_race_join_instr(prefix))); + } + + private Task Ar_OnStarted(AnimalRace race) + { + if (race.Users.Count == race.MaxUsers) + return SendConfirmAsync(GetText(strs.animal_race), GetText(strs.animal_race_full)); + return SendConfirmAsync(GetText(strs.animal_race), + GetText(strs.animal_race_starting_with_x(race.Users.Count))); + } + + private async Task Ar_OnStateUpdate(AnimalRace race) + { + var text = $@"|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚| +{string.Join("\n", race.Users.Select(p => +{ + var index = race.FinishedUsers.IndexOf(p); + var extra = index == -1 ? "" : $"#{index + 1} {(index == 0 ? "🏆" : "")}"; + return $"{(int)(p.Progress / 60f * 100),-2}%|{new string('‣', p.Progress) + p.Animal.Icon + extra}"; +}))} +|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|"; + + var msg = raceMessage; + + if (msg is null) + raceMessage = await SendConfirmAsync(text); + else + { + await msg.ModifyAsync(x => x.Embed = _eb.Create() + .WithTitle(GetText(strs.animal_race)) + .WithDescription(text) + .WithOkColor() + .Build()); + } + } + + private Task Ar_OnStartingFailed(AnimalRace race) + { + _service.AnimalRaces.TryRemove(ctx.Guild.Id, out _); + race.Dispose(); + return ReplyErrorLocalizedAsync(strs.animal_race_failed); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task JoinRace([OverrideTypeReader(typeof(BalanceTypeReader))] long amount = default) + { + if (!await CheckBetOptional(amount)) + return; + + if (!_service.AnimalRaces.TryGetValue(ctx.Guild.Id, out var ar)) + { + await ReplyErrorLocalizedAsync(strs.race_not_exist); + return; + } + + try + { + var user = await ar.JoinRace(ctx.User.Id, ctx.User.ToString(), amount); + if (amount > 0) + { + await SendConfirmAsync(GetText(strs.animal_race_join_bet(ctx.User.Mention, + user.Animal.Icon, + amount + CurrencySign))); + } + else + await SendConfirmAsync(GetText(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon))); + } + catch (ArgumentOutOfRangeException) + { + //ignore if user inputed an invalid amount + } + catch (AlreadyJoinedException) + { + // just ignore this + } + catch (AlreadyStartedException) + { + //ignore + } + catch (AnimalRaceFullException) + { + await SendConfirmAsync(GetText(strs.animal_race), GetText(strs.animal_race_full)); + } + catch (NotEnoughFundsException) + { + await SendErrorAsync(GetText(strs.not_enough(CurrencySign))); + } + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRacingUser.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRacingUser.cs new file mode 100644 index 0000000..66bfd48 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/AnimalRacingUser.cs @@ -0,0 +1,26 @@ +#nullable disable +using Ellie.Modules.Games.Common; + +namespace Ellie.Modules.Gambling.Common.AnimalRacing; + +public class AnimalRacingUser +{ + public long Bet { get; } + public string Username { get; } + public ulong UserId { get; } + public RaceAnimal Animal { get; set; } + public int Progress { get; set; } + + public AnimalRacingUser(string username, ulong userId, long bet) + { + Bet = bet; + Username = username; + UserId = userId; + } + + public override bool Equals(object obj) + => obj is AnimalRacingUser x ? x.UserId == UserId : false; + + public override int GetHashCode() + => UserId.GetHashCode(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs new file mode 100644 index 0000000..9099331 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions; + +public class AlreadyJoinedException : Exception +{ + public AlreadyJoinedException() + { + } + + public AlreadyJoinedException(string message) + : base(message) + { + } + + public AlreadyJoinedException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs new file mode 100644 index 0000000..70e08ba --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions; + +public class AlreadyStartedException : Exception +{ + public AlreadyStartedException() + { + } + + public AlreadyStartedException(string message) + : base(message) + { + } + + public AlreadyStartedException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs new file mode 100644 index 0000000..8a32104 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions; + +public class AnimalRaceFullException : Exception +{ + public AnimalRaceFullException() + { + } + + public AnimalRaceFullException(string message) + : base(message) + { + } + + public AnimalRaceFullException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs new file mode 100644 index 0000000..7f3d70d --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions; + +public class NotEnoughFundsException : Exception +{ + public NotEnoughFundsException() + { + } + + public NotEnoughFundsException(string message) + : base(message) + { + } + + public NotEnoughFundsException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/RaceOptions.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/RaceOptions.cs new file mode 100644 index 0000000..21e8c3a --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/AnimalRacing/RaceOptions.cs @@ -0,0 +1,16 @@ +#nullable disable +using CommandLine; + +namespace Ellie.Modules.Gambling.Common.AnimalRacing; + +public class RaceOptions : IEllieCommandOptions +{ + [Option('s', "start-time", Default = 20, Required = false)] + public int StartTime { get; set; } = 20; + + public void NormalizeOptions() + { + if (StartTime is < 10 or > 120) + StartTime = 20; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Bank/BankCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Bank/BankCommands.cs new file mode 100644 index 0000000..8c67afb --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Bank/BankCommands.cs @@ -0,0 +1,118 @@ +using Ellie.Common.TypeReaders; +using Ellie.Modules.Gambling.Bank; +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Services; + +namespace Ellie.Modules.Gambling; + +public partial class Gambling +{ + [Name("Bank")] + [Group("bank")] + public partial class BankCommands : GamblingModule + { + private readonly IBankService _bank; + private readonly DiscordSocketClient _client; + + public BankCommands(GamblingConfigService gcs, + IBankService bank, + DiscordSocketClient client) : base(gcs) + { + _bank = bank; + _client = client; + } + + [Cmd] + public async Task BankDeposit([OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + { + if (amount <= 0) + return; + + if (await _bank.DepositAsync(ctx.User.Id, amount)) + { + await ReplyConfirmLocalizedAsync(strs.bank_deposited(N(amount))); + } + else + { + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + } + } + + [Cmd] + public async Task BankWithdraw([OverrideTypeReader(typeof(BankBalanceTypeReader))] long amount) + { + if (amount <= 0) + return; + + if (await _bank.WithdrawAsync(ctx.User.Id, amount)) + { + await ReplyConfirmLocalizedAsync(strs.bank_withdrew(N(amount))); + } + else + { + await ReplyErrorLocalizedAsync(strs.bank_withdraw_insuff(CurrencySign)); + } + } + + [Cmd] + public async Task BankBalance() + { + var bal = await _bank.GetBalanceAsync(ctx.User.Id); + + var eb = _eb.Create(ctx) + .WithOkColor() + .WithDescription(GetText(strs.bank_balance(N(bal)))); + + try + { + await ctx.User.EmbedAsync(eb); + await ctx.OkAsync(); + } + catch + { + await ReplyErrorLocalizedAsync(strs.cant_dm); + } + } + + private async Task BankTakeInternalAsync(long amount, ulong userId) + { + if (await _bank.TakeAsync(userId, amount)) + { + await ctx.OkAsync(); + return; + } + + await ReplyErrorLocalizedAsync(strs.take_fail(N(amount), + _client.GetUser(userId)?.ToString() + ?? userId.ToString(), + CurrencySign)); + } + + private async Task BankAwardInternalAsync(long amount, ulong userId) + { + if (await _bank.AwardAsync(userId, amount)) + { + await ctx.OkAsync(); + return; + } + + } + + [Cmd] + [OwnerOnly] + [Priority(1)] + public async Task BankTake(long amount, [Leftover] IUser user) + => await BankTakeInternalAsync(amount, user.Id); + + [Cmd] + [OwnerOnly] + [Priority(0)] + public async Task BankTake(long amount, ulong userId) + => await BankTakeInternalAsync(amount, userId); + + [Cmd] + [OwnerOnly] + public async Task BankAward(long amount, [Leftover] IUser user) + => await BankAwardInternalAsync(amount, user.Id); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Bank/BankService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Bank/BankService.cs new file mode 100644 index 0000000..dba4b0b --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Bank/BankService.cs @@ -0,0 +1,119 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Ellie.Db.Models; + +namespace Ellie.Modules.Gambling.Bank; + +public sealed class BankService : IBankService, IEService +{ + private readonly ICurrencyService _cur; + private readonly DbService _db; + + public BankService(ICurrencyService cur, DbService db) + { + _cur = cur; + _db = db; + } + + public async Task AwardAsync(ulong userId, long amount) + { + if (amount <= 0) + throw new ArgumentOutOfRangeException(nameof(amount)); + + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + UserId = userId, + Balance = amount + }, + (old) => new() + { + Balance = old.Balance + amount + }, + () => new() + { + UserId = userId + }); + + return true; + } + + public async Task TakeAsync(ulong userId, long amount) + { + if (amount <= 0) + throw new ArgumentOutOfRangeException(nameof(amount)); + + await using var ctx = _db.GetDbContext(); + var rows = await ctx.Set() + .ToLinqToDBTable() + .Where(x => x.UserId == userId && x.Balance >= amount) + .UpdateAsync((old) => new() + { + Balance = old.Balance - amount + }); + + return rows > 0; + } + + public async Task DepositAsync(ulong userId, long amount) + { + if (amount <= 0) + throw new ArgumentOutOfRangeException(nameof(amount)); + + if (!await _cur.RemoveAsync(userId, amount, new("bank", "deposit"))) + return false; + + await using var ctx = _db.GetDbContext(); + await ctx.Set() + .ToLinqToDBTable() + .InsertOrUpdateAsync(() => new() + { + UserId = userId, + Balance = amount + }, + (old) => new() + { + Balance = old.Balance + amount + }, + () => new() + { + UserId = userId + }); + + return true; + } + + public async Task WithdrawAsync(ulong userId, long amount) + { + if (amount <= 0) + throw new ArgumentOutOfRangeException(nameof(amount)); + + await using var ctx = _db.GetDbContext(); + var rows = await ctx.Set() + .ToLinqToDBTable() + .Where(x => x.UserId == userId && x.Balance >= amount) + .UpdateAsync((old) => new() + { + Balance = old.Balance - amount + }); + + if (rows > 0) + { + await _cur.AddAsync(userId, amount, new("bank", "withdraw")); + return true; + } + + return false; + } + + public async Task GetBalanceAsync(ulong userId) + { + await using var ctx = _db.GetDbContext(); + return (await ctx.Set() + .ToLinqToDBTable() + .FirstOrDefaultAsync(x => x.UserId == userId)) + ?.Balance + ?? 0; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/BlackJackCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/BlackJackCommands.cs new file mode 100644 index 0000000..246eba9 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/BlackJackCommands.cs @@ -0,0 +1,183 @@ +#nullable disable +using Ellie.Common.TypeReaders; +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Common.Blackjack; +using Ellie.Modules.Gambling.Services; + +namespace Ellie.Modules.Gambling; + +public partial class Gambling +{ + public partial class BlackJackCommands : GamblingSubmodule + { + public enum BjAction + { + Hit = int.MinValue, + Stand, + Double + } + + private readonly ICurrencyService _cs; + private readonly DbService _db; + private IUserMessage msg; + + public BlackJackCommands(ICurrencyService cs, DbService db, GamblingConfigService gamblingConf) + : base(gamblingConf) + { + _cs = cs; + _db = db; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task BlackJack([OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + { + if (!await CheckBetMandatory(amount)) + return; + + var newBj = new Blackjack(_cs); + Blackjack bj; + if (newBj == (bj = _service.Games.GetOrAdd(ctx.Channel.Id, newBj))) + { + if (!await bj.Join(ctx.User, amount)) + { + _service.Games.TryRemove(ctx.Channel.Id, out _); + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + return; + } + + bj.StateUpdated += Bj_StateUpdated; + bj.GameEnded += Bj_GameEnded; + bj.Start(); + + await ReplyConfirmLocalizedAsync(strs.bj_created); + } + else + { + if (await bj.Join(ctx.User, amount)) + await ReplyConfirmLocalizedAsync(strs.bj_joined); + else + { + Log.Information("{User} can't join a blackjack game as it's in {BlackjackState} state already", + ctx.User, + bj.State); + } + } + + await ctx.Message.DeleteAsync(); + } + + private Task Bj_GameEnded(Blackjack arg) + { + _service.Games.TryRemove(ctx.Channel.Id, out _); + return Task.CompletedTask; + } + + private async Task Bj_StateUpdated(Blackjack bj) + { + try + { + if (msg is not null) + _ = msg.DeleteAsync(); + + var c = bj.Dealer.Cards.Select(x => x.GetEmojiString()) + .ToList(); + var dealerIcon = "❔ "; + if (bj.State == Blackjack.GameState.Ended) + { + if (bj.Dealer.GetHandValue() == 21) + dealerIcon = "💰 "; + else if (bj.Dealer.GetHandValue() > 21) + dealerIcon = "💥 "; + else + dealerIcon = "🏁 "; + } + + var cStr = string.Concat(c.Select(x => x[..^1] + " ")); + cStr += "\n" + string.Concat(c.Select(x => x.Last() + " ")); + var embed = _eb.Create() + .WithOkColor() + .WithTitle("BlackJack") + .AddField($"{dealerIcon} Dealer's Hand | Value: {bj.Dealer.GetHandValue()}", cStr); + + if (bj.CurrentUser is not null) + embed.WithFooter($"Player to make a choice: {bj.CurrentUser.DiscordUser}"); + + foreach (var p in bj.Players) + { + c = p.Cards.Select(x => x.GetEmojiString()).ToList(); + cStr = "-\t" + string.Concat(c.Select(x => x[..^1] + " ")); + cStr += "\n-\t" + string.Concat(c.Select(x => x.Last() + " ")); + var full = $"{p.DiscordUser.ToString().TrimTo(20)} | Bet: {N(p.Bet)} | Value: {p.GetHandValue()}"; + if (bj.State == Blackjack.GameState.Ended) + { + if (p.State == User.UserState.Lost) + full = "❌ " + full; + else + full = "✅ " + full; + } + else if (p == bj.CurrentUser) + full = "▶ " + full; + else if (p.State == User.UserState.Stand) + full = "⏹ " + full; + else if (p.State == User.UserState.Bust) + full = "💥 " + full; + else if (p.State == User.UserState.Blackjack) + full = "💰 " + full; + + embed.AddField(full, cStr); + } + + msg = await ctx.Channel.EmbedAsync(embed); + } + catch + { + } + } + + private string UserToString(User x) + { + var playerName = x.State == User.UserState.Bust + ? Format.Strikethrough(x.DiscordUser.ToString().TrimTo(30)) + : x.DiscordUser.ToString(); + + // var hand = $"{string.Concat(x.Cards.Select(y => "〖" + y.GetEmojiString() + "〗"))}"; + + + return $"{playerName} | Bet: {x.Bet}\n"; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Hit() + => InternalBlackJack(BjAction.Hit); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Stand() + => InternalBlackJack(BjAction.Stand); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Double() + => InternalBlackJack(BjAction.Double); + + private async Task InternalBlackJack(BjAction a) + { + if (!_service.Games.TryGetValue(ctx.Channel.Id, out var bj)) + return; + + if (a == BjAction.Hit) + await bj.Hit(ctx.User); + else if (a == BjAction.Stand) + await bj.Stand(ctx.User); + else if (a == BjAction.Double) + { + if (!await bj.Double(ctx.User)) + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + } + + await ctx.Message.DeleteAsync(); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/BlackJackService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/BlackJackService.cs new file mode 100644 index 0000000..216cc2f --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/BlackJackService.cs @@ -0,0 +1,9 @@ +#nullable disable +using Ellie.Modules.Gambling.Common.Blackjack; + +namespace Ellie.Modules.Gambling.Services; + +public class BlackJackService : IEService +{ + public ConcurrentDictionary Games { get; } = new(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/Blackjack.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/Blackjack.cs new file mode 100644 index 0000000..95f545a --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/Blackjack.cs @@ -0,0 +1,329 @@ +#nullable disable +using Ellie.Econ; + +namespace Ellie.Modules.Gambling.Common.Blackjack; + +public class Blackjack +{ + public enum GameState + { + Starting, + Playing, + Ended + } + + public event Func StateUpdated; + public event Func GameEnded; + + private Deck Deck { get; } = new QuadDeck(); + public Dealer Dealer { get; set; } + + + public List Players { get; set; } = new(); + public GameState State { get; set; } = GameState.Starting; + public User CurrentUser { get; private set; } + + private TaskCompletionSource currentUserMove; + private readonly ICurrencyService _cs; + + private readonly SemaphoreSlim _locker = new(1, 1); + + public Blackjack(ICurrencyService cs) + { + _cs = cs; + Dealer = new(); + } + + public void Start() + => _ = GameLoop(); + + public async Task GameLoop() + { + try + { + //wait for players to join + await Task.Delay(20000); + await _locker.WaitAsync(); + try + { + State = GameState.Playing; + } + finally + { + _locker.Release(); + } + + await PrintState(); + //if no users joined the game, end it + if (!Players.Any()) + { + State = GameState.Ended; + _ = GameEnded?.Invoke(this); + return; + } + + //give 1 card to the dealer and 2 to each player + Dealer.Cards.Add(Deck.Draw()); + foreach (var usr in Players) + { + usr.Cards.Add(Deck.Draw()); + usr.Cards.Add(Deck.Draw()); + + if (usr.GetHandValue() == 21) + usr.State = User.UserState.Blackjack; + } + + //go through all users and ask them what they want to do + foreach (var usr in Players.Where(x => !x.Done)) + { + while (!usr.Done) + { + Log.Information("Waiting for {DiscordUser}'s move", usr.DiscordUser); + await PromptUserMove(usr); + } + } + + await PrintState(); + State = GameState.Ended; + await Task.Delay(2500); + Log.Information("Dealer moves"); + await DealerMoves(); + await PrintState(); + _ = GameEnded?.Invoke(this); + } + catch (Exception ex) + { + Log.Error(ex, "REPORT THE MESSAGE BELOW IN #NadekoLog SERVER PLEASE"); + State = GameState.Ended; + _ = GameEnded?.Invoke(this); + } + } + + private async Task PromptUserMove(User usr) + { + using var cts = new CancellationTokenSource(); + var pause = Task.Delay(20000, cts.Token); //10 seconds to decide + CurrentUser = usr; + currentUserMove = new(); + await PrintState(); + // either wait for the user to make an action and + // if he doesn't - stand + var finished = await Task.WhenAny(pause, currentUserMove.Task); + if (finished == pause) + await Stand(usr); + else + cts.Cancel(); + + CurrentUser = null; + currentUserMove = null; + } + + public async Task Join(IUser user, long bet) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Starting) + return false; + + if (Players.Count >= 5) + return false; + + if (!await _cs.RemoveAsync(user, bet, new("blackjack", "gamble"))) + return false; + + Players.Add(new(user, bet)); + _ = PrintState(); + return true; + } + finally + { + _locker.Release(); + } + } + + public async Task Stand(IUser u) + { + var cu = CurrentUser; + + if (cu is not null && cu.DiscordUser == u) + return await Stand(cu); + + return false; + } + + public async Task Stand(User u) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Playing) + return false; + + if (CurrentUser != u) + return false; + + u.State = User.UserState.Stand; + currentUserMove.TrySetResult(true); + return true; + } + finally + { + _locker.Release(); + } + } + + private async Task DealerMoves() + { + var hw = Dealer.GetHandValue(); + while (hw < 17 + || (hw == 17 + && Dealer.Cards.Count(x => x.Number == 1) > (Dealer.GetRawHandValue() - 17) / 10)) // hit on soft 17 + { + /* Dealer has + A 6 + That's 17, soft + hw == 17 => true + number of aces = 1 + 1 > 17-17 /10 => true + + AA 5 + That's 17, again soft, since one ace is worth 11, even though another one is 1 + hw == 17 => true + number of aces = 2 + 2 > 27 - 17 / 10 => true + + AA Q 5 + That's 17, but not soft, since both aces are worth 1 + hw == 17 => true + number of aces = 2 + 2 > 37 - 17 / 10 => false + * */ + Dealer.Cards.Add(Deck.Draw()); + hw = Dealer.GetHandValue(); + } + + if (hw > 21) + { + foreach (var usr in Players) + { + if (usr.State is User.UserState.Stand or User.UserState.Blackjack) + usr.State = User.UserState.Won; + else + usr.State = User.UserState.Lost; + } + } + else + { + foreach (var usr in Players) + { + if (usr.State == User.UserState.Blackjack) + usr.State = User.UserState.Won; + else if (usr.State == User.UserState.Stand) + usr.State = hw < usr.GetHandValue() ? User.UserState.Won : User.UserState.Lost; + else + usr.State = User.UserState.Lost; + } + } + + foreach (var usr in Players) + { + if (usr.State is User.UserState.Won or User.UserState.Blackjack) + await _cs.AddAsync(usr.DiscordUser.Id, usr.Bet * 2, new("blackjack", "win")); + } + } + + public async Task Double(IUser u) + { + var cu = CurrentUser; + + if (cu is not null && cu.DiscordUser == u) + return await Double(cu); + + return false; + } + + public async Task Double(User u) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Playing) + return false; + + if (CurrentUser != u) + return false; + + if (!await _cs.RemoveAsync(u.DiscordUser.Id, u.Bet, new("blackjack", "double"))) + return false; + + u.Bet *= 2; + + u.Cards.Add(Deck.Draw()); + + if (u.GetHandValue() == 21) + //blackjack + u.State = User.UserState.Blackjack; + else if (u.GetHandValue() > 21) + // user busted + u.State = User.UserState.Bust; + else + //with double you just get one card, and then you're done + u.State = User.UserState.Stand; + currentUserMove.TrySetResult(true); + + return true; + } + finally + { + _locker.Release(); + } + } + + public async Task Hit(IUser u) + { + var cu = CurrentUser; + + if (cu is not null && cu.DiscordUser == u) + return await Hit(cu); + + return false; + } + + public async Task Hit(User u) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Playing) + return false; + + if (CurrentUser != u) + return false; + + u.Cards.Add(Deck.Draw()); + + if (u.GetHandValue() == 21) + //blackjack + u.State = User.UserState.Blackjack; + else if (u.GetHandValue() > 21) + // user busted + u.State = User.UserState.Bust; + + currentUserMove.TrySetResult(true); + + return true; + } + finally + { + _locker.Release(); + } + } + + public Task PrintState() + { + if (StateUpdated is null) + return Task.CompletedTask; + return StateUpdated.Invoke(this); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/Player.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/Player.cs new file mode 100644 index 0000000..fb6a2f8 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/BlackJack/Player.cs @@ -0,0 +1,58 @@ +#nullable disable +using Ellie.Econ; + +namespace Ellie.Modules.Gambling.Common.Blackjack; + +public abstract class Player +{ + public List Cards { get; } = new(); + + public int GetHandValue() + { + var val = GetRawHandValue(); + + // while the hand value is greater than 21, for each ace you have in the deck + // reduce the value by 10 until it drops below 22 + // (emulating the fact that ace is either a 1 or a 11) + var i = Cards.Count(x => x.Number == 1); + while (val > 21 && i-- > 0) + val -= 10; + return val; + } + + public int GetRawHandValue() + => Cards.Sum(x => x.Number == 1 ? 11 : x.Number >= 10 ? 10 : x.Number); +} + +public class Dealer : Player +{ +} + +public class User : Player +{ + public enum UserState + { + Waiting, + Stand, + Bust, + Blackjack, + Won, + Lost + } + + public UserState State { get; set; } = UserState.Waiting; + public long Bet { get; set; } + public IUser DiscordUser { get; } + + public bool Done + => State != UserState.Waiting; + + public User(IUser user, long bet) + { + if (bet <= 0) + throw new ArgumentOutOfRangeException(nameof(bet)); + + Bet = bet; + DiscordUser = user; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/CleanupCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/CleanupCommands.cs new file mode 100644 index 0000000..e69de29 diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Connect4/Connect4.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Connect4/Connect4.cs new file mode 100644 index 0000000..6a608fa --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Connect4/Connect4.cs @@ -0,0 +1,409 @@ +#nullable disable +using CommandLine; +using System.Collections.Immutable; + +namespace Ellie.Modules.Gambling.Common.Connect4; + +public sealed class Connect4Game : IDisposable +{ + public enum Field //temporary most likely + { + Empty, + P1, + P2 + } + + public enum Phase + { + Joining, // waiting for second player to join + P1Move, + P2Move, + Ended + } + + public enum Result + { + Draw, + CurrentPlayerWon, + OtherPlayerWon + } + + public const int NUMBER_OF_COLUMNS = 7; + public const int NUMBER_OF_ROWS = 6; + + //public event Func OnGameStarted; + public event Func OnGameStateUpdated; + public event Func OnGameFailedToStart; + public event Func OnGameEnded; + + public Phase CurrentPhase { get; private set; } = Phase.Joining; + + public ImmutableArray GameState + => _gameState.ToImmutableArray(); + + public ImmutableArray<(ulong UserId, string Username)?> Players + => _players.ToImmutableArray(); + + public (ulong UserId, string Username) CurrentPlayer + => CurrentPhase == Phase.P1Move ? _players[0].Value : _players[1].Value; + + public (ulong UserId, string Username) OtherPlayer + => CurrentPhase == Phase.P2Move ? _players[0].Value : _players[1].Value; + + //state is bottom to top, left to right + private readonly Field[] _gameState = new Field[NUMBER_OF_ROWS * NUMBER_OF_COLUMNS]; + private readonly (ulong UserId, string Username)?[] _players = new (ulong, string)?[2]; + + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly Options _options; + private readonly ICurrencyService _cs; + private readonly EllieRandom _rng; + + private Timer playerTimeoutTimer; + + /* [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + */ + + public Connect4Game( + ulong userId, + string userName, + Options options, + ICurrencyService cs) + { + _players[0] = (userId, userName); + _options = options; + _cs = cs; + + _rng = new(); + for (var i = 0; i < NUMBER_OF_COLUMNS * NUMBER_OF_ROWS; i++) + _gameState[i] = Field.Empty; + } + + public void Initialize() + { + if (CurrentPhase != Phase.Joining) + return; + _ = Task.Run(async () => + { + await Task.Delay(15000); + await _locker.WaitAsync(); + try + { + if (_players[1] is null) + { + _ = OnGameFailedToStart?.Invoke(this); + CurrentPhase = Phase.Ended; + await _cs.AddAsync(_players[0].Value.UserId, _options.Bet, new("connect4", "refund")); + } + } + finally { _locker.Release(); } + }); + } + + public async Task Join(ulong userId, string userName, int bet) + { + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.Joining) //can't join if its not a joining phase + return false; + + if (_players[0].Value.UserId == userId) // same user can't join own game + return false; + + if (bet != _options.Bet) // can't join if bet amount is not the same + return false; + + if (!await _cs.RemoveAsync(userId, bet, new("connect4", "bet"))) // user doesn't have enough money to gamble + return false; + + if (_rng.Next(0, 2) == 0) //rolling from 0-1, if number is 0, join as first player + { + _players[1] = _players[0]; + _players[0] = (userId, userName); + } + else //else join as a second player + _players[1] = (userId, userName); + + CurrentPhase = Phase.P1Move; //start the game + playerTimeoutTimer = new(async _ => + { + await _locker.WaitAsync(); + try + { + EndGame(Result.OtherPlayerWon, OtherPlayer.UserId); + } + finally { _locker.Release(); } + }, + null, + TimeSpan.FromSeconds(_options.TurnTimer), + TimeSpan.FromSeconds(_options.TurnTimer)); + _ = OnGameStateUpdated?.Invoke(this); + + return true; + } + finally { _locker.Release(); } + } + + public async Task Input(ulong userId, int inputCol) + { + await _locker.WaitAsync(); + try + { + inputCol -= 1; + if (CurrentPhase is Phase.Ended or Phase.Joining) + return false; + + if (!((_players[0].Value.UserId == userId && CurrentPhase == Phase.P1Move) + || (_players[1].Value.UserId == userId && CurrentPhase == Phase.P2Move))) + return false; + + if (inputCol is < 0 or > NUMBER_OF_COLUMNS) //invalid input + return false; + + if (IsColumnFull(inputCol)) //can't play there event? + return false; + + var start = NUMBER_OF_ROWS * inputCol; + for (var i = start; i < start + NUMBER_OF_ROWS; i++) + { + if (_gameState[i] == Field.Empty) + { + _gameState[i] = GetPlayerPiece(userId); + break; + } + } + + //check winnning condition + // ok, i'll go from [0-2] in rows (and through all columns) and check upward if 4 are connected + + for (var i = 0; i < NUMBER_OF_ROWS - 3; i++) + { + if (CurrentPhase == Phase.Ended) + break; + + for (var j = 0; j < NUMBER_OF_COLUMNS; j++) + { + if (CurrentPhase == Phase.Ended) + break; + + var first = _gameState[i + (j * NUMBER_OF_ROWS)]; + if (first != Field.Empty) + { + for (var k = 1; k < 4; k++) + { + var next = _gameState[i + k + (j * NUMBER_OF_ROWS)]; + if (next == first) + { + if (k == 3) + EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId); + else + continue; + } + else + break; + } + } + } + } + + // i'll go [0-1] in columns (and through all rows) and check to the right if 4 are connected + for (var i = 0; i < NUMBER_OF_COLUMNS - 3; i++) + { + if (CurrentPhase == Phase.Ended) + break; + + for (var j = 0; j < NUMBER_OF_ROWS; j++) + { + if (CurrentPhase == Phase.Ended) + break; + + var first = _gameState[j + (i * NUMBER_OF_ROWS)]; + if (first != Field.Empty) + { + for (var k = 1; k < 4; k++) + { + var next = _gameState[j + ((i + k) * NUMBER_OF_ROWS)]; + if (next == first) + { + if (k == 3) + EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId); + else + continue; + } + else + break; + } + } + } + } + + //need to check diagonal now + for (var col = 0; col < NUMBER_OF_COLUMNS; col++) + { + if (CurrentPhase == Phase.Ended) + break; + + for (var row = 0; row < NUMBER_OF_ROWS; row++) + { + if (CurrentPhase == Phase.Ended) + break; + + var first = _gameState[row + (col * NUMBER_OF_ROWS)]; + + if (first != Field.Empty) + { + var same = 1; + + //top left + for (var i = 1; i < 4; i++) + { + //while going top left, rows are increasing, columns are decreasing + var curRow = row + i; + var curCol = col - i; + + //check if current values are in range + if (curRow is >= NUMBER_OF_ROWS or < 0) + break; + if (curCol is < 0 or >= NUMBER_OF_COLUMNS) + break; + + var cur = _gameState[curRow + (curCol * NUMBER_OF_ROWS)]; + if (cur == first) + same++; + else + break; + } + + if (same == 4) + { + EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId); + break; + } + + same = 1; + + //top right + for (var i = 1; i < 4; i++) + { + //while going top right, rows are increasing, columns are increasing + var curRow = row + i; + var curCol = col + i; + + //check if current values are in range + if (curRow is >= NUMBER_OF_ROWS or < 0) + break; + if (curCol is < 0 or >= NUMBER_OF_COLUMNS) + break; + + var cur = _gameState[curRow + (curCol * NUMBER_OF_ROWS)]; + if (cur == first) + same++; + else + break; + } + + if (same == 4) + { + EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId); + break; + } + } + } + } + + //check draw? if it's even possible + if (_gameState.All(x => x != Field.Empty)) + EndGame(Result.Draw, null); + + if (CurrentPhase != Phase.Ended) + { + if (CurrentPhase == Phase.P1Move) + CurrentPhase = Phase.P2Move; + else + CurrentPhase = Phase.P1Move; + + ResetTimer(); + } + + _ = OnGameStateUpdated?.Invoke(this); + return true; + } + finally { _locker.Release(); } + } + + private void ResetTimer() + => playerTimeoutTimer.Change(TimeSpan.FromSeconds(_options.TurnTimer), + TimeSpan.FromSeconds(_options.TurnTimer)); + + private void EndGame(Result result, ulong? winId) + { + if (CurrentPhase == Phase.Ended) + return; + _ = OnGameEnded?.Invoke(this, result); + CurrentPhase = Phase.Ended; + + if (result == Result.Draw) + { + _cs.AddAsync(CurrentPlayer.UserId, _options.Bet, new("connect4", "draw")); + _cs.AddAsync(OtherPlayer.UserId, _options.Bet, new("connect4", "draw")); + return; + } + + if (winId is not null) + _cs.AddAsync(winId.Value, (long)(_options.Bet * 1.98), new("connect4", "win")); + } + + private Field GetPlayerPiece(ulong userId) + => _players[0].Value.UserId == userId ? Field.P1 : Field.P2; + + //column is full if there are no empty fields + private bool IsColumnFull(int column) + { + var start = NUMBER_OF_ROWS * column; + for (var i = start; i < start + NUMBER_OF_ROWS; i++) + { + if (_gameState[i] == Field.Empty) + return false; + } + + return true; + } + + public void Dispose() + { + OnGameFailedToStart = null; + OnGameStateUpdated = null; + OnGameEnded = null; + playerTimeoutTimer?.Change(Timeout.Infinite, Timeout.Infinite); + } + + + public class Options : IEllieCommandOptions + { + [Option('t', + "turn-timer", + Required = false, + Default = 15, + HelpText = "Turn time in seconds. It has to be between 5 and 60. Default 15.")] + public int TurnTimer { get; set; } = 15; + + [Option('b', "bet", Required = false, Default = 0, HelpText = "Amount you bet. Default 0.")] + public int Bet { get; set; } + + public void NormalizeOptions() + { + if (TurnTimer is < 5 or > 60) + TurnTimer = 15; + + if (Bet < 0) + Bet = 0; + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Connect4/Connect4Commands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Connect4/Connect4Commands.cs new file mode 100644 index 0000000..57ecdf0 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Connect4/Connect4Commands.cs @@ -0,0 +1,204 @@ +#nullable disable +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Common.Connect4; +using Ellie.Modules.Gambling.Services; +using System.Text; + +namespace Ellie.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class Connect4Commands : GamblingSubmodule + { + private static readonly string[] _numbers = + { + ":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:" + }; + + private int RepostCounter + { + get => repostCounter; + set + { + if (value is < 0 or > 7) + repostCounter = 0; + else + repostCounter = value; + } + } + + private readonly DiscordSocketClient _client; + private readonly ICurrencyService _cs; + + private IUserMessage msg; + + private int repostCounter; + + public Connect4Commands(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService gamb) + : base(gamb) + { + _client = client; + _cs = cs; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task Connect4(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new Connect4Game.Options(), args); + if (!await CheckBetOptional(options.Bet)) + return; + + var newGame = new Connect4Game(ctx.User.Id, ctx.User.ToString(), options, _cs); + Connect4Game game; + if ((game = _service.Connect4Games.GetOrAdd(ctx.Channel.Id, newGame)) != newGame) + { + if (game.CurrentPhase != Connect4Game.Phase.Joining) + return; + + newGame.Dispose(); + //means game already exists, try to join + await game.Join(ctx.User.Id, ctx.User.ToString(), options.Bet); + return; + } + + if (options.Bet > 0) + { + if (!await _cs.RemoveAsync(ctx.User.Id, options.Bet, new("connect4", "bet"))) + { + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + _service.Connect4Games.TryRemove(ctx.Channel.Id, out _); + game.Dispose(); + return; + } + } + + game.OnGameStateUpdated += Game_OnGameStateUpdated; + game.OnGameFailedToStart += GameOnGameFailedToStart; + game.OnGameEnded += GameOnGameEnded; + _client.MessageReceived += ClientMessageReceived; + + game.Initialize(); + if (options.Bet == 0) + await ReplyConfirmLocalizedAsync(strs.connect4_created); + else + await ReplyErrorLocalizedAsync(strs.connect4_created_bet(N(options.Bet))); + + Task ClientMessageReceived(SocketMessage arg) + { + if (ctx.Channel.Id != arg.Channel.Id) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var success = false; + if (int.TryParse(arg.Content, out var col)) + success = await game.Input(arg.Author.Id, col); + + if (success) + { + try { await arg.DeleteAsync(); } + catch { } + } + else + { + if (game.CurrentPhase is Connect4Game.Phase.Joining or Connect4Game.Phase.Ended) + return; + RepostCounter++; + if (RepostCounter == 0) + { + try { msg = await ctx.Channel.SendMessageAsync("", embed: (Embed)msg.Embeds.First()); } + catch { } + } + } + }); + return Task.CompletedTask; + } + + Task GameOnGameFailedToStart(Connect4Game arg) + { + if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose)) + { + _client.MessageReceived -= ClientMessageReceived; + toDispose.Dispose(); + } + + return ErrorLocalizedAsync(strs.connect4_failed_to_start); + } + + Task GameOnGameEnded(Connect4Game arg, Connect4Game.Result result) + { + if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose)) + { + _client.MessageReceived -= ClientMessageReceived; + toDispose.Dispose(); + } + + string title; + if (result == Connect4Game.Result.CurrentPlayerWon) + { + title = GetText(strs.connect4_won(Format.Bold(arg.CurrentPlayer.Username), + Format.Bold(arg.OtherPlayer.Username))); + } + else if (result == Connect4Game.Result.OtherPlayerWon) + { + title = GetText(strs.connect4_won(Format.Bold(arg.OtherPlayer.Username), + Format.Bold(arg.CurrentPlayer.Username))); + } + else + title = GetText(strs.connect4_draw); + + return msg.ModifyAsync(x => x.Embed = _eb.Create() + .WithTitle(title) + .WithDescription(GetGameStateText(game)) + .WithOkColor() + .Build()); + } + } + + private async Task Game_OnGameStateUpdated(Connect4Game game) + { + var embed = _eb.Create() + .WithTitle($"{game.CurrentPlayer.Username} vs {game.OtherPlayer.Username}") + .WithDescription(GetGameStateText(game)) + .WithOkColor(); + + + if (msg is null) + msg = await ctx.Channel.EmbedAsync(embed); + else + await msg.ModifyAsync(x => x.Embed = embed.Build()); + } + + private string GetGameStateText(Connect4Game game) + { + var sb = new StringBuilder(); + + if (game.CurrentPhase is Connect4Game.Phase.P1Move or Connect4Game.Phase.P2Move) + sb.AppendLine(GetText(strs.connect4_player_to_move(Format.Bold(game.CurrentPlayer.Username)))); + + for (var i = Connect4Game.NUMBER_OF_ROWS; i > 0; i--) + { + for (var j = 0; j < Connect4Game.NUMBER_OF_COLUMNS; j++) + { + var cur = game.GameState[i + (j * Connect4Game.NUMBER_OF_ROWS) - 1]; + + if (cur == Connect4Game.Field.Empty) + sb.Append("⚫"); //black circle + else if (cur == Connect4Game.Field.P1) + sb.Append("🔴"); //red circle + else + sb.Append("🔵"); //blue circle + } + + sb.AppendLine(); + } + + for (var i = 0; i < Connect4Game.NUMBER_OF_COLUMNS; i++) + sb.Append(_numbers[i]); + return sb.ToString(); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/DiceRoll/DiceRollCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/DiceRoll/DiceRollCommands.cs new file mode 100644 index 0000000..fe7b91e --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/DiceRoll/DiceRollCommands.cs @@ -0,0 +1,224 @@ +#nullable disable +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using System.Text.RegularExpressions; +using Image = SixLabors.ImageSharp.Image; + +namespace Ellie.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class DiceRollCommands : EllieModule + { + private static readonly Regex _dndRegex = new(@"^(?\d+)d(?\d+)(?:\+(?\d+))?(?:\-(?\d+))?$", + RegexOptions.Compiled); + + private static readonly Regex _fudgeRegex = new(@"^(?\d+)d(?:F|f)$", RegexOptions.Compiled); + + private static readonly char[] _fateRolls = { '-', ' ', '+' }; + private readonly IImageCache _images; + + public DiceRollCommands(IImageCache images) + => _images = images; + + [Cmd] + public async Task Roll() + { + var rng = new EllieRandom(); + var gen = rng.Next(1, 101); + + var num1 = gen / 10; + var num2 = gen % 10; + + using var img1 = await GetDiceAsync(num1); + using var img2 = await GetDiceAsync(num2); + using var img = new[] { img1, img2 }.Merge(out var format); + await using var ms = await img.ToStreamAsync(format); + + var fileName = $"dice.{format.FileExtensions.First()}"; + + var eb = _eb.Create(ctx) + .WithOkColor() + .WithAuthor(ctx.User) + .AddField(GetText(strs.roll2), gen) + .WithImageUrl($"attachment://{fileName}"); + + await ctx.Channel.SendFileAsync(ms, + fileName, + embed: eb.Build()); + } + + [Cmd] + [Priority(1)] + public async Task Roll(int num) + => await InternalRoll(num, true); + + + [Cmd] + [Priority(1)] + public async Task Rolluo(int num = 1) + => await InternalRoll(num, false); + + [Cmd] + [Priority(0)] + public async Task Roll(string arg) + => await InternallDndRoll(arg, true); + + [Cmd] + [Priority(0)] + public async Task Rolluo(string arg) + => await InternallDndRoll(arg, false); + + private async Task InternalRoll(int num, bool ordered) + { + if (num is < 1 or > 30) + { + await ReplyErrorLocalizedAsync(strs.dice_invalid_number(1, 30)); + return; + } + + var rng = new EllieRandom(); + + var dice = new List>(num); + var values = new List(num); + for (var i = 0; i < num; i++) + { + var randomNumber = rng.Next(1, 7); + var toInsert = dice.Count; + if (ordered) + { + if (randomNumber == 6 || dice.Count == 0) + toInsert = 0; + else if (randomNumber != 1) + { + for (var j = 0; j < dice.Count; j++) + { + if (values[j] < randomNumber) + { + toInsert = j; + break; + } + } + } + } + else + toInsert = dice.Count; + + dice.Insert(toInsert, await GetDiceAsync(randomNumber)); + values.Insert(toInsert, randomNumber); + } + + using var bitmap = dice.Merge(out var format); + await using var ms = bitmap.ToStream(format); + foreach (var d in dice) + d.Dispose(); + + var imageName = $"dice.{format.FileExtensions.First()}"; + var eb = _eb.Create(ctx) + .WithOkColor() + .WithAuthor(ctx.User) + .AddField(GetText(strs.rolls), values.Select(x => Format.Code(x.ToString())).Join(' '), true) + .AddField(GetText(strs.total), values.Sum(), true) + .WithDescription(GetText(strs.dice_rolled_num(Format.Bold(values.Count.ToString())))) + .WithImageUrl($"attachment://{imageName}"); + + await ctx.Channel.SendFileAsync(ms, + imageName, + embed: eb.Build()); + } + + private async Task InternallDndRoll(string arg, bool ordered) + { + Match match; + if ((match = _fudgeRegex.Match(arg)).Length != 0 + && int.TryParse(match.Groups["n1"].ToString(), out var n1) + && n1 is > 0 and < 500) + { + var rng = new EllieRandom(); + + var rolls = new List(); + + for (var i = 0; i < n1; i++) + rolls.Add(_fateRolls[rng.Next(0, _fateRolls.Length)]); + var embed = _eb.Create() + .WithOkColor() + .WithAuthor(ctx.User) + .WithDescription(GetText(strs.dice_rolled_num(Format.Bold(n1.ToString())))) + .AddField(Format.Bold("Result"), + string.Join(" ", rolls.Select(c => Format.Code($"[{c}]")))); + + await ctx.Channel.EmbedAsync(embed); + } + else if ((match = _dndRegex.Match(arg)).Length != 0) + { + var rng = new EllieRandom(); + if (int.TryParse(match.Groups["n1"].ToString(), out n1) + && int.TryParse(match.Groups["n2"].ToString(), out var n2) + && n1 <= 50 + && n2 <= 100000 + && n1 > 0 + && n2 > 0) + { + if (!int.TryParse(match.Groups["add"].Value, out var add)) + add = 0; + if (!int.TryParse(match.Groups["sub"].Value, out var sub)) + sub = 0; + + var arr = new int[n1]; + for (var i = 0; i < n1; i++) + arr[i] = rng.Next(1, n2 + 1); + + var sum = arr.Sum(); + var embed = _eb.Create() + .WithOkColor() + .WithAuthor(ctx.User) + .WithDescription(GetText(strs.dice_rolled_num(n1 + $"`1 - {n2}`"))) + .AddField(Format.Bold(GetText(strs.rolls)), + string.Join(" ", + (ordered ? arr.OrderBy(x => x).AsEnumerable() : arr).Select(x + => Format.Code(x.ToString())))) + .AddField(Format.Bold("Sum"), + sum + " + " + add + " - " + sub + " = " + (sum + add - sub)); + await ctx.Channel.EmbedAsync(embed); + } + } + } + + [Cmd] + public async Task NRoll([Leftover] string range) + { + int rolled; + if (range.Contains("-")) + { + var arr = range.Split('-').Take(2).Select(int.Parse).ToArray(); + if (arr[0] > arr[1]) + { + await ReplyErrorLocalizedAsync(strs.second_larger_than_first); + return; + } + + rolled = new EllieRandom().Next(arr[0], arr[1] + 1); + } + else + rolled = new EllieRandom().Next(0, int.Parse(range) + 1); + + await ReplyConfirmLocalizedAsync(strs.dice_rolled(Format.Bold(rolled.ToString()))); + } + + private async Task> GetDiceAsync(int num) + { + if (num is < 0 or > 10) + throw new ArgumentOutOfRangeException(nameof(num)); + + if (num == 10) + { + using var imgOne = Image.Load(await _images.GetDiceAsync(1)); + using var imgZero = Image.Load(await _images.GetDiceAsync(0)); + return new[] { imgOne, imgZero }.Merge(); + } + + return Image.Load(await _images.GetDiceAsync(num)); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Draw/DrawCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Draw/DrawCommands.cs new file mode 100644 index 0000000..2b9cf8c --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Draw/DrawCommands.cs @@ -0,0 +1,234 @@ +#nullable disable +using Ellie.Econ; +using Ellie.Common.TypeReaders; +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Services; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Image = SixLabors.ImageSharp.Image; + +namespace Ellie.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class DrawCommands : GamblingSubmodule + { + private static readonly ConcurrentDictionary _allDecks = new(); + private readonly IImageCache _images; + + public DrawCommands(IImageCache images, GamblingConfigService gcs) : base(gcs) + => _images = images; + + private async Task InternalDraw(int count, ulong? guildId = null) + { + if (count is < 1 or > 10) + throw new ArgumentOutOfRangeException(nameof(count)); + + var cards = guildId is null ? new() : _allDecks.GetOrAdd(ctx.Guild, _ => new()); + var images = new List>(); + var cardObjects = new List(); + for (var i = 0; i < count; i++) + { + if (cards.CardPool.Count == 0 && i != 0) + { + try + { + await ReplyErrorLocalizedAsync(strs.no_more_cards); + } + catch + { + // ignored + } + + break; + } + + var currentCard = cards.Draw(); + cardObjects.Add(currentCard); + var image = await GetCardImageAsync(currentCard); + images.Add(image); + } + + var imgName = "cards.jpg"; + using var img = images.Merge(); + foreach (var i in images) + i.Dispose(); + + var eb = _eb.Create(ctx) + .WithOkColor(); + + var toSend = string.Empty; + if (cardObjects.Count == 5) + eb.AddField(GetText(strs.hand_value), Deck.GetHandValue(cardObjects), true); + + if (guildId is not null) + toSend += GetText(strs.cards_left(Format.Bold(cards.CardPool.Count.ToString()))); + + eb.WithDescription(toSend) + .WithAuthor(ctx.User) + .WithImageUrl($"attachment://{imgName}"); + + if (count > 1) + eb.AddField(GetText(strs.cards), count.ToString(), true); + + await using var imageStream = await img.ToStreamAsync(); + await ctx.Channel.SendFileAsync(imageStream, + imgName, + embed: eb.Build()); + } + + private async Task> GetCardImageAsync(RegularCard currentCard) + { + var cardName = currentCard.GetName().ToLowerInvariant().Replace(' ', '_'); + var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg"); + return Image.Load(cardBytes); + } + + private async Task> GetCardImageAsync(Deck.Card currentCard) + { + var cardName = currentCard.ToString().ToLowerInvariant().Replace(' ', '_'); + var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg"); + return Image.Load(cardBytes); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Draw(int num = 1) + { + if (num < 1) + return; + + if (num > 10) + num = 10; + + await InternalDraw(num, ctx.Guild.Id); + } + + [Cmd] + public async Task DrawNew(int num = 1) + { + if (num < 1) + return; + + if (num > 10) + num = 10; + + await InternalDraw(num); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task DeckShuffle() + { + //var channel = (ITextChannel)ctx.Channel; + + _allDecks.AddOrUpdate(ctx.Guild, + _ => new(), + (_, c) => + { + c.Restart(); + return c; + }); + + await ReplyConfirmLocalizedAsync(strs.deck_reshuffled); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task BetDraw([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, InputValueGuess val, InputColorGuess? col = null) + => BetDrawInternal(amount, val, col); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task BetDraw([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, InputColorGuess col, InputValueGuess? val = null) + => BetDrawInternal(amount, val, col); + + public async Task BetDrawInternal(long amount, InputValueGuess? val, InputColorGuess? col) + { + if (amount <= 0) + return; + + var res = await _service.BetDrawAsync(ctx.User.Id, + amount, + (byte?)val, + (byte?)col); + + if (!res.TryPickT0(out var result, out _)) + { + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + return; + } + + var eb = _eb.Create(ctx) + .WithOkColor() + .WithAuthor(ctx.User) + .WithDescription(result.Card.GetEmoji()) + .AddField(GetText(strs.guess), GetGuessInfo(val, col), true) + .AddField(GetText(strs.card), GetCardInfo(result.Card), true) + .AddField(GetText(strs.won), N((long)result.Won), false) + .WithImageUrl("attachment://card.png"); + + using var img = await GetCardImageAsync(result.Card); + await using var imgStream = await img.ToStreamAsync(); + await ctx.Channel.SendFileAsync(imgStream, "card.png", embed: eb.Build()); + } + + private string GetGuessInfo(InputValueGuess? valG, InputColorGuess? colG) + { + var val = valG switch + { + InputValueGuess.H => "Hi ⬆️", + InputValueGuess.L => "Lo ⬇️", + _ => "❓" + }; + + var col = colG switch + { + InputColorGuess.Red => "R 🔴", + InputColorGuess.Black => "B ⚫", + _ => "❓" + }; + + return $"{val} / {col}"; + } + private string GetCardInfo(RegularCard card) + { + var val = (int)card.Value switch + { + < 7 => "Lo ⬇️", + > 7 => "Hi ⬆️", + _ => "7 💀" + }; + + var col = card.Value == RegularValue.Seven + ? "7 💀" + : card.Suit switch + { + RegularSuit.Diamonds or RegularSuit.Hearts => "R 🔴", + _ => "B ⚫" + }; + + return $"{val} / {col}"; + } + + public enum InputValueGuess + { + High = 0, + H = 0, + Hi = 0, + Low = 1, + L = 1, + Lo = 1, + } + + public enum InputColorGuess + { + R = 0, + Red = 0, + B = 1, + Bl = 1, + Black = 1, + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/EconomyResult.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/EconomyResult.cs new file mode 100644 index 0000000..811d1c5 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/EconomyResult.cs @@ -0,0 +1,12 @@ +#nullable disable +namespace Ellie.Modules.Gambling.Services; + +public sealed class EconomyResult +{ + public decimal Cash { get; init; } + public decimal Planted { get; init; } + public decimal Waifus { get; init; } + public decimal OnePercent { get; init; } + public decimal Bank { get; init; } + public long Bot { get; init; } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Events/CurrencyEventsCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/CurrencyEventsCommands.cs new file mode 100644 index 0000000..fd0f655 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/CurrencyEventsCommands.cs @@ -0,0 +1,60 @@ +#nullable disable +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Common.Events; +using Ellie.Modules.Gambling.Services; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class CurrencyEventsCommands : GamblingSubmodule + { + public CurrencyEventsCommands(GamblingConfigService gamblingConf) + : base(gamblingConf) + { + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + [OwnerOnly] + public async Task EventStart(CurrencyEvent.Type ev, params string[] options) + { + var (opts, _) = OptionsParser.ParseFrom(new EventOptions(), options); + if (!await _service.TryCreateEventAsync(ctx.Guild.Id, ctx.Channel.Id, ev, opts, GetEmbed)) + await ReplyErrorLocalizedAsync(strs.start_event_fail); + } + + private IEmbedBuilder GetEmbed(CurrencyEvent.Type type, EventOptions opts, long currentPot) + => type switch + { + CurrencyEvent.Type.Reaction => _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.event_title(type.ToString()))) + .WithDescription(GetReactionDescription(opts.Amount, currentPot)) + .WithFooter(GetText(strs.event_duration_footer(opts.Hours))), + CurrencyEvent.Type.GameStatus => _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.event_title(type.ToString()))) + .WithDescription(GetGameStatusDescription(opts.Amount, currentPot)) + .WithFooter(GetText(strs.event_duration_footer(opts.Hours))), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; + + private string GetReactionDescription(long amount, long potSize) + { + var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : N(potSize)); + + return GetText(strs.new_reaction_event(CurrencySign, Format.Bold(N(amount)), potSizeStr)); + } + + private string GetGameStatusDescription(long amount, long potSize) + { + var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : potSize + CurrencySign); + + return GetText(strs.new_gamestatus_event(CurrencySign, Format.Bold(N(amount)), potSizeStr)); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Events/CurrencyEventsService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/CurrencyEventsService.cs new file mode 100644 index 0000000..be62459 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/CurrencyEventsService.cs @@ -0,0 +1,67 @@ +#nullable disable +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Common.Events; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Gambling.Services; + +public class CurrencyEventsService : IEService +{ + private readonly DiscordSocketClient _client; + private readonly ICurrencyService _cs; + private readonly GamblingConfigService _configService; + + private readonly ConcurrentDictionary _events = new(); + + public CurrencyEventsService(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService configService) + { + _client = client; + _cs = cs; + _configService = configService; + } + + public async Task TryCreateEventAsync( + ulong guildId, + ulong channelId, + CurrencyEvent.Type type, + EventOptions opts, + Func embed) + { + var g = _client.GetGuild(guildId); + if (g?.GetChannel(channelId) is not ITextChannel ch) + return false; + + ICurrencyEvent ce; + + if (type == CurrencyEvent.Type.Reaction) + ce = new ReactionEvent(_client, _cs, g, ch, opts, _configService.Data, embed); + else if (type == CurrencyEvent.Type.GameStatus) + ce = new GameStatusEvent(_client, _cs, g, ch, opts, embed); + else + return false; + + var added = _events.TryAdd(guildId, ce); + if (added) + { + try + { + ce.OnEnded += OnEventEnded; + await ce.StartEvent(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error starting event"); + _events.TryRemove(guildId, out ce); + return false; + } + } + + return added; + } + + private Task OnEventEnded(ulong gid) + { + _events.TryRemove(gid, out _); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Events/EventOptions.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/EventOptions.cs new file mode 100644 index 0000000..163f9a9 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/EventOptions.cs @@ -0,0 +1,39 @@ +#nullable disable +using CommandLine; + +namespace Ellie.Modules.Gambling.Common.Events; + +public class EventOptions : IEllieCommandOptions +{ + [Option('a', "amount", Required = false, Default = 100, HelpText = "Amount of currency each user receives.")] + public long Amount { get; set; } = 100; + + [Option('p', + "pot-size", + Required = false, + Default = 0, + HelpText = "The maximum amount of currency that can be rewarded. 0 means no limit.")] + public long PotSize { get; set; } + + //[Option('t', "type", Required = false, Default = "reaction", HelpText = "Type of the event. reaction, gamestatus or joinserver.")] + //public string TypeString { get; set; } = "reaction"; + [Option('d', + "duration", + Required = false, + Default = 24, + HelpText = "Number of hours the event should run for. Default 24.")] + public int Hours { get; set; } = 24; + + + public void NormalizeOptions() + { + if (Amount < 0) + Amount = 100; + if (PotSize < 0) + PotSize = 0; + if (Hours <= 0) + Hours = 24; + if (PotSize != 0 && PotSize < Amount) + PotSize = 0; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Events/GameStatusEvent.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/GameStatusEvent.cs new file mode 100644 index 0000000..10ec416 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/GameStatusEvent.cs @@ -0,0 +1,195 @@ +#nullable disable +using Ellie.Services.Database.Models; +using System.Collections.Concurrent; + +namespace Ellie.Modules.Gambling.Common.Events; + +public class GameStatusEvent : ICurrencyEvent +{ + public event Func OnEnded; + private long PotSize { get; set; } + public bool Stopped { get; private set; } + public bool PotEmptied { get; private set; } + private readonly DiscordSocketClient _client; + private readonly IGuild _guild; + private IUserMessage msg; + private readonly ICurrencyService _cs; + private readonly long _amount; + + private readonly Func _embedFunc; + private readonly bool _isPotLimited; + private readonly ITextChannel _channel; + private readonly ConcurrentHashSet _awardedUsers = new(); + private readonly ConcurrentQueue _toAward = new(); + private readonly Timer _t; + private readonly Timer _timeout; + private readonly EventOptions _opts; + + private readonly string _code; + + private readonly char[] _sneakyGameStatusChars = Enumerable.Range(48, 10) + .Concat(Enumerable.Range(65, 26)) + .Concat(Enumerable.Range(97, 26)) + .Select(x => (char)x) + .ToArray(); + + private readonly object _stopLock = new(); + + private readonly object _potLock = new(); + + public GameStatusEvent( + DiscordSocketClient client, + ICurrencyService cs, + SocketGuild g, + ITextChannel ch, + EventOptions opt, + Func embedFunc) + { + _client = client; + _guild = g; + _cs = cs; + _amount = opt.Amount; + PotSize = opt.PotSize; + _embedFunc = embedFunc; + _isPotLimited = PotSize > 0; + _channel = ch; + _opts = opt; + // generate code + _code = new(_sneakyGameStatusChars.Shuffle().Take(5).ToArray()); + + _t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2)); + if (_opts.Hours > 0) + _timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan); + } + + private void EventTimeout(object state) + => _ = StopEvent(); + + private async void OnTimerTick(object state) + { + var potEmpty = PotEmptied; + var toAward = new List(); + while (_toAward.TryDequeue(out var x)) + toAward.Add(x); + + if (!toAward.Any()) + return; + + try + { + await _cs.AddBulkAsync(toAward, + _amount, + new("event", "gamestatus") + ); + + if (_isPotLimited) + { + await msg.ModifyAsync(m => + { + m.Embed = GetEmbed(PotSize).Build(); + }); + } + + Log.Information("Game status event awarded {Count} users {Amount} currency.{Remaining}", + toAward.Count, + _amount, + _isPotLimited ? $" {PotSize} left." : ""); + + if (potEmpty) + _ = StopEvent(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in OnTimerTick in gamestatusevent"); + } + } + + public async Task StartEvent() + { + msg = await _channel.EmbedAsync(GetEmbed(_opts.PotSize)); + await _client.SetGameAsync(_code); + _client.MessageDeleted += OnMessageDeleted; + _client.MessageReceived += HandleMessage; + _t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); + } + + private IEmbedBuilder GetEmbed(long pot) + => _embedFunc(CurrencyEvent.Type.GameStatus, _opts, pot); + + private async Task OnMessageDeleted(Cacheable message, Cacheable cacheable) + { + if (message.Id == msg.Id) + await StopEvent(); + } + + public Task StopEvent() + { + lock (_stopLock) + { + if (Stopped) + return Task.CompletedTask; + Stopped = true; + _client.MessageDeleted -= OnMessageDeleted; + _client.MessageReceived -= HandleMessage; + _t.Change(Timeout.Infinite, Timeout.Infinite); + _timeout?.Change(Timeout.Infinite, Timeout.Infinite); + _ = _client.SetGameAsync(null); + try + { + _ = msg.DeleteAsync(); + } + catch { } + + _ = OnEnded?.Invoke(_guild.Id); + } + + return Task.CompletedTask; + } + + private Task HandleMessage(SocketMessage message) + { + _ = Task.Run(async () => + { + if (message.Author is not IGuildUser gu // no unknown users, as they could be bots, or alts + || gu.IsBot // no bots + || message.Content != _code // code has to be the same + || (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5) // no recently created accounts + return; + // there has to be money left in the pot + // and the user wasn't rewarded + if (_awardedUsers.Add(message.Author.Id) && TryTakeFromPot()) + { + _toAward.Enqueue(message.Author.Id); + if (_isPotLimited && PotSize < _amount) + PotEmptied = true; + } + + try + { + await message.DeleteAsync(new() + { + RetryMode = RetryMode.AlwaysFail + }); + } + catch { } + }); + return Task.CompletedTask; + } + + private bool TryTakeFromPot() + { + if (_isPotLimited) + { + lock (_potLock) + { + if (PotSize < _amount) + return false; + + PotSize -= _amount; + return true; + } + } + + return true; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Events/ICurrencyEvent.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/ICurrencyEvent.cs new file mode 100644 index 0000000..fdb0431 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/ICurrencyEvent.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace Ellie.Modules.Gambling.Common; + +public interface ICurrencyEvent +{ + event Func OnEnded; + Task StopEvent(); + Task StartEvent(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Events/ReactionEvent.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/ReactionEvent.cs new file mode 100644 index 0000000..85ecfe0 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Events/ReactionEvent.cs @@ -0,0 +1,194 @@ +#nullable disable +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Gambling.Common.Events; + +public class ReactionEvent : ICurrencyEvent +{ + public event Func OnEnded; + private long PotSize { get; set; } + public bool Stopped { get; private set; } + public bool PotEmptied { get; private set; } + private readonly DiscordSocketClient _client; + private readonly IGuild _guild; + private IUserMessage msg; + private IEmote emote; + private readonly ICurrencyService _cs; + private readonly long _amount; + + private readonly Func _embedFunc; + private readonly bool _isPotLimited; + private readonly ITextChannel _channel; + private readonly ConcurrentHashSet _awardedUsers = new(); + private readonly System.Collections.Concurrent.ConcurrentQueue _toAward = new(); + private readonly Timer _t; + private readonly Timer _timeout; + private readonly bool _noRecentlyJoinedServer; + private readonly EventOptions _opts; + private readonly GamblingConfig _config; + + private readonly object _stopLock = new(); + + private readonly object _potLock = new(); + + public ReactionEvent( + DiscordSocketClient client, + ICurrencyService cs, + SocketGuild g, + ITextChannel ch, + EventOptions opt, + GamblingConfig config, + Func embedFunc) + { + _client = client; + _guild = g; + _cs = cs; + _amount = opt.Amount; + PotSize = opt.PotSize; + _embedFunc = embedFunc; + _isPotLimited = PotSize > 0; + _channel = ch; + _noRecentlyJoinedServer = false; + _opts = opt; + _config = config; + + _t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2)); + if (_opts.Hours > 0) + _timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan); + } + + private void EventTimeout(object state) + => _ = StopEvent(); + + private async void OnTimerTick(object state) + { + var potEmpty = PotEmptied; + var toAward = new List(); + while (_toAward.TryDequeue(out var x)) + toAward.Add(x); + + if (!toAward.Any()) + return; + + try + { + await _cs.AddBulkAsync(toAward, _amount, new("event", "reaction")); + + if (_isPotLimited) + { + await msg.ModifyAsync(m => + { + m.Embed = GetEmbed(PotSize).Build(); + }); + } + + Log.Information("Reaction Event awarded {Count} users {Amount} currency.{Remaining}", + toAward.Count, + _amount, + _isPotLimited ? $" {PotSize} left." : ""); + + if (potEmpty) + _ = StopEvent(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error adding bulk currency to users"); + } + } + + public async Task StartEvent() + { + if (Emote.TryParse(_config.Currency.Sign, out var parsedEmote)) + emote = parsedEmote; + else + emote = new Emoji(_config.Currency.Sign); + msg = await _channel.EmbedAsync(GetEmbed(_opts.PotSize)); + await msg.AddReactionAsync(emote); + _client.MessageDeleted += OnMessageDeleted; + _client.ReactionAdded += HandleReaction; + _t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); + } + + private IEmbedBuilder GetEmbed(long pot) + => _embedFunc(CurrencyEvent.Type.Reaction, _opts, pot); + + private async Task OnMessageDeleted(Cacheable message, Cacheable cacheable) + { + if (message.Id == msg.Id) + await StopEvent(); + } + + public Task StopEvent() + { + lock (_stopLock) + { + if (Stopped) + return Task.CompletedTask; + + Stopped = true; + _client.MessageDeleted -= OnMessageDeleted; + _client.ReactionAdded -= HandleReaction; + _t.Change(Timeout.Infinite, Timeout.Infinite); + _timeout?.Change(Timeout.Infinite, Timeout.Infinite); + try + { + _ = msg.DeleteAsync(); + } + catch { } + + _ = OnEnded?.Invoke(_guild.Id); + } + + return Task.CompletedTask; + } + + private Task HandleReaction( + Cacheable message, + Cacheable cacheable, + SocketReaction r) + { + _ = Task.Run(() => + { + if (emote.Name != r.Emote.Name) + return; + if ((r.User.IsSpecified + ? r.User.Value + : null) is not IGuildUser gu // no unknown users, as they could be bots, or alts + || message.Id != msg.Id // same message + || gu.IsBot // no bots + || (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5 // no recently created accounts + || (_noRecentlyJoinedServer + && // if specified, no users who joined the server in the last 24h + (gu.JoinedAt is null + || (DateTime.UtcNow - gu.JoinedAt.Value).TotalDays + < 1))) // and no users for who we don't know when they joined + return; + // there has to be money left in the pot + // and the user wasn't rewarded + if (_awardedUsers.Add(r.UserId) && TryTakeFromPot()) + { + _toAward.Enqueue(r.UserId); + if (_isPotLimited && PotSize < _amount) + PotEmptied = true; + } + }); + return Task.CompletedTask; + } + + private bool TryTakeFromPot() + { + if (_isPotLimited) + { + lock (_potLock) + { + if (PotSize < _amount) + return false; + + PotSize -= _amount; + return true; + } + } + + return true; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/FlipCoin/FlipCoinCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/FlipCoin/FlipCoinCommands.cs new file mode 100644 index 0000000..0b759b5 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/FlipCoin/FlipCoinCommands.cs @@ -0,0 +1,140 @@ +#nullable disable +using Ellie.Common.TypeReaders; +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Services; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Image = SixLabors.ImageSharp.Image; + +namespace Ellie.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class FlipCoinCommands : GamblingSubmodule + { + public enum BetFlipGuess : byte + { + H = 0, + Head = 0, + Heads = 0, + T = 1, + Tail = 1, + Tails = 1 + } + + private static readonly EllieRandom _rng = new(); + private readonly IImageCache _images; + private readonly ICurrencyService _cs; + private readonly ImagesConfig _ic; + + public FlipCoinCommands( + IImageCache images, + ImagesConfig ic, + ICurrencyService cs, + GamblingConfigService gss) + : base(gss) + { + _ic = ic; + _images = images; + _cs = cs; + } + + [Cmd] + public async Task Flip(int count = 1) + { + if (count is > 10 or < 1) + { + await ReplyErrorLocalizedAsync(strs.flip_invalid(10)); + return; + } + + var headCount = 0; + var tailCount = 0; + var imgs = new Image[count]; + var headsArr = await _images.GetHeadsImageAsync(); + var tailsArr = await _images.GetTailsImageAsync(); + + var result = await _service.FlipAsync(count); + + for (var i = 0; i < result.Length; i++) + { + if (result[i].Side == 0) + { + imgs[i] = Image.Load(headsArr); + headCount++; + } + else + { + imgs[i] = Image.Load(tailsArr); + tailCount++; + } + } + + using var img = imgs.Merge(out var format); + await using var stream = await img.ToStreamAsync(format); + foreach (var i in imgs) + i.Dispose(); + + var imgName = $"coins.{format.FileExtensions.First()}"; + + var msg = count != 1 + ? Format.Bold(GetText(strs.flip_results(count, headCount, tailCount))) + : GetText(strs.flipped(headCount > 0 + ? Format.Bold(GetText(strs.heads)) + : Format.Bold(GetText(strs.tails)))); + + var eb = _eb.Create(ctx) + .WithOkColor() + .WithAuthor(ctx.User) + .WithDescription(msg) + .WithImageUrl($"attachment://{imgName}"); + + await ctx.Channel.SendFileAsync(stream, + imgName, + embed: eb.Build()); + } + + [Cmd] + public async Task Betflip([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, BetFlipGuess guess) + { + if (!await CheckBetMandatory(amount) || amount == 1) + return; + + var res = await _service.BetFlipAsync(ctx.User.Id, amount, (byte)guess); + if (!res.TryPickT0(out var result, out _)) + { + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + return; + } + + Uri imageToSend; + var coins = _ic.Data.Coins; + if (result.Side == 0) + { + imageToSend = coins.Heads[_rng.Next(0, coins.Heads.Length)]; + } + else + { + imageToSend = coins.Tails[_rng.Next(0, coins.Tails.Length)]; + } + + string str; + var won = (long)result.Won; + if (won > 0) + { + str = Format.Bold(GetText(strs.flip_guess(N(won)))); + } + else + { + str = Format.Bold(GetText(strs.better_luck)); + } + + await ctx.Channel.EmbedAsync(_eb.Create() + .WithAuthor(ctx.User) + .WithDescription(str) + .WithOkColor() + .WithImageUrl(imageToSend.ToString())); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/FlipCoin/FlipResult.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/FlipCoin/FlipResult.cs new file mode 100644 index 0000000..044f991 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/FlipCoin/FlipResult.cs @@ -0,0 +1,7 @@ +namespace Ellie.Econ.Gambling; + +public readonly struct FlipResult +{ + public long Won { get; init; } + public int Side { get; init; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Gambling.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Gambling.cs new file mode 100644 index 0000000..141864a --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Gambling.cs @@ -0,0 +1,1002 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Ellie.Db; +using Ellie.Db.Models; +using Ellie.Modules.Gambling.Bank; +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Services; +using Ellie.Modules.Utility.Services; +using Ellie.Services.Currency; +using Ellie.Services.Database.Models; +using System.Collections.Immutable; +using System.Globalization; +using System.Text; +using Ellie.Econ.Gambling.Rps; +using Ellie.Common.TypeReaders; +using Ellie.Modules.Patronage; + +namespace Ellie.Modules.Gambling; + +public partial class Gambling : GamblingModule +{ + private readonly IGamblingService _gs; + private readonly DbService _db; + private readonly ICurrencyService _cs; + private readonly DiscordSocketClient _client; + private readonly NumberFormatInfo _enUsCulture; + private readonly DownloadTracker _tracker; + private readonly GamblingConfigService _configService; + private readonly IBankService _bank; + private readonly IPatronageService _ps; + private readonly IRemindService _remind; + private readonly GamblingTxTracker _gamblingTxTracker; + + private IUserMessage rdMsg; + + public Gambling( + IGamblingService gs, + DbService db, + ICurrencyService currency, + DiscordSocketClient client, + DownloadTracker tracker, + GamblingConfigService configService, + IBankService bank, + IPatronageService ps, + IRemindService remind, + GamblingTxTracker gamblingTxTracker) + : base(configService) + { + _gs = gs; + _db = db; + _cs = currency; + _client = client; + _bank = bank; + _ps = ps; + _remind = remind; + _gamblingTxTracker = gamblingTxTracker; + + _enUsCulture = new CultureInfo("en-US", false).NumberFormat; + _enUsCulture.NumberDecimalDigits = 0; + _enUsCulture.NumberGroupSeparator = " "; + _tracker = tracker; + _configService = configService; + } + + public async Task GetBalanceStringAsync(ulong userId) + { + var bal = await _cs.GetBalanceAsync(userId); + return N(bal); + } + + [Cmd] + public async Task BetStats() + { + var stats = await _gamblingTxTracker.GetAllAsync(); + + var eb = _eb.Create(ctx) + .WithOkColor(); + + var str = "` Feature `|`   Bet  `|`Paid Out`|`  RoI  `\n"; + str += "――――――――――――――――――――\n"; + foreach (var stat in stats) + { + var perc = (stat.PaidOut / stat.Bet).ToString("P2", Culture); + str += $"`{stat.Feature.PadBoth(9)}`" + + $"|`{stat.Bet.ToString("N0").PadLeft(8, ' ')}`" + + $"|`{stat.PaidOut.ToString("N0").PadLeft(8, ' ')}`" + + $"|`{perc.PadLeft(6, ' ')}`\n"; + } + + var bet = stats.Sum(x => x.Bet); + var paidOut = stats.Sum(x => x.PaidOut); + + if (bet == 0) + bet = 1; + + var tPerc = (paidOut / bet).ToString("P2", Culture); + str += "――――――――――――――――――――\n"; + str += $"` {("TOTAL").PadBoth(7)}` " + + $"|**{N(bet).PadLeft(8, ' ')}**" + + $"|**{N(paidOut).PadLeft(8, ' ')}**" + + $"|`{tPerc.PadLeft(6, ' ')}`"; + + eb.WithDescription(str); + + await ctx.Channel.EmbedAsync(eb); + } + + [Cmd] + public async Task Economy() + { + var ec = await _service.GetEconomyAsync(); + decimal onePercent = 0; + + // This stops the top 1% from owning more than 100% of the money + if (ec.Cash > 0) + { + onePercent = ec.OnePercent / (ec.Cash - ec.Bot); + } + + // [21:03] Bob Page: Kinda remids me of US economy + var embed = _eb.Create() + .WithTitle(GetText(strs.economy_state)) + .AddField(GetText(strs.currency_owned), N(ec.Cash - ec.Bot)) + .AddField(GetText(strs.currency_one_percent), (onePercent * 100).ToString("F2") + "%") + .AddField(GetText(strs.currency_planted), N(ec.Planted)) + .AddField(GetText(strs.owned_waifus_total), N(ec.Waifus)) + .AddField(GetText(strs.bot_currency), N(ec.Bot)) + .AddField(GetText(strs.bank_accounts), N(ec.Bank)) + .AddField(GetText(strs.total), N(ec.Cash + ec.Planted + ec.Waifus + ec.Bank)) + .WithOkColor(); + + // ec.Cash already contains ec.Bot as it's the total of all values in the CurrencyAmount column of the DiscordUser table + await ctx.Channel.EmbedAsync(embed); + } + + private static readonly FeatureLimitKey _timelyKey = new FeatureLimitKey() + { + Key = "timely:extra_percent", + PrettyName = "Timely" + }; + + private async Task RemindTimelyAction(SocketMessageComponent smc, DateTime when) + { + var tt = TimestampTag.FromDateTime(when, TimestampTagStyles.Relative); + + await _remind.AddReminderAsync(ctx.User.Id, + ctx.User.Id, + ctx.Guild?.Id, + true, + when, + GetText(strs.timely_time)); + + await smc.RespondConfirmAsync(_eb, GetText(strs.remind_timely(tt)), ephemeral: true); + } + + [Cmd] + public async Task Timely() + { + var val = Config.Timely.Amount; + var period = Config.Timely.Cooldown; + if (val <= 0 || period <= 0) + { + await ReplyErrorLocalizedAsync(strs.timely_none); + return; + } + + if (await _service.ClaimTimelyAsync(ctx.User.Id, period) is { } rem) + { + var now = DateTime.UtcNow; + var relativeTag = TimestampTag.FromDateTime(now.Add(rem), TimestampTagStyles.Relative); + await ReplyPendingLocalizedAsync(strs.timely_already_claimed(relativeTag)); + return; + } + + var result = await _ps.TryGetFeatureLimitAsync(_timelyKey, ctx.User.Id, 0); + + val = (int)(val * (1 + (result.Quota! * 0.01f))); + + await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim")); + + var inter = _inter + .Create(ctx.User.Id, + new SimpleInteraction( + new ButtonBuilder( + label: "Remind me", + emote: Emoji.Parse("⏰"), + customId: "timely:remind_me"), + RemindTimelyAction, + DateTime.UtcNow.Add(TimeSpan.FromHours(period)))); + + await ReplyConfirmLocalizedAsync(strs.timely(N(val), period), inter); + } + + [Cmd] + [OwnerOnly] + public async Task TimelyReset() + { + await _service.RemoveAllTimelyClaimsAsync(); + await ReplyConfirmLocalizedAsync(strs.timely_reset); + } + + [Cmd] + [OwnerOnly] + public async Task TimelySet(int amount, int period = 24) + { + if (amount < 0 || period < 0) + { + return; + } + + _configService.ModifyConfig(gs => + { + gs.Timely.Amount = amount; + gs.Timely.Cooldown = period; + }); + + if (amount == 0) + { + await ReplyConfirmLocalizedAsync(strs.timely_set_none); + } + else + { + await ReplyConfirmLocalizedAsync(strs.timely_set(Format.Bold(N(amount)), Format.Bold(period.ToString()))); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Raffle([Leftover] IRole role = null) + { + role ??= ctx.Guild.EveryoneRole; + + var members = (await role.GetMembersAsync()).Where(u => u.Status != UserStatus.Offline); + var membersArray = members as IUser[] ?? members.ToArray(); + if (membersArray.Length == 0) + { + return; + } + + var usr = membersArray[new EllieRandom().Next(0, membersArray.Length)]; + await SendConfirmAsync("🎟 " + GetText(strs.raffled_user), + $"**{usr.Username}#{usr.Discriminator}**", + footer: $"ID: {usr.Id}"); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RaffleAny([Leftover] IRole role = null) + { + role ??= ctx.Guild.EveryoneRole; + + var members = await role.GetMembersAsync(); + var membersArray = members as IUser[] ?? members.ToArray(); + if (membersArray.Length == 0) + { + return; + } + + var usr = membersArray[new EllieRandom().Next(0, membersArray.Length)]; + await SendConfirmAsync("🎟 " + GetText(strs.raffled_user), + $"**{usr.Username}#{usr.Discriminator}**", + footer: $"ID: {usr.Id}"); + } + + [Cmd] + [Priority(2)] + public Task CurrencyTransactions(int page = 1) + => InternalCurrencyTransactions(ctx.User.Id, page); + + [Cmd] + [OwnerOnly] + [Priority(0)] + public Task CurrencyTransactions([Leftover] IUser usr) + => InternalCurrencyTransactions(usr.Id, 1); + + [Cmd] + [OwnerOnly] + [Priority(1)] + public Task CurrencyTransactions(IUser usr, int page) + => InternalCurrencyTransactions(usr.Id, page); + + private async Task InternalCurrencyTransactions(ulong userId, int page) + { + if (--page < 0) + { + return; + } + + List trs; + await using (var uow = _db.GetDbContext()) + { + trs = await uow.Set().GetPageFor(userId, page); + } + + var embed = _eb.Create() + .WithTitle(GetText(strs.transactions(((SocketGuild)ctx.Guild)?.GetUser(userId)?.ToString() + ?? $"{userId}"))) + .WithOkColor(); + + var sb = new StringBuilder(); + foreach (var tr in trs) + { + var change = tr.Amount >= 0 ? "🔵" : "🔴"; + var kwumId = new kwum(tr.Id).ToString(); + var date = $"#{Format.Code(kwumId)} `〖{GetFormattedCurtrDate(tr)}〗`"; + + sb.AppendLine($"\\{change} {date} {Format.Bold(N(tr.Amount))}"); + var transactionString = GetHumanReadableTransaction(tr.Type, tr.Extra, tr.OtherId); + if (transactionString is not null) + { + sb.AppendLine(transactionString); + } + + if (!string.IsNullOrWhiteSpace(tr.Note)) + { + sb.AppendLine($"\t`Note:` {tr.Note.TrimTo(50)}"); + } + } + + embed.WithDescription(sb.ToString()); + embed.WithFooter(GetText(strs.page(page + 1))); + await ctx.Channel.EmbedAsync(embed); + } + + private static string GetFormattedCurtrDate(CurrencyTransaction ct) + => $"{ct.DateAdded:HH:mm yyyy-MM-dd}"; + + [Cmd] + public async Task CurrencyTransaction(kwum id) + { + int intId = id; + await using var uow = _db.GetDbContext(); + + var tr = await uow.Set().ToLinqToDBTable() + .Where(x => x.Id == intId && x.UserId == ctx.User.Id) + .FirstOrDefaultAsync(); + + if (tr is null) + { + await ReplyErrorLocalizedAsync(strs.not_found); + return; + } + + var eb = _eb.Create(ctx).WithOkColor(); + + eb.WithAuthor(ctx.User); + eb.WithTitle(GetText(strs.transaction)); + eb.WithDescription(new kwum(tr.Id).ToString()); + eb.AddField("Amount", N(tr.Amount)); + eb.AddField("Type", tr.Type, true); + eb.AddField("Extra", tr.Extra, true); + + if (tr.OtherId is ulong other) + { + eb.AddField("From Id", other); + } + + if (!string.IsNullOrWhiteSpace(tr.Note)) + { + eb.AddField("Note", tr.Note); + } + + eb.WithFooter(GetFormattedCurtrDate(tr)); + + await ctx.Channel.EmbedAsync(eb); + } + + private string GetHumanReadableTransaction(string type, string subType, ulong? maybeUserId) + => (type, subType, maybeUserId) switch + { + ("gift", var name, ulong userId) => GetText(strs.curtr_gift(name, userId)), + ("award", var name, ulong userId) => GetText(strs.curtr_award(name, userId)), + ("take", var name, ulong userId) => GetText(strs.curtr_take(name, userId)), + ("blackjack", _, _) => $"Blackjack - {subType}", + ("wheel", _, _) => $"Lucky Ladder - {subType}", + ("lula", _, _) => $"Lucky Ladder - {subType}", + ("rps", _, _) => $"Rock Paper Scissors - {subType}", + (null, _, _) => null, + (_, null, _) => null, + (_, _, ulong userId) => $"{type.Titleize()} - {subType.Titleize()} | [{userId}]", + _ => $"{type.Titleize()} - {subType.Titleize()}" + }; + + [Cmd] + [Priority(0)] + public async Task Cash(ulong userId) + { + var cur = await GetBalanceStringAsync(userId); + await ReplyConfirmLocalizedAsync(strs.has(Format.Code(userId.ToString()), cur)); + } + + private async Task BankAction(SocketMessageComponent smc, object _) + { + var balance = await _bank.GetBalanceAsync(ctx.User.Id); + + await N(balance) + .Pipe(strs.bank_balance) + .Pipe(GetText) + .Pipe(text => smc.RespondConfirmAsync(_eb, text, ephemeral: true)); + } + + private EllieInteraction CreateCashInteraction() + => _inter.Create(ctx.User.Id, + new(new( + 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 ConfirmLocalizedAsync( + user.ToString() + .Pipe(Format.Bold) + .With(cur) + .Pipe(strs.has), + inter); + } + + [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(_eb, ctx.User, receiver, amount, msg, N(amount))) + { + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + return; + } + + await ReplyConfirmLocalizedAsync(strs.gifted(N(amount), Format.Bold(receiver.ToString()))); + } + + [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 ReplyErrorLocalizedAsync(strs.user_not_found); + return; + } + + await _cs.AddAsync(usr.Id, amount, new("award", ctx.User.ToString()!, msg, ctx.User.Id)); + await ReplyConfirmLocalizedAsync(strs.awarded(N(amount), $"<@{usrId}>")); + } + + [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 ReplyConfirmLocalizedAsync(strs.mass_award(N(amount), + Format.Bold(users.Count.ToString()), + Format.Bold(role.Name))); + } + + [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 ReplyConfirmLocalizedAsync(strs.mass_take(N(amount), + Format.Bold(users.Count.ToString()), + Format.Bold(role.Name))); + } + + [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 ReplyConfirmLocalizedAsync(strs.take(N(amount), Format.Bold(user.ToString()))); + } + else + { + await ReplyErrorLocalizedAsync(strs.take_fail(N(amount), Format.Bold(user.ToString()), CurrencySign)); + } + } + + [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 ReplyConfirmLocalizedAsync(strs.take(N(amount), $"<@{usrId}>")); + } + else + { + await ReplyErrorLocalizedAsync(strs.take_fail(N(amount), Format.Code(usrId.ToString()), CurrencySign)); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RollDuel(IUser u) + { + if (ctx.User.Id == u.Id) + { + return; + } + + //since the challenge is created by another user, we need to reverse the ids + //if it gets removed, means challenge is accepted + if (_service.Duels.TryRemove((ctx.User.Id, u.Id), out var game)) + { + await game.StartGame(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RollDuel([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, IUser u) + { + if (ctx.User.Id == u.Id) + { + return; + } + + if (amount <= 0) + { + return; + } + + var embed = _eb.Create().WithOkColor().WithTitle(GetText(strs.roll_duel)); + + var description = string.Empty; + + var game = new RollDuelGame(_cs, _client.CurrentUser.Id, ctx.User.Id, u.Id, amount); + //means challenge is just created + if (_service.Duels.TryGetValue((ctx.User.Id, u.Id), out var other)) + { + if (other.Amount != amount) + { + await ReplyErrorLocalizedAsync(strs.roll_duel_already_challenged); + } + else + { + await RollDuel(u); + } + + return; + } + + if (_service.Duels.TryAdd((u.Id, ctx.User.Id), game)) + { + game.OnGameTick += GameOnGameTick; + game.OnEnded += GameOnEnded; + + await ReplyConfirmLocalizedAsync(strs.roll_duel_challenge(Format.Bold(ctx.User.ToString()), + Format.Bold(u.ToString()), + Format.Bold(N(amount)))); + } + + async Task GameOnGameTick(RollDuelGame arg) + { + var rolls = arg.Rolls.Last(); + description += $@"{Format.Bold(ctx.User.ToString())} rolled **{rolls.Item1}** +{Format.Bold(u.ToString())} rolled **{rolls.Item2}** +-- +"; + embed = embed.WithDescription(description); + + if (rdMsg is null) + { + rdMsg = await ctx.Channel.EmbedAsync(embed); + } + else + { + await rdMsg.ModifyAsync(x => { x.Embed = embed.Build(); }); + } + } + + async Task GameOnEnded(RollDuelGame rdGame, RollDuelGame.Reason reason) + { + try + { + if (reason == RollDuelGame.Reason.Normal) + { + var winner = rdGame.Winner == rdGame.P1 ? ctx.User : u; + description += $"\n**{winner}** Won {N((long)(rdGame.Amount * 2 * 0.98))}"; + + embed = embed.WithDescription(description); + + await rdMsg.ModifyAsync(x => x.Embed = embed.Build()); + } + else if (reason == RollDuelGame.Reason.Timeout) + { + await ReplyErrorLocalizedAsync(strs.roll_duel_timeout); + } + else if (reason == RollDuelGame.Reason.NoFunds) + { + await ReplyErrorLocalizedAsync(strs.roll_duel_no_funds); + } + } + finally + { + _service.Duels.TryRemove((u.Id, ctx.User.Id), out _); + } + } + } + + [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 ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + 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 = _eb.Create(ctx) + .WithAuthor(ctx.User) + .WithDescription(Format.Bold(str)) + .AddField(GetText(strs.roll2), result.Roll.ToString(CultureInfo.InvariantCulture)) + .WithOkColor(); + + await ctx.Channel.EmbedAsync(eb); + } + + [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; + } + + if (opts.Clean) + { + await using (var uow = _db.GetDbContext()) + { + cleanRichest = uow.Set().GetTopRichest(_client.CurrentUser.Id, 10_000); + } + + await ctx.Channel.TriggerTypingAsync(); + await _tracker.EnsureUsersDownloadedAsync(ctx.Guild); + + var sg = (SocketGuild)ctx.Guild!; + cleanRichest = cleanRichest.Where(x => sg.GetUser(x.UserId) is not null).ToList(); + } + else + { + await using var uow = _db.GetDbContext(); + cleanRichest = uow.Set().GetTopRichest(_client.CurrentUser.Id, 9, page).ToList(); + } + + await ctx.SendPaginatedConfirmAsync(page, + curPage => + { + var embed = _eb.Create().WithOkColor().WithTitle(CurrencySign + " " + GetText(strs.leaderboard)); + + List toSend; + if (!opts.Clean) + { + using var uow = _db.GetDbContext(); + toSend = uow.Set().GetTopRichest(_client.CurrentUser.Id, 9, curPage); + } + else + { + toSend = cleanRichest.Skip(curPage * 9).Take(9).ToList(); + } + + if (!toSend.Any()) + { + embed.WithDescription(GetText(strs.no_user_on_this_page)); + return 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 embed; + }, + opts.Clean ? cleanRichest.Count() : 9000, + 9, + opts.Clean); + } + + 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 ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + return; + } + + var embed = _eb.Create(); + + 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 ctx.Channel.EmbedAsync(embed); + } + + 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 ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + 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 = _eb.Create(ctx) + .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 ctx.Channel.EmbedAsync(eb); + } + + + 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 SendConfirmAsync(GetText(strs.available_tests), values); + } + + [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 SendConfirmAsync(GetText(strs.test_results_for(target)), + sb.ToString(), + footer: $"Total Bet: {tests} | Payout: {payout:F0} | {payout * 1.0M / tests * 100}%"); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/GamblingConfig.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/GamblingConfig.cs new file mode 100644 index 0000000..76f25e1 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/GamblingConfig.cs @@ -0,0 +1,387 @@ +#nullable disable +using Cloneable; +using Ellie.Common.Yml; +using SixLabors.ImageSharp.PixelFormats; +using YamlDotNet.Serialization; +using Color = SixLabors.ImageSharp.Color; + +namespace Ellie.Modules.Gambling.Common; + +[Cloneable] +public sealed partial class GamblingConfig : ICloneable +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } = 2; + + [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("""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(); + } +} + +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; } = "Nadeko Flower"; + + [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 BetRollPair[] + { + 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 = new[] { 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; } = new(); + + public WaifuConfig() + => Items = new() + { + 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(""" + Percentage (0 - 100) of the waifu value to reduce. + Set 0 to disable + 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 Percent { 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; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/GamblingConfigService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/GamblingConfigService.cs new file mode 100644 index 0000000..f87e8c8 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/GamblingConfigService.cs @@ -0,0 +1,186 @@ +#nullable disable +using Ellie.Common.Configs; +using Ellie.Modules.Gambling.Common; + +namespace Ellie.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; + }); + } + } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/GamblingService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/GamblingService.cs new file mode 100644 index 0000000..f6ba92e --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/GamblingService.cs @@ -0,0 +1,214 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using Ellie.Db.Models; +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Common.Connect4; +using Ellie.Services.Database.Models; + +namespace Ellie.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"); + + public async Task GetEconomyAsync() + { + var data = await _cache.GetOrAddAsync(_ecoKey, + async () => + { + await using var uow = _db.GetDbContext(); + var cash = uow.Set().GetTotalCurrency(); + var onePercent = uow.Set().GetTopOnePercentCurrency(_client.CurrentUser.Id); + decimal planted = uow.Set().AsQueryable().Sum(x => x.Amount); + var waifus = uow.Set().GetTotalValue(); + var bot = await uow.Set().GetUserCurrencyAsync(_client.CurrentUser.Id); + decimal bank = await uow.GetTable() + .SumAsyncLinqToDB(x => x.Balance); + + var result = new EconomyResult + { + Cash = cash, + Planted = planted, + Bot = bot, + Waifus = waifus, + OnePercent = onePercent, + Bank = bank + }; + + return result; + }, + TimeSpan.FromMinutes(3)); + + return data; + } + + + 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 async Task RemoveAllTimelyClaimsAsync() + => await _cache.RemoveAsync(_timelyKey); +} diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/GamblingTopLevelModule.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/GamblingTopLevelModule.cs new file mode 100644 index 0000000..d3e0192 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/GamblingTopLevelModule.cs @@ -0,0 +1,68 @@ +#nullable disable +using Ellie.Modules.Gambling.Services; +using System.Numerics; +using Ellie.Bot.Common; + +namespace Ellie.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 ReplyErrorLocalizedAsync(strs.min_bet_limit(Format.Bold(Config.MinBet.ToString()) + CurrencySign)); + return false; + } + + if (Config.MaxBet > 0 && amount > Config.MaxBet) + { + await ReplyErrorLocalizedAsync(strs.max_bet_limit(Format.Bold(Config.MaxBet.ToString()) + CurrencySign)); + 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) + { + } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/GlobalUsings.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/GlobalUsings.cs new file mode 100644 index 0000000..1ba5b1d --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/GlobalUsings.cs @@ -0,0 +1,31 @@ +// global using System.Collections.Generic +global using NonBlocking; + +// packages +global using Serilog; +global using Humanizer; + +// ellie +global using Ellie; +global using Ellie.Services; +global using Ellise.Common; // new project +global using Ellie.Common; // old + ellie specific things +global using Ellie.Common.Attributes; +global using Ellie.Extensions; +global using Ellie.Marmalade; + +// discord +global using Discord; +global using Discord.Commands; +global using Discord.Net; +global using Discord.WebSocket; + +// aliases +global using GuildPerm = Discord.GuildPermission; +global using ChannelPerm = Discord.ChannelPermission; +global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute; +global using LeftoverAttribute = Discord.Commands.RemainderAttribute; +global using TypeReaderResult = Ellie.Common.TypeReaders.TypeReaderResult; + +// non-essential +global using JetBrains.Annotations; diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/InputRpsPick.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/InputRpsPick.cs new file mode 100644 index 0000000..3cf537a --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/InputRpsPick.cs @@ -0,0 +1,2 @@ +#nullable disable +namespace Ellie.Modules.Gambling; \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/PlantPick/PlantAndPickCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/PlantPick/PlantAndPickCommands.cs new file mode 100644 index 0000000..fc81124 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/PlantPick/PlantAndPickCommands.cs @@ -0,0 +1,112 @@ +#nullable disable +using Ellie.Common.TypeReaders; +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Services; + +namespace Ellie.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 ReplyConfirmLocalizedAsync(strs.picked(N(picked))); + 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 ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + } + + [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 ReplyConfirmLocalizedAsync(strs.curgen_enabled); + else + await ReplyConfirmLocalizedAsync(strs.curgen_disabled); + } + + [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 ctx.SendPaginatedConfirmAsync(page, + _ => + { + var items = enabledIn.Skip(page * 9).Take(9).ToList(); + + if (!items.Any()) + return _eb.Create().WithErrorColor().WithDescription("-"); + + return items.Aggregate(_eb.Create().WithOkColor(), + (eb, i) => eb.AddField(i.GuildId.ToString(), i.ChannelId)); + }, + enabledIn.Count(), + 9); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/PlantPick/PlantPickService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/PlantPick/PlantPickService.cs new file mode 100644 index 0000000..331e76b --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/PlantPick/PlantPickService.cs @@ -0,0 +1,385 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using Ellie.Services.Database.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 Ellie.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, + CommandHandler cmd, + 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. + /// Extension of the file, defaults to png + /// Stream of the currency image + public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass) + { + var curImg = await _images.GetCurrencyImageAsync(); + + if (string.IsNullOrWhiteSpace(pass)) + { + // determine the extension + using var load = _ = Image.Load(curImg, out var format); + + // 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, out var format); + // 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.Measure(pass, new TextOptions(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 + 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/Ellie.Bot.Modules.Gambling/Gambling/Raffle/CurrencyRaffleCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Raffle/CurrencyRaffleCommands.cs new file mode 100644 index 0000000..ff5d79e --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Raffle/CurrencyRaffleCommands.cs @@ -0,0 +1,57 @@ +#nullable disable +using Ellie.Common.TypeReaders; +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Services; + +namespace Ellie.Modules.Gambling; + +public partial class Gambling +{ + public partial class CurrencyRaffleCommands : GamblingSubmodule + { + public enum Mixed { Mixed } + + public CurrencyRaffleCommands(GamblingConfigService gamblingConfService) + : base(gamblingConfService) + { + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task RaffleCur(Mixed _, [OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + => RaffleCur(amount, true); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task RaffleCur([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, bool mixed = false) + { + if (!await CheckBetMandatory(amount)) + return; + + async Task OnEnded(IUser arg, long won) + { + await SendConfirmAsync(GetText(strs.rafflecur_ended(CurrencyName, + Format.Bold(arg.ToString()), + won + CurrencySign))); + } + + var res = await _service.JoinOrCreateGame(ctx.Channel.Id, ctx.User, amount, mixed, OnEnded); + + if (res.Item1 is not null) + { + await SendConfirmAsync(GetText(strs.rafflecur(res.Item1.GameType.ToString())), + string.Join("\n", res.Item1.Users.Select(x => $"{x.DiscordUser} ({N(x.Amount)})")), + footer: GetText(strs.rafflecur_joined(ctx.User.ToString()))); + } + else + { + if (res.Item2 == CurrencyRaffleService.JoinErrorType.AlreadyJoinedOrInvalidAmount) + await ReplyErrorLocalizedAsync(strs.rafflecur_already_joined); + else if (res.Item2 == CurrencyRaffleService.JoinErrorType.NotEnoughCurrency) + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + } + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Raffle/CurrencyRaffleGame.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Raffle/CurrencyRaffleGame.cs new file mode 100644 index 0000000..f730e2d --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Raffle/CurrencyRaffleGame.cs @@ -0,0 +1,69 @@ +#nullable disable +namespace Ellie.Modules.Gambling.Common; + +public class CurrencyRaffleGame +{ + public enum Type + { + Mixed, + Normal + } + + public IEnumerable Users + => _users; + + public Type GameType { get; } + + private readonly HashSet _users = new(); + + public CurrencyRaffleGame(Type type) + => GameType = type; + + public bool AddUser(IUser usr, long amount) + { + // if game type is normal, and someone already joined the game + // (that's the user who created it) + if (GameType == Type.Normal && _users.Count > 0 && _users.First().Amount != amount) + return false; + + if (!_users.Add(new() + { + DiscordUser = usr, + Amount = amount + })) + return false; + + return true; + } + + public User GetWinner() + { + var rng = new EllieRandom(); + if (GameType == Type.Mixed) + { + var num = rng.NextLong(0L, Users.Sum(x => x.Amount)); + var sum = 0L; + foreach (var u in Users) + { + sum += u.Amount; + if (sum > num) + return u; + } + } + + var usrs = _users.ToArray(); + return usrs[rng.Next(0, usrs.Length)]; + } + + public class User + { + public IUser DiscordUser { get; set; } + public long Amount { get; set; } + + public override int GetHashCode() + => DiscordUser.GetHashCode(); + + public override bool Equals(object obj) + => obj is User u ? u.DiscordUser == DiscordUser : false; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Raffle/CurrencyRaffleService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Raffle/CurrencyRaffleService.cs new file mode 100644 index 0000000..31094fd --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Raffle/CurrencyRaffleService.cs @@ -0,0 +1,81 @@ +#nullable disable +using Ellie.Modules.Gambling.Common; + +namespace Ellie.Modules.Gambling.Services; + +public class CurrencyRaffleService : IEService +{ + public enum JoinErrorType + { + NotEnoughCurrency, + AlreadyJoinedOrInvalidAmount + } + + public Dictionary Games { get; } = new(); + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly ICurrencyService _cs; + + public CurrencyRaffleService(ICurrencyService cs) + => _cs = cs; + + public async Task<(CurrencyRaffleGame, JoinErrorType?)> JoinOrCreateGame( + ulong channelId, + IUser user, + long amount, + bool mixed, + Func onEnded) + { + await _locker.WaitAsync(); + try + { + var newGame = false; + if (!Games.TryGetValue(channelId, out var crg)) + { + newGame = true; + crg = new(mixed ? CurrencyRaffleGame.Type.Mixed : CurrencyRaffleGame.Type.Normal); + Games.Add(channelId, crg); + } + + //remove money, and stop the game if this + // user created it and doesn't have the money + if (!await _cs.RemoveAsync(user.Id, amount, new("raffle", "join"))) + { + if (newGame) + Games.Remove(channelId); + return (null, JoinErrorType.NotEnoughCurrency); + } + + if (!crg.AddUser(user, amount)) + { + await _cs.AddAsync(user.Id, amount, new("raffle", "refund")); + return (null, JoinErrorType.AlreadyJoinedOrInvalidAmount); + } + + if (newGame) + { + _ = Task.Run(async () => + { + await Task.Delay(60000); + await _locker.WaitAsync(); + try + { + var winner = crg.GetWinner(); + var won = crg.Users.Sum(x => x.Amount); + + await _cs.AddAsync(winner.DiscordUser.Id, won, new("raffle", "win")); + Games.Remove(channelId, out _); + _ = onEnded(winner.DiscordUser, won); + } + catch { } + finally { _locker.Release(); } + }); + } + + return (crg, null); + } + finally + { + _locker.Release(); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Shop/IShopService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Shop/IShopService.cs new file mode 100644 index 0000000..c3bddca --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Shop/IShopService.cs @@ -0,0 +1,43 @@ +#nullable disable +namespace Ellie.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); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Shop/ShopCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Shop/ShopCommands.cs new file mode 100644 index 0000000..c953a14 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Shop/ShopCommands.cs @@ -0,0 +1,496 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Db; +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Services; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class ShopCommands : GamblingSubmodule + { + public enum List + { + List + } + + public enum Role + { + Role + } + + 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 ctx.SendPaginatedConfirmAsync(page, + curPage => + { + var theseEntries = entries.Skip(curPage * 9).Take(9).ToArray(); + + if (!theseEntries.Any()) + return _eb.Create().WithErrorColor().WithDescription(GetText(strs.shop_none)); + var embed = _eb.Create().WithOkColor().WithTitle(GetText(strs.shop)); + + for (var i = 0; i < theseEntries.Length; i++) + { + var entry = theseEntries[i]; + embed.AddField($"#{(curPage * 9) + i + 1} - {N(entry.Price)}", + EntryToString(entry), + true); + } + + return embed; + }, + entries.Count, + 9); + } + + [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 ReplyErrorLocalizedAsync(strs.shop_item_not_found); + return; + } + + if (entry.RoleRequirement is ulong reqRoleId) + { + var role = ctx.Guild.GetRole(reqRoleId); + if (role is null) + { + await ReplyErrorLocalizedAsync(strs.shop_item_req_role_not_found); + return; + } + + var guser = (IGuildUser)ctx.User; + if (!guser.RoleIds.Contains(reqRoleId)) + { + await ReplyErrorLocalizedAsync(strs.shop_item_req_role_unfulfilled(Format.Bold(role.ToString()))); + return; + } + } + + if (entry.Type == ShopEntryType.Role) + { + var guser = (IGuildUser)ctx.User; + var role = ctx.Guild.GetRole(entry.RoleId); + + if (role is null) + { + await ReplyErrorLocalizedAsync(strs.shop_role_not_found); + return; + } + + if (guser.RoleIds.Any(id => id == role.Id)) + { + await ReplyErrorLocalizedAsync(strs.shop_role_already_bought); + 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 ReplyErrorLocalizedAsync(strs.shop_role_purchase_error); + 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 ReplyConfirmLocalizedAsync(strs.shop_role_purchase(Format.Bold(role.Name))); + return; + } + + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + return; + } + + if (entry.Type == ShopEntryType.List) + { + if (entry.Items.Count == 0) + { + await ReplyErrorLocalizedAsync(strs.out_of_stock); + 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); + uow.SaveChanges(); + } + + try + { + await ctx.User.EmbedAsync(_eb.Create() + .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)); + + 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 ReplyErrorLocalizedAsync(strs.shop_buy_error); + return; + } + + await ReplyConfirmLocalizedAsync(strs.shop_item_purchase); + } + else + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + } + } + + private static long GetProfitAmount(int price) + => (int)Math.Ceiling(0.90 * price); + + [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 ctx.Channel.EmbedAsync(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))); + } + + [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 ctx.Channel.EmbedAsync(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))); + } + + [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 ReplyErrorLocalizedAsync(strs.shop_item_not_found); + else if (!rightType) + await ReplyErrorLocalizedAsync(strs.shop_item_wrong_type); + else if (added == false) + await ReplyErrorLocalizedAsync(strs.shop_list_item_not_unique); + else + await ReplyConfirmLocalizedAsync(strs.shop_list_item_added); + } + + [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 ReplyErrorLocalizedAsync(strs.shop_item_not_found); + else + await ctx.Channel.EmbedAsync(EntryToEmbed(removed).WithTitle(GetText(strs.shop_item_rm))); + } + + [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 ReplyErrorLocalizedAsync(strs.shop_item_not_found); + return; + } + + if (role is null) + await ReplyConfirmLocalizedAsync(strs.shop_item_role_no_req(itemIndex)); + else + await ReplyConfirmLocalizedAsync(strs.shop_item_role_req(itemIndex + 1, role)); + } + + public IEmbedBuilder EntryToEmbed(ShopEntry entry) + { + var embed = _eb.Create().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.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; + return prepend; + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Shop/ShopService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Shop/ShopService.cs new file mode 100644 index 0000000..91ed00d --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Shop/ShopService.cs @@ -0,0 +1,112 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Db; +using Ellie.Services.Database; +using Ellie.Services.Database.Models; + +namespace Ellie.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) + { + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(index)); + if (newPrice <= 0) + throw new ArgumentOutOfRangeException(nameof(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) + { + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(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) + { + if (index1 < 0) + throw new ArgumentOutOfRangeException(nameof(index1)); + if (index2 < 0) + throw new ArgumentOutOfRangeException(nameof(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) + { + if (fromIndex < 0) + throw new ArgumentOutOfRangeException(nameof(fromIndex)); + if (toIndex < 0) + throw new ArgumentOutOfRangeException(nameof(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; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Slot/SlotCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Slot/SlotCommands.cs new file mode 100644 index 0000000..3e360b5 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Slot/SlotCommands.cs @@ -0,0 +1,227 @@ +#nullable disable warnings +using Ellie.Db.Models; +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Services; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Ellie.Econ.Gambling; +using Ellie.Common.TypeReaders; +using Color = SixLabors.ImageSharp.Color; +using Image = SixLabors.ImageSharp.Image; + +namespace Ellie.Modules.Gambling; + +public enum GamblingError +{ + InsufficientFunds, +} + +public partial class Gambling +{ + [Group] + public partial class SlotCommands : GamblingSubmodule + { + private static decimal totalBet; + private static decimal totalPaidOut; + + 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 ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + return; + } + + var text = GetSlotMessageTextInternal(result); + + using var image = await GenerateSlotImageAsync(amount, result); + await using var imgStream = await image.ToStreamAsync(); + + + var eb = _eb.Create(ctx) + .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 si = new SimpleInteraction(bb, (_, amount) => Slot(amount), amount); + + var inter = _inter.Create(ctx.User.Id, si); + 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; + } + + lock (_slotStatsLock) + { + totalBet += amount; + totalPaidOut += result.Won; + } + + 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, out _); + var numbers = new int[3]; + result.Rolls.CopyTo(numbers, 0); + + Color fontColor = Config.Slots.CurrencyFontColor; + + bgImage.Mutate(x => x.DrawText(new TextOptions(_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 TextOptions(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/Ellie.Bot.Modules.Gambling/Gambling/VoteRewardService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/VoteRewardService.cs new file mode 100644 index 0000000..21688ac --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/VoteRewardService.cs @@ -0,0 +1,106 @@ +#nullable disable +using Ellie.Common.ModuleBehaviors; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Ellie.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"); + } + } + } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/WaifuClaimCommands.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/WaifuClaimCommands.cs new file mode 100644 index 0000000..3543231 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/WaifuClaimCommands.cs @@ -0,0 +1,373 @@ +#nullable disable +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Common.Waifu; +using Ellie.Modules.Gambling.Services; +using Ellie.Services.Database.Models; + +namespace Ellie.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 = _eb.Create() + .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 ReplyConfirmLocalizedAsync(strs.waifu_reset); + return; + } + + await ReplyErrorLocalizedAsync(strs.waifu_reset_fail); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task WaifuClaim(long amount, [Leftover] IUser target) + { + if (amount < Config.Waifu.MinPrice) + { + await ReplyErrorLocalizedAsync(strs.waifu_isnt_cheap(Config.Waifu.MinPrice + CurrencySign)); + return; + } + + if (target.Id == ctx.User.Id) + { + await ReplyErrorLocalizedAsync(strs.waifu_not_yourself); + return; + } + + var (w, isAffinity, result) = await _service.ClaimWaifuAsync(ctx.User, target, amount); + + if (result == WaifuClaimResult.InsufficientAmount) + { + await ReplyErrorLocalizedAsync( + strs.waifu_not_enough(N((long)Math.Ceiling(w.Price * (isAffinity ? 0.88f : 1.1f))))); + return; + } + + if (result == WaifuClaimResult.NotEnoughFunds) + { + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + return; + } + + var msg = GetText(strs.waifu_claimed(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 SendConfirmAsync(ctx.User.Mention + msg); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task WaifuTransfer(ulong waifuId, IUser newOwner) + { + if (!await _service.WaifuTransfer(ctx.User, waifuId, newOwner)) + { + await ReplyErrorLocalizedAsync(strs.waifu_transfer_fail); + return; + } + + await ReplyConfirmLocalizedAsync(strs.waifu_transfer_success(Format.Bold(waifuId.ToString()), + Format.Bold(ctx.User.ToString()), + Format.Bold(newOwner.ToString()))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task WaifuTransfer(IUser waifu, IUser newOwner) + { + if (!await _service.WaifuTransfer(ctx.User, waifu.Id, newOwner)) + { + await ReplyErrorLocalizedAsync(strs.waifu_transfer_fail); + return; + } + + await ReplyConfirmLocalizedAsync(strs.waifu_transfer_success(Format.Bold(waifu.ToString()), + Format.Bold(ctx.User.ToString()), + Format.Bold(newOwner.ToString()))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(-1)] + public Task Divorce([Leftover] string target) + { + var waifuUserId = _service.GetWaifuUserId(ctx.User.Id, target); + if (waifuUserId == default) + return ReplyErrorLocalizedAsync(strs.waifu_not_yours); + + 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 ReplyConfirmLocalizedAsync(strs.waifu_divorced_like(Format.Bold(w.Waifu.ToString()), + N(amount))); + } + else if (result == DivorceResult.Success) + await ReplyConfirmLocalizedAsync(strs.waifu_divorced_notlike(N(amount))); + else if (result == DivorceResult.NotYourWife) + await ReplyErrorLocalizedAsync(strs.waifu_not_yours); + else + { + await ReplyErrorLocalizedAsync(strs.waifu_recent_divorce( + Format.Bold(((int)remaining?.TotalHours).ToString()), + Format.Bold(remaining?.Minutes.ToString()))); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Affinity([Leftover] IGuildUser user = null) + { + if (user?.Id == ctx.User.Id) + { + await ReplyErrorLocalizedAsync(strs.waifu_egomaniac); + return; + } + + var (oldAff, sucess, remaining) = await _service.ChangeAffinityAsync(ctx.User, user); + if (!sucess) + { + if (remaining is not null) + { + await ReplyErrorLocalizedAsync(strs.waifu_affinity_cooldown( + Format.Bold(((int)remaining?.TotalHours).ToString()), + Format.Bold(remaining?.Minutes.ToString()))); + } + else + await ReplyErrorLocalizedAsync(strs.waifu_affinity_already); + + return; + } + + if (user is null) + await ReplyConfirmLocalizedAsync(strs.waifu_affinity_reset); + else if (oldAff is null) + await ReplyConfirmLocalizedAsync(strs.waifu_affinity_set(Format.Bold(user.ToString()))); + else + { + await ReplyConfirmLocalizedAsync(strs.waifu_affinity_changed(Format.Bold(oldAff.ToString()), + Format.Bold(user.ToString()))); + } + } + + [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 ReplyConfirmLocalizedAsync(strs.waifus_none); + return; + } + + var embed = _eb.Create().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 ctx.Channel.EmbedAsync(embed); + } + + private string GetLbString(WaifuLbResult w) + { + var claimer = "no one"; + var status = string.Empty; + + 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 _)) + .OrderBy(x => waifuItems[x.ItemEmoji].Price) + .GroupBy(x => x.ItemEmoji) + .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 + .Select((x) => claimsNames.Contains(x) ? $"{x} 💞" : x) + .Join('\n'); + + + if (string.IsNullOrWhiteSpace(fansStr)) + fansStr = "-"; + + var embed = _eb.Create() + .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 ctx.Channel.EmbedAsync(embed); + } + + [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 ctx.SendPaginatedConfirmAsync(page, + cur => + { + var embed = _eb.Create().WithTitle(GetText(strs.waifu_gift_shop)).WithOkColor(); + + waifuItems.OrderBy(x => x.Negative) + .ThenBy(x => x.Price) + .Skip(9 * cur) + .Take(9) + .ToList() + .ForEach(x => embed.AddField( + $"{(!x.Negative ? string.Empty : "\\💔")} {x.ItemEmoji} {x.Name}", + Format.Bold(N(x.Price)), + true)); + + return embed; + }, + waifuItems.Count, + 9); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task WaifuGift(string itemName, [Leftover] IUser waifu) + { + if (waifu.Id == ctx.User.Id) + return; + + var allItems = _service.GetWaifuItems(); + var item = allItems.FirstOrDefault(x => x.Name.ToLowerInvariant() == itemName.ToLowerInvariant()); + if (item is null) + { + await ReplyErrorLocalizedAsync(strs.waifu_gift_not_exist); + return; + } + + var sucess = await _service.GiftWaifuAsync(ctx.User, waifu, item); + + if (sucess) + { + await ReplyConfirmLocalizedAsync(strs.waifu_gift(Format.Bold(item + " " + item.ItemEmoji), + Format.Bold(waifu.ToString()))); + } + else + await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign)); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/WaifuService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/WaifuService.cs new file mode 100644 index 0000000..8341033 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/WaifuService.cs @@ -0,0 +1,583 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using Ellie.Db.Models; +using Ellie.Modules.Gambling.Common; +using Ellie.Modules.Gambling.Common.Waifu; +using Ellie.Services.Database.Models; + +namespace Ellie.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) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetTop(9, page * 9); + } + + 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) + { + if (!await _cs.RemoveAsync(from, itemObj.Price, new("waifu", "item"))) + return false; + + 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.Add(new() + { + Name = itemObj.Name.ToLowerInvariant(), + ItemEmoji = itemObj.ItemEmoji + }); + + if (w.Claimer?.UserId == from.Id) + w.Price += (long)(itemObj.Price * _gss.Data.Waifu.Multipliers.GiftEffect); + else + w.Price += itemObj.Price / 2; + } + else + { + w.Price -= (long)(itemObj.Price * _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 multi = _gss.Data.Waifu.Decay.Percent / 100f; + var minPrice = _gss.Data.Waifu.Decay.MinPrice; + var decayInterval = _gss.Data.Waifu.Decay.HourInterval; + + if (multi is < 0f or > 1f || decayInterval < 0) + 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); + + await using var uow = _db.GetDbContext(); + + await uow.GetTable() + .Where(x => x.Price > minPrice && x.ClaimerId == null) + .UpdateAsync(old => new() + { + Price = (long)(old.Price * multi) + }); + + } + 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/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/AffinityTitle.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/AffinityTitle.cs new file mode 100644 index 0000000..090def6 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/AffinityTitle.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace Ellie.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/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/ClaimTitle.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/ClaimTitle.cs new file mode 100644 index 0000000..981de85 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/ClaimTitle.cs @@ -0,0 +1,18 @@ +#nullable disable +namespace Ellie.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/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/DivorceResult.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/DivorceResult.cs new file mode 100644 index 0000000..44b3b0a --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/DivorceResult.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace Ellie.Modules.Gambling.Common.Waifu; + +public enum DivorceResult +{ + Success, + SucessWithPenalty, + NotYourWife, + Cooldown +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/Extensions.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/Extensions.cs new file mode 100644 index 0000000..62d3850 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/Extensions.cs @@ -0,0 +1,6 @@ +namespace Ellie.Modules.Gambling.Common.Waifu; + +public class Extensions +{ + +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/WaifuClaimResult.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/WaifuClaimResult.cs new file mode 100644 index 0000000..0513ae1 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/_common/WaifuClaimResult.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace Ellie.Modules.Gambling.Common.Waifu; + +public enum WaifuClaimResult +{ + Success, + NotEnoughFunds, + InsufficientAmount +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/Waifu.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/Waifu.cs new file mode 100644 index 0000000..182c4d9 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/Waifu.cs @@ -0,0 +1,19 @@ +#nullable disable +using Ellie.Db.Models; + +namespace Ellie.Services.Database.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/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuExtensions.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuExtensions.cs new file mode 100644 index 0000000..4112430 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuExtensions.cs @@ -0,0 +1,133 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Ellie.Db.Models; +using Ellie.Services.Database; +using Ellie.Services.Database.Models; + +namespace Ellie.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) + { + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count)); + if (count == 0) + return new List(); + + 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.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/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuInfoStats.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuInfoStats.cs new file mode 100644 index 0000000..944488a --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuInfoStats.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace Ellie.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/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuItem.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuItem.cs new file mode 100644 index 0000000..95491c8 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuItem.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace Ellie.Services.Database.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/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuLbResult.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuLbResult.cs new file mode 100644 index 0000000..4eabfba --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuLbResult.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace Ellie.Services.Database.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/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuUpdate.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuUpdate.cs new file mode 100644 index 0000000..b2b7871 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuUpdate.cs @@ -0,0 +1,17 @@ +#nullable disable +using Ellie.Db.Models; + +namespace Ellie.Services.Database.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/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuUpdateType.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuUpdateType.cs new file mode 100644 index 0000000..68323f4 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/Waifus/db/WaifuUpdateType.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace Ellie.Services.Database.Models; + +public enum WaifuUpdateType +{ + AffinityChanged, + Claimed +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/_common/Decks/QuadDeck.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/Decks/QuadDeck.cs new file mode 100644 index 0000000..f1adb06 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/Decks/QuadDeck.cs @@ -0,0 +1,19 @@ +using Ellie.Econ; + +namespace Ellie.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/Ellie.Bot.Modules.Gambling/Gambling/_common/IGamblingCleanupService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/IGamblingCleanupService.cs new file mode 100644 index 0000000..ede6040 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/IGamblingCleanupService.cs @@ -0,0 +1,67 @@ +using LinqToDB; +using Ellie.Db.Models; +using Ellie.Services.Database.Models; + +namespace Ellie.Bot.Modules.Gambling.Gambling._Common; + +public interface IGamblingCleanupService +{ + Task DeleteWaifus(); + Task DeleteWaifu(ulong userId); + Task DeleteCurrency(); +} + +public class GamblingCleanupService : IGamblingCleanupService +{ + private readonly DbService _db; + + public GamblingCleanupService(DbService db) + { + _db = db; + } + + public async Task DeleteWaifus() + { + await using var ctx = _db.GetDbContext(); + await ctx.Set().DeleteAsync(); + await ctx.Set().DeleteAsync(); + await ctx.Set().DeleteAsync(); + await ctx.SaveChangesAsync(); + } + + public async Task DeleteWaifu(ulong userId) + { + await using var ctx = _db.GetDbContext(); + await ctx.Set() + .Where(x => x.User.UserId == userId) + .DeleteAsync(); + await ctx.Set() + .Where(x => x.WaifuInfo.Waifu.UserId == userId) + .DeleteAsync(); + await ctx.Set() + .Where(x => x.Claimer.UserId == userId) + .UpdateAsync(old => new WaifuInfo() + { + ClaimerId = null, + }); + await ctx.Set() + .Where(x => x.Waifu.UserId == userId) + .DeleteAsync(); + await ctx.SaveChangesAsync(); + } + + public async Task DeleteCurrency() + { + await using var uow = _db.GetDbContext(); + await uow.Set().UpdateAsync(_ => new DiscordUser() + { + CurrencyAmount = 0 + }); + + await uow.Set().DeleteAsync(); + await uow.Set().DeleteAsync(); + await uow.Set().DeleteAsync(); + await uow.SaveChangesAsync(); + } + +} diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/_common/IGamblingService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/IGamblingService.cs new file mode 100644 index 0000000..9e67852 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/IGamblingService.cs @@ -0,0 +1,18 @@ +#nullable disable +using Ellie.Econ.Gambling; +using Ellie.Econ.Gambling.Betdraw; +using Ellie.Econ.Gambling.Rps; +using OneOf; + +namespace Ellie.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? guessValue, byte? guessColor); +} diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/_common/NewGamblingService.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/NewGamblingService.cs new file mode 100644 index 0000000..150e0a2 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/NewGamblingService.cs @@ -0,0 +1,279 @@ +#nullable disable +using Ellie.Econ.Gambling; +using Ellie.Econ.Gambling.Betdraw; +using Ellie.Econ.Gambling.Rps; +using Ellie.Modules.Gambling.Services; +using OneOf; + +namespace Ellie.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) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(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) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(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) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(amount)); + + if (guess > 1) + throw new ArgumentOutOfRangeException(nameof(guess)); + + 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? guessValue, byte? guessColor) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(amount)); + + if (guessColor is null && guessValue is null) + throw new ArgumentNullException(); + + if (guessColor > 1) + throw new ArgumentOutOfRangeException(nameof(guessColor)); + + if (guessValue > 1) + throw new ArgumentOutOfRangeException(nameof(guessValue)); + + 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?)guessValue, (BetdrawColorGuess?)guessColor, 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) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(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) + { + if (count < 1) + throw new ArgumentOutOfRangeException(nameof(count)); + + 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) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(amount)); + + if (pick > 2) + throw new ArgumentOutOfRangeException(nameof(pick)); + + 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; + } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/_common/RollDuelGame.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/RollDuelGame.cs new file mode 100644 index 0000000..4add729 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/RollDuelGame.cs @@ -0,0 +1,139 @@ +#nullable disable +namespace Ellie.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 EllieRandom _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; } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs new file mode 100644 index 0000000..47abcce --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs @@ -0,0 +1,95 @@ +using System.Text.RegularExpressions; +using Ellie.Db; +using Ellie.Db.Models; +using Ellie.Modules.Gambling.Services; +using NCalc; +using OneOf; + +namespace Ellie.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/Ellie.Bot.Modules.Gambling/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs new file mode 100644 index 0000000..49ad387 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs @@ -0,0 +1,21 @@ +using Ellie.Modules.Gambling.Bank; +using Ellie.Modules.Gambling.Services; + +namespace Ellie.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/Ellie.Bot.Modules.Gambling/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs new file mode 100644 index 0000000..e39916f --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs @@ -0,0 +1,57 @@ +#nullable disable +using Ellie.Modules.Gambling.Bank; +using Ellie.Modules.Gambling.Services; + +namespace Ellie.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); + } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Acrophobia/Acrophobia.cs b/src/Ellie.Bot.Modules.Gambling/Games/Acrophobia/Acrophobia.cs new file mode 100644 index 0000000..66b6bdd --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Acrophobia/Acrophobia.cs @@ -0,0 +1,200 @@ +#nullable disable +using CommandLine; +using System.Collections.Immutable; + +namespace Ellie.Modules.Games.Common.Acrophobia; + +public sealed class AcrophobiaGame : IDisposable +{ + public enum Phase + { + Submission, + Voting, + Ended + } + + public enum UserInputResult + { + Submitted, + SubmissionFailed, + Voted, + VotingFailed, + Failed + } + + public event Func OnStarted = delegate { return Task.CompletedTask; }; + + public event Func>, Task> OnVotingStarted = + delegate { return Task.CompletedTask; }; + + public event Func OnUserVoted = delegate { return Task.CompletedTask; }; + + public event Func>, Task> OnEnded = delegate + { + return Task.CompletedTask; + }; + + public Phase CurrentPhase { get; private set; } = Phase.Submission; + public ImmutableArray StartingLetters { get; private set; } + public Options Opts { get; } + + private readonly Dictionary _submissions = new(); + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly EllieRandom _rng; + + private readonly HashSet _usersWhoVoted = new(); + + public AcrophobiaGame(Options options) + { + Opts = options; + _rng = new(); + InitializeStartingLetters(); + } + + public async Task Run() + { + await OnStarted(this); + await Task.Delay(Opts.SubmissionTime * 1000); + await _locker.WaitAsync(); + try + { + if (_submissions.Count == 0) + { + CurrentPhase = Phase.Ended; + await OnVotingStarted(this, ImmutableArray.Create>()); + return; + } + + if (_submissions.Count == 1) + { + CurrentPhase = Phase.Ended; + await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray()); + return; + } + + CurrentPhase = Phase.Voting; + + await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray()); + } + finally { _locker.Release(); } + + await Task.Delay(Opts.VoteTime * 1000); + await _locker.WaitAsync(); + try + { + CurrentPhase = Phase.Ended; + await OnEnded(this, _submissions.ToArray().ToImmutableArray()); + } + finally { _locker.Release(); } + } + + private void InitializeStartingLetters() + { + var wordCount = _rng.Next(3, 6); + + var lettersArr = new char[wordCount]; + + for (var i = 0; i < wordCount; i++) + { + var randChar = (char)_rng.Next(65, 91); + lettersArr[i] = randChar == 'X' ? (char)_rng.Next(65, 88) : randChar; + } + + StartingLetters = lettersArr.ToImmutableArray(); + } + + public async Task UserInput(ulong userId, string userName, string input) + { + var user = new AcrophobiaUser(userId, userName, input.ToLowerInvariant().ToTitleCase()); + + await _locker.WaitAsync(); + try + { + switch (CurrentPhase) + { + case Phase.Submission: + if (_submissions.ContainsKey(user) || !IsValidAnswer(input)) + break; + + _submissions.Add(user, 0); + return true; + case Phase.Voting: + AcrophobiaUser toVoteFor; + if (!int.TryParse(input, out var index) + || --index < 0 + || index >= _submissions.Count + || (toVoteFor = _submissions.ToArray()[index].Key).UserId == user.UserId + || !_usersWhoVoted.Add(userId)) + break; + ++_submissions[toVoteFor]; + _ = Task.Run(() => OnUserVoted(userName)); + return true; + } + + return false; + } + finally + { + _locker.Release(); + } + } + + private bool IsValidAnswer(string input) + { + input = input.ToUpperInvariant(); + + var inputWords = input.Split(' '); + + if (inputWords.Length + != StartingLetters.Length) // number of words must be the same as the number of the starting letters + return false; + + for (var i = 0; i < StartingLetters.Length; i++) + { + var letter = StartingLetters[i]; + + if (!inputWords[i] + .StartsWith(letter.ToString(), StringComparison.InvariantCulture)) // all first letters must match + return false; + } + + return true; + } + + public void Dispose() + { + CurrentPhase = Phase.Ended; + OnStarted = null; + OnEnded = null; + OnUserVoted = null; + OnVotingStarted = null; + _usersWhoVoted.Clear(); + _submissions.Clear(); + _locker.Dispose(); + } + + public class Options : IEllieCommandOptions + { + [Option('s', + "submission-time", + Required = false, + Default = 60, + HelpText = "Time after which the submissions are closed and voting starts.")] + public int SubmissionTime { get; set; } = 60; + + [Option('v', + "vote-time", + Required = false, + Default = 60, + HelpText = "Time after which the voting is closed and the winner is declared.")] + public int VoteTime { get; set; } = 30; + + public void NormalizeOptions() + { + if (SubmissionTime is < 15 or > 300) + SubmissionTime = 60; + if (VoteTime is < 15 or > 120) + VoteTime = 30; + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Acrophobia/AcrophobiaUser.cs b/src/Ellie.Bot.Modules.Gambling/Games/Acrophobia/AcrophobiaUser.cs new file mode 100644 index 0000000..83ba8e7 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Acrophobia/AcrophobiaUser.cs @@ -0,0 +1,22 @@ +#nullable disable +namespace Ellie.Modules.Games.Common.Acrophobia; + +public class AcrophobiaUser +{ + public string UserName { get; } + public ulong UserId { get; } + public string Input { get; } + + public AcrophobiaUser(ulong userId, string userName, string input) + { + UserName = userName; + UserId = userId; + Input = input; + } + + public override int GetHashCode() + => UserId.GetHashCode(); + + public override bool Equals(object obj) + => obj is AcrophobiaUser x ? x.UserId == UserId : false; +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Acrophobia/AcropobiaCommands.cs b/src/Ellie.Bot.Modules.Gambling/Games/Acrophobia/AcropobiaCommands.cs new file mode 100644 index 0000000..6dfb0d3 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Acrophobia/AcropobiaCommands.cs @@ -0,0 +1,140 @@ +#nullable disable +using Ellie.Modules.Games.Common.Acrophobia; +using Ellie.Modules.Games.Services; +using System.Collections.Immutable; + +namespace Ellie.Modules.Games; + +public partial class Games +{ + [Group] + public partial class AcropobiaCommands : EllieModule + { + private readonly DiscordSocketClient _client; + + public AcropobiaCommands(DiscordSocketClient client) + => _client = client; + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task Acrophobia(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new AcrophobiaGame.Options(), args); + var channel = (ITextChannel)ctx.Channel; + + var game = new AcrophobiaGame(options); + if (_service.AcrophobiaGames.TryAdd(channel.Id, game)) + { + try + { + game.OnStarted += Game_OnStarted; + game.OnEnded += Game_OnEnded; + game.OnVotingStarted += Game_OnVotingStarted; + game.OnUserVoted += Game_OnUserVoted; + _client.MessageReceived += ClientMessageReceived; + await game.Run(); + } + finally + { + _client.MessageReceived -= ClientMessageReceived; + _service.AcrophobiaGames.TryRemove(channel.Id, out game); + game?.Dispose(); + } + } + else + await ReplyErrorLocalizedAsync(strs.acro_running); + + Task ClientMessageReceived(SocketMessage msg) + { + if (msg.Channel.Id != ctx.Channel.Id) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + var success = await game.UserInput(msg.Author.Id, msg.Author.ToString(), msg.Content); + if (success) + await msg.DeleteAsync(); + } + catch { } + }); + + return Task.CompletedTask; + } + } + + private Task Game_OnStarted(AcrophobiaGame game) + { + var embed = _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.acrophobia)) + .WithDescription( + GetText(strs.acro_started(Format.Bold(string.Join(".", game.StartingLetters))))) + .WithFooter(GetText(strs.acro_started_footer(game.Opts.SubmissionTime))); + + return ctx.Channel.EmbedAsync(embed); + } + + private Task Game_OnUserVoted(string user) + => SendConfirmAsync(GetText(strs.acrophobia), GetText(strs.acro_vote_cast(Format.Bold(user)))); + + private async Task Game_OnVotingStarted( + AcrophobiaGame game, + ImmutableArray> submissions) + { + if (submissions.Length == 0) + { + await SendErrorAsync(GetText(strs.acrophobia), GetText(strs.acro_ended_no_sub)); + return; + } + + if (submissions.Length == 1) + { + await ctx.Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithDescription(GetText( + strs.acro_winner_only( + Format.Bold(submissions.First().Key.UserName)))) + .WithFooter(submissions.First().Key.Input)); + return; + } + + + var i = 0; + var embed = _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.acrophobia) + " - " + GetText(strs.submissions_closed)) + .WithDescription(GetText(strs.acro_nym_was( + Format.Bold(string.Join(".", game.StartingLetters)) + + "\n" + + $@"-- +{submissions.Aggregate("", (agg, cur) => agg + $"`{++i}.` **{cur.Key.Input}**\n")} +--"))) + .WithFooter(GetText(strs.acro_vote)); + + await ctx.Channel.EmbedAsync(embed); + } + + private async Task Game_OnEnded(AcrophobiaGame game, ImmutableArray> votes) + { + if (!votes.Any() || votes.All(x => x.Value == 0)) + { + await SendErrorAsync(GetText(strs.acrophobia), GetText(strs.acro_no_votes_cast)); + return; + } + + var table = votes.OrderByDescending(v => v.Value); + var winner = table.First(); + var embed = _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.acrophobia)) + .WithDescription(GetText(strs.acro_winner(Format.Bold(winner.Key.UserName), + Format.Bold(winner.Value.ToString())))) + .WithFooter(winner.Key.Input); + + await ctx.Channel.EmbedAsync(embed); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/ChatterbotService.cs b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/ChatterbotService.cs new file mode 100644 index 0000000..06f1e7d --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/ChatterbotService.cs @@ -0,0 +1,217 @@ +#nullable disable +using Ellie.Bot.Common; +using Ellie.Common.ModuleBehaviors; +using Ellie.Db.Models; +using Ellie.Modules.Games.Common; +using Ellie.Modules.Games.Common.ChatterBot; +using Ellie.Modules.Patronage; +using Ellie.Modules.Permissions; + +namespace Ellie.Modules.Games.Services; + +public class ChatterBotService : IExecOnMessage +{ + public ConcurrentDictionary> ChatterBotGuilds { get; } + + public int Priority + => 1; + + private readonly FeatureLimitKey _flKey; + + private readonly DiscordSocketClient _client; + private readonly IPermissionChecker _perms; + private readonly CommandHandler _cmd; + private readonly IBotStrings _strings; + private readonly IBotCredentials _creds; + private readonly IEmbedBuilderService _eb; + private readonly IHttpClientFactory _httpFactory; + private readonly IPatronageService _ps; + private readonly GamesConfigService _gcs; + + public ChatterBotService( + DiscordSocketClient client, + IPermissionChecker perms, + IBot bot, + CommandHandler cmd, + IBotStrings strings, + IHttpClientFactory factory, + IBotCredentials creds, + IEmbedBuilderService eb, + IPatronageService ps, + GamesConfigService gcs) + { + _client = client; + _perms = perms; + _cmd = cmd; + _strings = strings; + _creds = creds; + _eb = eb; + _httpFactory = factory; + _ps = ps; + _perms = perms; + _gcs = gcs; + + _flKey = new FeatureLimitKey() + { + Key = CleverBotResponseStr.CLEVERBOT_RESPONSE, + PrettyName = "Cleverbot Replies" + }; + + ChatterBotGuilds = new(bot.AllGuildConfigs + .Where(gc => gc.CleverbotEnabled) + .ToDictionary(gc => gc.GuildId, + _ => new Lazy(() => CreateSession(), true))); + } + + public IChatterBotSession CreateSession() + { + switch (_gcs.Data.ChatBot) + { + case ChatBotImplementation.Cleverbot: + if (!string.IsNullOrWhiteSpace(_creds.CleverbotApiKey)) + return new OfficialCleverbotSession(_creds.CleverbotApiKey, _httpFactory); + + Log.Information("Cleverbot will not work as the api key is missing."); + return null; + case ChatBotImplementation.Gpt3: + if (!string.IsNullOrWhiteSpace(_creds.Gpt3ApiKey)) + return new OfficialGpt3Session(_creds.Gpt3ApiKey, + _gcs.Data.ChatGpt.Model, + _gcs.Data.ChatGpt.MaxTokens, + _httpFactory); + + Log.Information("Gpt3 will not work as the api key is missing."); + return null; + default: + return null; + } + } + + public string PrepareMessage(IUserMessage msg, out IChatterBotSession cleverbot) + { + var channel = msg.Channel as ITextChannel; + cleverbot = null; + + if (channel is null) + return null; + + if (!ChatterBotGuilds.TryGetValue(channel.Guild.Id, out var lazyCleverbot)) + return null; + + cleverbot = lazyCleverbot.Value; + + var nadekoId = _client.CurrentUser.Id; + var normalMention = $"<@{nadekoId}> "; + var nickMention = $"<@!{nadekoId}> "; + string message; + if (msg.Content.StartsWith(normalMention, StringComparison.InvariantCulture)) + message = msg.Content[normalMention.Length..].Trim(); + else if (msg.Content.StartsWith(nickMention, StringComparison.InvariantCulture)) + message = msg.Content[nickMention.Length..].Trim(); + else + return null; + + return message; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg) + { + if (guild is not SocketGuild sg) + return false; + + try + { + var message = PrepareMessage(usrMsg, out var cbs); + if (message is null || cbs is null) + return false; + + var res = await _perms.CheckAsync(sg, + usrMsg.Channel, + usrMsg.Author, + "games", + CleverBotResponseStr.CLEVERBOT_RESPONSE); + + // todo this needs checking, this might block all messages in a channel if cleverbot is enabled but blocked + // need to check what kind of block it is + // might be the case for other classes using permission checker + if (!res.IsT0) + return true; + + var channel = (ITextChannel)usrMsg.Channel; + var conf = _ps.GetConfig(); + if (!_creds.IsOwner(sg.OwnerId) && conf.IsEnabled) + { + var quota = await _ps.TryGetFeatureLimitAsync(_flKey, sg.OwnerId, 0); + + uint? daily = quota.Quota is int dVal and < 0 + ? (uint)-dVal + : null; + + uint? monthly = quota.Quota is int mVal and >= 0 + ? (uint)mVal + : null; + + var maybeLimit = await _ps.TryIncrementQuotaCounterAsync(sg.OwnerId, + sg.OwnerId == usrMsg.Author.Id, + FeatureType.Limit, + _flKey.Key, + null, + daily, + monthly); + + if (maybeLimit.TryPickT1(out var ql, out var counters)) + { + if (ql.Quota == 0) + { + await channel.SendErrorAsync(_eb, + null!, + text: + "In order to use the cleverbot feature, the owner of this server should be [Patron Tier X](https://patreon.com/join/emotionchild) on patreon.", + footer: + "You may disable the cleverbot feature, and this message via '.cleverbot' command"); + + return true; + } + + await channel.SendErrorAsync(_eb, + null!, + $"You've reached your quota limit of **{ql.Quota}** responses {ql.QuotaPeriod.ToFullName()} for the cleverbot feature.", + footer: "You may wait for the quota reset or ."); + + return true; + } + } + + _ = channel.TriggerTypingAsync(); + var response = await cbs.Think(message); + await channel.SendConfirmAsync(_eb, + title: null, + response.SanitizeMentions(true) + // , footer: counter > 0 ? counter.ToString() : null + ); + + Log.Information(""" + CleverBot Executed + Server: {GuildName} [{GuildId}] + Channel: {ChannelName} [{ChannelId}] + UserId: {Author} [{AuthorId}] + Message: {Content} + """, + guild.Name, + guild.Id, + usrMsg.Channel?.Name, + usrMsg.Channel?.Id, + usrMsg.Author, + usrMsg.Author.Id, + usrMsg.Content); + + return true; + } + catch (Exception ex) + { + Log.Warning(ex, "Error in cleverbot"); + } + + return false; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/CleverBotCommands.cs b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/CleverBotCommands.cs new file mode 100644 index 0000000..c1895f4 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/CleverBotCommands.cs @@ -0,0 +1,48 @@ +#nullable disable +using Ellie.Db; +using Ellie.Modules.Games.Services; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Games; + +public partial class Games +{ + [Group] + public partial class ChatterBotCommands : EllieModule + { + private readonly DbService _db; + + public ChatterBotCommands(DbService db) + => _db = db; + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task CleverBot() + { + var channel = (ITextChannel)ctx.Channel; + + if (_service.ChatterBotGuilds.TryRemove(channel.Guild.Id, out _)) + { + await using (var uow = _db.GetDbContext()) + { + uow.Set().SetCleverbotEnabled(ctx.Guild.Id, false); + await uow.SaveChangesAsync(); + } + + await ReplyConfirmLocalizedAsync(strs.cleverbot_disabled); + return; + } + + _service.ChatterBotGuilds.TryAdd(channel.Guild.Id, new(() => _service.CreateSession(), true)); + + await using (var uow = _db.GetDbContext()) + { + uow.Set().SetCleverbotEnabled(ctx.Guild.Id, true); + await uow.SaveChangesAsync(); + } + + await ReplyConfirmLocalizedAsync(strs.cleverbot_enabled); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/CleverbotResponse.cs b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/CleverbotResponse.cs new file mode 100644 index 0000000..6dc4db1 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/CleverbotResponse.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace Ellie.Modules.Games.Common.ChatterBot; + +public class CleverbotResponse +{ + public string Cs { get; set; } + public string Output { get; set; } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/Gpt3Response.cs b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/Gpt3Response.cs new file mode 100644 index 0000000..6051c94 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/Gpt3Response.cs @@ -0,0 +1,30 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace Ellie.Modules.Games.Common.ChatterBot; + +public class Gpt3Response +{ + [JsonPropertyName("choices")] + public Choice[] Choices { get; set; } +} + +public class Choice +{ + public string Text { get; set; } +} + +public class Gpt3ApiRequest +{ + [JsonPropertyName("model")] + public string Model { get; init; } + + [JsonPropertyName("prompt")] + public string Prompt { get; init; } + + [JsonPropertyName("temperature")] + public int Temperature { get; init; } + + [JsonPropertyName("max_tokens")] + public int MaxTokens { get; init; } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/IChatterBotSession.cs b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/IChatterBotSession.cs new file mode 100644 index 0000000..c16cec3 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/IChatterBotSession.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace Ellie.Modules.Games.Common.ChatterBot; + +public interface IChatterBotSession +{ + Task Think(string input); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/OfficialCleverbotSession.cs b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/OfficialCleverbotSession.cs new file mode 100644 index 0000000..f967b1b --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/OfficialCleverbotSession.cs @@ -0,0 +1,38 @@ +#nullable disable +using Newtonsoft.Json; + +namespace Ellie.Modules.Games.Common.ChatterBot; + +public class OfficialCleverbotSession : IChatterBotSession +{ + private string QueryString + => $"https://www.cleverbot.com/getreply?key={_apiKey}" + "&wrapper=ellie" + "&input={0}" + "&cs={1}"; + + private readonly string _apiKey; + private readonly IHttpClientFactory _httpFactory; + private string cs; + + public OfficialCleverbotSession(string apiKey, IHttpClientFactory factory) + { + _apiKey = apiKey; + _httpFactory = factory; + } + + public async Task Think(string input) + { + using var http = _httpFactory.CreateClient(); + var dataString = await http.GetStringAsync(string.Format(QueryString, input, cs ?? "")); + try + { + var data = JsonConvert.DeserializeObject(dataString); + + cs = data?.Cs; + return data?.Output; + } + catch + { + Log.Warning("Unexpected cleverbot response received: {ResponseString}", dataString); + return null; + } + } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/OfficialGpt3Session.cs b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/OfficialGpt3Session.cs new file mode 100644 index 0000000..4caaeb9 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/ChatterBot/_common/OfficialGpt3Session.cs @@ -0,0 +1,68 @@ +#nullable disable +using Newtonsoft.Json; +using System.Net.Http.Json; + +namespace Ellie.Modules.Games.Common.ChatterBot; + +public class OfficialGpt3Session : IChatterBotSession +{ + private string Uri + => $"https://api.openai.com/v1/completions"; + + private readonly string _apiKey; + private readonly string _model; + private readonly int _maxTokens; + private readonly IHttpClientFactory _httpFactory; + + public OfficialGpt3Session( + string apiKey, + Gpt3Model model, + int maxTokens, + IHttpClientFactory factory) + { + _apiKey = apiKey; + _httpFactory = factory; + switch (model) + { + case Gpt3Model.Ada001: + _model = "text-ada-001"; + break; + case Gpt3Model.Babbage001: + _model = "text-babbage-001"; + break; + case Gpt3Model.Curie001: + _model = "text-curie-001"; + break; + case Gpt3Model.Davinci003: + _model = "text-davinci-003"; + break; + } + + _maxTokens = maxTokens; + } + + public async Task Think(string input) + { + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", _apiKey); + var data = await http.PostAsJsonAsync(Uri, new Gpt3ApiRequest() + { + Model = _model, + Prompt = input, + MaxTokens = _maxTokens, + Temperature = 1, + }); + var dataString = await data.Content.ReadAsStringAsync(); + try + { + var response = JsonConvert.DeserializeObject(dataString); + + return response?.Choices[0]?.Text; + } + catch + { + Log.Warning("Unexpected GPT-3 response received: {ResponseString}", dataString); + return null; + } + } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Games.cs b/src/Ellie.Bot.Modules.Gambling/Games/Games.cs new file mode 100644 index 0000000..c97acdc --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Games.cs @@ -0,0 +1,150 @@ +#nullable disable +using Ellie.Modules.Games.Common; +using Ellie.Modules.Games.Services; + +namespace Ellie.Modules.Games; + +/* more games +- Shiritori +- Simple RPG adventure +*/ +public partial class Games : EllieModule +{ + private readonly IImageCache _images; + private readonly IHttpClientFactory _httpFactory; + private readonly Random _rng = new(); + + public Games(IImageCache images, IHttpClientFactory factory) + { + _images = images; + _httpFactory = factory; + } + + [Cmd] + public async Task Choose([Leftover] string list = null) + { + if (string.IsNullOrWhiteSpace(list)) + return; + var listArr = list.Split(';'); + if (listArr.Length < 2) + return; + var rng = new EllieRandom(); + await SendConfirmAsync("🤔", listArr[rng.Next(0, listArr.Length)]); + } + + [Cmd] + public async Task EightBall([Leftover] string question = null) + { + if (string.IsNullOrWhiteSpace(question)) + return; + + var res = _service.GetEightballResponse(ctx.User.Id, question); + await ctx.Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithDescription(ctx.User.ToString()) + .AddField("❓ " + GetText(strs.question), question) + .AddField("🎱 " + GetText(strs._8ball), res)); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RateGirl([Leftover] IGuildUser usr) + { + var gr = _service.GirlRatings.GetOrAdd(usr.Id, GetGirl); + var originalStream = await gr.Stream; + + if (originalStream is null) + { + await ReplyErrorLocalizedAsync(strs.something_went_wrong); + return; + } + + await using var imgStream = new MemoryStream(); + lock (gr) + { + originalStream.Position = 0; + originalStream.CopyTo(imgStream); + } + + imgStream.Position = 0; + await ctx.Channel.SendFileAsync(imgStream, + $"rating.png", + Format.Bold($"{ctx.User.Mention} Girl Rating For {usr}"), + embed: _eb.Create() + .WithOkColor() + .AddField("Hot", gr.Hot.ToString("F2"), true) + .AddField("Crazy", gr.Crazy.ToString("F2"), true) + .AddField("Advice", gr.Advice) + .WithImageUrl($"attachment://rating.png") + .Build()); + } + + private double NextDouble(double x, double y) + => (_rng.NextDouble() * (y - x)) + x; + + private GirlRating GetGirl(ulong uid) + { + var rng = new EllieRandom(); + + var roll = rng.Next(1, 1001); + + var ratings = _service.Ratings.GetAwaiter().GetResult(); + + double hot; + double crazy; + string advice; + if (roll < 500) + { + hot = NextDouble(0, 5); + crazy = NextDouble(4, 10); + advice = ratings.Nog; + } + else if (roll < 750) + { + hot = NextDouble(5, 8); + crazy = NextDouble(4, (.6 * hot) + 4); + advice = ratings.Fun; + } + else if (roll < 900) + { + hot = NextDouble(5, 10); + crazy = NextDouble((.61 * hot) + 4, 10); + advice = ratings.Dan; + } + else if (roll < 951) + { + hot = NextDouble(8, 10); + crazy = NextDouble(7, (.6 * hot) + 4); + advice = ratings.Dat; + } + else if (roll < 990) + { + hot = NextDouble(8, 10); + crazy = NextDouble(5, 7); + advice = ratings.Wif; + } + else if (roll < 999) + { + hot = NextDouble(8, 10); + crazy = NextDouble(2, 3.99d); + advice = ratings.Tra; + } + else + { + hot = NextDouble(8, 10); + crazy = NextDouble(4, 5); + advice = ratings.Uni; + } + + return new(_images, crazy, hot, roll, advice); + } + + [Cmd] + public async Task Linux(string guhnoo, string loonix) + => await SendConfirmAsync( + $@"I'd just like to interject for moment. What you're refering to as {loonix}, is in fact, {guhnoo}/{loonix}, or as I've recently taken to calling it, {guhnoo} plus {loonix}. {loonix} is not an operating system unto itself, but rather another free component of a fully functioning {guhnoo} system made useful by the {guhnoo} corelibs, shell utilities and vital system components comprising a full OS as defined by POSIX. + +Many computer users run a modified version of the {guhnoo} system every day, without realizing it. Through a peculiar turn of events, the version of {guhnoo} which is widely used today is often called {loonix}, and many of its users are not aware that it is basically the {guhnoo} system, developed by the {guhnoo} Project. + +There really is a {loonix}, and these people are using it, but it is just a part of the system they use. {loonix} is the kernel: the program in the system that allocates the machine's resources to the other programs that you run. The kernel is an essential part of an operating system, but useless by itself; it can only function in the context of a complete operating system. {loonix} is normally used in combination with the {guhnoo} operating system: the whole system is basically {guhnoo} with {loonix} added, or {guhnoo}/{loonix}. All the so-called {loonix} distributions are really distributions of {guhnoo}/{loonix}."); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/GamesConfig.cs b/src/Ellie.Bot.Modules.Gambling/Games/GamesConfig.cs new file mode 100644 index 0000000..9667b8b --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/GamesConfig.cs @@ -0,0 +1,160 @@ +#nullable disable +using Cloneable; +using Ellie.Common.Yml; + +namespace Ellie.Modules.Games.Common; + +[Cloneable] +public sealed partial class GamesConfig : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 2; + + [Comment("Hangman related settings (.hangman command)")] + public HangmanConfig Hangman { get; set; } = new() + { + CurrencyReward = 0 + }; + + [Comment("Trivia related settings (.t command)")] + public TriviaConfig Trivia { get; set; } = new() + { + CurrencyReward = 0, + MinimumWinReq = 1 + }; + + [Comment("List of responses for the .8ball command. A random one will be selected every time")] + public List EightBallResponses { get; set; } = new() + { + "Most definitely yes.", + "For sure.", + "Totally!", + "Of course!", + "As I see it, yes.", + "My sources say yes.", + "Yes.", + "Most likely.", + "Perhaps...", + "Maybe...", + "Hm, not sure.", + "It is uncertain.", + "Ask me again later.", + "Don't count on it.", + "Probably not.", + "Very doubtful.", + "Most likely no.", + "Nope.", + "No.", + "My sources say no.", + "Don't even think about it.", + "Definitely no.", + "NO - It may cause disease contraction!" + }; + + [Comment("List of animals which will be used for the animal race game (.race)")] + public List RaceAnimals { get; set; } = new() + { + new() + { + Icon = "🐼", + Name = "Panda" + }, + new() + { + Icon = "🐻", + Name = "Bear" + }, + new() + { + Icon = "🐧", + Name = "Pengu" + }, + new() + { + Icon = "🐨", + Name = "Koala" + }, + new() + { + Icon = "🐬", + Name = "Dolphin" + }, + new() + { + Icon = "🐞", + Name = "Ladybird" + }, + new() + { + Icon = "🦀", + Name = "Crab" + }, + new() + { + Icon = "🦄", + Name = "Unicorn" + } + }; + + [Comment(@"Which chatbot API should bot use. +'cleverbot' - bot will use Cleverbot API. +'gpt3' - bot will use GPT-3 API")] + public ChatBotImplementation ChatBot { get; set; } = ChatBotImplementation.Gpt3; + + public ChatGptConfig ChatGpt { get; set; } = new(); +} + +[Cloneable] +public sealed partial class ChatGptConfig +{ + [Comment(@"Which GPT-3 Model should bot use. +'ada001' - cheapest and fastest +'babbage001' - 2nd option +'curie001' - 3rd option +'davinci003' - Most expensive, slowest")] + public Gpt3Model Model { get; set; } = Gpt3Model.Ada001; + + [Comment(@"The maximum number of tokens to use per GPT-3 API call")] + public int MaxTokens { get; set; } = 100; +} + +[Cloneable] +public sealed partial class HangmanConfig +{ + [Comment("The amount of currency awarded to the winner of a hangman game")] + public long CurrencyReward { get; set; } +} + +[Cloneable] +public sealed partial class TriviaConfig +{ + [Comment("The amount of currency awarded to the winner of the trivia game.")] + public long CurrencyReward { get; set; } + + [Comment(""" + Users won't be able to start trivia games which have + a smaller win requirement than the one specified by this setting. + """)] + public int MinimumWinReq { get; set; } = 1; +} + +[Cloneable] +public sealed partial class RaceAnimal +{ + public string Icon { get; set; } + public string Name { get; set; } +} + +public enum ChatBotImplementation +{ + Cleverbot, + Gpt3 +} + +public enum Gpt3Model +{ + Ada001, + Babbage001, + Curie001, + Davinci003 +} diff --git a/src/Ellie.Bot.Modules.Gambling/Games/GamesConfigService.cs b/src/Ellie.Bot.Modules.Gambling/Games/GamesConfigService.cs new file mode 100644 index 0000000..18056b7 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/GamesConfigService.cs @@ -0,0 +1,72 @@ +#nullable disable +using Ellie.Common.Configs; +using Ellie.Modules.Games.Common; + +namespace Ellie.Modules.Games.Services; + +public sealed class GamesConfigService : ConfigServiceBase +{ + private const string FILE_PATH = "data/games.yml"; + private static readonly TypedKey _changeKey = new("config.games.updated"); + public override string Name { get; } = "games"; + + public GamesConfigService(IConfigSeria serializer, IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("trivia.min_win_req", + gs => gs.Trivia.MinimumWinReq, + int.TryParse, + ConfigPrinters.ToString, + val => val > 0); + AddParsedProp("trivia.currency_reward", + gs => gs.Trivia.CurrencyReward, + long.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + AddParsedProp("hangman.currency_reward", + gs => gs.Hangman.CurrencyReward, + long.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("chatbot", + gs => gs.ChatBot, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + AddParsedProp("gpt.model", + gs => gs.ChatGpt.Model, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + AddParsedProp("gpt.max_tokens", + gs => gs.ChatGpt.MaxTokens, + int.TryParse, + ConfigPrinters.ToString, + val => val > 0); + + Migrate(); + } + + private void Migrate() + { + if (data.Version < 1) + { + ModifyConfig(c => + { + c.Version = 1; + c.Hangman = new() + { + CurrencyReward = 0 + }; + }); + } + + if (data.Version < 2) + { + ModifyConfig(c => + { + c.Version = 2; + c.ChatBot = ChatBotImplementation.Cleverbot; + }); + } + } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Games/GamesService.cs b/src/Ellie.Bot.Modules.Gambling/Games/GamesService.cs new file mode 100644 index 0000000..ed09034 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/GamesService.cs @@ -0,0 +1,118 @@ +#nullable disable +using Microsoft.Extensions.Caching.Memory; +using Ellie.Common.ModuleBehaviors; +using Ellie.Modules.Games.Common; +using Ellie.Modules.Games.Common.Acrophobia; +using Ellie.Modules.Games.Common.Nunchi; +using Newtonsoft.Json; + +namespace Ellie.Modules.Games.Services; + +public class GamesService : IEService, IReadyExecutor +{ + private const string TYPING_ARTICLES_PATH = "data/typing_articles3.json"; + + public ConcurrentDictionary GirlRatings { get; } = new(); + + public IReadOnlyList EightBallResponses + => _gamesConfig.Data.EightBallResponses; + + public List TypingArticles { get; } = new(); + + //channelId, game + public ConcurrentDictionary AcrophobiaGames { get; } = new(); + public Dictionary TicTacToeGames { get; } = new(); + public ConcurrentDictionary RunningContests { get; } = new(); + public ConcurrentDictionary NunchiGames { get; } = new(); + + public AsyncLazy Ratings { get; } + private readonly GamesConfigService _gamesConfig; + + private readonly IHttpClientFactory _httpFactory; + private readonly IMemoryCache _8BallCache; + private readonly Random _rng; + + public GamesService(GamesConfigService gamesConfig, IHttpClientFactory httpFactory) + { + _gamesConfig = gamesConfig; + _httpFactory = httpFactory; + _8BallCache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = 500_000 + }); + + Ratings = new(GetRatingTexts); + _rng = new EllieRandom(); + + try + { + TypingArticles = JsonConvert.DeserializeObject>(File.ReadAllText(TYPING_ARTICLES_PATH)); + } + catch (Exception ex) + { + Log.Warning(ex, "Error while loading typing articles: {ErrorMessage}", ex.Message); + TypingArticles = new(); + } + } + + public async Task OnReadyAsync() + { + // reset rating once a day + using var timer = new PeriodicTimer(TimeSpan.FromDays(1)); + while (await timer.WaitForNextTickAsync()) + GirlRatings.Clear(); + } + + private async Task GetRatingTexts() + { + using var http = _httpFactory.CreateClient(); + var text = await http.GetStringAsync( + "https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/rategirl/rates.json"); + return JsonConvert.DeserializeObject(text); + } + + public void AddTypingArticle(IUser user, string text) + { + TypingArticles.Add(new() + { + Source = user.ToString(), + Extra = $"Text added on {DateTime.UtcNow} by {user}.", + Text = text.SanitizeMentions(true) + }); + + File.WriteAllText(TYPING_ARTICLES_PATH, JsonConvert.SerializeObject(TypingArticles)); + } + + public string GetEightballResponse(ulong userId, string question) + => _8BallCache.GetOrCreate($"8ball:{userId}:{question}", + e => + { + e.Size = question.Length; + e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12); + return EightBallResponses[_rng.Next(0, EightBallResponses.Count)]; + }); + + public TypingArticle RemoveTypingArticle(int index) + { + var articles = TypingArticles; + if (index < 0 || index >= articles.Count) + return null; + + var removed = articles[index]; + TypingArticles.RemoveAt(index); + + File.WriteAllText(TYPING_ARTICLES_PATH, JsonConvert.SerializeObject(articles)); + return removed; + } + + public class RatingTexts + { + public string Nog { get; set; } + public string Tra { get; set; } + public string Fun { get; set; } + public string Uni { get; set; } + public string Wif { get; set; } + public string Dat { get; set; } + public string Dan { get; set; } + } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Games/GirlRating.cs b/src/Ellie.Bot.Modules.Gambling/Games/GirlRating.cs new file mode 100644 index 0000000..a391e4b --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/GirlRating.cs @@ -0,0 +1,61 @@ +#nullable disable +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using Image = SixLabors.ImageSharp.Image; + +namespace Ellie.Modules.Games.Common; + +public class GirlRating +{ + public double Crazy { get; } + public double Hot { get; } + public int Roll { get; } + public string Advice { get; } + + public AsyncLazy Stream { get; } + private readonly IImageCache _images; + + public GirlRating( + IImageCache images, + double crazy, + double hot, + int roll, + string advice) + { + _images = images; + Crazy = crazy; + Hot = hot; + Roll = roll; + Advice = advice; // convenient to have it here, even though atm there are only few different ones. + + Stream = new(async () => + { + try + { + var bgBytes = await _images.GetRategirlBgAsync(); + using var img = Image.Load(bgBytes); + const int minx = 35; + const int miny = 385; + const int length = 345; + + var pointx = (int)(minx + (length * (Hot / 10))); + var pointy = (int)(miny - (length * ((Crazy - 4) / 6))); + + var dotBytes = await _images.GetRategirlDotAsync(); + using (var pointImg = Image.Load(dotBytes)) + { + img.Mutate(x => x.DrawImage(pointImg, new(pointx - 10, pointy - 10), new GraphicsOptions())); + } + + var imgStream = new MemoryStream(); + img.SaveAsPng(imgStream); + return imgStream; + } + catch (Exception ex) + { + Log.Warning(ex, "Error getting RateGirl image"); + return null; + } + }); + } +} diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Hangman/DefaultHangmanSource.cs b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/DefaultHangmanSource.cs new file mode 100644 index 0000000..16c400b --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/DefaultHangmanSource.cs @@ -0,0 +1,64 @@ +using Ellie.Common.Yml; +using System.Diagnostics.CodeAnalysis; + +namespace Ellie.Modules.Games.Hangman; + +public sealed class DefaultHangmanSource : IHangmanSource +{ + private IReadOnlyDictionary termsDict = new Dictionary(); + private readonly Random _rng; + + public DefaultHangmanSource() + { + _rng = new EllieRandom(); + Reload(); + } + + public void Reload() + { + if (!Directory.Exists("data/hangman")) + { + Log.Error("Hangman game won't work. Folder 'data/hangman' is missing"); + return; + } + + var qs = new Dictionary(); + foreach (var file in Directory.EnumerateFiles("data/hangman/", "*.yml")) + { + try + { + var data = Yaml.Deserializer.Deserialize(File.ReadAllText(file)); + qs[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = data; + } + catch (Exception ex) + { + Log.Error(ex, "Loading {HangmanFile} failed", file); + } + } + + termsDict = qs; + + Log.Information("Loaded {HangmanCategoryCount} hangman categories", qs.Count); + } + + public IReadOnlyCollection GetCategories() + => termsDict.Keys.ToList(); + + public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term) + { + if (category is null) + { + var cats = GetCategories(); + category = cats.ElementAt(_rng.Next(0, cats.Count)); + } + + if (termsDict.TryGetValue(category, out var terms)) + { + term = terms[_rng.Next(0, terms.Length)]; + return true; + } + + term = null; + return false; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Hangman/HangmanCommands.cs b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/HangmanCommands.cs new file mode 100644 index 0000000..624dccf --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/HangmanCommands.cs @@ -0,0 +1,76 @@ +using Ellie.Modules.Games.Hangman; + +namespace Ellie.Modules.Games; + +public partial class Games +{ + [Group] + public partial class HangmanCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Hangmanlist() + => await SendConfirmAsync(GetText(strs.hangman_types(prefix)), _service.GetHangmanTypes().Join('\n')); + + private static string Draw(HangmanGame.State state) + => $""" + . ┌─────┐ + .┃...............┋ + .┃...............┋ + .┃{(state.Errors > 0 ? ".............😲" : "")} + .┃{(state.Errors > 1 ? "............./" : "")} {(state.Errors > 2 ? "|" : "")} {(state.Errors > 3 ? "\\" : "")} + .┃{(state.Errors > 4 ? "............../" : "")} {(state.Errors > 5 ? "\\" : "")} + /-\ + """; + + public static IEmbedBuilder GetEmbed(IEmbedBuilderService eb, HangmanGame.State state) + { + if (state.Phase == HangmanGame.Phase.Running) + { + return eb.Create() + .WithOkColor() + .AddField("Hangman", Draw(state)) + .AddField("Guess", Format.Code(state.Word)) + .WithFooter(state.MissedLetters.Join(' ')); + } + + if (state.Phase == HangmanGame.Phase.Ended && state.Failed) + { + return eb.Create() + .WithErrorColor() + .AddField("Hangman", Draw(state)) + .AddField("Guess", Format.Code(state.Word)) + .WithFooter(state.MissedLetters.Join(' ')); + } + + return eb.Create() + .WithOkColor() + .AddField("Hangman", Draw(state)) + .AddField("Guess", Format.Code(state.Word)) + .WithFooter(state.MissedLetters.Join(' ')); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Hangman([Leftover] string? type = null) + { + if (!_service.StartHangman(ctx.Channel.Id, type, out var hangman)) + { + await ReplyErrorLocalizedAsync(strs.hangman_running); + return; + } + + var eb = GetEmbed(_eb, hangman); + eb.WithDescription(GetText(strs.hangman_game_started)); + await ctx.Channel.EmbedAsync(eb); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task HangmanStop() + { + if (await _service.StopHangman(ctx.Channel.Id)) + await ReplyConfirmLocalizedAsync(strs.hangman_stopped); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Hangman/HangmanGame.cs b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/HangmanGame.cs new file mode 100644 index 0000000..dabe62e --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/HangmanGame.cs @@ -0,0 +1,112 @@ +#nullable disable + +namespace Ellie.Modules.Games.Hangman; + +public sealed class HangmanGame +{ + public enum GuessResult { NoAction, AlreadyTried, Incorrect, Guess, Win } + + public enum Phase { Running, Ended } + + private Phase CurrentPhase { get; set; } + + private readonly HashSet _incorrect = new(); + private readonly HashSet _correct = new(); + private readonly HashSet _remaining = new(); + + private readonly string _word; + private readonly string _imageUrl; + + public HangmanGame(HangmanTerm term) + { + _word = term.Word; + _imageUrl = term.ImageUrl; + + _remaining = _word.ToLowerInvariant().Where(x => char.IsLetter(x)).Select(char.ToLowerInvariant).ToHashSet(); + } + + public State GetState(GuessResult guessResult = GuessResult.NoAction) + => new(_incorrect.Count, + CurrentPhase, + CurrentPhase == Phase.Ended ? _word : GetScrambledWord(), + guessResult, + _incorrect.ToList(), + CurrentPhase == Phase.Ended ? _imageUrl : string.Empty); + + private string GetScrambledWord() + { + Span output = stackalloc char[_word.Length * 2]; + for (var i = 0; i < _word.Length; i++) + { + var ch = _word[i]; + if (ch == ' ') + output[i * 2] = ' '; + if (!char.IsLetter(ch) || !_remaining.Contains(char.ToLowerInvariant(ch))) + output[i * 2] = ch; + else + output[i * 2] = '_'; + + output[(i * 2) + 1] = ' '; + } + + return new(output); + } + + public State Guess(string guess) + { + if (CurrentPhase != Phase.Running) + return GetState(); + + guess = guess.Trim(); + if (guess.Length > 1) + { + if (guess.Equals(_word, StringComparison.InvariantCultureIgnoreCase)) + { + CurrentPhase = Phase.Ended; + return GetState(GuessResult.Win); + } + + return GetState(); + } + + var charGuess = guess[0]; + if (!char.IsLetter(charGuess)) + return GetState(); + + if (_incorrect.Contains(charGuess) || _correct.Contains(charGuess)) + return GetState(GuessResult.AlreadyTried); + + if (_remaining.Remove(charGuess)) + { + if (_remaining.Count == 0) + { + CurrentPhase = Phase.Ended; + return GetState(GuessResult.Win); + } + + _correct.Add(charGuess); + return GetState(GuessResult.Guess); + } + + _incorrect.Add(charGuess); + if (_incorrect.Count > 5) + { + CurrentPhase = Phase.Ended; + return GetState(GuessResult.Incorrect); + } + + return GetState(GuessResult.Incorrect); + } + + public record State( + int Errors, + Phase Phase, + string Word, + GuessResult GuessResult, + List MissedLetters, + string ImageUrl) + { + public bool Failed + => Errors > 5; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Hangman/HangmanService.cs b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/HangmanService.cs new file mode 100644 index 0000000..4383b81 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/HangmanService.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.Caching.Memory; +using Ellie.Common.ModuleBehaviors; +using Ellie.Modules.Games.Services; +using System.Diagnostics.CodeAnalysis; + +namespace Ellie.Modules.Games.Hangman; + +public sealed class HangmanService : IHangmanService, IExecNoCommand +{ + private readonly ConcurrentDictionary _hangmanGames = new(); + private readonly IHangmanSource _source; + private readonly IEmbedBuilderService _eb; + private readonly GamesConfigService _gcs; + private readonly ICurrencyService _cs; + private readonly IMemoryCache _cdCache; + private readonly object _locker = new(); + + public HangmanService( + IHangmanSource source, + IEmbedBuilderService eb, + GamesConfigService gcs, + ICurrencyService cs, + IMemoryCache cdCache) + { + _source = source; + _eb = eb; + _gcs = gcs; + _cs = cs; + _cdCache = cdCache; + } + + public bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? state) + { + state = null; + if (!_source.GetTerm(category, out var term)) + return false; + + + var game = new HangmanGame(term); + lock (_locker) + { + var hc = _hangmanGames.GetOrAdd(channelId, game); + if (hc == game) + { + state = hc.GetState(); + return true; + } + + return false; + } + } + + public ValueTask StopHangman(ulong channelId) + { + lock (_locker) + { + if (_hangmanGames.TryRemove(channelId, out _)) + return new(true); + } + + return new(false); + } + + public IReadOnlyCollection GetHangmanTypes() + => _source.GetCategories(); + + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + if (_hangmanGames.ContainsKey(msg.Channel.Id)) + { + if (string.IsNullOrWhiteSpace(msg.Content)) + return; + + if (_cdCache.TryGetValue(msg.Author.Id, out _)) + return; + + HangmanGame.State state; + long rew = 0; + lock (_locker) + { + if (!_hangmanGames.TryGetValue(msg.Channel.Id, out var game)) + return; + + state = game.Guess(msg.Content.ToLowerInvariant()); + + if (state.GuessResult == HangmanGame.GuessResult.NoAction) + return; + + if (state.GuessResult is HangmanGame.GuessResult.Incorrect or HangmanGame.GuessResult.AlreadyTried) + { + _cdCache.Set(msg.Author.Id, + string.Empty, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3) + }); + } + + if (state.Phase == HangmanGame.Phase.Ended) + { + if (_hangmanGames.TryRemove(msg.Channel.Id, out _)) + rew = _gcs.Data.Hangman.CurrencyReward; + } + } + + if (rew > 0) + await _cs.AddAsync(msg.Author, rew, new("hangman", "win")); + + await SendState((ITextChannel)msg.Channel, msg.Author, msg.Content, state); + } + } + + private Task SendState( + ITextChannel channel, + IUser user, + string content, + HangmanGame.State state) + { + var embed = Games.HangmanCommands.GetEmbed(_eb, state); + if (state.GuessResult == HangmanGame.GuessResult.Guess) + embed.WithDescription($"{user} guessed the letter {content}!").WithOkColor(); + else if (state.GuessResult == HangmanGame.GuessResult.Incorrect && state.Failed) + embed.WithDescription($"{user} Letter {content} doesn't exist! Game over!").WithErrorColor(); + else if (state.GuessResult == HangmanGame.GuessResult.Incorrect) + embed.WithDescription($"{user} Letter {content} doesn't exist!").WithErrorColor(); + else if (state.GuessResult == HangmanGame.GuessResult.AlreadyTried) + embed.WithDescription($"{user} Letter {content} has already been used.").WithPendingColor(); + else if (state.GuessResult == HangmanGame.GuessResult.Win) + embed.WithDescription($"{user} won!").WithOkColor(); + + if (!string.IsNullOrWhiteSpace(state.ImageUrl) && Uri.IsWellFormedUriString(state.ImageUrl, UriKind.Absolute)) + embed.WithImageUrl(state.ImageUrl); + + return channel.EmbedAsync(embed); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Hangman/HangmanTerm.cs b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/HangmanTerm.cs new file mode 100644 index 0000000..ce24982 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/HangmanTerm.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace Ellie.Modules.Games.Hangman; + +public sealed class HangmanTerm +{ + public string Word { get; set; } + public string ImageUrl { get; set; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Hangman/IHangmanService.cs b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/IHangmanService.cs new file mode 100644 index 0000000..bd603eb --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/IHangmanService.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Ellie.Modules.Games.Hangman; + +public interface IHangmanService +{ + bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? hangmanController); + ValueTask StopHangman(ulong channelId); + IReadOnlyCollection GetHangmanTypes(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Hangman/IHangmanSource.cs b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/IHangmanSource.cs new file mode 100644 index 0000000..c5c9c5c --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Hangman/IHangmanSource.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Ellie.Modules.Games.Hangman; + +public interface IHangmanSource : IEService +{ + public IReadOnlyCollection GetCategories(); + public void Reload(); + public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Nunchi/Nunchi.cs b/src/Ellie.Bot.Modules.Gambling/Games/Nunchi/Nunchi.cs new file mode 100644 index 0000000..8dcd787 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Nunchi/Nunchi.cs @@ -0,0 +1,183 @@ +#nullable disable +using System.Collections.Immutable; + +namespace Ellie.Modules.Games.Common.Nunchi; + +public sealed class NunchiGame : IDisposable +{ + public enum Phase + { + Joining, + Playing, + WaitingForNextRound, + Ended + } + + private const int KILL_TIMEOUT = 20 * 1000; + private const int NEXT_ROUND_TIMEOUT = 5 * 1000; + + public event Func OnGameStarted; + public event Func OnRoundStarted; + public event Func OnUserGuessed; + public event Func OnRoundEnded; // tuple of the user who failed + public event Func OnGameEnded; // name of the user who won + + public int CurrentNumber { get; private set; } = new EllieRandom().Next(0, 100); + public Phase CurrentPhase { get; private set; } = Phase.Joining; + + public ImmutableArray<(ulong Id, string Name)> Participants + => participants.ToImmutableArray(); + + public int ParticipantCount + => participants.Count; + + private readonly SemaphoreSlim _locker = new(1, 1); + + private HashSet<(ulong Id, string Name)> participants = new(); + private readonly HashSet<(ulong Id, string Name)> _passed = new(); + private Timer killTimer; + + public NunchiGame(ulong creatorId, string creatorName) + => participants.Add((creatorId, creatorName)); + + public async Task Join(ulong userId, string userName) + { + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.Joining) + return false; + + return participants.Add((userId, userName)); + } + finally { _locker.Release(); } + } + + public async Task Initialize() + { + CurrentPhase = Phase.Joining; + await Task.Delay(30000); + await _locker.WaitAsync(); + try + { + if (participants.Count < 3) + { + CurrentPhase = Phase.Ended; + return false; + } + + killTimer = new(async _ => + { + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.Playing) + return; + + //if some players took too long to type a number, boot them all out and start a new round + participants = new HashSet<(ulong, string)>(_passed); + EndRound(); + } + finally { _locker.Release(); } + }, + null, + KILL_TIMEOUT, + KILL_TIMEOUT); + + CurrentPhase = Phase.Playing; + _ = OnGameStarted?.Invoke(this); + _ = OnRoundStarted?.Invoke(this, CurrentNumber); + return true; + } + finally { _locker.Release(); } + } + + public async Task Input(ulong userId, string userName, int input) + { + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.Playing) + return; + + var userTuple = (Id: userId, Name: userName); + + // if the user is not a member of the race, + // or he already successfully typed the number + // ignore the input + if (!participants.Contains(userTuple) || !_passed.Add(userTuple)) + return; + + //if the number is correct + if (CurrentNumber == input - 1) + { + //increment current number + ++CurrentNumber; + if (_passed.Count == participants.Count - 1) + { + // if only n players are left, and n - 1 type the correct number, round is over + + // if only 2 players are left, game is over + if (participants.Count == 2) + { + killTimer.Change(Timeout.Infinite, Timeout.Infinite); + CurrentPhase = Phase.Ended; + _ = OnGameEnded?.Invoke(this, userTuple.Name); + } + else // else just start the new round without the user who was the last + { + var failure = participants.Except(_passed).First(); + + OnUserGuessed?.Invoke(this); + EndRound(failure); + return; + } + } + + OnUserGuessed?.Invoke(this); + } + else + { + //if the user failed + + EndRound(userTuple); + } + } + finally { _locker.Release(); } + } + + private void EndRound((ulong, string)? failure = null) + { + killTimer.Change(KILL_TIMEOUT, KILL_TIMEOUT); + CurrentNumber = new EllieRandom().Next(0, 100); // reset the counter + _passed.Clear(); // reset all users who passed (new round starts) + if (failure is not null) + participants.Remove(failure.Value); // remove the dude who failed from the list of players + + _ = OnRoundEnded?.Invoke(this, failure); + if (participants.Count <= 1) // means we have a winner or everyone was booted out + { + killTimer.Change(Timeout.Infinite, Timeout.Infinite); + CurrentPhase = Phase.Ended; + _ = OnGameEnded?.Invoke(this, participants.Count > 0 ? participants.First().Name : null); + return; + } + + CurrentPhase = Phase.WaitingForNextRound; + Task.Run(async () => + { + await Task.Delay(NEXT_ROUND_TIMEOUT); + CurrentPhase = Phase.Playing; + _ = OnRoundStarted?.Invoke(this, CurrentNumber); + }); + } + + public void Dispose() + { + OnGameEnded = null; + OnGameStarted = null; + OnRoundEnded = null; + OnRoundStarted = null; + OnUserGuessed = null; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Nunchi/NunchiCommands.cs b/src/Ellie.Bot.Modules.Gambling/Games/Nunchi/NunchiCommands.cs new file mode 100644 index 0000000..fa92b97 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Nunchi/NunchiCommands.cs @@ -0,0 +1,111 @@ +#nullable disable +using Ellie.Modules.Games.Common.Nunchi; +using Ellie.Modules.Games.Services; + +namespace Ellie.Modules.Games; + +public partial class Games +{ + [Group] + public partial class NunchiCommands : EllieModule + { + private readonly DiscordSocketClient _client; + + public NunchiCommands(DiscordSocketClient client) + => _client = client; + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Nunchi() + { + var newNunchi = new NunchiGame(ctx.User.Id, ctx.User.ToString()); + NunchiGame nunchi; + + //if a game was already active + if ((nunchi = _service.NunchiGames.GetOrAdd(ctx.Guild.Id, newNunchi)) != newNunchi) + { + // join it + if (!await nunchi.Join(ctx.User.Id, ctx.User.ToString())) + // if you failed joining, that means game is running or just ended + // await ReplyErrorLocalized("nunchi_already_started"); + return; + + await ReplyErrorLocalizedAsync(strs.nunchi_joined(nunchi.ParticipantCount)); + return; + } + + + try { await ConfirmLocalizedAsync(strs.nunchi_created); } + catch { } + + nunchi.OnGameEnded += NunchiOnGameEnded; + //nunchi.OnGameStarted += Nunchi_OnGameStarted; + nunchi.OnRoundEnded += Nunchi_OnRoundEnded; + nunchi.OnUserGuessed += Nunchi_OnUserGuessed; + nunchi.OnRoundStarted += Nunchi_OnRoundStarted; + _client.MessageReceived += ClientMessageReceived; + + var success = await nunchi.Initialize(); + if (!success) + { + if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game)) + game.Dispose(); + await ConfirmLocalizedAsync(strs.nunchi_failed_to_start); + } + + Task ClientMessageReceived(SocketMessage arg) + { + _ = Task.Run(async () => + { + if (arg.Channel.Id != ctx.Channel.Id) + return; + + if (!int.TryParse(arg.Content, out var number)) + return; + try + { + await nunchi.Input(arg.Author.Id, arg.Author.ToString(), number); + } + catch + { + } + }); + return Task.CompletedTask; + } + + Task NunchiOnGameEnded(NunchiGame arg1, string arg2) + { + if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game)) + { + _client.MessageReceived -= ClientMessageReceived; + game.Dispose(); + } + + if (arg2 is null) + return ConfirmLocalizedAsync(strs.nunchi_ended_no_winner); + return ConfirmLocalizedAsync(strs.nunchi_ended(Format.Bold(arg2))); + } + } + + private Task Nunchi_OnRoundStarted(NunchiGame arg, int cur) + => ConfirmLocalizedAsync(strs.nunchi_round_started(Format.Bold(arg.ParticipantCount.ToString()), + Format.Bold(cur.ToString()))); + + private Task Nunchi_OnUserGuessed(NunchiGame arg) + => ConfirmLocalizedAsync(strs.nunchi_next_number(Format.Bold(arg.CurrentNumber.ToString()))); + + private Task Nunchi_OnRoundEnded(NunchiGame arg1, (ulong Id, string Name)? arg2) + { + if (arg2.HasValue) + return ConfirmLocalizedAsync(strs.nunchi_round_ended(Format.Bold(arg2.Value.Name))); + return ConfirmLocalizedAsync(strs.nunchi_round_ended_boot( + Format.Bold("\n" + + string.Join("\n, ", + arg1.Participants.Select(x + => x.Name))))); // this won't work if there are too many users + } + + private Task Nunchi_OnGameStarted(NunchiGame arg) + => ConfirmLocalizedAsync(strs.nunchi_started(Format.Bold(arg.ParticipantCount.ToString()))); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Polls/PollCommands.cs b/src/Ellie.Bot.Modules.Gambling/Games/Polls/PollCommands.cs new file mode 100644 index 0000000..1d3d06b --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Polls/PollCommands.cs @@ -0,0 +1,102 @@ +#nullable disable +using Ellie.Modules.Games.Services; +using Ellie.Services.Database.Models; +using System.Text; + +namespace Ellie.Modules.Games; + +public partial class Games +{ + [Group] + public partial class PollCommands : EllieModule + { + private readonly DiscordSocketClient _client; + + public PollCommands(DiscordSocketClient client) + => _client = client; + + [Cmd] + [UserPerm(GuildPerm.ManageMessages)] + [RequireContext(ContextType.Guild)] + public async Task Poll([Leftover] string arg) + { + if (string.IsNullOrWhiteSpace(arg)) + return; + + var poll = _service.CreatePoll(ctx.Guild.Id, ctx.Channel.Id, arg); + if (poll is null) + { + await ReplyErrorLocalizedAsync(strs.poll_invalid_input); + return; + } + + if (_service.StartPoll(poll)) + { + await ctx.Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.poll_created(ctx.User.ToString()))) + .WithDescription(Format.Bold(poll.Question) + + "\n\n" + + string.Join("\n", + poll.Answers.Select(x + => $"`{x.Index + 1}.` {Format.Bold(x.Text)}")))); + } + else + await ReplyErrorLocalizedAsync(strs.poll_already_running); + } + + [Cmd] + [UserPerm(GuildPerm.ManageMessages)] + [RequireContext(ContextType.Guild)] + public async Task PollStats() + { + if (!_service.ActivePolls.TryGetValue(ctx.Guild.Id, out var pr)) + return; + + await ctx.Channel.EmbedAsync(GetStats(pr.Poll, GetText(strs.current_poll_results))); + } + + [Cmd] + [UserPerm(GuildPerm.ManageMessages)] + [RequireContext(ContextType.Guild)] + public async Task Pollend() + { + Poll p; + if ((p = _service.StopPoll(ctx.Guild.Id)) is null) + return; + + var embed = GetStats(p, GetText(strs.poll_closed)); + await ctx.Channel.EmbedAsync(embed); + } + + public IEmbedBuilder GetStats(Poll poll, string title) + { + var results = poll.Votes.GroupBy(kvp => kvp.VoteIndex).ToDictionary(x => x.Key, x => x.Sum(_ => 1)); + + var totalVotesCast = results.Sum(x => x.Value); + + var eb = _eb.Create().WithTitle(title); + + var sb = new StringBuilder().AppendLine(Format.Bold(poll.Question)).AppendLine(); + + var stats = poll.Answers.Select(x => + { + results.TryGetValue(x.Index, out var votes); + + return (x.Index, votes, x.Text); + }) + .OrderByDescending(x => x.votes) + .ToArray(); + + for (var i = 0; i < stats.Length; i++) + { + var (index, votes, text) = stats[i]; + sb.AppendLine(GetText(strs.poll_result(index + 1, Format.Bold(text), Format.Bold(votes.ToString())))); + } + + return eb.WithDescription(sb.ToString()) + .WithFooter(GetText(strs.x_votes_cast(totalVotesCast))) + .WithOkColor(); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Polls/PollExtensions.cs b/src/Ellie.Bot.Modules.Gambling/Games/Polls/PollExtensions.cs new file mode 100644 index 0000000..13a4f90 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Polls/PollExtensions.cs @@ -0,0 +1,36 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Services.Database; +using Ellie.Services.Database.Models; + +namespace Ellie.Db; + +public static class PollExtensions +{ + public static IEnumerable GetAllPolls(this DbSet polls) + => polls.Include(x => x.Answers) + .Include(x => x.Votes) + .ToArray(); + + public static void RemovePoll(this DbContext ctx, int id) + { + var p = ctx.Set().Include(x => x.Answers).Include(x => x.Votes).FirstOrDefault(x => x.Id == id); + + if (p is null) + return; + + if (p.Votes is not null) + { + ctx.RemoveRange(p.Votes); + p.Votes.Clear(); + } + + if (p.Answers is not null) + { + ctx.RemoveRange(p.Answers); + p.Answers.Clear(); + } + + ctx.Set().Remove(p); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Polls/PollRunner.cs b/src/Ellie.Bot.Modules.Gambling/Games/Polls/PollRunner.cs new file mode 100644 index 0000000..2f80d8e --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Polls/PollRunner.cs @@ -0,0 +1,63 @@ +#nullable disable +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Games.Common; + +public class PollRunner +{ + public event Func OnVoted; + public Poll Poll { get; } + private readonly DbService _db; + + private readonly SemaphoreSlim _locker = new(1, 1); + + public PollRunner(DbService db, Poll poll) + { + _db = db; + Poll = poll; + } + + public async Task TryVote(IUserMessage msg) + { + PollVote voteObj; + await _locker.WaitAsync(); + try + { + // has to be a user message + // channel must be the same the poll started in + if (msg is null || msg.Author.IsBot || msg.Channel.Id != Poll.ChannelId) + return false; + + // has to be an integer + if (!int.TryParse(msg.Content, out var vote)) + return false; + --vote; + if (vote < 0 || vote >= Poll.Answers.Count) + return false; + + var usr = msg.Author as IGuildUser; + if (usr is null) + return false; + + voteObj = new() + { + UserId = msg.Author.Id, + VoteIndex = vote + }; + if (!Poll.Votes.Add(voteObj)) + return false; + + _ = OnVoted?.Invoke(msg, usr); + } + finally { _locker.Release(); } + + await using var uow = _db.GetDbContext(); + var trackedPoll = uow.Set().FirstOrDefault(x => x.Id == Poll.Id); + trackedPoll.Votes.Add(voteObj); + uow.SaveChanges(); + return true; + } + + public void End() + => OnVoted = null; +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Polls/PollService.cs b/src/Ellie.Bot.Modules.Gambling/Games/Polls/PollService.cs new file mode 100644 index 0000000..8b066d6 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Polls/PollService.cs @@ -0,0 +1,140 @@ +#nullable disable +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using Ellie.Modules.Games.Common; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Games.Services; + +public class PollService : IExecOnMessage +{ + public ConcurrentDictionary ActivePolls { get; } = new(); + + public int Priority + => 5; + + private readonly DbService _db; + private readonly IBotStrings _strs; + private readonly IEmbedBuilderService _eb; + + public PollService(DbService db, IBotStrings strs, IEmbedBuilderService eb) + { + _db = db; + _strs = strs; + _eb = eb; + + using var uow = db.GetDbContext(); + ActivePolls = uow.Set().GetAllPolls() + .ToDictionary(x => x.GuildId, + x => + { + var pr = new PollRunner(db, x); + pr.OnVoted += Pr_OnVoted; + return pr; + }) + .ToConcurrent(); + } + + public Poll CreatePoll(ulong guildId, ulong channelId, string input) + { + if (string.IsNullOrWhiteSpace(input) || !input.Contains(";")) + return null; + var data = input.Split(';'); + if (data.Length < 3) + return null; + + var col = new IndexedCollection(data.Skip(1) + .Select(x => new PollAnswer + { + Text = x + })); + + return new() + { + Answers = col, + Question = data[0], + ChannelId = channelId, + GuildId = guildId, + Votes = new() + }; + } + + public bool StartPoll(Poll p) + { + var pr = new PollRunner(_db, p); + if (ActivePolls.TryAdd(p.GuildId, pr)) + { + using (var uow = _db.GetDbContext()) + { + uow.Set().Add(p); + uow.SaveChanges(); + } + + pr.OnVoted += Pr_OnVoted; + return true; + } + + return false; + } + + public Poll StopPoll(ulong guildId) + { + if (ActivePolls.TryRemove(guildId, out var pr)) + { + pr.OnVoted -= Pr_OnVoted; + + using var uow = _db.GetDbContext(); + uow.RemovePoll(pr.Poll.Id); + uow.SaveChanges(); + + return pr.Poll; + } + + return null; + } + + private async Task Pr_OnVoted(IUserMessage msg, IGuildUser usr) + { + var toDelete = await msg.Channel.SendConfirmAsync(_eb, + _strs.GetText(strs.poll_voted(Format.Bold(usr.ToString())), usr.GuildId)); + toDelete.DeleteAfter(5); + try + { + await msg.DeleteAsync(); + } + catch + { + } + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) + { + if (guild is null) + return false; + + if (!ActivePolls.TryGetValue(guild.Id, out var poll)) + return false; + + try + { + var voted = await poll.TryVote(msg); + + if (voted) + { + Log.Information("User {UserName} [{UserId}] voted in a poll on {GuildName} [{GuildId}] server", + msg.Author.ToString(), + msg.Author.Id, + guild.Name, + guild.Id); + } + + return voted; + } + catch (Exception ex) + { + Log.Warning(ex, "Error voting"); + } + + return false; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/SpeedTyping/SpeedTypingCommands.cs b/src/Ellie.Bot.Modules.Gambling/Games/SpeedTyping/SpeedTypingCommands.cs new file mode 100644 index 0000000..147d0ba --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/SpeedTyping/SpeedTypingCommands.cs @@ -0,0 +1,103 @@ +#nullable disable +using Ellie.Modules.Games.Common; +using Ellie.Modules.Games.Services; + +namespace Ellie.Modules.Games; + +public partial class Games +{ + [Group] + public partial class SpeedTypingCommands : EllieModule + { + private readonly GamesService _games; + private readonly DiscordSocketClient _client; + + public SpeedTypingCommands(DiscordSocketClient client, GamesService games) + { + _games = games; + _client = client; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task TypeStart(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new TypingGame.Options(), args); + var channel = (ITextChannel)ctx.Channel; + + var game = _service.RunningContests.GetOrAdd(ctx.Guild.Id, + _ => new(_games, _client, channel, prefix, options, _eb)); + + if (game.IsActive) + await SendErrorAsync($"Contest already running in {game.Channel.Mention} channel."); + else + await game.Start(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task TypeStop() + { + if (_service.RunningContests.TryRemove(ctx.Guild.Id, out var game)) + { + await game.Stop(); + return; + } + + await SendErrorAsync("No contest to stop on this channel."); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task Typeadd([Leftover] string text) + { + if (string.IsNullOrWhiteSpace(text)) + return; + + _games.AddTypingArticle(ctx.User, text); + + await SendConfirmAsync("Added new article for typing game."); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Typelist(int page = 1) + { + if (page < 1) + return; + + var articles = _games.TypingArticles.Skip((page - 1) * 15).Take(15).ToArray(); + + if (!articles.Any()) + { + await SendErrorAsync($"{ctx.User.Mention} `No articles found on that page.`"); + return; + } + + var i = (page - 1) * 15; + await SendConfirmAsync("List of articles for Type Race", + string.Join("\n", articles.Select(a => $"`#{++i}` - {a.Text.TrimTo(50)}"))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task Typedel(int index) + { + var removed = _service.RemoveTypingArticle(--index); + + if (removed is null) + return; + + var embed = _eb.Create() + .WithTitle($"Removed typing article #{index + 1}") + .WithDescription(removed.Text.TrimTo(50)) + .WithOkColor(); + + await ctx.Channel.EmbedAsync(embed); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/SpeedTyping/TypingArticle.cs b/src/Ellie.Bot.Modules.Gambling/Games/SpeedTyping/TypingArticle.cs new file mode 100644 index 0000000..bc144f6 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/SpeedTyping/TypingArticle.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace Ellie.Modules.Games.Common; + +public class TypingArticle +{ + public string Source { get; set; } + public string Extra { get; set; } + public string Text { get; set; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/SpeedTyping/TypingGame.cs b/src/Ellie.Bot.Modules.Gambling/Games/SpeedTyping/TypingGame.cs new file mode 100644 index 0000000..e787139 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/SpeedTyping/TypingGame.cs @@ -0,0 +1,183 @@ +#nullable disable +using CommandLine; +using Ellie.Modules.Games.Services; +using System.Diagnostics; + +namespace Ellie.Modules.Games.Common; + +public class TypingGame +{ + public const float WORD_VALUE = 4.5f; + public ITextChannel Channel { get; } + public string CurrentSentence { get; private set; } + public bool IsActive { get; private set; } + private readonly Stopwatch _sw; + private readonly List _finishedUserIds; + private readonly DiscordSocketClient _client; + private readonly GamesService _games; + private readonly string _prefix; + private readonly Options _options; + private readonly IEmbedBuilderService _eb; + + public TypingGame( + GamesService games, + DiscordSocketClient client, + ITextChannel channel, + string prefix, + Options options, + IEmbedBuilderService eb) + { + _games = games; + _client = client; + _prefix = prefix; + _options = options; + _eb = eb; + + Channel = channel; + IsActive = false; + _sw = new(); + _finishedUserIds = new(); + } + + public async Task Stop() + { + if (!IsActive) + return false; + _client.MessageReceived -= AnswerReceived; + _finishedUserIds.Clear(); + IsActive = false; + _sw.Stop(); + _sw.Reset(); + try + { + await Channel.SendConfirmAsync(_eb, "Typing contest stopped."); + } + catch + { + } + + return true; + } + + public async Task Start() + { + if (IsActive) + return; // can't start running game + IsActive = true; + CurrentSentence = GetRandomSentence(); + var i = (int)(CurrentSentence.Length / WORD_VALUE * 1.7f); + try + { + await Channel.SendConfirmAsync(_eb, + $":clock2: Next contest will last for {i} seconds. Type the bolded text as fast as you can."); + + + var time = _options.StartTime; + + var msg = await Channel.SendMessageAsync($"Starting new typing contest in **{time}**..."); + + do + { + await Task.Delay(2000); + time -= 2; + try { await msg.ModifyAsync(m => m.Content = $"Starting new typing contest in **{time}**.."); } + catch { } + } while (time > 2); + + await msg.ModifyAsync(m => + { + m.Content = CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture); + }); + _sw.Start(); + HandleAnswers(); + + while (i > 0) + { + await Task.Delay(1000); + i--; + if (!IsActive) + return; + } + } + catch { } + finally + { + await Stop(); + } + } + + public string GetRandomSentence() + { + if (_games.TypingArticles.Any()) + return _games.TypingArticles[new EllieRandom().Next(0, _games.TypingArticles.Count)].Text; + return $"No typing articles found. Use {_prefix}typeadd command to add a new article for typing."; + } + + private void HandleAnswers() + => _client.MessageReceived += AnswerReceived; + + private Task AnswerReceived(SocketMessage imsg) + { + _ = Task.Run(async () => + { + try + { + if (imsg.Author.IsBot) + return; + if (imsg is not SocketUserMessage msg) + return; + + if (Channel is null || Channel.Id != msg.Channel.Id) + return; + + var guess = msg.Content; + + var distance = CurrentSentence.LevenshteinDistance(guess); + var decision = Judge(distance, guess.Length); + if (decision && !_finishedUserIds.Contains(msg.Author.Id)) + { + var elapsed = _sw.Elapsed; + var wpm = CurrentSentence.Length / WORD_VALUE / elapsed.TotalSeconds * 60; + _finishedUserIds.Add(msg.Author.Id); + await Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle($"{msg.Author} finished the race!") + .AddField("Place", $"#{_finishedUserIds.Count}", true) + .AddField("WPM", $"{wpm:F1} *[{elapsed.TotalSeconds:F2}sec]*", true) + .AddField("Errors", distance.ToString(), true)); + + if (_finishedUserIds.Count % 4 == 0) + { + await Channel.SendConfirmAsync(_eb, + ":exclamation: A lot of people finished, here is the text for those still typing:" + + $"\n\n**{Format.Sanitize(CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture)).SanitizeMentions(true)}**"); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error receiving typing game answer: {ErrorMessage}", ex.Message); + } + }); + return Task.CompletedTask; + } + + private static bool Judge(int errors, int textLength) + => errors <= textLength / 25; + + public class Options : IEllieCommandOptions + { + [Option('s', + "start-time", + Default = 5, + Required = false, + HelpText = "How long does it take for the race to start. Default 5.")] + public int StartTime { get; set; } = 5; + + public void NormalizeOptions() + { + if (StartTime is < 3 or > 30) + StartTime = 5; + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/TicTacToe/TicTacToe.cs b/src/Ellie.Bot.Modules.Gambling/Games/TicTacToe/TicTacToe.cs new file mode 100644 index 0000000..1d4075e --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/TicTacToe/TicTacToe.cs @@ -0,0 +1,307 @@ +#nullable disable +using CommandLine; +using System.Text; + +namespace Ellie.Modules.Games.Common; + +public class TicTacToe +{ + public event Action OnEnded; + private readonly ITextChannel _channel; + private readonly IGuildUser[] _users; + private readonly int?[,] _state; + private Phase phase; + private int curUserIndex; + private readonly SemaphoreSlim _moveLock; + + private IGuildUser winner; + + private readonly string[] _numbers = + { + ":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:", ":nine:" + }; + + private IUserMessage previousMessage; + private Timer timeoutTimer; + private readonly IBotStrings _strings; + private readonly DiscordSocketClient _client; + private readonly Options _options; + private readonly IEmbedBuilderService _eb; + + public TicTacToe( + IBotStrings strings, + DiscordSocketClient client, + ITextChannel channel, + IGuildUser firstUser, + Options options, + IEmbedBuilderService eb) + { + _channel = channel; + _strings = strings; + _client = client; + _options = options; + _eb = eb; + + _users = new[] { firstUser, null }; + _state = new int?[,] { { null, null, null }, { null, null, null }, { null, null, null } }; + + phase = Phase.Starting; + _moveLock = new(1, 1); + } + + private string GetText(LocStr key) + => _strings.GetText(key, _channel.GuildId); + + public string GetState() + { + var sb = new StringBuilder(); + for (var i = 0; i < _state.GetLength(0); i++) + { + for (var j = 0; j < _state.GetLength(1); j++) + { + sb.Append(_state[i, j] is null ? _numbers[(i * 3) + j] : GetIcon(_state[i, j])); + if (j < _state.GetLength(1) - 1) + sb.Append("┃"); + } + + if (i < _state.GetLength(0) - 1) + sb.AppendLine("\n──────────"); + } + + return sb.ToString(); + } + + public IEmbedBuilder GetEmbed(string title = null) + { + var embed = _eb.Create() + .WithOkColor() + .WithDescription(Environment.NewLine + GetState()) + .WithAuthor(GetText(strs.vs(_users[0], _users[1]))); + + if (!string.IsNullOrWhiteSpace(title)) + embed.WithTitle(title); + + if (winner is null) + { + if (phase == Phase.Ended) + embed.WithFooter(GetText(strs.ttt_no_moves)); + else + embed.WithFooter(GetText(strs.ttt_users_move(_users[curUserIndex]))); + } + else + embed.WithFooter(GetText(strs.ttt_has_won(winner))); + + return embed; + } + + private static string GetIcon(int? val) + { + switch (val) + { + case 0: + return "❌"; + case 1: + return "⭕"; + case 2: + return "❎"; + case 3: + return "🅾"; + default: + return "⬛"; + } + } + + public async Task Start(IGuildUser user) + { + if (phase is Phase.Started or Phase.Ended) + { + await _channel.SendErrorAsync(_eb, user.Mention + GetText(strs.ttt_already_running)); + return; + } + + if (_users[0] == user) + { + await _channel.SendErrorAsync(_eb, user.Mention + GetText(strs.ttt_against_yourself)); + return; + } + + _users[1] = user; + + phase = Phase.Started; + + timeoutTimer = new(async _ => + { + await _moveLock.WaitAsync(); + try + { + if (phase == Phase.Ended) + return; + + phase = Phase.Ended; + if (_users[1] is not null) + { + winner = _users[curUserIndex ^= 1]; + var del = previousMessage?.DeleteAsync(); + try + { + await _channel.EmbedAsync(GetEmbed(GetText(strs.ttt_time_expired))); + if (del is not null) + await del; + } + catch { } + } + + OnEnded?.Invoke(this); + } + catch { } + finally + { + _moveLock.Release(); + } + }, + null, + _options.TurnTimer * 1000, + Timeout.Infinite); + + _client.MessageReceived += Client_MessageReceived; + + + previousMessage = await _channel.EmbedAsync(GetEmbed(GetText(strs.game_started))); + } + + private bool IsDraw() + { + for (var i = 0; i < 3; i++) + for (var j = 0; j < 3; j++) + { + if (_state[i, j] is null) + return false; + } + + return true; + } + + private Task Client_MessageReceived(SocketMessage msg) + { + _ = Task.Run(async () => + { + await _moveLock.WaitAsync(); + try + { + var curUser = _users[curUserIndex]; + if (phase == Phase.Ended || msg.Author?.Id != curUser.Id) + return; + + if (int.TryParse(msg.Content, out var index) + && --index >= 0 + && index <= 9 + && _state[index / 3, index % 3] is null) + { + _state[index / 3, index % 3] = curUserIndex; + + // i'm lazy + if (_state[index / 3, 0] == _state[index / 3, 1] && _state[index / 3, 1] == _state[index / 3, 2]) + { + _state[index / 3, 0] = curUserIndex + 2; + _state[index / 3, 1] = curUserIndex + 2; + _state[index / 3, 2] = curUserIndex + 2; + + phase = Phase.Ended; + } + else if (_state[0, index % 3] == _state[1, index % 3] + && _state[1, index % 3] == _state[2, index % 3]) + { + _state[0, index % 3] = curUserIndex + 2; + _state[1, index % 3] = curUserIndex + 2; + _state[2, index % 3] = curUserIndex + 2; + + phase = Phase.Ended; + } + else if (curUserIndex == _state[0, 0] + && _state[0, 0] == _state[1, 1] + && _state[1, 1] == _state[2, 2]) + { + _state[0, 0] = curUserIndex + 2; + _state[1, 1] = curUserIndex + 2; + _state[2, 2] = curUserIndex + 2; + + phase = Phase.Ended; + } + else if (curUserIndex == _state[0, 2] + && _state[0, 2] == _state[1, 1] + && _state[1, 1] == _state[2, 0]) + { + _state[0, 2] = curUserIndex + 2; + _state[1, 1] = curUserIndex + 2; + _state[2, 0] = curUserIndex + 2; + + phase = Phase.Ended; + } + + var reason = string.Empty; + + if (phase == Phase.Ended) // if user won, stop receiving moves + { + reason = GetText(strs.ttt_matched_three); + winner = _users[curUserIndex]; + _client.MessageReceived -= Client_MessageReceived; + OnEnded?.Invoke(this); + } + else if (IsDraw()) + { + reason = GetText(strs.ttt_a_draw); + phase = Phase.Ended; + _client.MessageReceived -= Client_MessageReceived; + OnEnded?.Invoke(this); + } + + _ = Task.Run(async () => + { + var del1 = msg.DeleteAsync(); + var del2 = previousMessage?.DeleteAsync(); + try { previousMessage = await _channel.EmbedAsync(GetEmbed(reason)); } + catch { } + + try { await del1; } + catch { } + + try + { + if (del2 is not null) + await del2; + } + catch { } + }); + curUserIndex ^= 1; + + timeoutTimer.Change(_options.TurnTimer * 1000, Timeout.Infinite); + } + } + finally + { + _moveLock.Release(); + } + }); + + return Task.CompletedTask; + } + + public class Options : IEllieCommandOptions + { + [Option('t', "turn-timer", Required = false, Default = 15, HelpText = "Turn time in seconds. Default 15.")] + public int TurnTimer { get; set; } = 15; + + public void NormalizeOptions() + { + if (TurnTimer is < 5 or > 60) + TurnTimer = 15; + } + } + + private enum Phase + { + Starting, + Started, + Ended + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/TicTacToe/TicTacToeCommands.cs b/src/Ellie.Bot.Modules.Gambling/Games/TicTacToe/TicTacToeCommands.cs new file mode 100644 index 0000000..674ebe0 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/TicTacToe/TicTacToeCommands.cs @@ -0,0 +1,54 @@ +#nullable disable +using Ellie.Modules.Games.Common; +using Ellie.Modules.Games.Services; + +namespace Ellie.Modules.Games; + +public partial class Games +{ + [Group] + public partial class TicTacToeCommands : EllieModule + { + private readonly SemaphoreSlim _sem = new(1, 1); + private readonly DiscordSocketClient _client; + + public TicTacToeCommands(DiscordSocketClient client) + => _client = client; + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task TicTacToe(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new TicTacToe.Options(), args); + var channel = (ITextChannel)ctx.Channel; + + await _sem.WaitAsync(1000); + try + { + if (_service.TicTacToeGames.TryGetValue(channel.Id, out var game)) + { + _ = Task.Run(async () => + { + await game.Start((IGuildUser)ctx.User); + }); + return; + } + + game = new(Strings, _client, channel, (IGuildUser)ctx.User, options, _eb); + _service.TicTacToeGames.Add(channel.Id, game); + await ReplyConfirmLocalizedAsync(strs.ttt_created); + + game.OnEnded += _ => + { + _service.TicTacToeGames.Remove(channel.Id); + _sem.Dispose(); + }; + } + finally + { + _sem.Release(); + } + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Trivia/Games.cs b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/Games.cs new file mode 100644 index 0000000..f9a8ee1 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/Games.cs @@ -0,0 +1,275 @@ +using System.Net; +using System.Text; +using Ellie.Modules.Games.Common.Trivia; +using Ellie.Modules.Games.Services; + +namespace Ellie.Modules.Games; + +public partial class Games +{ + [Group] + public partial class TriviaCommands : EllieModule + { + private readonly ILocalDataCache _cache; + private readonly ICurrencyService _cs; + private readonly GamesConfigService _gamesConfig; + private readonly DiscordSocketClient _client; + + public TriviaCommands( + DiscordSocketClient client, + ILocalDataCache cache, + ICurrencyService cs, + GamesConfigService gamesConfig) + { + _cache = cache; + _cs = cs; + _gamesConfig = gamesConfig; + _client = client; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + [EllieOptions] + public async Task Trivia(params string[] args) + { + var (opts, _) = OptionsParser.ParseFrom(new TriviaOptions(), args); + + var config = _gamesConfig.Data; + if (opts.WinRequirement != 0 && config.Trivia.MinimumWinReq > 0 && config.Trivia.MinimumWinReq > opts.WinRequirement) + return; + + var trivia = new TriviaGame(opts, _cache); + if (_service.RunningTrivias.TryAdd(ctx.Guild.Id, trivia)) + { + RegisterEvents(trivia); + await trivia.RunAsync(); + return; + } + + if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var tg)) + { + await SendErrorAsync(GetText(strs.trivia_already_running)); + await tg.TriggerQuestionAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Tl() + { + if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var trivia)) + { + await trivia.TriggerStatsAsync(); + return; + } + + await ReplyErrorLocalizedAsync(strs.trivia_none); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Tq() + { + var channel = (ITextChannel)ctx.Channel; + + if (_service.RunningTrivias.TryGetValue(channel.Guild.Id, out var trivia)) + { + if (trivia.Stop()) + { + try + { + await ctx.Channel.SendConfirmAsync(_eb, + GetText(strs.trivia_game), + GetText(strs.trivia_stopping)); + } + catch (Exception ex) + { + Log.Warning(ex, "Error sending trivia stopping message"); + } + } + + return; + } + + await ReplyErrorLocalizedAsync(strs.trivia_none); + } + + private string GetLeaderboardString(TriviaGame tg) + { + var sb = new StringBuilder(); + + foreach (var (id, pts) in tg.GetLeaderboard()) + sb.AppendLine(GetText(strs.trivia_points(Format.Bold($"<@{id}>"), pts))); + + return sb.ToString(); + + } + + private IEmbedBuilder? questionEmbed = null; + private IUserMessage? questionMessage = null; + private bool showHowToQuit = false; + + private void RegisterEvents(TriviaGame trivia) + { + trivia.OnQuestion += OnTriviaQuestion; + trivia.OnHint += OnTriviaHint; + trivia.OnGuess += OnTriviaGuess; + trivia.OnEnded += OnTriviaEnded; + trivia.OnStats += OnTriviaStats; + trivia.OnTimeout += OnTriviaTimeout; + } + + private void UnregisterEvents(TriviaGame trivia) + { + trivia.OnQuestion -= OnTriviaQuestion; + trivia.OnHint -= OnTriviaHint; + trivia.OnGuess -= OnTriviaGuess; + trivia.OnEnded -= OnTriviaEnded; + trivia.OnStats -= OnTriviaStats; + trivia.OnTimeout -= OnTriviaTimeout; + } + + private async Task OnTriviaHint(TriviaGame game, TriviaQuestion question) + { + try + { + if (questionMessage is null) + { + game.Stop(); + return; + } + + if (questionEmbed is not null) + await questionMessage.ModifyAsync(m => m.Embed = questionEmbed.WithFooter(question.GetHint()).Build()); + } + catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) + { + Log.Warning("Unable to edit message to show hint. Stopping trivia"); + game.Stop(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error editing trivia message"); + } + } + + private async Task OnTriviaQuestion(TriviaGame game, TriviaQuestion question) + { + try + { + questionEmbed = _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.trivia_game)) + .AddField(GetText(strs.category), question.Category) + .AddField(GetText(strs.question), question.Question); + + showHowToQuit = !showHowToQuit; + if (showHowToQuit) + questionEmbed.WithFooter(GetText(strs.trivia_quit($"{prefix}tq"))); + + if (Uri.IsWellFormedUriString(question.ImageUrl, UriKind.Absolute)) + questionEmbed.WithImageUrl(question.ImageUrl); + + questionMessage = await ctx.Channel.EmbedAsync(questionEmbed); + } + catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound or HttpStatusCode.Forbidden + or HttpStatusCode.BadRequest) + { + Log.Warning("Unable to send trivia questions. Stopping immediately"); + game.Stop(); + throw; + } + } + + private async Task OnTriviaTimeout(TriviaGame _, TriviaQuestion question) + { + try + { + var embed = _eb.Create() + .WithErrorColor() + .WithTitle(GetText(strs.trivia_game)) + .WithDescription(GetText(strs.trivia_times_up(Format.Bold(question.Answer)))); + + if (Uri.IsWellFormedUriString(question.AnswerImageUrl, UriKind.Absolute)) + embed.WithImageUrl(question.AnswerImageUrl); + + await ctx.Channel.EmbedAsync(embed); + } + catch + { + // ignored + } + } + + private async Task OnTriviaStats(TriviaGame game) + { + try + { + await SendConfirmAsync(GetText(strs.leaderboard), GetLeaderboardString(game)); + } + catch + { + // ignored + } + } + + private async Task OnTriviaEnded(TriviaGame game) + { + try + { + await ctx.Channel.EmbedAsync(_eb.Create(ctx) + .WithOkColor() + .WithAuthor(GetText(strs.trivia_ended)) + .WithTitle(GetText(strs.leaderboard)) + .WithDescription(GetLeaderboardString(game))); + } + catch + { + // ignored + } + finally + { + _service.RunningTrivias.TryRemove(ctx.Guild.Id, out _); + } + + UnregisterEvents(game); + } + + private async Task OnTriviaGuess(TriviaGame _, TriviaUser user, TriviaQuestion question, bool isWin) + { + try + { + var embed = _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.trivia_game)) + .WithDescription(GetText(strs.trivia_win(user.Name, + Format.Bold(question.Answer)))); + + if (Uri.IsWellFormedUriString(question.AnswerImageUrl, UriKind.Absolute)) + embed.WithImageUrl(question.AnswerImageUrl); + + + if (isWin) + { + await ctx.Channel.EmbedAsync(embed); + + var reward = _gamesConfig.Data.Trivia.CurrencyReward; + if (reward > 0) + await _cs.AddAsync(user.Id, reward, new("trivia", "win")); + + return; + } + + embed.WithDescription(GetText(strs.trivia_guess(user.Name, + Format.Bold(question.Answer)))); + + await ctx.Channel.EmbedAsync(embed); + } + catch + { + // ignored + } + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Trivia/QuestionPool/DefaultQuestionPool.cs b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/QuestionPool/DefaultQuestionPool.cs new file mode 100644 index 0000000..032a95e --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/QuestionPool/DefaultQuestionPool.cs @@ -0,0 +1,22 @@ +namespace Ellie.Modules.Games.Common.Trivia; + +public sealed class DefaultQuestionPool : IQuestionPool +{ + private readonly ILocalDataCache _cache; + private readonly EllieRandom _rng; + + public DefaultQuestionPool(ILocalDataCache cache) + { + _cache = cache; + _rng = new EllieRandom(); + } + public async Task GetQuestionAsync() + { + var pool = await _cache.GetTriviaQuestionsAsync(); + + if(pool is null or {Length: 0}) + return default; + + return new(pool[_rng.Next(0, pool.Length)]); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Trivia/QuestionPool/IQuestionPool.cs b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/QuestionPool/IQuestionPool.cs new file mode 100644 index 0000000..e9470f9 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/QuestionPool/IQuestionPool.cs @@ -0,0 +1,6 @@ +namespace Ellie.Modules.Games.Common.Trivia; + +public interface IQuestionPool +{ + Task GetQuestionAsync(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Trivia/QuestionPool/PokemonQuestionPool.cs b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/QuestionPool/PokemonQuestionPool.cs new file mode 100644 index 0000000..70ca61d --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/QuestionPool/PokemonQuestionPool.cs @@ -0,0 +1,32 @@ +namespace Ellie.Modules.Games.Common.Trivia; + +public sealed class PokemonQuestionPool : IQuestionPool +{ + public int QuestionsCount => 905; // xd + private readonly EllieRandom _rng; + private readonly ILocalDataCache _cache; + + public PokemonQuestionPool(ILocalDataCache cache) + { + _cache = cache; + _rng = new EllieRandom(); + } + + public async Task GetQuestionAsync() + { + var pokes = await _cache.GetPokemonMapAsync(); + + if (pokes is null or { Count: 0 }) + return default; + + var num = _rng.Next(1, QuestionsCount + 1); + return new(new() + { + Question = "Who's That Pokémon?", + Answer = pokes[num].ToTitleCase(), + Category = "Pokemon", + ImageUrl = $@"https://nadeko.bot/images/pokemon/shadows/{num}.png", + AnswerImageUrl = $@"https://nadeko.bot/images/pokemon/real/{num}.png" + }); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaGame.cs b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaGame.cs new file mode 100644 index 0000000..234d236 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaGame.cs @@ -0,0 +1,219 @@ +using System.Threading.Channels; +using Exception = System.Exception; + +namespace Ellie.Modules.Games.Common.Trivia; + +public sealed class TriviaGame +{ + private readonly TriviaOptions _opts; + + + private readonly IQuestionPool _questionPool; + + #region Events + public event Func OnQuestion = static delegate { return Task.CompletedTask; }; + public event Func OnHint = static delegate { return Task.CompletedTask; }; + public event Func OnStats = static delegate { return Task.CompletedTask; }; + public event Func OnGuess = static delegate { return Task.CompletedTask; }; + public event Func OnTimeout = static delegate { return Task.CompletedTask; }; + public event Func OnEnded = static delegate { return Task.CompletedTask; }; + #endregion + + private bool _isStopped; + + public TriviaQuestion? CurrentQuestion { get; set; } + + + private readonly ConcurrentDictionary _users = new (); + + private readonly Channel<(TriviaUser User, string Input)> _inputs + = Channel.CreateUnbounded<(TriviaUser, string)>(new UnboundedChannelOptions + { + AllowSynchronousContinuations = true, + SingleReader = true, + SingleWriter = false, + }); + + public TriviaGame(TriviaOptions options, ILocalDataCache cache) + { + _opts = options; + + _questionPool = _opts.IsPokemon + ? new PokemonQuestionPool(cache) + : new DefaultQuestionPool(cache); + + } + public async Task RunAsync() + { + await GameLoop(); + } + + private async Task GameLoop() + { + Task TimeOutFactory() => Task.Delay(_opts.QuestionTimer * 1000 / 2); + + var errorCount = 0; + var inactivity = 0; + + // loop until game is stopped + // each iteration is one round + var firstRun = true; + try + { + while (!_isStopped) + { + if (errorCount >= 5) + { + Log.Warning("Trivia errored 5 times and will quit"); + break; + } + + // wait for 3 seconds before posting the next question + if (firstRun) + { + firstRun = false; + } + else + { + await Task.Delay(3000); + } + + var maybeQuestion = await _questionPool.GetQuestionAsync(); + + if (maybeQuestion is not { } question) + { + // if question is null (ran out of question, or other bugg ) - stop + break; + } + + CurrentQuestion = question; + try + { + // clear out all of the past guesses + while (_inputs.Reader.TryRead(out _)) + ; + + await OnQuestion(this, question); + } + catch (Exception ex) + { + Log.Warning(ex, "Error executing OnQuestion: {Message}", ex.Message); + errorCount++; + continue; + } + + + // just keep looping through user inputs until someone guesses the answer + // or the timer expires + var halfGuessTimerTask = TimeOutFactory(); + var hintSent = false; + var guessed = false; + while (true) + { + using var readCancel = new CancellationTokenSource(); + var readTask = _inputs.Reader.ReadAsync(readCancel.Token).AsTask(); + + // wait for either someone to attempt to guess + // or for timeout + var task = await Task.WhenAny(readTask, halfGuessTimerTask); + + // if the task which completed is the timeout task + if (task == halfGuessTimerTask) + { + readCancel.Cancel(); + + // if hint is already sent, means time expired + // break (end the round) + if (hintSent) + break; + + // else, means half time passed, send a hint + hintSent = true; + // start a new countdown of the same length + halfGuessTimerTask = TimeOutFactory(); + if (!_opts.NoHint) + { + // send a hint out + await OnHint(this, question); + } + + continue; + } + + // otherwise, read task is successful, and we're gonna + // get the user input data + var (user, input) = await readTask; + + // check the guess + if (question.IsAnswerCorrect(input)) + { + // add 1 point to the user + var val = _users.AddOrUpdate(user.Id, 1, (_, points) => ++points); + guessed = true; + + // reset inactivity counter + inactivity = 0; + errorCount = 0; + + var isWin = false; + // if user won the game, tell the game to stop + if (_opts.WinRequirement != 0 && val >= _opts.WinRequirement) + { + _isStopped = true; + isWin = true; + } + + // call onguess + await OnGuess(this, user, question, isWin); + break; + } + } + + if (!guessed) + { + await OnTimeout(this, question); + + if (_opts.Timeout != 0 && ++inactivity >= _opts.Timeout) + { + Log.Information("Trivia game is stopping due to inactivity"); + break; + } + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Fatal error in trivia game: {ErrorMessage}", ex.Message); + } + finally + { + // make sure game is set as ended + _isStopped = true; + _ = OnEnded(this); + } + } + + public IReadOnlyList<(ulong User, int points)> GetLeaderboard() + => _users.Select(x => (x.Key, x.Value)).ToArray(); + + public ValueTask InputAsync(TriviaUser user, string input) + => _inputs.Writer.WriteAsync((user, input)); + + public bool Stop() + { + var isStopped = _isStopped; + _isStopped = true; + return !isStopped; + } + + public async ValueTask TriggerStatsAsync() + { + await OnStats(this); + } + + public async Task TriggerQuestionAsync() + { + if(CurrentQuestion is TriviaQuestion q) + await OnQuestion(this, q); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaGamesService.cs b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaGamesService.cs new file mode 100644 index 0000000..ba3a83f --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaGamesService.cs @@ -0,0 +1,37 @@ +#nullable disable +using Ellie.Common.ModuleBehaviors; +using Ellie.Modules.Games.Common.Trivia; + +namespace Ellie.Modules.Games; + +public sealed class TriviaGamesService : IReadyExecutor, IEService +{ + private readonly DiscordSocketClient _client; + public ConcurrentDictionary RunningTrivias { get; } = new(); + + public TriviaGamesService(DiscordSocketClient client) + { + _client = client; + } + + public Task OnReadyAsync() + { + _client.MessageReceived += OnMessageReceived; + + return Task.CompletedTask; + } + + private async Task OnMessageReceived(SocketMessage msg) + { + if (msg.Author.IsBot) + return; + + var umsg = msg as SocketUserMessage; + + if (umsg?.Channel is not IGuildChannel gc) + return; + + if (RunningTrivias.TryGetValue(gc.GuildId, out var tg)) + await tg.InputAsync(new(umsg.Author.Mention, umsg.Author.Id), umsg.Content); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaOptions.cs b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaOptions.cs new file mode 100644 index 0000000..303a5ab --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaOptions.cs @@ -0,0 +1,44 @@ +#nullable disable +using CommandLine; + +namespace Ellie.Modules.Games.Common.Trivia; + +public class TriviaOptions : IEllieCommandOptions +{ + [Option('p', "pokemon", Required = false, Default = false, HelpText = "Whether it's 'Who's that pokemon?' trivia.")] + public bool IsPokemon { get; set; } = false; + + [Option("nohint", Required = false, Default = false, HelpText = "Don't show any hints.")] + public bool NoHint { get; set; } = false; + + [Option('w', + "win-req", + Required = false, + Default = 10, + HelpText = "Winning requirement. Set 0 for an infinite game. Default 10.")] + public int WinRequirement { get; set; } = 10; + + [Option('q', + "question-timer", + Required = false, + Default = 30, + HelpText = "How long until the question ends. Default 30.")] + public int QuestionTimer { get; set; } = 30; + + [Option('t', + "timeout", + Required = false, + Default = 10, + HelpText = "Number of questions of inactivity in order stop. Set 0 for never. Default 10.")] + public int Timeout { get; set; } = 10; + + public void NormalizeOptions() + { + if (WinRequirement < 0) + WinRequirement = 10; + if (QuestionTimer is < 10 or > 300) + QuestionTimer = 30; + if (Timeout is < 0 or > 20) + Timeout = 10; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaQuestion.cs b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaQuestion.cs new file mode 100644 index 0000000..c8bd521 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaQuestion.cs @@ -0,0 +1,115 @@ +#nullable disable +using System.Text.RegularExpressions; + +namespace Ellie.Modules.Games.Common.Trivia; + +public class TriviaQuestion +{ + public const int MAX_STRING_LENGTH = 22; + + //represents the min size to judge levDistance with + private static readonly HashSet> _strictness = new() + { + new(9, 0), + new(14, 1), + new(19, 2), + new(22, 3) + }; + + public string Category + => _qModel.Category; + + public string Question + => _qModel.Question; + + public string ImageUrl + => _qModel.ImageUrl; + + public string AnswerImageUrl + => _qModel.AnswerImageUrl ?? ImageUrl; + + public string Answer + => _qModel.Answer; + + public string CleanAnswer + => cleanAnswer ?? (cleanAnswer = Clean(Answer)); + + private string cleanAnswer; + private readonly TriviaQuestionModel _qModel; + + public TriviaQuestion(TriviaQuestionModel qModel) + { + _qModel = qModel; + } + + public string GetHint() + => Scramble(Answer); + + public bool IsAnswerCorrect(string guess) + { + if (Answer.Equals(guess, StringComparison.InvariantCulture)) + return true; + var cleanGuess = Clean(guess); + if (CleanAnswer.Equals(cleanGuess, StringComparison.InvariantCulture)) + return true; + + var levDistanceClean = CleanAnswer.LevenshteinDistance(cleanGuess); + var levDistanceNormal = Answer.LevenshteinDistance(guess); + return JudgeGuess(CleanAnswer.Length, cleanGuess.Length, levDistanceClean) + || JudgeGuess(Answer.Length, guess.Length, levDistanceNormal); + } + + private static bool JudgeGuess(int guessLength, int answerLength, int levDistance) + { + foreach (var level in _strictness) + { + if (guessLength <= level.Item1 || answerLength <= level.Item1) + { + if (levDistance <= level.Item2) + return true; + return false; + } + } + + return false; + } + + private static string Clean(string str) + { + str = " " + str.ToLowerInvariant() + " "; + str = Regex.Replace(str, @"\s+", " "); + str = Regex.Replace(str, @"[^\w\d\s]", ""); + //Here's where custom modification can be done + str = Regex.Replace(str, @"\s(a|an|the|of|in|for|to|as|at|be)\s", " "); + //End custom mod and cleanup whitespace + str = Regex.Replace(str, @"^\s+", ""); + str = Regex.Replace(str, @"\s+$", ""); + //Trim the really long answers + str = str.Length <= MAX_STRING_LENGTH ? str : str[..MAX_STRING_LENGTH]; + return str; + } + + private static string Scramble(string word) + { + var letters = word.ToCharArray(); + var count = 0; + for (var i = 0; i < letters.Length; i++) + { + if (letters[i] == ' ') + continue; + + count++; + if (count <= letters.Length / 5) + continue; + + if (count % 3 == 0) + continue; + + if (letters[i] != ' ') + letters[i] = '_'; + } + + return string.Join(" ", + new string(letters).Replace(" ", " \u2000", StringComparison.InvariantCulture).AsEnumerable()); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaUser.cs b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaUser.cs new file mode 100644 index 0000000..f2e9b80 --- /dev/null +++ b/src/Ellie.Bot.Modules.Gambling/Games/Trivia/TriviaUser.cs @@ -0,0 +1,3 @@ +namespace Ellie.Modules.Games.Common.Trivia; + +public record class TriviaUser(string Name, ulong Id); \ No newline at end of file