Added Gambling module
This commit is contained in:
parent
ff653b5c57
commit
eb17820a50
67 changed files with 8522 additions and 0 deletions
153
src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs
Normal file
153
src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
|
||||||
|
using EllieBot.Modules.Games.Common;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.AnimalRacing;
|
||||||
|
|
||||||
|
public sealed class AnimalRace : IDisposable
|
||||||
|
{
|
||||||
|
public enum Phase
|
||||||
|
{
|
||||||
|
WaitingForPlayers,
|
||||||
|
Running,
|
||||||
|
Ended
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Func<AnimalRace, Task> OnStarted = delegate { return Task.CompletedTask; };
|
||||||
|
public event Func<AnimalRace, Task> OnStartingFailed = delegate { return Task.CompletedTask; };
|
||||||
|
public event Func<AnimalRace, Task> OnStateUpdate = delegate { return Task.CompletedTask; };
|
||||||
|
public event Func<AnimalRace, Task> OnEnded = delegate { return Task.CompletedTask; };
|
||||||
|
|
||||||
|
public Phase CurrentPhase { get; private set; } = Phase.WaitingForPlayers;
|
||||||
|
|
||||||
|
public IReadOnlyCollection<AnimalRacingUser> Users
|
||||||
|
=> _users.ToList();
|
||||||
|
|
||||||
|
public List<AnimalRacingUser> FinishedUsers { get; } = new();
|
||||||
|
public int MaxUsers { get; }
|
||||||
|
|
||||||
|
private readonly SemaphoreSlim _locker = new(1, 1);
|
||||||
|
private readonly HashSet<AnimalRacingUser> _users = new();
|
||||||
|
private readonly ICurrencyService _currency;
|
||||||
|
private readonly RaceOptions _options;
|
||||||
|
private readonly Queue<RaceAnimal> _animalsQueue;
|
||||||
|
|
||||||
|
public AnimalRace(RaceOptions options, ICurrencyService currency, IEnumerable<RaceAnimal> availableAnimals)
|
||||||
|
{
|
||||||
|
_currency = currency;
|
||||||
|
_options = options;
|
||||||
|
_animalsQueue = new(availableAnimals);
|
||||||
|
MaxUsers = _animalsQueue.Count;
|
||||||
|
|
||||||
|
if (_animalsQueue.Count == 0)
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Initialize() //lame name
|
||||||
|
=> _ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(_options.StartTime * 1000);
|
||||||
|
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (CurrentPhase != Phase.WaitingForPlayers)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Start();
|
||||||
|
}
|
||||||
|
finally { _locker.Release(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
public async Task<AnimalRacingUser> JoinRace(ulong userId, string userName, long bet = 0)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(bet);
|
||||||
|
|
||||||
|
var user = new AnimalRacingUser(userName, userId, bet);
|
||||||
|
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_users.Count == MaxUsers)
|
||||||
|
throw new AnimalRaceFullException();
|
||||||
|
|
||||||
|
if (CurrentPhase != Phase.WaitingForPlayers)
|
||||||
|
throw new AlreadyStartedException();
|
||||||
|
|
||||||
|
if (!await _currency.RemoveAsync(userId, bet, new("animalrace", "bet")))
|
||||||
|
throw new NotEnoughFundsException();
|
||||||
|
|
||||||
|
if (_users.Contains(user))
|
||||||
|
throw new AlreadyJoinedException();
|
||||||
|
|
||||||
|
var animal = _animalsQueue.Dequeue();
|
||||||
|
user.Animal = animal;
|
||||||
|
_users.Add(user);
|
||||||
|
|
||||||
|
if (_animalsQueue.Count == 0) //start if no more spots left
|
||||||
|
await Start();
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
finally { _locker.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Start()
|
||||||
|
{
|
||||||
|
CurrentPhase = Phase.Running;
|
||||||
|
if (_users.Count <= 1)
|
||||||
|
{
|
||||||
|
foreach (var user in _users)
|
||||||
|
{
|
||||||
|
if (user.Bet > 0)
|
||||||
|
await _currency.AddAsync(user.UserId, user.Bet, new("animalrace", "refund"));
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = OnStartingFailed?.Invoke(this);
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = OnStarted?.Invoke(this);
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var rng = new NadekoRandom();
|
||||||
|
while (!_users.All(x => x.Progress >= 60))
|
||||||
|
{
|
||||||
|
foreach (var user in _users)
|
||||||
|
{
|
||||||
|
user.Progress += rng.Next(1, 11);
|
||||||
|
if (user.Progress >= 60)
|
||||||
|
user.Progress = 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
var finished = _users.Where(x => x.Progress >= 60 && !FinishedUsers.Contains(x)).Shuffle();
|
||||||
|
|
||||||
|
FinishedUsers.AddRange(finished);
|
||||||
|
|
||||||
|
_ = OnStateUpdate?.Invoke(this);
|
||||||
|
await Task.Delay(2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FinishedUsers[0].Bet > 0)
|
||||||
|
{
|
||||||
|
await _currency.AddAsync(FinishedUsers[0].UserId,
|
||||||
|
FinishedUsers[0].Bet * (_users.Count - 1),
|
||||||
|
new("animalrace", "win"));
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = OnEnded?.Invoke(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
OnStarted = null;
|
||||||
|
OnEnded = null;
|
||||||
|
OnStartingFailed = null;
|
||||||
|
OnStateUpdate = null;
|
||||||
|
_locker.Dispose();
|
||||||
|
_users.Clear();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Gambling.Common.AnimalRacing;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
public class AnimalRaceService : IEService
|
||||||
|
{
|
||||||
|
public ConcurrentDictionary<ulong, AnimalRace> AnimalRaces { get; } = new();
|
||||||
|
}
|
|
@ -0,0 +1,197 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Common.TypeReaders;
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Common.AnimalRacing;
|
||||||
|
using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using EllieBot.Modules.Games.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
// wth is this, needs full rewrite
|
||||||
|
public partial class Gambling
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class AnimalRacingCommands : GamblingSubmodule<AnimalRaceService>
|
||||||
|
{
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly GamesConfigService _gamesConf;
|
||||||
|
|
||||||
|
private IUserMessage raceMessage;
|
||||||
|
|
||||||
|
public AnimalRacingCommands(
|
||||||
|
ICurrencyService cs,
|
||||||
|
DiscordSocketClient client,
|
||||||
|
GamblingConfigService gamblingConf,
|
||||||
|
GamesConfigService gamesConf)
|
||||||
|
: base(gamblingConf)
|
||||||
|
{
|
||||||
|
_cs = cs;
|
||||||
|
_client = client;
|
||||||
|
_gamesConf = gamesConf;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[EllieOptions<RaceOptions>]
|
||||||
|
public Task Race(params string[] args)
|
||||||
|
{
|
||||||
|
var (options, _) = OptionsParser.ParseFrom(new RaceOptions(), args);
|
||||||
|
|
||||||
|
var ar = new AnimalRace(options, _cs, _gamesConf.Data.RaceAnimals.Shuffle());
|
||||||
|
if (!_service.AnimalRaces.TryAdd(ctx.Guild.Id, ar))
|
||||||
|
return Response()
|
||||||
|
.Error(GetText(strs.animal_race), GetText(strs.animal_race_already_started))
|
||||||
|
.SendAsync();
|
||||||
|
|
||||||
|
ar.Initialize();
|
||||||
|
|
||||||
|
var count = 0;
|
||||||
|
|
||||||
|
Task ClientMessageReceived(SocketMessage arg)
|
||||||
|
{
|
||||||
|
_ = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (arg.Channel.Id == ctx.Channel.Id)
|
||||||
|
{
|
||||||
|
if (ar.CurrentPhase == AnimalRace.Phase.Running && ++count % 9 == 0)
|
||||||
|
raceMessage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
});
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task ArOnEnded(AnimalRace race)
|
||||||
|
{
|
||||||
|
_client.MessageReceived -= ClientMessageReceived;
|
||||||
|
_service.AnimalRaces.TryRemove(ctx.Guild.Id, out _);
|
||||||
|
var winner = race.FinishedUsers[0];
|
||||||
|
if (race.FinishedUsers[0].Bet > 0)
|
||||||
|
{
|
||||||
|
return Response()
|
||||||
|
.Confirm(GetText(strs.animal_race),
|
||||||
|
GetText(strs.animal_race_won_money(Format.Bold(winner.Username),
|
||||||
|
winner.Animal.Icon,
|
||||||
|
(race.FinishedUsers[0].Bet * (race.Users.Count - 1)) + CurrencySign)))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
ar.Dispose();
|
||||||
|
return Response()
|
||||||
|
.Confirm(GetText(strs.animal_race),
|
||||||
|
GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon)))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
ar.OnStartingFailed += Ar_OnStartingFailed;
|
||||||
|
ar.OnStateUpdate += Ar_OnStateUpdate;
|
||||||
|
ar.OnEnded += ArOnEnded;
|
||||||
|
ar.OnStarted += Ar_OnStarted;
|
||||||
|
_client.MessageReceived += ClientMessageReceived;
|
||||||
|
|
||||||
|
return Response()
|
||||||
|
.Confirm(GetText(strs.animal_race),
|
||||||
|
GetText(strs.animal_race_starting(options.StartTime)),
|
||||||
|
footer: GetText(strs.animal_race_join_instr(prefix)))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task Ar_OnStarted(AnimalRace race)
|
||||||
|
{
|
||||||
|
if (race.Users.Count == race.MaxUsers)
|
||||||
|
return Response().Confirm(GetText(strs.animal_race), GetText(strs.animal_race_full)).SendAsync();
|
||||||
|
return Response()
|
||||||
|
.Confirm(GetText(strs.animal_race),
|
||||||
|
GetText(strs.animal_race_starting_with_x(race.Users.Count)))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Ar_OnStateUpdate(AnimalRace race)
|
||||||
|
{
|
||||||
|
var text = $@"|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|
|
||||||
|
{string.Join("\n", race.Users.Select(p =>
|
||||||
|
{
|
||||||
|
var index = race.FinishedUsers.IndexOf(p);
|
||||||
|
var extra = index == -1 ? "" : $"#{index + 1} {(index == 0 ? "🏆" : "")}";
|
||||||
|
return $"{(int)(p.Progress / 60f * 100),-2}%|{new string('‣', p.Progress) + p.Animal.Icon + extra}";
|
||||||
|
}))}
|
||||||
|
|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|";
|
||||||
|
|
||||||
|
var msg = raceMessage;
|
||||||
|
|
||||||
|
if (msg is null)
|
||||||
|
raceMessage = await Response().Confirm(text).SendAsync();
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await msg.ModifyAsync(x => x.Embed = _sender.CreateEmbed()
|
||||||
|
.WithTitle(GetText(strs.animal_race))
|
||||||
|
.WithDescription(text)
|
||||||
|
.WithOkColor()
|
||||||
|
.Build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task Ar_OnStartingFailed(AnimalRace race)
|
||||||
|
{
|
||||||
|
_service.AnimalRaces.TryRemove(ctx.Guild.Id, out _);
|
||||||
|
race.Dispose();
|
||||||
|
return Response().Error(strs.animal_race_failed).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task JoinRace([OverrideTypeReader(typeof(BalanceTypeReader))] long amount = default)
|
||||||
|
{
|
||||||
|
if (!await CheckBetOptional(amount))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_service.AnimalRaces.TryGetValue(ctx.Guild.Id, out var ar))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.race_not_exist).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await ar.JoinRace(ctx.User.Id, ctx.User.ToString(), amount);
|
||||||
|
if (amount > 0)
|
||||||
|
{
|
||||||
|
await Response()
|
||||||
|
.Confirm(GetText(strs.animal_race_join_bet(ctx.User.Mention,
|
||||||
|
user.Animal.Icon,
|
||||||
|
amount + CurrencySign)))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await Response()
|
||||||
|
.Confirm(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
//ignore if user inputed an invalid amount
|
||||||
|
}
|
||||||
|
catch (AlreadyJoinedException)
|
||||||
|
{
|
||||||
|
// just ignore this
|
||||||
|
}
|
||||||
|
catch (AlreadyStartedException)
|
||||||
|
{
|
||||||
|
//ignore
|
||||||
|
}
|
||||||
|
catch (AnimalRaceFullException)
|
||||||
|
{
|
||||||
|
await Response().Confirm(GetText(strs.animal_race), GetText(strs.animal_race_full)).SendAsync();
|
||||||
|
}
|
||||||
|
catch (NotEnoughFundsException)
|
||||||
|
{
|
||||||
|
await Response().Error(GetText(strs.not_enough(CurrencySign))).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Games.Common;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.AnimalRacing;
|
||||||
|
|
||||||
|
public class AnimalRacingUser
|
||||||
|
{
|
||||||
|
public long Bet { get; }
|
||||||
|
public string Username { get; }
|
||||||
|
public ulong UserId { get; }
|
||||||
|
public RaceAnimal Animal { get; set; }
|
||||||
|
public int Progress { get; set; }
|
||||||
|
|
||||||
|
public AnimalRacingUser(string username, ulong userId, long bet)
|
||||||
|
{
|
||||||
|
Bet = bet;
|
||||||
|
Username = username;
|
||||||
|
UserId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool Equals(object obj)
|
||||||
|
=> obj is AnimalRacingUser x ? x.UserId == UserId : false;
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
=> UserId.GetHashCode();
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
|
||||||
|
|
||||||
|
public class AlreadyJoinedException : Exception
|
||||||
|
{
|
||||||
|
public AlreadyJoinedException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AlreadyJoinedException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AlreadyJoinedException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
|
||||||
|
|
||||||
|
public class AlreadyStartedException : Exception
|
||||||
|
{
|
||||||
|
public AlreadyStartedException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AlreadyStartedException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AlreadyStartedException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
|
||||||
|
|
||||||
|
public class AnimalRaceFullException : Exception
|
||||||
|
{
|
||||||
|
public AnimalRaceFullException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AnimalRaceFullException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AnimalRaceFullException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
|
||||||
|
|
||||||
|
public class NotEnoughFundsException : Exception
|
||||||
|
{
|
||||||
|
public NotEnoughFundsException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotEnoughFundsException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotEnoughFundsException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
16
src/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs
Normal file
16
src/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
#nullable disable
|
||||||
|
using CommandLine;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.AnimalRacing;
|
||||||
|
|
||||||
|
public class RaceOptions : IEllieCommandOptions
|
||||||
|
{
|
||||||
|
[Option('s', "start-time", Default = 20, Required = false)]
|
||||||
|
public int StartTime { get; set; } = 20;
|
||||||
|
|
||||||
|
public void NormalizeOptions()
|
||||||
|
{
|
||||||
|
if (StartTime is < 10 or > 120)
|
||||||
|
StartTime = 20;
|
||||||
|
}
|
||||||
|
}
|
139
src/EllieBot/Modules/Gambling/Bank/BankCommands.cs
Normal file
139
src/EllieBot/Modules/Gambling/Bank/BankCommands.cs
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
using EllieBot.Common.TypeReaders;
|
||||||
|
using EllieBot.Modules.Gambling.Bank;
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public partial class Gambling
|
||||||
|
{
|
||||||
|
[Name("Bank")]
|
||||||
|
[Group("bank")]
|
||||||
|
public partial class BankCommands : GamblingModule<IBankService>
|
||||||
|
{
|
||||||
|
private readonly IBankService _bank;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
|
||||||
|
public BankCommands(GamblingConfigService gcs,
|
||||||
|
IBankService bank,
|
||||||
|
DiscordSocketClient client) : base(gcs)
|
||||||
|
{
|
||||||
|
_bank = bank;
|
||||||
|
_client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task BankDeposit([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
|
||||||
|
{
|
||||||
|
if (amount <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (await _bank.DepositAsync(ctx.User.Id, amount))
|
||||||
|
{
|
||||||
|
await Response().Confirm(strs.bank_deposited(N(amount))).SendAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task BankWithdraw([OverrideTypeReader(typeof(BankBalanceTypeReader))] long amount)
|
||||||
|
{
|
||||||
|
if (amount <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (await _bank.WithdrawAsync(ctx.User.Id, amount))
|
||||||
|
{
|
||||||
|
await Response().Confirm(strs.bank_withdrew(N(amount))).SendAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Response().Error(strs.bank_withdraw_insuff(CurrencySign)).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task BankBalance()
|
||||||
|
{
|
||||||
|
var bal = await _bank.GetBalanceAsync(ctx.User.Id);
|
||||||
|
|
||||||
|
var eb = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithDescription(GetText(strs.bank_balance(N(bal))));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Response().User(ctx.User).Embed(eb).SendAsync();
|
||||||
|
await ctx.OkAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await Response().Error(strs.cant_dm).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
public async Task BankBalance([Leftover] IUser user)
|
||||||
|
{
|
||||||
|
var bal = await _bank.GetBalanceAsync(user.Id);
|
||||||
|
|
||||||
|
var eb = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithDescription(GetText(strs.bank_balance_other(user.ToString(), N(bal))));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Response().User(ctx.User).Embed(eb).SendAsync();
|
||||||
|
await ctx.OkAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await Response().Error(strs.cant_dm).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BankTakeInternalAsync(long amount, ulong userId)
|
||||||
|
{
|
||||||
|
if (await _bank.TakeAsync(userId, amount))
|
||||||
|
{
|
||||||
|
await ctx.OkAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Error(strs.take_fail(N(amount),
|
||||||
|
_client.GetUser(userId)?.ToString()
|
||||||
|
?? userId.ToString(),
|
||||||
|
CurrencySign)).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BankAwardInternalAsync(long amount, ulong userId)
|
||||||
|
{
|
||||||
|
if (await _bank.AwardAsync(userId, amount))
|
||||||
|
{
|
||||||
|
await ctx.OkAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
[Priority(1)]
|
||||||
|
public async Task BankTake(long amount, [Leftover] IUser user)
|
||||||
|
=> await BankTakeInternalAsync(amount, user.Id);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
[Priority(0)]
|
||||||
|
public async Task BankTake(long amount, ulong userId)
|
||||||
|
=> await BankTakeInternalAsync(amount, userId);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
public async Task BankAward(long amount, [Leftover] IUser user)
|
||||||
|
=> await BankAwardInternalAsync(amount, user.Id);
|
||||||
|
}
|
||||||
|
}
|
115
src/EllieBot/Modules/Gambling/Bank/BankService.cs
Normal file
115
src/EllieBot/Modules/Gambling/Bank/BankService.cs
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
using LinqToDB;
|
||||||
|
using LinqToDB.EntityFrameworkCore;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Bank;
|
||||||
|
|
||||||
|
public sealed class BankService : IBankService, IEService
|
||||||
|
{
|
||||||
|
private readonly ICurrencyService _cur;
|
||||||
|
private readonly DbService _db;
|
||||||
|
|
||||||
|
public BankService(ICurrencyService cur, DbService db)
|
||||||
|
{
|
||||||
|
_cur = cur;
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> AwardAsync(ulong userId, long amount)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
|
||||||
|
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
await ctx.GetTable<BankUser>()
|
||||||
|
.InsertOrUpdateAsync(() => new()
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Balance = amount
|
||||||
|
},
|
||||||
|
(old) => new()
|
||||||
|
{
|
||||||
|
Balance = old.Balance + amount
|
||||||
|
},
|
||||||
|
() => new()
|
||||||
|
{
|
||||||
|
UserId = userId
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> TakeAsync(ulong userId, long amount)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
|
||||||
|
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
var rows = await ctx.Set<BankUser>()
|
||||||
|
.ToLinqToDBTable()
|
||||||
|
.Where(x => x.UserId == userId && x.Balance >= amount)
|
||||||
|
.UpdateAsync((old) => new()
|
||||||
|
{
|
||||||
|
Balance = old.Balance - amount
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DepositAsync(ulong userId, long amount)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
|
||||||
|
|
||||||
|
if (!await _cur.RemoveAsync(userId, amount, new("bank", "deposit")))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
await ctx.Set<BankUser>()
|
||||||
|
.ToLinqToDBTable()
|
||||||
|
.InsertOrUpdateAsync(() => new()
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Balance = amount
|
||||||
|
},
|
||||||
|
(old) => new()
|
||||||
|
{
|
||||||
|
Balance = old.Balance + amount
|
||||||
|
},
|
||||||
|
() => new()
|
||||||
|
{
|
||||||
|
UserId = userId
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> WithdrawAsync(ulong userId, long amount)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
|
||||||
|
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
var rows = await ctx.Set<BankUser>()
|
||||||
|
.ToLinqToDBTable()
|
||||||
|
.Where(x => x.UserId == userId && x.Balance >= amount)
|
||||||
|
.UpdateAsync((old) => new()
|
||||||
|
{
|
||||||
|
Balance = old.Balance - amount
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rows > 0)
|
||||||
|
{
|
||||||
|
await _cur.AddAsync(userId, amount, new("bank", "withdraw"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> GetBalanceAsync(ulong userId)
|
||||||
|
{
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
return (await ctx.Set<BankUser>()
|
||||||
|
.ToLinqToDBTable()
|
||||||
|
.FirstOrDefaultAsync(x => x.UserId == userId))
|
||||||
|
?.Balance
|
||||||
|
?? 0;
|
||||||
|
}
|
||||||
|
}
|
183
src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs
Normal file
183
src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Common.TypeReaders;
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Common.Blackjack;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public partial class Gambling
|
||||||
|
{
|
||||||
|
public partial class BlackJackCommands : GamblingSubmodule<BlackJackService>
|
||||||
|
{
|
||||||
|
public enum BjAction
|
||||||
|
{
|
||||||
|
Hit = int.MinValue,
|
||||||
|
Stand,
|
||||||
|
Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
private readonly DbService _db;
|
||||||
|
private IUserMessage msg;
|
||||||
|
|
||||||
|
public BlackJackCommands(ICurrencyService cs, DbService db, GamblingConfigService gamblingConf)
|
||||||
|
: base(gamblingConf)
|
||||||
|
{
|
||||||
|
_cs = cs;
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task BlackJack([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
|
||||||
|
{
|
||||||
|
if (!await CheckBetMandatory(amount))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var newBj = new Blackjack(_cs);
|
||||||
|
Blackjack bj;
|
||||||
|
if (newBj == (bj = _service.Games.GetOrAdd(ctx.Channel.Id, newBj)))
|
||||||
|
{
|
||||||
|
if (!await bj.Join(ctx.User, amount))
|
||||||
|
{
|
||||||
|
_service.Games.TryRemove(ctx.Channel.Id, out _);
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bj.StateUpdated += Bj_StateUpdated;
|
||||||
|
bj.GameEnded += Bj_GameEnded;
|
||||||
|
bj.Start();
|
||||||
|
|
||||||
|
await Response().NoReply().Confirm(strs.bj_created(ctx.User.ToString())).SendAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (await bj.Join(ctx.User, amount))
|
||||||
|
await Response().NoReply().Confirm(strs.bj_joined(ctx.User.ToString())).SendAsync();
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.Information("{User} can't join a blackjack game as it's in {BlackjackState} state already",
|
||||||
|
ctx.User,
|
||||||
|
bj.State);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.Message.DeleteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task Bj_GameEnded(Blackjack arg)
|
||||||
|
{
|
||||||
|
_service.Games.TryRemove(ctx.Channel.Id, out _);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Bj_StateUpdated(Blackjack bj)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (msg is not null)
|
||||||
|
_ = msg.DeleteAsync();
|
||||||
|
|
||||||
|
var c = bj.Dealer.Cards.Select(x => x.GetEmojiString())
|
||||||
|
.ToList();
|
||||||
|
var dealerIcon = "❔ ";
|
||||||
|
if (bj.State == Blackjack.GameState.Ended)
|
||||||
|
{
|
||||||
|
if (bj.Dealer.GetHandValue() == 21)
|
||||||
|
dealerIcon = "💰 ";
|
||||||
|
else if (bj.Dealer.GetHandValue() > 21)
|
||||||
|
dealerIcon = "💥 ";
|
||||||
|
else
|
||||||
|
dealerIcon = "🏁 ";
|
||||||
|
}
|
||||||
|
|
||||||
|
var cStr = string.Concat(c.Select(x => x[..^1] + " "));
|
||||||
|
cStr += "\n" + string.Concat(c.Select(x => x.Last() + " "));
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithTitle("BlackJack")
|
||||||
|
.AddField($"{dealerIcon} Dealer's Hand | Value: {bj.Dealer.GetHandValue()}", cStr);
|
||||||
|
|
||||||
|
if (bj.CurrentUser is not null)
|
||||||
|
embed.WithFooter($"Player to make a choice: {bj.CurrentUser.DiscordUser}");
|
||||||
|
|
||||||
|
foreach (var p in bj.Players)
|
||||||
|
{
|
||||||
|
c = p.Cards.Select(x => x.GetEmojiString()).ToList();
|
||||||
|
cStr = "-\t" + string.Concat(c.Select(x => x[..^1] + " "));
|
||||||
|
cStr += "\n-\t" + string.Concat(c.Select(x => x.Last() + " "));
|
||||||
|
var full = $"{p.DiscordUser.ToString().TrimTo(20)} | Bet: {N(p.Bet)} | Value: {p.GetHandValue()}";
|
||||||
|
if (bj.State == Blackjack.GameState.Ended)
|
||||||
|
{
|
||||||
|
if (p.State == User.UserState.Lost)
|
||||||
|
full = "❌ " + full;
|
||||||
|
else
|
||||||
|
full = "✅ " + full;
|
||||||
|
}
|
||||||
|
else if (p == bj.CurrentUser)
|
||||||
|
full = "▶ " + full;
|
||||||
|
else if (p.State == User.UserState.Stand)
|
||||||
|
full = "⏹ " + full;
|
||||||
|
else if (p.State == User.UserState.Bust)
|
||||||
|
full = "💥 " + full;
|
||||||
|
else if (p.State == User.UserState.Blackjack)
|
||||||
|
full = "💰 " + full;
|
||||||
|
|
||||||
|
embed.AddField(full, cStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
msg = await Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string UserToString(User x)
|
||||||
|
{
|
||||||
|
var playerName = x.State == User.UserState.Bust
|
||||||
|
? Format.Strikethrough(x.DiscordUser.ToString().TrimTo(30))
|
||||||
|
: x.DiscordUser.ToString();
|
||||||
|
|
||||||
|
// var hand = $"{string.Concat(x.Cards.Select(y => "〖" + y.GetEmojiString() + "〗"))}";
|
||||||
|
|
||||||
|
|
||||||
|
return $"{playerName} | Bet: {x.Bet}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public Task Hit()
|
||||||
|
=> InternalBlackJack(BjAction.Hit);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public Task Stand()
|
||||||
|
=> InternalBlackJack(BjAction.Stand);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public Task Double()
|
||||||
|
=> InternalBlackJack(BjAction.Double);
|
||||||
|
|
||||||
|
private async Task InternalBlackJack(BjAction a)
|
||||||
|
{
|
||||||
|
if (!_service.Games.TryGetValue(ctx.Channel.Id, out var bj))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (a == BjAction.Hit)
|
||||||
|
await bj.Hit(ctx.User);
|
||||||
|
else if (a == BjAction.Stand)
|
||||||
|
await bj.Stand(ctx.User);
|
||||||
|
else if (a == BjAction.Double)
|
||||||
|
{
|
||||||
|
if (!await bj.Double(ctx.User))
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.Message.DeleteAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Gambling.Common.Blackjack;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
public class BlackJackService : IEService
|
||||||
|
{
|
||||||
|
public ConcurrentDictionary<ulong, Blackjack> Games { get; } = new();
|
||||||
|
}
|
329
src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs
Normal file
329
src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs
Normal file
|
@ -0,0 +1,329 @@
|
||||||
|
#nullable disable
|
||||||
|
using Ellie.Econ;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.Blackjack;
|
||||||
|
|
||||||
|
public class Blackjack
|
||||||
|
{
|
||||||
|
public enum GameState
|
||||||
|
{
|
||||||
|
Starting,
|
||||||
|
Playing,
|
||||||
|
Ended
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Func<Blackjack, Task> StateUpdated;
|
||||||
|
public event Func<Blackjack, Task> GameEnded;
|
||||||
|
|
||||||
|
private Deck Deck { get; } = new QuadDeck();
|
||||||
|
public Dealer Dealer { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
public List<User> Players { get; set; } = new();
|
||||||
|
public GameState State { get; set; } = GameState.Starting;
|
||||||
|
public User CurrentUser { get; private set; }
|
||||||
|
|
||||||
|
private TaskCompletionSource<bool> currentUserMove;
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
|
||||||
|
private readonly SemaphoreSlim _locker = new(1, 1);
|
||||||
|
|
||||||
|
public Blackjack(ICurrencyService cs)
|
||||||
|
{
|
||||||
|
_cs = cs;
|
||||||
|
Dealer = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
=> _ = GameLoop();
|
||||||
|
|
||||||
|
public async Task GameLoop()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
//wait for players to join
|
||||||
|
await Task.Delay(20000);
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
State = GameState.Playing;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_locker.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
await PrintState();
|
||||||
|
//if no users joined the game, end it
|
||||||
|
if (!Players.Any())
|
||||||
|
{
|
||||||
|
State = GameState.Ended;
|
||||||
|
_ = GameEnded?.Invoke(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//give 1 card to the dealer and 2 to each player
|
||||||
|
Dealer.Cards.Add(Deck.Draw());
|
||||||
|
foreach (var usr in Players)
|
||||||
|
{
|
||||||
|
usr.Cards.Add(Deck.Draw());
|
||||||
|
usr.Cards.Add(Deck.Draw());
|
||||||
|
|
||||||
|
if (usr.GetHandValue() == 21)
|
||||||
|
usr.State = User.UserState.Blackjack;
|
||||||
|
}
|
||||||
|
|
||||||
|
//go through all users and ask them what they want to do
|
||||||
|
foreach (var usr in Players.Where(x => !x.Done))
|
||||||
|
{
|
||||||
|
while (!usr.Done)
|
||||||
|
{
|
||||||
|
Log.Information("Waiting for {DiscordUser}'s move", usr.DiscordUser);
|
||||||
|
await PromptUserMove(usr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await PrintState();
|
||||||
|
State = GameState.Ended;
|
||||||
|
await Task.Delay(2500);
|
||||||
|
Log.Information("Dealer moves");
|
||||||
|
await DealerMoves();
|
||||||
|
await PrintState();
|
||||||
|
_ = GameEnded?.Invoke(this);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "REPORT THE MESSAGE BELOW IN Ellie's Home SERVER PLEASE");
|
||||||
|
State = GameState.Ended;
|
||||||
|
_ = GameEnded?.Invoke(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PromptUserMove(User usr)
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var pause = Task.Delay(20000, cts.Token); //10 seconds to decide
|
||||||
|
CurrentUser = usr;
|
||||||
|
currentUserMove = new();
|
||||||
|
await PrintState();
|
||||||
|
// either wait for the user to make an action and
|
||||||
|
// if he doesn't - stand
|
||||||
|
var finished = await Task.WhenAny(pause, currentUserMove.Task);
|
||||||
|
if (finished == pause)
|
||||||
|
await Stand(usr);
|
||||||
|
else
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
CurrentUser = null;
|
||||||
|
currentUserMove = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Join(IUser user, long bet)
|
||||||
|
{
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (State != GameState.Starting)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (Players.Count >= 5)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!await _cs.RemoveAsync(user, bet, new("blackjack", "gamble")))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Players.Add(new(user, bet));
|
||||||
|
_ = PrintState();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_locker.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Stand(IUser u)
|
||||||
|
{
|
||||||
|
var cu = CurrentUser;
|
||||||
|
|
||||||
|
if (cu is not null && cu.DiscordUser == u)
|
||||||
|
return await Stand(cu);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Stand(User u)
|
||||||
|
{
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (State != GameState.Playing)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (CurrentUser != u)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
u.State = User.UserState.Stand;
|
||||||
|
currentUserMove.TrySetResult(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_locker.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DealerMoves()
|
||||||
|
{
|
||||||
|
var hw = Dealer.GetHandValue();
|
||||||
|
while (hw < 17
|
||||||
|
|| (hw == 17
|
||||||
|
&& Dealer.Cards.Count(x => x.Number == 1) > (Dealer.GetRawHandValue() - 17) / 10)) // hit on soft 17
|
||||||
|
{
|
||||||
|
/* Dealer has
|
||||||
|
A 6
|
||||||
|
That's 17, soft
|
||||||
|
hw == 17 => true
|
||||||
|
number of aces = 1
|
||||||
|
1 > 17-17 /10 => true
|
||||||
|
|
||||||
|
AA 5
|
||||||
|
That's 17, again soft, since one ace is worth 11, even though another one is 1
|
||||||
|
hw == 17 => true
|
||||||
|
number of aces = 2
|
||||||
|
2 > 27 - 17 / 10 => true
|
||||||
|
|
||||||
|
AA Q 5
|
||||||
|
That's 17, but not soft, since both aces are worth 1
|
||||||
|
hw == 17 => true
|
||||||
|
number of aces = 2
|
||||||
|
2 > 37 - 17 / 10 => false
|
||||||
|
* */
|
||||||
|
Dealer.Cards.Add(Deck.Draw());
|
||||||
|
hw = Dealer.GetHandValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hw > 21)
|
||||||
|
{
|
||||||
|
foreach (var usr in Players)
|
||||||
|
{
|
||||||
|
if (usr.State is User.UserState.Stand or User.UserState.Blackjack)
|
||||||
|
usr.State = User.UserState.Won;
|
||||||
|
else
|
||||||
|
usr.State = User.UserState.Lost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var usr in Players)
|
||||||
|
{
|
||||||
|
if (usr.State == User.UserState.Blackjack)
|
||||||
|
usr.State = User.UserState.Won;
|
||||||
|
else if (usr.State == User.UserState.Stand)
|
||||||
|
usr.State = hw < usr.GetHandValue() ? User.UserState.Won : User.UserState.Lost;
|
||||||
|
else
|
||||||
|
usr.State = User.UserState.Lost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var usr in Players)
|
||||||
|
{
|
||||||
|
if (usr.State is User.UserState.Won or User.UserState.Blackjack)
|
||||||
|
await _cs.AddAsync(usr.DiscordUser.Id, usr.Bet * 2, new("blackjack", "win"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Double(IUser u)
|
||||||
|
{
|
||||||
|
var cu = CurrentUser;
|
||||||
|
|
||||||
|
if (cu is not null && cu.DiscordUser == u)
|
||||||
|
return await Double(cu);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Double(User u)
|
||||||
|
{
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (State != GameState.Playing)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (CurrentUser != u)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!await _cs.RemoveAsync(u.DiscordUser.Id, u.Bet, new("blackjack", "double")))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
u.Bet *= 2;
|
||||||
|
|
||||||
|
u.Cards.Add(Deck.Draw());
|
||||||
|
|
||||||
|
if (u.GetHandValue() == 21)
|
||||||
|
//blackjack
|
||||||
|
u.State = User.UserState.Blackjack;
|
||||||
|
else if (u.GetHandValue() > 21)
|
||||||
|
// user busted
|
||||||
|
u.State = User.UserState.Bust;
|
||||||
|
else
|
||||||
|
//with double you just get one card, and then you're done
|
||||||
|
u.State = User.UserState.Stand;
|
||||||
|
currentUserMove.TrySetResult(true);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_locker.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Hit(IUser u)
|
||||||
|
{
|
||||||
|
var cu = CurrentUser;
|
||||||
|
|
||||||
|
if (cu is not null && cu.DiscordUser == u)
|
||||||
|
return await Hit(cu);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Hit(User u)
|
||||||
|
{
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (State != GameState.Playing)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (CurrentUser != u)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
u.Cards.Add(Deck.Draw());
|
||||||
|
|
||||||
|
if (u.GetHandValue() == 21)
|
||||||
|
//blackjack
|
||||||
|
u.State = User.UserState.Blackjack;
|
||||||
|
else if (u.GetHandValue() > 21)
|
||||||
|
// user busted
|
||||||
|
u.State = User.UserState.Bust;
|
||||||
|
|
||||||
|
currentUserMove.TrySetResult(true);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_locker.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task PrintState()
|
||||||
|
{
|
||||||
|
if (StateUpdated is null)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
return StateUpdated.Invoke(this);
|
||||||
|
}
|
||||||
|
}
|
57
src/EllieBot/Modules/Gambling/BlackJack/Player.cs
Normal file
57
src/EllieBot/Modules/Gambling/BlackJack/Player.cs
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
#nullable disable
|
||||||
|
using Ellie.Econ;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.Blackjack;
|
||||||
|
|
||||||
|
public abstract class Player
|
||||||
|
{
|
||||||
|
public List<Deck.Card> Cards { get; } = new();
|
||||||
|
|
||||||
|
public int GetHandValue()
|
||||||
|
{
|
||||||
|
var val = GetRawHandValue();
|
||||||
|
|
||||||
|
// while the hand value is greater than 21, for each ace you have in the deck
|
||||||
|
// reduce the value by 10 until it drops below 22
|
||||||
|
// (emulating the fact that ace is either a 1 or a 11)
|
||||||
|
var i = Cards.Count(x => x.Number == 1);
|
||||||
|
while (val > 21 && i-- > 0)
|
||||||
|
val -= 10;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetRawHandValue()
|
||||||
|
=> Cards.Sum(x => x.Number == 1 ? 11 : x.Number >= 10 ? 10 : x.Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Dealer : Player
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public class User : Player
|
||||||
|
{
|
||||||
|
public enum UserState
|
||||||
|
{
|
||||||
|
Waiting,
|
||||||
|
Stand,
|
||||||
|
Bust,
|
||||||
|
Blackjack,
|
||||||
|
Won,
|
||||||
|
Lost
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserState State { get; set; } = UserState.Waiting;
|
||||||
|
public long Bet { get; set; }
|
||||||
|
public IUser DiscordUser { get; }
|
||||||
|
|
||||||
|
public bool Done
|
||||||
|
=> State != UserState.Waiting;
|
||||||
|
|
||||||
|
public User(IUser user, long bet)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bet);
|
||||||
|
|
||||||
|
Bet = bet;
|
||||||
|
DiscordUser = user;
|
||||||
|
}
|
||||||
|
}
|
390
src/EllieBot/Modules/Gambling/Connect4/Connect4.cs
Normal file
390
src/EllieBot/Modules/Gambling/Connect4/Connect4.cs
Normal file
|
@ -0,0 +1,390 @@
|
||||||
|
#nullable disable
|
||||||
|
using CommandLine;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.Connect4;
|
||||||
|
|
||||||
|
public sealed class Connect4Game : IDisposable
|
||||||
|
{
|
||||||
|
public enum Field //temporary most likely
|
||||||
|
{
|
||||||
|
Empty,
|
||||||
|
P1,
|
||||||
|
P2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Phase
|
||||||
|
{
|
||||||
|
Joining, // waiting for second player to join
|
||||||
|
P1Move,
|
||||||
|
P2Move,
|
||||||
|
Ended
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Result
|
||||||
|
{
|
||||||
|
Draw,
|
||||||
|
CurrentPlayerWon,
|
||||||
|
OtherPlayerWon
|
||||||
|
}
|
||||||
|
|
||||||
|
public const int NUMBER_OF_COLUMNS = 7;
|
||||||
|
public const int NUMBER_OF_ROWS = 6;
|
||||||
|
|
||||||
|
//public event Func<Connect4Game, Task> OnGameStarted;
|
||||||
|
public event Func<Connect4Game, Task> OnGameStateUpdated;
|
||||||
|
public event Func<Connect4Game, Task> OnGameFailedToStart;
|
||||||
|
public event Func<Connect4Game, Result, Task> OnGameEnded;
|
||||||
|
|
||||||
|
public Phase CurrentPhase { get; private set; } = Phase.Joining;
|
||||||
|
|
||||||
|
public IReadOnlyList<Field> GameState
|
||||||
|
=> _gameState.AsReadOnly();
|
||||||
|
|
||||||
|
public IReadOnlyCollection<(ulong UserId, string Username)?> Players
|
||||||
|
=> _players.AsReadOnly();
|
||||||
|
|
||||||
|
public (ulong UserId, string Username) CurrentPlayer
|
||||||
|
=> CurrentPhase == Phase.P1Move ? _players[0].Value : _players[1].Value;
|
||||||
|
|
||||||
|
public (ulong UserId, string Username) OtherPlayer
|
||||||
|
=> CurrentPhase == Phase.P2Move ? _players[0].Value : _players[1].Value;
|
||||||
|
|
||||||
|
//state is bottom to top, left to right
|
||||||
|
private readonly Field[] _gameState = new Field[NUMBER_OF_ROWS * NUMBER_OF_COLUMNS];
|
||||||
|
private readonly (ulong UserId, string Username)?[] _players = new (ulong, string)?[2];
|
||||||
|
|
||||||
|
private readonly SemaphoreSlim _locker = new(1, 1);
|
||||||
|
private readonly Options _options;
|
||||||
|
private readonly EllieRandom _rng;
|
||||||
|
|
||||||
|
private Timer playerTimeoutTimer;
|
||||||
|
|
||||||
|
/* [ ][ ][ ][ ][ ][ ]
|
||||||
|
* [ ][ ][ ][ ][ ][ ]
|
||||||
|
* [ ][ ][ ][ ][ ][ ]
|
||||||
|
* [ ][ ][ ][ ][ ][ ]
|
||||||
|
* [ ][ ][ ][ ][ ][ ]
|
||||||
|
* [ ][ ][ ][ ][ ][ ]
|
||||||
|
* [ ][ ][ ][ ][ ][ ]
|
||||||
|
*/
|
||||||
|
|
||||||
|
public Connect4Game(
|
||||||
|
ulong userId,
|
||||||
|
string userName,
|
||||||
|
Options options
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_players[0] = (userId, userName);
|
||||||
|
_options = options;
|
||||||
|
|
||||||
|
_rng = new();
|
||||||
|
for (var i = 0; i < NUMBER_OF_COLUMNS * NUMBER_OF_ROWS; i++)
|
||||||
|
_gameState[i] = Field.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
if (CurrentPhase != Phase.Joining)
|
||||||
|
return;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(15000);
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_players[1] is null)
|
||||||
|
{
|
||||||
|
_ = OnGameFailedToStart?.Invoke(this);
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { _locker.Release(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Join(ulong userId, string userName)
|
||||||
|
{
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (CurrentPhase != Phase.Joining) //can't join if its not a joining phase
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (_players[0].Value.UserId == userId) // same user can't join own game
|
||||||
|
return false;
|
||||||
|
|
||||||
|
|
||||||
|
if (_rng.Next(0, 2) == 0) //rolling from 0-1, if number is 0, join as first player
|
||||||
|
{
|
||||||
|
_players[1] = _players[0];
|
||||||
|
_players[0] = (userId, userName);
|
||||||
|
}
|
||||||
|
else //else join as a second player
|
||||||
|
_players[1] = (userId, userName);
|
||||||
|
|
||||||
|
CurrentPhase = Phase.P1Move; //start the game
|
||||||
|
playerTimeoutTimer = new(async _ =>
|
||||||
|
{
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EndGame(Result.OtherPlayerWon, OtherPlayer.UserId);
|
||||||
|
}
|
||||||
|
finally { _locker.Release(); }
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
TimeSpan.FromSeconds(_options.TurnTimer),
|
||||||
|
TimeSpan.FromSeconds(_options.TurnTimer));
|
||||||
|
_ = OnGameStateUpdated?.Invoke(this);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally { _locker.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Input(ulong userId, int inputCol)
|
||||||
|
{
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
inputCol -= 1;
|
||||||
|
if (CurrentPhase is Phase.Ended or Phase.Joining)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!((_players[0].Value.UserId == userId && CurrentPhase == Phase.P1Move)
|
||||||
|
|| (_players[1].Value.UserId == userId && CurrentPhase == Phase.P2Move)))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (inputCol is < 0 or > NUMBER_OF_COLUMNS) //invalid input
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (IsColumnFull(inputCol)) //can't play there event?
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var start = NUMBER_OF_ROWS * inputCol;
|
||||||
|
for (var i = start; i < start + NUMBER_OF_ROWS; i++)
|
||||||
|
{
|
||||||
|
if (_gameState[i] == Field.Empty)
|
||||||
|
{
|
||||||
|
_gameState[i] = GetPlayerPiece(userId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//check winnning condition
|
||||||
|
// ok, i'll go from [0-2] in rows (and through all columns) and check upward if 4 are connected
|
||||||
|
|
||||||
|
for (var i = 0; i < NUMBER_OF_ROWS - 3; i++)
|
||||||
|
{
|
||||||
|
if (CurrentPhase == Phase.Ended)
|
||||||
|
break;
|
||||||
|
|
||||||
|
for (var j = 0; j < NUMBER_OF_COLUMNS; j++)
|
||||||
|
{
|
||||||
|
if (CurrentPhase == Phase.Ended)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var first = _gameState[i + (j * NUMBER_OF_ROWS)];
|
||||||
|
if (first != Field.Empty)
|
||||||
|
{
|
||||||
|
for (var k = 1; k < 4; k++)
|
||||||
|
{
|
||||||
|
var next = _gameState[i + k + (j * NUMBER_OF_ROWS)];
|
||||||
|
if (next == first)
|
||||||
|
{
|
||||||
|
if (k == 3)
|
||||||
|
EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId);
|
||||||
|
else
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// i'll go [0-1] in columns (and through all rows) and check to the right if 4 are connected
|
||||||
|
for (var i = 0; i < NUMBER_OF_COLUMNS - 3; i++)
|
||||||
|
{
|
||||||
|
if (CurrentPhase == Phase.Ended)
|
||||||
|
break;
|
||||||
|
|
||||||
|
for (var j = 0; j < NUMBER_OF_ROWS; j++)
|
||||||
|
{
|
||||||
|
if (CurrentPhase == Phase.Ended)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var first = _gameState[j + (i * NUMBER_OF_ROWS)];
|
||||||
|
if (first != Field.Empty)
|
||||||
|
{
|
||||||
|
for (var k = 1; k < 4; k++)
|
||||||
|
{
|
||||||
|
var next = _gameState[j + ((i + k) * NUMBER_OF_ROWS)];
|
||||||
|
if (next == first)
|
||||||
|
{
|
||||||
|
if (k == 3)
|
||||||
|
EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId);
|
||||||
|
else
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//need to check diagonal now
|
||||||
|
for (var col = 0; col < NUMBER_OF_COLUMNS; col++)
|
||||||
|
{
|
||||||
|
if (CurrentPhase == Phase.Ended)
|
||||||
|
break;
|
||||||
|
|
||||||
|
for (var row = 0; row < NUMBER_OF_ROWS; row++)
|
||||||
|
{
|
||||||
|
if (CurrentPhase == Phase.Ended)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var first = _gameState[row + (col * NUMBER_OF_ROWS)];
|
||||||
|
|
||||||
|
if (first != Field.Empty)
|
||||||
|
{
|
||||||
|
var same = 1;
|
||||||
|
|
||||||
|
//top left
|
||||||
|
for (var i = 1; i < 4; i++)
|
||||||
|
{
|
||||||
|
//while going top left, rows are increasing, columns are decreasing
|
||||||
|
var curRow = row + i;
|
||||||
|
var curCol = col - i;
|
||||||
|
|
||||||
|
//check if current values are in range
|
||||||
|
if (curRow is >= NUMBER_OF_ROWS or < 0)
|
||||||
|
break;
|
||||||
|
if (curCol is < 0 or >= NUMBER_OF_COLUMNS)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var cur = _gameState[curRow + (curCol * NUMBER_OF_ROWS)];
|
||||||
|
if (cur == first)
|
||||||
|
same++;
|
||||||
|
else
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (same == 4)
|
||||||
|
{
|
||||||
|
EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
same = 1;
|
||||||
|
|
||||||
|
//top right
|
||||||
|
for (var i = 1; i < 4; i++)
|
||||||
|
{
|
||||||
|
//while going top right, rows are increasing, columns are increasing
|
||||||
|
var curRow = row + i;
|
||||||
|
var curCol = col + i;
|
||||||
|
|
||||||
|
//check if current values are in range
|
||||||
|
if (curRow is >= NUMBER_OF_ROWS or < 0)
|
||||||
|
break;
|
||||||
|
if (curCol is < 0 or >= NUMBER_OF_COLUMNS)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var cur = _gameState[curRow + (curCol * NUMBER_OF_ROWS)];
|
||||||
|
if (cur == first)
|
||||||
|
same++;
|
||||||
|
else
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (same == 4)
|
||||||
|
{
|
||||||
|
EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//check draw? if it's even possible
|
||||||
|
if (_gameState.All(x => x != Field.Empty))
|
||||||
|
EndGame(Result.Draw, null);
|
||||||
|
|
||||||
|
if (CurrentPhase != Phase.Ended)
|
||||||
|
{
|
||||||
|
if (CurrentPhase == Phase.P1Move)
|
||||||
|
CurrentPhase = Phase.P2Move;
|
||||||
|
else
|
||||||
|
CurrentPhase = Phase.P1Move;
|
||||||
|
|
||||||
|
ResetTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = OnGameStateUpdated?.Invoke(this);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally { _locker.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetTimer()
|
||||||
|
=> playerTimeoutTimer.Change(TimeSpan.FromSeconds(_options.TurnTimer),
|
||||||
|
TimeSpan.FromSeconds(_options.TurnTimer));
|
||||||
|
|
||||||
|
private void EndGame(Result result, ulong? winId)
|
||||||
|
{
|
||||||
|
if (CurrentPhase == Phase.Ended)
|
||||||
|
return;
|
||||||
|
_ = OnGameEnded?.Invoke(this, result);
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
|
||||||
|
if (result == Result.Draw)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Field GetPlayerPiece(ulong userId)
|
||||||
|
=> _players[0].Value.UserId == userId ? Field.P1 : Field.P2;
|
||||||
|
|
||||||
|
//column is full if there are no empty fields
|
||||||
|
private bool IsColumnFull(int column)
|
||||||
|
{
|
||||||
|
var start = NUMBER_OF_ROWS * column;
|
||||||
|
for (var i = start; i < start + NUMBER_OF_ROWS; i++)
|
||||||
|
{
|
||||||
|
if (_gameState[i] == Field.Empty)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
OnGameFailedToStart = null;
|
||||||
|
OnGameStateUpdated = null;
|
||||||
|
OnGameEnded = null;
|
||||||
|
playerTimeoutTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class Options : IEllieCommandOptions
|
||||||
|
{
|
||||||
|
[Option('t',
|
||||||
|
"turn-timer",
|
||||||
|
Required = false,
|
||||||
|
Default = 15,
|
||||||
|
HelpText = "Turn time in seconds. It has to be between 5 and 60. Default 15.")]
|
||||||
|
public int TurnTimer { get; set; } = 15;
|
||||||
|
|
||||||
|
public void NormalizeOptions()
|
||||||
|
{
|
||||||
|
if (TurnTimer is < 5 or > 60)
|
||||||
|
TurnTimer = 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
187
src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs
Normal file
187
src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Common.Connect4;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public partial class Gambling
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class Connect4Commands : GamblingSubmodule<GamblingService>
|
||||||
|
{
|
||||||
|
private static readonly string[] _numbers =
|
||||||
|
[
|
||||||
|
":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:"
|
||||||
|
];
|
||||||
|
|
||||||
|
private int RepostCounter
|
||||||
|
{
|
||||||
|
get => repostCounter;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value is < 0 or > 7)
|
||||||
|
repostCounter = 0;
|
||||||
|
else
|
||||||
|
repostCounter = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
|
||||||
|
private IUserMessage msg;
|
||||||
|
|
||||||
|
private int repostCounter;
|
||||||
|
|
||||||
|
public Connect4Commands(DiscordSocketClient client, GamblingConfigService gamb)
|
||||||
|
: base(gamb)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[EllieOptions<Connect4Game.Options>]
|
||||||
|
public async Task Connect4(params string[] args)
|
||||||
|
{
|
||||||
|
var (options, _) = OptionsParser.ParseFrom(new Connect4Game.Options(), args);
|
||||||
|
|
||||||
|
var newGame = new Connect4Game(ctx.User.Id, ctx.User.ToString(), options);
|
||||||
|
Connect4Game game;
|
||||||
|
if ((game = _service.Connect4Games.GetOrAdd(ctx.Channel.Id, newGame)) != newGame)
|
||||||
|
{
|
||||||
|
if (game.CurrentPhase != Connect4Game.Phase.Joining)
|
||||||
|
return;
|
||||||
|
|
||||||
|
newGame.Dispose();
|
||||||
|
//means game already exists, try to join
|
||||||
|
await game.Join(ctx.User.Id, ctx.User.ToString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
game.OnGameStateUpdated += Game_OnGameStateUpdated;
|
||||||
|
game.OnGameFailedToStart += GameOnGameFailedToStart;
|
||||||
|
game.OnGameEnded += GameOnGameEnded;
|
||||||
|
_client.MessageReceived += ClientMessageReceived;
|
||||||
|
|
||||||
|
game.Initialize();
|
||||||
|
await Response().Confirm(strs.connect4_created).SendAsync();
|
||||||
|
|
||||||
|
Task ClientMessageReceived(SocketMessage arg)
|
||||||
|
{
|
||||||
|
if (ctx.Channel.Id != arg.Channel.Id)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var success = false;
|
||||||
|
if (int.TryParse(arg.Content, out var col))
|
||||||
|
success = await game.Input(arg.Author.Id, col);
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
try { await arg.DeleteAsync(); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (game.CurrentPhase is Connect4Game.Phase.Joining or Connect4Game.Phase.Ended)
|
||||||
|
return;
|
||||||
|
RepostCounter++;
|
||||||
|
if (RepostCounter == 0)
|
||||||
|
{
|
||||||
|
try { msg = await Response().Embed(msg.Embeds.First().ToEmbedBuilder()).SendAsync(); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task GameOnGameFailedToStart(Connect4Game arg)
|
||||||
|
{
|
||||||
|
if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose))
|
||||||
|
{
|
||||||
|
_client.MessageReceived -= ClientMessageReceived;
|
||||||
|
toDispose.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response().Error(strs.connect4_failed_to_start).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Task GameOnGameEnded(Connect4Game arg, Connect4Game.Result result)
|
||||||
|
{
|
||||||
|
if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose))
|
||||||
|
{
|
||||||
|
_client.MessageReceived -= ClientMessageReceived;
|
||||||
|
toDispose.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
string title;
|
||||||
|
if (result == Connect4Game.Result.CurrentPlayerWon)
|
||||||
|
{
|
||||||
|
title = GetText(strs.connect4_won(Format.Bold(arg.CurrentPlayer.Username),
|
||||||
|
Format.Bold(arg.OtherPlayer.Username)));
|
||||||
|
}
|
||||||
|
else if (result == Connect4Game.Result.OtherPlayerWon)
|
||||||
|
{
|
||||||
|
title = GetText(strs.connect4_won(Format.Bold(arg.OtherPlayer.Username),
|
||||||
|
Format.Bold(arg.CurrentPlayer.Username)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
title = GetText(strs.connect4_draw);
|
||||||
|
|
||||||
|
return msg.ModifyAsync(x => x.Embed = _sender.CreateEmbed()
|
||||||
|
.WithTitle(title)
|
||||||
|
.WithDescription(GetGameStateText(game))
|
||||||
|
.WithOkColor()
|
||||||
|
.Build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Game_OnGameStateUpdated(Connect4Game game)
|
||||||
|
{
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithTitle($"{game.CurrentPlayer.Username} vs {game.OtherPlayer.Username}")
|
||||||
|
.WithDescription(GetGameStateText(game))
|
||||||
|
.WithOkColor();
|
||||||
|
|
||||||
|
|
||||||
|
if (msg is null)
|
||||||
|
msg = await Response().Embed(embed).SendAsync();
|
||||||
|
else
|
||||||
|
await msg.ModifyAsync(x => x.Embed = embed.Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetGameStateText(Connect4Game game)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
if (game.CurrentPhase is Connect4Game.Phase.P1Move or Connect4Game.Phase.P2Move)
|
||||||
|
sb.AppendLine(GetText(strs.connect4_player_to_move(Format.Bold(game.CurrentPlayer.Username))));
|
||||||
|
|
||||||
|
for (var i = Connect4Game.NUMBER_OF_ROWS; i > 0; i--)
|
||||||
|
{
|
||||||
|
for (var j = 0; j < Connect4Game.NUMBER_OF_COLUMNS; j++)
|
||||||
|
{
|
||||||
|
var cur = game.GameState[i + (j * Connect4Game.NUMBER_OF_ROWS) - 1];
|
||||||
|
|
||||||
|
if (cur == Connect4Game.Field.Empty)
|
||||||
|
sb.Append("⚫"); //black circle
|
||||||
|
else if (cur == Connect4Game.Field.P1)
|
||||||
|
sb.Append("🔴"); //red circle
|
||||||
|
else
|
||||||
|
sb.Append("🔵"); //blue circle
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < Connect4Game.NUMBER_OF_COLUMNS; i++)
|
||||||
|
sb.Append(_numbers[i]);
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
src/EllieBot/Modules/Gambling/CurrencyProvider.cs
Normal file
16
src/EllieBot/Modules/Gambling/CurrencyProvider.cs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public sealed class CurrencyProvider : ICurrencyProvider, IEService
|
||||||
|
{
|
||||||
|
private readonly GamblingConfigService _cs;
|
||||||
|
|
||||||
|
public CurrencyProvider(GamblingConfigService cs)
|
||||||
|
{
|
||||||
|
_cs = cs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetCurrencySign()
|
||||||
|
=> _cs.Data.Currency.Sign;
|
||||||
|
}
|
224
src/EllieBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs
Normal file
224
src/EllieBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
#nullable disable
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Image = SixLabors.ImageSharp.Image;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public partial class Gambling
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class DiceRollCommands : EllieModule
|
||||||
|
{
|
||||||
|
private static readonly Regex _dndRegex = new(@"^(?<n1>\d+)d(?<n2>\d+)(?:\+(?<add>\d+))?(?:\-(?<sub>\d+))?$",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly Regex _fudgeRegex = new(@"^(?<n1>\d+)d(?:F|f)$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly char[] _fateRolls = ['-', ' ', '+'];
|
||||||
|
private readonly IImageCache _images;
|
||||||
|
|
||||||
|
public DiceRollCommands(IImageCache images)
|
||||||
|
=> _images = images;
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task Roll()
|
||||||
|
{
|
||||||
|
var rng = new EllieRandom();
|
||||||
|
var gen = rng.Next(1, 101);
|
||||||
|
|
||||||
|
var num1 = gen / 10;
|
||||||
|
var num2 = gen % 10;
|
||||||
|
|
||||||
|
using var img1 = await GetDiceAsync(num1);
|
||||||
|
using var img2 = await GetDiceAsync(num2);
|
||||||
|
using var img = new[] { img1, img2 }.Merge(out var format);
|
||||||
|
await using var ms = await img.ToStreamAsync(format);
|
||||||
|
|
||||||
|
var fileName = $"dice.{format.FileExtensions.First()}";
|
||||||
|
|
||||||
|
var eb = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithAuthor(ctx.User)
|
||||||
|
.AddField(GetText(strs.roll2), gen)
|
||||||
|
.WithImageUrl($"attachment://{fileName}");
|
||||||
|
|
||||||
|
await ctx.Channel.SendFileAsync(ms,
|
||||||
|
fileName,
|
||||||
|
embed: eb.Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[Priority(1)]
|
||||||
|
public async Task Roll(int num)
|
||||||
|
=> await InternalRoll(num, true);
|
||||||
|
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[Priority(1)]
|
||||||
|
public async Task Rolluo(int num = 1)
|
||||||
|
=> await InternalRoll(num, false);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[Priority(0)]
|
||||||
|
public async Task Roll(string arg)
|
||||||
|
=> await InternallDndRoll(arg, true);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[Priority(0)]
|
||||||
|
public async Task Rolluo(string arg)
|
||||||
|
=> await InternallDndRoll(arg, false);
|
||||||
|
|
||||||
|
private async Task InternalRoll(int num, bool ordered)
|
||||||
|
{
|
||||||
|
if (num is < 1 or > 30)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.dice_invalid_number(1, 30)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rng = new EllieRandom();
|
||||||
|
|
||||||
|
var dice = new List<Image<Rgba32>>(num);
|
||||||
|
var values = new List<int>(num);
|
||||||
|
for (var i = 0; i < num; i++)
|
||||||
|
{
|
||||||
|
var randomNumber = rng.Next(1, 7);
|
||||||
|
var toInsert = dice.Count;
|
||||||
|
if (ordered)
|
||||||
|
{
|
||||||
|
if (randomNumber == 6 || dice.Count == 0)
|
||||||
|
toInsert = 0;
|
||||||
|
else if (randomNumber != 1)
|
||||||
|
{
|
||||||
|
for (var j = 0; j < dice.Count; j++)
|
||||||
|
{
|
||||||
|
if (values[j] < randomNumber)
|
||||||
|
{
|
||||||
|
toInsert = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
toInsert = dice.Count;
|
||||||
|
|
||||||
|
dice.Insert(toInsert, await GetDiceAsync(randomNumber));
|
||||||
|
values.Insert(toInsert, randomNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var bitmap = dice.Merge(out var format);
|
||||||
|
await using var ms = bitmap.ToStream(format);
|
||||||
|
foreach (var d in dice)
|
||||||
|
d.Dispose();
|
||||||
|
|
||||||
|
var imageName = $"dice.{format.FileExtensions.First()}";
|
||||||
|
var eb = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithAuthor(ctx.User)
|
||||||
|
.AddField(GetText(strs.rolls), values.Select(x => Format.Code(x.ToString())).Join(' '), true)
|
||||||
|
.AddField(GetText(strs.total), values.Sum(), true)
|
||||||
|
.WithDescription(GetText(strs.dice_rolled_num(Format.Bold(values.Count.ToString()))))
|
||||||
|
.WithImageUrl($"attachment://{imageName}");
|
||||||
|
|
||||||
|
await ctx.Channel.SendFileAsync(ms,
|
||||||
|
imageName,
|
||||||
|
embed: eb.Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InternallDndRoll(string arg, bool ordered)
|
||||||
|
{
|
||||||
|
Match match;
|
||||||
|
if ((match = _fudgeRegex.Match(arg)).Length != 0
|
||||||
|
&& int.TryParse(match.Groups["n1"].ToString(), out var n1)
|
||||||
|
&& n1 is > 0 and < 500)
|
||||||
|
{
|
||||||
|
var rng = new EllieRandom();
|
||||||
|
|
||||||
|
var rolls = new List<char>();
|
||||||
|
|
||||||
|
for (var i = 0; i < n1; i++)
|
||||||
|
rolls.Add(_fateRolls[rng.Next(0, _fateRolls.Length)]);
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithAuthor(ctx.User)
|
||||||
|
.WithDescription(GetText(strs.dice_rolled_num(Format.Bold(n1.ToString()))))
|
||||||
|
.AddField(Format.Bold("Result"),
|
||||||
|
string.Join(" ", rolls.Select(c => Format.Code($"[{c}]"))));
|
||||||
|
|
||||||
|
await Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
else if ((match = _dndRegex.Match(arg)).Length != 0)
|
||||||
|
{
|
||||||
|
var rng = new EllieRandom();
|
||||||
|
if (int.TryParse(match.Groups["n1"].ToString(), out n1)
|
||||||
|
&& int.TryParse(match.Groups["n2"].ToString(), out var n2)
|
||||||
|
&& n1 <= 50
|
||||||
|
&& n2 <= 100000
|
||||||
|
&& n1 > 0
|
||||||
|
&& n2 > 0)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(match.Groups["add"].Value, out var add))
|
||||||
|
add = 0;
|
||||||
|
if (!int.TryParse(match.Groups["sub"].Value, out var sub))
|
||||||
|
sub = 0;
|
||||||
|
|
||||||
|
var arr = new int[n1];
|
||||||
|
for (var i = 0; i < n1; i++)
|
||||||
|
arr[i] = rng.Next(1, n2 + 1);
|
||||||
|
|
||||||
|
var sum = arr.Sum();
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithAuthor(ctx.User)
|
||||||
|
.WithDescription(GetText(strs.dice_rolled_num(n1 + $"`1 - {n2}`")))
|
||||||
|
.AddField(Format.Bold(GetText(strs.rolls)),
|
||||||
|
string.Join(" ",
|
||||||
|
(ordered ? arr.OrderBy(x => x).AsEnumerable() : arr).Select(x
|
||||||
|
=> Format.Code(x.ToString()))))
|
||||||
|
.AddField(Format.Bold("Sum"),
|
||||||
|
sum + " + " + add + " - " + sub + " = " + (sum + add - sub));
|
||||||
|
await Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task NRoll([Leftover] string range)
|
||||||
|
{
|
||||||
|
int rolled;
|
||||||
|
if (range.Contains("-"))
|
||||||
|
{
|
||||||
|
var arr = range.Split('-').Take(2).Select(int.Parse).ToArray();
|
||||||
|
if (arr[0] > arr[1])
|
||||||
|
{
|
||||||
|
await Response().Error(strs.second_larger_than_first).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rolled = new EllieRandom().Next(arr[0], arr[1] + 1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
rolled = new EllieRandom().Next(0, int.Parse(range) + 1);
|
||||||
|
|
||||||
|
await Response().Confirm(strs.dice_rolled(Format.Bold(rolled.ToString()))).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Image<Rgba32>> GetDiceAsync(int num)
|
||||||
|
{
|
||||||
|
if (num is < 0 or > 10)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(num));
|
||||||
|
|
||||||
|
if (num == 10)
|
||||||
|
{
|
||||||
|
using var imgOne = Image.Load<Rgba32>(await _images.GetDiceAsync(1));
|
||||||
|
using var imgZero = Image.Load<Rgba32>(await _images.GetDiceAsync(0));
|
||||||
|
return new[] { imgOne, imgZero }.Merge();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Image.Load<Rgba32>(await _images.GetDiceAsync(num));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
246
src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs
Normal file
246
src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
#nullable disable
|
||||||
|
using Ellie.Econ;
|
||||||
|
using EllieBot.Common.TypeReaders;
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
using Image = SixLabors.ImageSharp.Image;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public partial class Gambling
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class DrawCommands : GamblingSubmodule<IGamblingService>
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<IGuild, Deck> _allDecks = new();
|
||||||
|
private readonly IImageCache _images;
|
||||||
|
|
||||||
|
public DrawCommands(IImageCache images, GamblingConfigService gcs)
|
||||||
|
: base(gcs)
|
||||||
|
=> _images = images;
|
||||||
|
|
||||||
|
private async Task InternalDraw(int count, ulong? guildId = null)
|
||||||
|
{
|
||||||
|
if (count is < 1 or > 10)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(count));
|
||||||
|
|
||||||
|
var cards = guildId is null ? new() : _allDecks.GetOrAdd(ctx.Guild, _ => new());
|
||||||
|
var images = new List<Image<Rgba32>>();
|
||||||
|
var cardObjects = new List<Deck.Card>();
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
if (cards.CardPool.Count == 0 && i != 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_more_cards).SendAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentCard = cards.Draw();
|
||||||
|
cardObjects.Add(currentCard);
|
||||||
|
var image = await GetCardImageAsync(currentCard);
|
||||||
|
images.Add(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
var imgName = "cards.jpg";
|
||||||
|
using var img = images.Merge();
|
||||||
|
foreach (var i in images)
|
||||||
|
i.Dispose();
|
||||||
|
|
||||||
|
var eb = _sender.CreateEmbed()
|
||||||
|
.WithOkColor();
|
||||||
|
|
||||||
|
var toSend = string.Empty;
|
||||||
|
if (cardObjects.Count == 5)
|
||||||
|
eb.AddField(GetText(strs.hand_value), Deck.GetHandValue(cardObjects), true);
|
||||||
|
|
||||||
|
if (guildId is not null)
|
||||||
|
toSend += GetText(strs.cards_left(Format.Bold(cards.CardPool.Count.ToString())));
|
||||||
|
|
||||||
|
eb.WithDescription(toSend)
|
||||||
|
.WithAuthor(ctx.User)
|
||||||
|
.WithImageUrl($"attachment://{imgName}");
|
||||||
|
|
||||||
|
if (count > 1)
|
||||||
|
eb.AddField(GetText(strs.cards), count.ToString(), true);
|
||||||
|
|
||||||
|
await using var imageStream = await img.ToStreamAsync();
|
||||||
|
await ctx.Channel.SendFileAsync(imageStream,
|
||||||
|
imgName,
|
||||||
|
embed: eb.Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Image<Rgba32>> GetCardImageAsync(RegularCard currentCard)
|
||||||
|
{
|
||||||
|
var cardName = currentCard.GetName().ToLowerInvariant().Replace(' ', '_');
|
||||||
|
var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg");
|
||||||
|
return Image.Load<Rgba32>(cardBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Image<Rgba32>> GetCardImageAsync(Deck.Card currentCard)
|
||||||
|
{
|
||||||
|
var cardName = currentCard.ToString().ToLowerInvariant().Replace(' ', '_');
|
||||||
|
var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg");
|
||||||
|
return Image.Load<Rgba32>(cardBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Draw(int num = 1)
|
||||||
|
{
|
||||||
|
if (num < 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (num > 10)
|
||||||
|
num = 10;
|
||||||
|
|
||||||
|
await InternalDraw(num, ctx.Guild.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task DrawNew(int num = 1)
|
||||||
|
{
|
||||||
|
if (num < 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (num > 10)
|
||||||
|
num = 10;
|
||||||
|
|
||||||
|
await InternalDraw(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task DeckShuffle()
|
||||||
|
{
|
||||||
|
//var channel = (ITextChannel)ctx.Channel;
|
||||||
|
|
||||||
|
_allDecks.AddOrUpdate(ctx.Guild,
|
||||||
|
_ => new(),
|
||||||
|
(_, c) =>
|
||||||
|
{
|
||||||
|
c.Restart();
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Response().Confirm(strs.deck_reshuffled).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public Task BetDraw(
|
||||||
|
[OverrideTypeReader(typeof(BalanceTypeReader))]
|
||||||
|
long amount,
|
||||||
|
InputValueGuess val,
|
||||||
|
InputColorGuess? col = null)
|
||||||
|
=> BetDrawInternal(amount, val, col);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public Task BetDraw(
|
||||||
|
[OverrideTypeReader(typeof(BalanceTypeReader))]
|
||||||
|
long amount,
|
||||||
|
InputColorGuess col,
|
||||||
|
InputValueGuess? val = null)
|
||||||
|
=> BetDrawInternal(amount, val, col);
|
||||||
|
|
||||||
|
public async Task BetDrawInternal(long amount, InputValueGuess? val, InputColorGuess? col)
|
||||||
|
{
|
||||||
|
if (!await CheckBetMandatory(amount))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var res = await _service.BetDrawAsync(ctx.User.Id,
|
||||||
|
amount,
|
||||||
|
(byte?)val,
|
||||||
|
(byte?)col);
|
||||||
|
|
||||||
|
if (!res.TryPickT0(out var result, out _))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var eb = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithAuthor(ctx.User)
|
||||||
|
.WithDescription(result.Card.GetEmoji())
|
||||||
|
.AddField(GetText(strs.guess), GetGuessInfo(val, col), true)
|
||||||
|
.AddField(GetText(strs.card), GetCardInfo(result.Card), true)
|
||||||
|
.AddField(GetText(strs.won), N((long)result.Won), false)
|
||||||
|
.WithImageUrl("attachment://card.png");
|
||||||
|
|
||||||
|
using var img = await GetCardImageAsync(result.Card);
|
||||||
|
await using var imgStream = await img.ToStreamAsync();
|
||||||
|
await ctx.Channel.SendFileAsync(imgStream, "card.png", embed: eb.Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetGuessInfo(InputValueGuess? valG, InputColorGuess? colG)
|
||||||
|
{
|
||||||
|
var val = valG switch
|
||||||
|
{
|
||||||
|
InputValueGuess.H => "Hi ⬆️",
|
||||||
|
InputValueGuess.L => "Lo ⬇️",
|
||||||
|
_ => "❓"
|
||||||
|
};
|
||||||
|
|
||||||
|
var col = colG switch
|
||||||
|
{
|
||||||
|
InputColorGuess.Red => "R 🔴",
|
||||||
|
InputColorGuess.Black => "B ⚫",
|
||||||
|
_ => "❓"
|
||||||
|
};
|
||||||
|
|
||||||
|
return $"{val} / {col}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetCardInfo(RegularCard card)
|
||||||
|
{
|
||||||
|
var val = (int)card.Value switch
|
||||||
|
{
|
||||||
|
< 7 => "Lo ⬇️",
|
||||||
|
> 7 => "Hi ⬆️",
|
||||||
|
_ => "7 💀"
|
||||||
|
};
|
||||||
|
|
||||||
|
var col = card.Value == RegularValue.Seven
|
||||||
|
? "7 💀"
|
||||||
|
: card.Suit switch
|
||||||
|
{
|
||||||
|
RegularSuit.Diamonds or RegularSuit.Hearts => "R 🔴",
|
||||||
|
_ => "B ⚫"
|
||||||
|
};
|
||||||
|
|
||||||
|
return $"{val} / {col}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum InputValueGuess
|
||||||
|
{
|
||||||
|
High = 0,
|
||||||
|
H = 0,
|
||||||
|
Hi = 0,
|
||||||
|
Low = 1,
|
||||||
|
L = 1,
|
||||||
|
Lo = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum InputColorGuess
|
||||||
|
{
|
||||||
|
R = 0,
|
||||||
|
Red = 0,
|
||||||
|
B = 1,
|
||||||
|
Bl = 1,
|
||||||
|
Black = 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
12
src/EllieBot/Modules/Gambling/EconomyResult.cs
Normal file
12
src/EllieBot/Modules/Gambling/EconomyResult.cs
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
public sealed class EconomyResult
|
||||||
|
{
|
||||||
|
public decimal Cash { get; init; }
|
||||||
|
public decimal Planted { get; init; }
|
||||||
|
public decimal Waifus { get; init; }
|
||||||
|
public decimal OnePercent { get; init; }
|
||||||
|
public decimal Bank { get; init; }
|
||||||
|
public long Bot { get; init; }
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Common.Events;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public partial class Gambling
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class CurrencyEventsCommands : GamblingSubmodule<CurrencyEventsService>
|
||||||
|
{
|
||||||
|
public CurrencyEventsCommands(GamblingConfigService gamblingConf)
|
||||||
|
: base(gamblingConf)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[EllieOptions<EventOptions>]
|
||||||
|
[OwnerOnly]
|
||||||
|
public async Task EventStart(CurrencyEvent.Type ev, params string[] options)
|
||||||
|
{
|
||||||
|
var (opts, _) = OptionsParser.ParseFrom(new EventOptions(), options);
|
||||||
|
if (!await _service.TryCreateEventAsync(ctx.Guild.Id, ctx.Channel.Id, ev, opts, GetEmbed))
|
||||||
|
await Response().Error(strs.start_event_fail).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private EmbedBuilder GetEmbed(CurrencyEvent.Type type, EventOptions opts, long currentPot)
|
||||||
|
=> type switch
|
||||||
|
{
|
||||||
|
CurrencyEvent.Type.Reaction => _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithTitle(GetText(strs.event_title(type.ToString())))
|
||||||
|
.WithDescription(GetReactionDescription(opts.Amount, currentPot))
|
||||||
|
.WithFooter(GetText(strs.event_duration_footer(opts.Hours))),
|
||||||
|
CurrencyEvent.Type.GameStatus => _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithTitle(GetText(strs.event_title(type.ToString())))
|
||||||
|
.WithDescription(GetGameStatusDescription(opts.Amount, currentPot))
|
||||||
|
.WithFooter(GetText(strs.event_duration_footer(opts.Hours))),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(type))
|
||||||
|
};
|
||||||
|
|
||||||
|
private string GetReactionDescription(long amount, long potSize)
|
||||||
|
{
|
||||||
|
var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : N(potSize));
|
||||||
|
|
||||||
|
return GetText(strs.new_reaction_event(CurrencySign, Format.Bold(N(amount)), potSizeStr));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetGameStatusDescription(long amount, long potSize)
|
||||||
|
{
|
||||||
|
var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : potSize + CurrencySign);
|
||||||
|
|
||||||
|
return GetText(strs.new_gamestatus_event(CurrencySign, Format.Bold(N(amount)), potSizeStr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Common.Events;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
public class CurrencyEventsService : IEService
|
||||||
|
{
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
private readonly GamblingConfigService _configService;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<ulong, ICurrencyEvent> _events = new();
|
||||||
|
private readonly IMessageSenderService _sender;
|
||||||
|
|
||||||
|
public CurrencyEventsService(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService configService,
|
||||||
|
IMessageSenderService sender)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_cs = cs;
|
||||||
|
_configService = configService;
|
||||||
|
_sender = sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> TryCreateEventAsync(
|
||||||
|
ulong guildId,
|
||||||
|
ulong channelId,
|
||||||
|
CurrencyEvent.Type type,
|
||||||
|
EventOptions opts,
|
||||||
|
Func<CurrencyEvent.Type, EventOptions, long, EmbedBuilder> embed)
|
||||||
|
{
|
||||||
|
var g = _client.GetGuild(guildId);
|
||||||
|
if (g?.GetChannel(channelId) is not ITextChannel ch)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
ICurrencyEvent ce;
|
||||||
|
|
||||||
|
if (type == CurrencyEvent.Type.Reaction)
|
||||||
|
ce = new ReactionEvent(_client, _cs, g, ch, opts, _configService.Data, _sender, embed);
|
||||||
|
else if (type == CurrencyEvent.Type.GameStatus)
|
||||||
|
ce = new GameStatusEvent(_client, _cs, g, ch, opts, _sender, embed);
|
||||||
|
else
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var added = _events.TryAdd(guildId, ce);
|
||||||
|
if (added)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ce.OnEnded += OnEventEnded;
|
||||||
|
await ce.StartEvent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error starting event");
|
||||||
|
_events.TryRemove(guildId, out ce);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task OnEventEnded(ulong gid)
|
||||||
|
{
|
||||||
|
_events.TryRemove(gid, out _);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
39
src/EllieBot/Modules/Gambling/Events/EventOptions.cs
Normal file
39
src/EllieBot/Modules/Gambling/Events/EventOptions.cs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
#nullable disable
|
||||||
|
using CommandLine;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.Events;
|
||||||
|
|
||||||
|
public class EventOptions : IEllieCommandOptions
|
||||||
|
{
|
||||||
|
[Option('a', "amount", Required = false, Default = 100, HelpText = "Amount of currency each user receives.")]
|
||||||
|
public long Amount { get; set; } = 100;
|
||||||
|
|
||||||
|
[Option('p',
|
||||||
|
"pot-size",
|
||||||
|
Required = false,
|
||||||
|
Default = 0,
|
||||||
|
HelpText = "The maximum amount of currency that can be rewarded. 0 means no limit.")]
|
||||||
|
public long PotSize { get; set; }
|
||||||
|
|
||||||
|
//[Option('t', "type", Required = false, Default = "reaction", HelpText = "Type of the event. reaction, gamestatus or joinserver.")]
|
||||||
|
//public string TypeString { get; set; } = "reaction";
|
||||||
|
[Option('d',
|
||||||
|
"duration",
|
||||||
|
Required = false,
|
||||||
|
Default = 24,
|
||||||
|
HelpText = "Number of hours the event should run for. Default 24.")]
|
||||||
|
public int Hours { get; set; } = 24;
|
||||||
|
|
||||||
|
|
||||||
|
public void NormalizeOptions()
|
||||||
|
{
|
||||||
|
if (Amount < 0)
|
||||||
|
Amount = 100;
|
||||||
|
if (PotSize < 0)
|
||||||
|
PotSize = 0;
|
||||||
|
if (Hours <= 0)
|
||||||
|
Hours = 24;
|
||||||
|
if (PotSize != 0 && PotSize < Amount)
|
||||||
|
PotSize = 0;
|
||||||
|
}
|
||||||
|
}
|
195
src/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs
Normal file
195
src/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.Events;
|
||||||
|
|
||||||
|
public class GameStatusEvent : ICurrencyEvent
|
||||||
|
{
|
||||||
|
public event Func<ulong, Task> OnEnded;
|
||||||
|
private long PotSize { get; set; }
|
||||||
|
public bool Stopped { get; private set; }
|
||||||
|
public bool PotEmptied { get; private set; }
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly IGuild _guild;
|
||||||
|
private IUserMessage msg;
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
private readonly long _amount;
|
||||||
|
|
||||||
|
private readonly Func<CurrencyEvent.Type, EventOptions, long, EmbedBuilder> _embedFunc;
|
||||||
|
private readonly bool _isPotLimited;
|
||||||
|
private readonly ITextChannel _channel;
|
||||||
|
private readonly ConcurrentHashSet<ulong> _awardedUsers = new();
|
||||||
|
private readonly ConcurrentQueue<ulong> _toAward = new();
|
||||||
|
private readonly Timer _t;
|
||||||
|
private readonly Timer _timeout;
|
||||||
|
private readonly EventOptions _opts;
|
||||||
|
|
||||||
|
private readonly string _code;
|
||||||
|
|
||||||
|
private readonly object _stopLock = new();
|
||||||
|
|
||||||
|
private readonly object _potLock = new();
|
||||||
|
private readonly IMessageSenderService _sender;
|
||||||
|
|
||||||
|
private static readonly EllieRandom _rng = new EllieRandom();
|
||||||
|
|
||||||
|
public GameStatusEvent(
|
||||||
|
DiscordSocketClient client,
|
||||||
|
ICurrencyService cs,
|
||||||
|
SocketGuild g,
|
||||||
|
ITextChannel ch,
|
||||||
|
EventOptions opt,
|
||||||
|
IMessageSenderService sender,
|
||||||
|
Func<CurrencyEvent.Type, EventOptions, long, EmbedBuilder> embedFunc)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_guild = g;
|
||||||
|
_cs = cs;
|
||||||
|
_amount = opt.Amount;
|
||||||
|
PotSize = opt.PotSize;
|
||||||
|
_embedFunc = embedFunc;
|
||||||
|
_isPotLimited = PotSize > 0;
|
||||||
|
_channel = ch;
|
||||||
|
_opts = opt;
|
||||||
|
_sender = sender;
|
||||||
|
// generate code
|
||||||
|
|
||||||
|
_code = new kwum(_rng.Next(1_000_000, 10_000_000)).ToString();
|
||||||
|
|
||||||
|
_t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2));
|
||||||
|
if (_opts.Hours > 0)
|
||||||
|
_timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EventTimeout(object state)
|
||||||
|
=> _ = StopEvent();
|
||||||
|
|
||||||
|
private async void OnTimerTick(object state)
|
||||||
|
{
|
||||||
|
var potEmpty = PotEmptied;
|
||||||
|
var toAward = new List<ulong>();
|
||||||
|
while (_toAward.TryDequeue(out var x))
|
||||||
|
toAward.Add(x);
|
||||||
|
|
||||||
|
if (!toAward.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _cs.AddBulkAsync(toAward,
|
||||||
|
_amount,
|
||||||
|
new("event", "gamestatus")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_isPotLimited)
|
||||||
|
{
|
||||||
|
await msg.ModifyAsync(m =>
|
||||||
|
{
|
||||||
|
m.Embed = GetEmbed(PotSize).Build();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("Game status event awarded {Count} users {Amount} currency.{Remaining}",
|
||||||
|
toAward.Count,
|
||||||
|
_amount,
|
||||||
|
_isPotLimited ? $" {PotSize} left." : "");
|
||||||
|
|
||||||
|
if (potEmpty)
|
||||||
|
_ = StopEvent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error in OnTimerTick in gamestatusevent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartEvent()
|
||||||
|
{
|
||||||
|
msg = await _sender.Response(_channel).Embed(GetEmbed(_opts.PotSize)).SendAsync();
|
||||||
|
await _client.SetGameAsync(_code);
|
||||||
|
_client.MessageDeleted += OnMessageDeleted;
|
||||||
|
_client.MessageReceived += HandleMessage;
|
||||||
|
_t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private EmbedBuilder GetEmbed(long pot)
|
||||||
|
=> _embedFunc(CurrencyEvent.Type.GameStatus, _opts, pot);
|
||||||
|
|
||||||
|
private async Task OnMessageDeleted(Cacheable<IMessage, ulong> message, Cacheable<IMessageChannel, ulong> cacheable)
|
||||||
|
{
|
||||||
|
if (message.Id == msg.Id)
|
||||||
|
await StopEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopEvent()
|
||||||
|
{
|
||||||
|
lock (_stopLock)
|
||||||
|
{
|
||||||
|
if (Stopped)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
Stopped = true;
|
||||||
|
_client.MessageDeleted -= OnMessageDeleted;
|
||||||
|
_client.MessageReceived -= HandleMessage;
|
||||||
|
_t.Change(Timeout.Infinite, Timeout.Infinite);
|
||||||
|
_timeout?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||||
|
_ = _client.SetGameAsync(null);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = msg.DeleteAsync();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
_ = OnEnded?.Invoke(_guild.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleMessage(SocketMessage message)
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
if (message.Author is not IGuildUser gu // no unknown users, as they could be bots, or alts
|
||||||
|
|| gu.IsBot // no bots
|
||||||
|
|| message.Content != _code // code has to be the same
|
||||||
|
|| (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5) // no recently created accounts
|
||||||
|
return;
|
||||||
|
// there has to be money left in the pot
|
||||||
|
// and the user wasn't rewarded
|
||||||
|
if (_awardedUsers.Add(message.Author.Id) && TryTakeFromPot())
|
||||||
|
{
|
||||||
|
_toAward.Enqueue(message.Author.Id);
|
||||||
|
if (_isPotLimited && PotSize < _amount)
|
||||||
|
PotEmptied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await message.DeleteAsync(new()
|
||||||
|
{
|
||||||
|
RetryMode = RetryMode.AlwaysFail
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
});
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryTakeFromPot()
|
||||||
|
{
|
||||||
|
if (_isPotLimited)
|
||||||
|
{
|
||||||
|
lock (_potLock)
|
||||||
|
{
|
||||||
|
if (PotSize < _amount)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
PotSize -= _amount;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
9
src/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs
Normal file
9
src/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Gambling.Common;
|
||||||
|
|
||||||
|
public interface ICurrencyEvent
|
||||||
|
{
|
||||||
|
event Func<ulong, Task> OnEnded;
|
||||||
|
Task StopEvent();
|
||||||
|
Task StartEvent();
|
||||||
|
}
|
197
src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs
Normal file
197
src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.Events;
|
||||||
|
|
||||||
|
public class ReactionEvent : ICurrencyEvent
|
||||||
|
{
|
||||||
|
public event Func<ulong, Task> OnEnded;
|
||||||
|
private long PotSize { get; set; }
|
||||||
|
public bool Stopped { get; private set; }
|
||||||
|
public bool PotEmptied { get; private set; }
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly IGuild _guild;
|
||||||
|
private IUserMessage msg;
|
||||||
|
private IEmote emote;
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
private readonly long _amount;
|
||||||
|
|
||||||
|
private readonly Func<CurrencyEvent.Type, EventOptions, long, EmbedBuilder> _embedFunc;
|
||||||
|
private readonly bool _isPotLimited;
|
||||||
|
private readonly ITextChannel _channel;
|
||||||
|
private readonly ConcurrentHashSet<ulong> _awardedUsers = new();
|
||||||
|
private readonly System.Collections.Concurrent.ConcurrentQueue<ulong> _toAward = new();
|
||||||
|
private readonly Timer _t;
|
||||||
|
private readonly Timer _timeout;
|
||||||
|
private readonly bool _noRecentlyJoinedServer;
|
||||||
|
private readonly EventOptions _opts;
|
||||||
|
private readonly GamblingConfig _config;
|
||||||
|
|
||||||
|
private readonly object _stopLock = new();
|
||||||
|
|
||||||
|
private readonly object _potLock = new();
|
||||||
|
private readonly IMessageSenderService _sender;
|
||||||
|
|
||||||
|
public ReactionEvent(
|
||||||
|
DiscordSocketClient client,
|
||||||
|
ICurrencyService cs,
|
||||||
|
SocketGuild g,
|
||||||
|
ITextChannel ch,
|
||||||
|
EventOptions opt,
|
||||||
|
GamblingConfig config,
|
||||||
|
IMessageSenderService sender,
|
||||||
|
Func<CurrencyEvent.Type, EventOptions, long, EmbedBuilder> embedFunc)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_guild = g;
|
||||||
|
_cs = cs;
|
||||||
|
_amount = opt.Amount;
|
||||||
|
PotSize = opt.PotSize;
|
||||||
|
_embedFunc = embedFunc;
|
||||||
|
_isPotLimited = PotSize > 0;
|
||||||
|
_channel = ch;
|
||||||
|
_noRecentlyJoinedServer = false;
|
||||||
|
_opts = opt;
|
||||||
|
_config = config;
|
||||||
|
_sender = sender;
|
||||||
|
|
||||||
|
_t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2));
|
||||||
|
if (_opts.Hours > 0)
|
||||||
|
_timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EventTimeout(object state)
|
||||||
|
=> _ = StopEvent();
|
||||||
|
|
||||||
|
private async void OnTimerTick(object state)
|
||||||
|
{
|
||||||
|
var potEmpty = PotEmptied;
|
||||||
|
var toAward = new List<ulong>();
|
||||||
|
while (_toAward.TryDequeue(out var x))
|
||||||
|
toAward.Add(x);
|
||||||
|
|
||||||
|
if (!toAward.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _cs.AddBulkAsync(toAward, _amount, new("event", "reaction"));
|
||||||
|
|
||||||
|
if (_isPotLimited)
|
||||||
|
{
|
||||||
|
await msg.ModifyAsync(m =>
|
||||||
|
{
|
||||||
|
m.Embed = GetEmbed(PotSize).Build();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("Reaction Event awarded {Count} users {Amount} currency.{Remaining}",
|
||||||
|
toAward.Count,
|
||||||
|
_amount,
|
||||||
|
_isPotLimited ? $" {PotSize} left." : "");
|
||||||
|
|
||||||
|
if (potEmpty)
|
||||||
|
_ = StopEvent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error adding bulk currency to users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartEvent()
|
||||||
|
{
|
||||||
|
if (Emote.TryParse(_config.Currency.Sign, out var parsedEmote))
|
||||||
|
emote = parsedEmote;
|
||||||
|
else
|
||||||
|
emote = new Emoji(_config.Currency.Sign);
|
||||||
|
msg = await _sender.Response(_channel).Embed(GetEmbed(_opts.PotSize)).SendAsync();
|
||||||
|
await msg.AddReactionAsync(emote);
|
||||||
|
_client.MessageDeleted += OnMessageDeleted;
|
||||||
|
_client.ReactionAdded += HandleReaction;
|
||||||
|
_t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private EmbedBuilder GetEmbed(long pot)
|
||||||
|
=> _embedFunc(CurrencyEvent.Type.Reaction, _opts, pot);
|
||||||
|
|
||||||
|
private async Task OnMessageDeleted(Cacheable<IMessage, ulong> message, Cacheable<IMessageChannel, ulong> cacheable)
|
||||||
|
{
|
||||||
|
if (message.Id == msg.Id)
|
||||||
|
await StopEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopEvent()
|
||||||
|
{
|
||||||
|
lock (_stopLock)
|
||||||
|
{
|
||||||
|
if (Stopped)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
Stopped = true;
|
||||||
|
_client.MessageDeleted -= OnMessageDeleted;
|
||||||
|
_client.ReactionAdded -= HandleReaction;
|
||||||
|
_t.Change(Timeout.Infinite, Timeout.Infinite);
|
||||||
|
_timeout?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = msg.DeleteAsync();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
_ = OnEnded?.Invoke(_guild.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleReaction(
|
||||||
|
Cacheable<IUserMessage, ulong> message,
|
||||||
|
Cacheable<IMessageChannel, ulong> cacheable,
|
||||||
|
SocketReaction r)
|
||||||
|
{
|
||||||
|
_ = Task.Run(() =>
|
||||||
|
{
|
||||||
|
if (emote.Name != r.Emote.Name)
|
||||||
|
return;
|
||||||
|
if ((r.User.IsSpecified
|
||||||
|
? r.User.Value
|
||||||
|
: null) is not IGuildUser gu // no unknown users, as they could be bots, or alts
|
||||||
|
|| message.Id != msg.Id // same message
|
||||||
|
|| gu.IsBot // no bots
|
||||||
|
|| (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5 // no recently created accounts
|
||||||
|
|| (_noRecentlyJoinedServer
|
||||||
|
&& // if specified, no users who joined the server in the last 24h
|
||||||
|
(gu.JoinedAt is null
|
||||||
|
|| (DateTime.UtcNow - gu.JoinedAt.Value).TotalDays
|
||||||
|
< 1))) // and no users for who we don't know when they joined
|
||||||
|
return;
|
||||||
|
// there has to be money left in the pot
|
||||||
|
// and the user wasn't rewarded
|
||||||
|
if (_awardedUsers.Add(r.UserId) && TryTakeFromPot())
|
||||||
|
{
|
||||||
|
_toAward.Enqueue(r.UserId);
|
||||||
|
if (_isPotLimited && PotSize < _amount)
|
||||||
|
PotEmptied = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryTakeFromPot()
|
||||||
|
{
|
||||||
|
if (_isPotLimited)
|
||||||
|
{
|
||||||
|
lock (_potLock)
|
||||||
|
{
|
||||||
|
if (PotSize < _amount)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
PotSize -= _amount;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
140
src/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs
Normal file
140
src/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Common.TypeReaders;
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
using Image = SixLabors.ImageSharp.Image;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public partial class Gambling
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class FlipCoinCommands : GamblingSubmodule<IGamblingService>
|
||||||
|
{
|
||||||
|
public enum BetFlipGuess : byte
|
||||||
|
{
|
||||||
|
H = 0,
|
||||||
|
Head = 0,
|
||||||
|
Heads = 0,
|
||||||
|
T = 1,
|
||||||
|
Tail = 1,
|
||||||
|
Tails = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly EllieRandom _rng = new();
|
||||||
|
private readonly IImageCache _images;
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
private readonly ImagesConfig _ic;
|
||||||
|
|
||||||
|
public FlipCoinCommands(
|
||||||
|
IImageCache images,
|
||||||
|
ImagesConfig ic,
|
||||||
|
ICurrencyService cs,
|
||||||
|
GamblingConfigService gss)
|
||||||
|
: base(gss)
|
||||||
|
{
|
||||||
|
_ic = ic;
|
||||||
|
_images = images;
|
||||||
|
_cs = cs;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task Flip(int count = 1)
|
||||||
|
{
|
||||||
|
if (count is > 10 or < 1)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.flip_invalid(10)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var headCount = 0;
|
||||||
|
var tailCount = 0;
|
||||||
|
var imgs = new Image<Rgba32>[count];
|
||||||
|
var headsArr = await _images.GetHeadsImageAsync();
|
||||||
|
var tailsArr = await _images.GetTailsImageAsync();
|
||||||
|
|
||||||
|
var result = await _service.FlipAsync(count);
|
||||||
|
|
||||||
|
for (var i = 0; i < result.Length; i++)
|
||||||
|
{
|
||||||
|
if (result[i].Side == 0)
|
||||||
|
{
|
||||||
|
imgs[i] = Image.Load<Rgba32>(headsArr);
|
||||||
|
headCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
imgs[i] = Image.Load<Rgba32>(tailsArr);
|
||||||
|
tailCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using var img = imgs.Merge(out var format);
|
||||||
|
await using var stream = await img.ToStreamAsync(format);
|
||||||
|
foreach (var i in imgs)
|
||||||
|
i.Dispose();
|
||||||
|
|
||||||
|
var imgName = $"coins.{format.FileExtensions.First()}";
|
||||||
|
|
||||||
|
var msg = count != 1
|
||||||
|
? Format.Bold(GetText(strs.flip_results(count, headCount, tailCount)))
|
||||||
|
: GetText(strs.flipped(headCount > 0
|
||||||
|
? Format.Bold(GetText(strs.heads))
|
||||||
|
: Format.Bold(GetText(strs.tails))));
|
||||||
|
|
||||||
|
var eb = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithAuthor(ctx.User)
|
||||||
|
.WithDescription(msg)
|
||||||
|
.WithImageUrl($"attachment://{imgName}");
|
||||||
|
|
||||||
|
await ctx.Channel.SendFileAsync(stream,
|
||||||
|
imgName,
|
||||||
|
embed: eb.Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task Betflip([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, BetFlipGuess guess)
|
||||||
|
{
|
||||||
|
if (!await CheckBetMandatory(amount) || amount == 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var res = await _service.BetFlipAsync(ctx.User.Id, amount, (byte)guess);
|
||||||
|
if (!res.TryPickT0(out var result, out _))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri imageToSend;
|
||||||
|
var coins = _ic.Data.Coins;
|
||||||
|
if (result.Side == 0)
|
||||||
|
{
|
||||||
|
imageToSend = coins.Heads[_rng.Next(0, coins.Heads.Length)];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
imageToSend = coins.Tails[_rng.Next(0, coins.Tails.Length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
string str;
|
||||||
|
var won = (long)result.Won;
|
||||||
|
if (won > 0)
|
||||||
|
{
|
||||||
|
str = Format.Bold(GetText(strs.flip_guess(N(won))));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
str = Format.Bold(GetText(strs.better_luck));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Embed(_sender.CreateEmbed()
|
||||||
|
.WithAuthor(ctx.User)
|
||||||
|
.WithDescription(str)
|
||||||
|
.WithOkColor()
|
||||||
|
.WithImageUrl(imageToSend.ToString())).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
7
src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs
Normal file
7
src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public readonly struct FlipResult
|
||||||
|
{
|
||||||
|
public long Won { get; init; }
|
||||||
|
public int Side { get; init; }
|
||||||
|
}
|
903
src/EllieBot/Modules/Gambling/Gambling.cs
Normal file
903
src/EllieBot/Modules/Gambling/Gambling.cs
Normal file
|
@ -0,0 +1,903 @@
|
||||||
|
#nullable disable
|
||||||
|
using LinqToDB;
|
||||||
|
using LinqToDB.EntityFrameworkCore;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using EllieBot.Modules.Gambling.Bank;
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using EllieBot.Modules.Utility.Services;
|
||||||
|
using EllieBot.Services.Currency;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using EllieBot.Modules.Gambling.Rps;
|
||||||
|
using EllieBot.Common.TypeReaders;
|
||||||
|
using EllieBot.Modules.Patronage;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public partial class Gambling : GamblingModule<GamblingService>
|
||||||
|
{
|
||||||
|
private readonly IGamblingService _gs;
|
||||||
|
private readonly DbService _db;
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly NumberFormatInfo _enUsCulture;
|
||||||
|
private readonly DownloadTracker _tracker;
|
||||||
|
private readonly GamblingConfigService _configService;
|
||||||
|
private readonly IBankService _bank;
|
||||||
|
private readonly IRemindService _remind;
|
||||||
|
private readonly GamblingTxTracker _gamblingTxTracker;
|
||||||
|
private readonly IPatronageService _ps;
|
||||||
|
|
||||||
|
public Gambling(
|
||||||
|
IGamblingService gs,
|
||||||
|
DbService db,
|
||||||
|
ICurrencyService currency,
|
||||||
|
DiscordSocketClient client,
|
||||||
|
DownloadTracker tracker,
|
||||||
|
GamblingConfigService configService,
|
||||||
|
IBankService bank,
|
||||||
|
IRemindService remind,
|
||||||
|
IPatronageService patronage,
|
||||||
|
GamblingTxTracker gamblingTxTracker)
|
||||||
|
: base(configService)
|
||||||
|
{
|
||||||
|
_gs = gs;
|
||||||
|
_db = db;
|
||||||
|
_cs = currency;
|
||||||
|
_client = client;
|
||||||
|
_bank = bank;
|
||||||
|
_remind = remind;
|
||||||
|
_gamblingTxTracker = gamblingTxTracker;
|
||||||
|
_ps = patronage;
|
||||||
|
|
||||||
|
_enUsCulture = new CultureInfo("en-US", false).NumberFormat;
|
||||||
|
_enUsCulture.NumberDecimalDigits = 0;
|
||||||
|
_enUsCulture.NumberGroupSeparator = " ";
|
||||||
|
_tracker = tracker;
|
||||||
|
_configService = configService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetBalanceStringAsync(ulong userId)
|
||||||
|
{
|
||||||
|
var bal = await _cs.GetBalanceAsync(userId);
|
||||||
|
return N(bal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task BetStats()
|
||||||
|
{
|
||||||
|
var stats = await _gamblingTxTracker.GetAllAsync();
|
||||||
|
|
||||||
|
var eb = _sender.CreateEmbed()
|
||||||
|
.WithOkColor();
|
||||||
|
|
||||||
|
var str = "` Feature `|` Bet `|`Paid Out`|` RoI `\n";
|
||||||
|
str += "――――――――――――――――――――\n";
|
||||||
|
foreach (var stat in stats)
|
||||||
|
{
|
||||||
|
var perc = (stat.PaidOut / stat.Bet).ToString("P2", Culture);
|
||||||
|
str += $"`{stat.Feature.PadBoth(9)}`"
|
||||||
|
+ $"|`{stat.Bet.ToString("N0").PadLeft(8, ' ')}`"
|
||||||
|
+ $"|`{stat.PaidOut.ToString("N0").PadLeft(8, ' ')}`"
|
||||||
|
+ $"|`{perc.PadLeft(6, ' ')}`\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
var bet = stats.Sum(x => x.Bet);
|
||||||
|
var paidOut = stats.Sum(x => x.PaidOut);
|
||||||
|
|
||||||
|
if (bet == 0)
|
||||||
|
bet = 1;
|
||||||
|
|
||||||
|
var tPerc = (paidOut / bet).ToString("P2", Culture);
|
||||||
|
str += "――――――――――――――――――――\n";
|
||||||
|
str += $"` {("TOTAL").PadBoth(7)}` "
|
||||||
|
+ $"|**{N(bet).PadLeft(8, ' ')}**"
|
||||||
|
+ $"|**{N(paidOut).PadLeft(8, ' ')}**"
|
||||||
|
+ $"|`{tPerc.PadLeft(6, ' ')}`";
|
||||||
|
|
||||||
|
eb.WithDescription(str);
|
||||||
|
|
||||||
|
await Response().Embed(eb).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemindTimelyAction(SocketMessageComponent smc, DateTime when)
|
||||||
|
{
|
||||||
|
var tt = TimestampTag.FromDateTime(when, TimestampTagStyles.Relative);
|
||||||
|
|
||||||
|
await _remind.AddReminderAsync(ctx.User.Id,
|
||||||
|
ctx.User.Id,
|
||||||
|
ctx.Guild?.Id,
|
||||||
|
true,
|
||||||
|
when,
|
||||||
|
GetText(strs.timely_time),
|
||||||
|
ReminderType.Timely);
|
||||||
|
|
||||||
|
await smc.RespondConfirmAsync(_sender, GetText(strs.remind_timely(tt)), ephemeral: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates timely reminder button, parameter in hours.
|
||||||
|
private EllieInteractionBase CreateRemindMeInteraction(int period)
|
||||||
|
=> _inter
|
||||||
|
.Create(ctx.User.Id,
|
||||||
|
new ButtonBuilder(
|
||||||
|
label: "Remind me",
|
||||||
|
emote: Emoji.Parse("⏰"),
|
||||||
|
customId: "timely:remind_me"),
|
||||||
|
(smc) => RemindTimelyAction(smc, DateTime.UtcNow.Add(TimeSpan.FromHours(period)))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Creates timely reminder button, parameter in milliseconds.
|
||||||
|
private EllieInteractionBase CreateRemindMeInteraction(double ms)
|
||||||
|
=> _inter
|
||||||
|
.Create(ctx.User.Id,
|
||||||
|
new ButtonBuilder(
|
||||||
|
label: "Remind me",
|
||||||
|
emote: Emoji.Parse("⏰"),
|
||||||
|
customId: "timely:remind_me"),
|
||||||
|
(smc) => RemindTimelyAction(smc, DateTime.UtcNow.Add(TimeSpan.FromMilliseconds(ms)))
|
||||||
|
);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task Timely()
|
||||||
|
{
|
||||||
|
var val = Config.Timely.Amount;
|
||||||
|
var period = Config.Timely.Cooldown;
|
||||||
|
if (val <= 0 || period <= 0)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.timely_none).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await _service.ClaimTimelyAsync(ctx.User.Id, period) is { } remainder)
|
||||||
|
{
|
||||||
|
// Get correct time form remainder
|
||||||
|
var interaction = CreateRemindMeInteraction(remainder.TotalMilliseconds);
|
||||||
|
|
||||||
|
// Removes timely button if there is a timely reminder in DB
|
||||||
|
if (_service.UserHasTimelyReminder(ctx.User.Id))
|
||||||
|
{
|
||||||
|
interaction = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var relativeTag = TimestampTag.FromDateTime(now.Add(remainder), TimestampTagStyles.Relative);
|
||||||
|
await Response().Pending(strs.timely_already_claimed(relativeTag)).Interaction(interaction).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var patron = await _ps.GetPatronAsync(ctx.User.Id);
|
||||||
|
|
||||||
|
var percentBonus = (_ps.PercentBonus(patron) / 100f);
|
||||||
|
|
||||||
|
val += (int)(val * percentBonus);
|
||||||
|
|
||||||
|
var inter = CreateRemindMeInteraction(period);
|
||||||
|
|
||||||
|
await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim"));
|
||||||
|
|
||||||
|
await Response().Confirm(strs.timely(N(val), period)).Interaction(inter).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
public async Task TimelyReset()
|
||||||
|
{
|
||||||
|
await _service.RemoveAllTimelyClaimsAsync();
|
||||||
|
await Response().Confirm(strs.timely_reset).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
public async Task TimelySet(int amount, int period = 24)
|
||||||
|
{
|
||||||
|
if (amount < 0 || period < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_configService.ModifyConfig(gs =>
|
||||||
|
{
|
||||||
|
gs.Timely.Amount = amount;
|
||||||
|
gs.Timely.Cooldown = period;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (amount == 0)
|
||||||
|
{
|
||||||
|
await Response().Confirm(strs.timely_set_none).SendAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Response()
|
||||||
|
.Confirm(strs.timely_set(Format.Bold(N(amount)), Format.Bold(period.ToString())))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Raffle([Leftover] IRole role = null)
|
||||||
|
{
|
||||||
|
role ??= ctx.Guild.EveryoneRole;
|
||||||
|
|
||||||
|
var members = (await role.GetMembersAsync()).Where(u => u.Status != UserStatus.Offline);
|
||||||
|
var membersArray = members as IUser[] ?? members.ToArray();
|
||||||
|
if (membersArray.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var usr = membersArray[new EllieRandom().Next(0, membersArray.Length)];
|
||||||
|
await Response()
|
||||||
|
.Confirm("🎟 " + GetText(strs.raffled_user),
|
||||||
|
$"**{usr.Username}**",
|
||||||
|
footer: $"ID: {usr.Id}")
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task RaffleAny([Leftover] IRole role = null)
|
||||||
|
{
|
||||||
|
role ??= ctx.Guild.EveryoneRole;
|
||||||
|
|
||||||
|
var members = await role.GetMembersAsync();
|
||||||
|
var membersArray = members as IUser[] ?? members.ToArray();
|
||||||
|
if (membersArray.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var usr = membersArray[new EllieRandom().Next(0, membersArray.Length)];
|
||||||
|
await Response()
|
||||||
|
.Confirm("🎟 " + GetText(strs.raffled_user),
|
||||||
|
$"**{usr.Username}**",
|
||||||
|
footer: $"ID: {usr.Id}")
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[Priority(2)]
|
||||||
|
public Task CurrencyTransactions(int page = 1)
|
||||||
|
=> InternalCurrencyTransactions(ctx.User.Id, page);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
[Priority(0)]
|
||||||
|
public Task CurrencyTransactions([Leftover] IUser usr)
|
||||||
|
=> InternalCurrencyTransactions(usr.Id, 1);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
[Priority(1)]
|
||||||
|
public Task CurrencyTransactions(IUser usr, int page)
|
||||||
|
=> InternalCurrencyTransactions(usr.Id, page);
|
||||||
|
|
||||||
|
private async Task InternalCurrencyTransactions(ulong userId, int page)
|
||||||
|
{
|
||||||
|
if (--page < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CurrencyTransaction> trs;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
trs = await uow.Set<CurrencyTransaction>().GetPageFor(userId, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithTitle(GetText(strs.transactions(((SocketGuild)ctx.Guild)?.GetUser(userId)?.ToString()
|
||||||
|
?? $"{userId}")))
|
||||||
|
.WithOkColor();
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var tr in trs)
|
||||||
|
{
|
||||||
|
var change = tr.Amount >= 0 ? "🔵" : "🔴";
|
||||||
|
var kwumId = new kwum(tr.Id).ToString();
|
||||||
|
var date = $"#{Format.Code(kwumId)} `〖{GetFormattedCurtrDate(tr)}〗`";
|
||||||
|
|
||||||
|
sb.AppendLine($"\\{change} {date} {Format.Bold(N(tr.Amount))}");
|
||||||
|
var transactionString = GetHumanReadableTransaction(tr.Type, tr.Extra, tr.OtherId);
|
||||||
|
if (transactionString is not null)
|
||||||
|
{
|
||||||
|
sb.AppendLine(transactionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(tr.Note))
|
||||||
|
{
|
||||||
|
sb.AppendLine($"\t`Note:` {tr.Note.TrimTo(50)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.WithDescription(sb.ToString());
|
||||||
|
embed.WithFooter(GetText(strs.page(page + 1)));
|
||||||
|
await Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetFormattedCurtrDate(CurrencyTransaction ct)
|
||||||
|
=> $"{ct.DateAdded:HH:mm yyyy-MM-dd}";
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task CurrencyTransaction(kwum id)
|
||||||
|
{
|
||||||
|
int intId = id;
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
|
||||||
|
var tr = await uow.Set<CurrencyTransaction>()
|
||||||
|
.ToLinqToDBTable()
|
||||||
|
.Where(x => x.Id == intId && x.UserId == ctx.User.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (tr is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_found).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var eb = _sender.CreateEmbed().WithOkColor();
|
||||||
|
|
||||||
|
eb.WithAuthor(ctx.User);
|
||||||
|
eb.WithTitle(GetText(strs.transaction));
|
||||||
|
eb.WithDescription(new kwum(tr.Id).ToString());
|
||||||
|
eb.AddField("Amount", N(tr.Amount));
|
||||||
|
eb.AddField("Type", tr.Type, true);
|
||||||
|
eb.AddField("Extra", tr.Extra, true);
|
||||||
|
|
||||||
|
if (tr.OtherId is ulong other)
|
||||||
|
{
|
||||||
|
eb.AddField("From Id", other);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(tr.Note))
|
||||||
|
{
|
||||||
|
eb.AddField("Note", tr.Note);
|
||||||
|
}
|
||||||
|
|
||||||
|
eb.WithFooter(GetFormattedCurtrDate(tr));
|
||||||
|
|
||||||
|
await Response().Embed(eb).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetHumanReadableTransaction(string type, string subType, ulong? maybeUserId)
|
||||||
|
=> (type, subType, maybeUserId) switch
|
||||||
|
{
|
||||||
|
("gift", var name, ulong userId) => GetText(strs.curtr_gift(name, userId)),
|
||||||
|
("award", var name, ulong userId) => GetText(strs.curtr_award(name, userId)),
|
||||||
|
("take", var name, ulong userId) => GetText(strs.curtr_take(name, userId)),
|
||||||
|
("blackjack", _, _) => $"Blackjack - {subType}",
|
||||||
|
("wheel", _, _) => $"Lucky Ladder - {subType}",
|
||||||
|
("lula", _, _) => $"Lucky Ladder - {subType}",
|
||||||
|
("rps", _, _) => $"Rock Paper Scissors - {subType}",
|
||||||
|
(null, _, _) => null,
|
||||||
|
(_, null, _) => null,
|
||||||
|
(_, _, ulong userId) => $"{type} - {subType} | [{userId}]",
|
||||||
|
_ => $"{type} - {subType}"
|
||||||
|
};
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[Priority(0)]
|
||||||
|
public async Task Cash(ulong userId)
|
||||||
|
{
|
||||||
|
var cur = await GetBalanceStringAsync(userId);
|
||||||
|
await Response().Confirm(strs.has(Format.Code(userId.ToString()), cur)).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BankAction(SocketMessageComponent smc)
|
||||||
|
{
|
||||||
|
var balance = await _bank.GetBalanceAsync(ctx.User.Id);
|
||||||
|
|
||||||
|
await N(balance)
|
||||||
|
.Pipe(strs.bank_balance)
|
||||||
|
.Pipe(GetText)
|
||||||
|
.Pipe(text => smc.RespondConfirmAsync(_sender, text, ephemeral: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private EllieInteractionBase CreateCashInteraction()
|
||||||
|
=> _inter.Create(ctx.User.Id,
|
||||||
|
new ButtonBuilder(
|
||||||
|
customId: "cash:bank_show_balance",
|
||||||
|
emote: new Emoji("🏦")),
|
||||||
|
BankAction);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[Priority(1)]
|
||||||
|
public async Task Cash([Leftover] IUser user = null)
|
||||||
|
{
|
||||||
|
user ??= ctx.User;
|
||||||
|
var cur = await GetBalanceStringAsync(user.Id);
|
||||||
|
|
||||||
|
var inter = user == ctx.User
|
||||||
|
? CreateCashInteraction()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await Response()
|
||||||
|
.Confirm(
|
||||||
|
user.ToString()
|
||||||
|
.Pipe(Format.Bold)
|
||||||
|
.With(cur)
|
||||||
|
.Pipe(strs.has))
|
||||||
|
.Interaction(inter)
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(0)]
|
||||||
|
public async Task Give(
|
||||||
|
[OverrideTypeReader(typeof(BalanceTypeReader))]
|
||||||
|
long amount,
|
||||||
|
IGuildUser receiver,
|
||||||
|
[Leftover] string msg)
|
||||||
|
{
|
||||||
|
if (amount <= 0 || ctx.User.Id == receiver.Id || receiver.IsBot)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await _cs.TransferAsync(_sender, ctx.User, receiver, amount, msg, N(amount)))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Confirm(strs.gifted(N(amount), Format.Bold(receiver.ToString()), ctx.User)).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(1)]
|
||||||
|
public Task Give([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, [Leftover] IGuildUser receiver)
|
||||||
|
=> Give(amount, receiver, null);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[OwnerOnly]
|
||||||
|
[Priority(0)]
|
||||||
|
public Task Award(long amount, IGuildUser usr, [Leftover] string msg)
|
||||||
|
=> Award(amount, usr.Id, msg);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[OwnerOnly]
|
||||||
|
[Priority(1)]
|
||||||
|
public Task Award(long amount, [Leftover] IGuildUser usr)
|
||||||
|
=> Award(amount, usr.Id);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
[Priority(2)]
|
||||||
|
public async Task Award(long amount, ulong usrId, [Leftover] string msg = null)
|
||||||
|
{
|
||||||
|
if (amount <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var usr = await ((DiscordSocketClient)Context.Client).Rest.GetUserAsync(usrId);
|
||||||
|
|
||||||
|
if (usr is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.user_not_found).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _cs.AddAsync(usr.Id, amount, new("award", ctx.User.ToString()!, msg, ctx.User.Id));
|
||||||
|
await Response().Confirm(strs.awarded(N(amount), $"<@{usrId}>", ctx.User)).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[OwnerOnly]
|
||||||
|
[Priority(3)]
|
||||||
|
public async Task Award(long amount, [Leftover] IRole role)
|
||||||
|
{
|
||||||
|
var users = (await ctx.Guild.GetUsersAsync()).Where(u => u.GetRoles().Contains(role)).ToList();
|
||||||
|
|
||||||
|
await _cs.AddBulkAsync(users.Select(x => x.Id).ToList(),
|
||||||
|
amount,
|
||||||
|
new("award", ctx.User.ToString()!, role.Name, ctx.User.Id));
|
||||||
|
|
||||||
|
await Response()
|
||||||
|
.Confirm(strs.mass_award(N(amount),
|
||||||
|
Format.Bold(users.Count.ToString()),
|
||||||
|
Format.Bold(role.Name)))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[OwnerOnly]
|
||||||
|
[Priority(0)]
|
||||||
|
public async Task Take(long amount, [Leftover] IRole role)
|
||||||
|
{
|
||||||
|
var users = (await role.GetMembersAsync()).ToList();
|
||||||
|
|
||||||
|
await _cs.RemoveBulkAsync(users.Select(x => x.Id).ToList(),
|
||||||
|
amount,
|
||||||
|
new("take", ctx.User.ToString()!, null, ctx.User.Id));
|
||||||
|
|
||||||
|
await Response()
|
||||||
|
.Confirm(strs.mass_take(N(amount),
|
||||||
|
Format.Bold(users.Count.ToString()),
|
||||||
|
Format.Bold(role.Name)))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[OwnerOnly]
|
||||||
|
[Priority(1)]
|
||||||
|
public async Task Take(long amount, [Leftover] IGuildUser user)
|
||||||
|
{
|
||||||
|
if (amount <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var extra = new TxData("take", ctx.User.ToString()!, null, ctx.User.Id);
|
||||||
|
|
||||||
|
if (await _cs.RemoveAsync(user.Id, amount, extra))
|
||||||
|
{
|
||||||
|
await Response().Confirm(strs.take(N(amount), Format.Bold(user.ToString()))).SendAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Response().Error(strs.take_fail(N(amount), Format.Bold(user.ToString()), CurrencySign)).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
public async Task Take(long amount, [Leftover] ulong usrId)
|
||||||
|
{
|
||||||
|
if (amount <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var extra = new TxData("take", ctx.User.ToString()!, null, ctx.User.Id);
|
||||||
|
|
||||||
|
if (await _cs.RemoveAsync(usrId, amount, extra))
|
||||||
|
{
|
||||||
|
await Response().Confirm(strs.take(N(amount), $"<@{usrId}>")).SendAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Response().Error(strs.take_fail(N(amount), Format.Code(usrId.ToString()), CurrencySign)).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task BetRoll([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
|
||||||
|
{
|
||||||
|
if (!await CheckBetMandatory(amount))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maybeResult = await _gs.BetRollAsync(ctx.User.Id, amount);
|
||||||
|
if (!maybeResult.TryPickT0(out var result, out _))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var win = (long)result.Won;
|
||||||
|
string str;
|
||||||
|
if (win > 0)
|
||||||
|
{
|
||||||
|
str = GetText(strs.br_win(N(win), result.Threshold + (result.Roll == 100 ? " 👑" : "")));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
str = GetText(strs.better_luck);
|
||||||
|
}
|
||||||
|
|
||||||
|
var eb = _sender.CreateEmbed()
|
||||||
|
.WithAuthor(ctx.User)
|
||||||
|
.WithDescription(Format.Bold(str))
|
||||||
|
.AddField(GetText(strs.roll2), result.Roll.ToString(CultureInfo.InvariantCulture))
|
||||||
|
.WithOkColor();
|
||||||
|
|
||||||
|
await Response().Embed(eb).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[EllieOptions<LbOpts>]
|
||||||
|
[Priority(0)]
|
||||||
|
public Task Leaderboard(params string[] args)
|
||||||
|
=> Leaderboard(1, args);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[EllieOptions<LbOpts>]
|
||||||
|
[Priority(1)]
|
||||||
|
public async Task Leaderboard(int page = 1, params string[] args)
|
||||||
|
{
|
||||||
|
if (--page < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (opts, _) = OptionsParser.ParseFrom(new LbOpts(), args);
|
||||||
|
|
||||||
|
// List<DiscordUser> cleanRichest;
|
||||||
|
// it's pointless to have clean on dm context
|
||||||
|
if (ctx.Guild is null)
|
||||||
|
{
|
||||||
|
opts.Clean = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async Task<IReadOnlyCollection<DiscordUser>> GetTopRichest(int curPage)
|
||||||
|
{
|
||||||
|
if (opts.Clean)
|
||||||
|
{
|
||||||
|
await ctx.Channel.TriggerTypingAsync();
|
||||||
|
await _tracker.EnsureUsersDownloadedAsync(ctx.Guild);
|
||||||
|
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
|
||||||
|
var cleanRichest = await uow.Set<DiscordUser>()
|
||||||
|
.GetTopRichest(_client.CurrentUser.Id, 0, 1000);
|
||||||
|
|
||||||
|
var sg = (SocketGuild)ctx.Guild!;
|
||||||
|
return cleanRichest.Where(x => sg.GetUser(x.UserId) is not null).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
return await uow.Set<DiscordUser>().GetTopRichest(_client.CurrentUser.Id, curPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var res = Response()
|
||||||
|
.Paginated();
|
||||||
|
|
||||||
|
await Response()
|
||||||
|
.Paginated()
|
||||||
|
.PageItems(GetTopRichest)
|
||||||
|
.TotalElements(900)
|
||||||
|
.PageSize(9)
|
||||||
|
.CurrentPage(page)
|
||||||
|
.Page((toSend, curPage) =>
|
||||||
|
{
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithTitle(CurrencySign + " " + GetText(strs.leaderboard));
|
||||||
|
|
||||||
|
if (!toSend.Any())
|
||||||
|
{
|
||||||
|
embed.WithDescription(GetText(strs.no_user_on_this_page));
|
||||||
|
return Task.FromResult(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < toSend.Count; i++)
|
||||||
|
{
|
||||||
|
var x = toSend[i];
|
||||||
|
var usrStr = x.ToString().TrimTo(20, true);
|
||||||
|
|
||||||
|
var j = i;
|
||||||
|
embed.AddField("#" + ((9 * curPage) + j + 1) + " " + usrStr, N(x.CurrencyAmount), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(embed);
|
||||||
|
})
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum InputRpsPick : byte
|
||||||
|
{
|
||||||
|
R = 0,
|
||||||
|
Rock = 0,
|
||||||
|
Rocket = 0,
|
||||||
|
P = 1,
|
||||||
|
Paper = 1,
|
||||||
|
Paperclip = 1,
|
||||||
|
S = 2,
|
||||||
|
Scissors = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task Rps(InputRpsPick pick, [OverrideTypeReader(typeof(BalanceTypeReader))] long amount = default)
|
||||||
|
{
|
||||||
|
static string GetRpsPick(InputRpsPick p)
|
||||||
|
{
|
||||||
|
switch (p)
|
||||||
|
{
|
||||||
|
case InputRpsPick.R:
|
||||||
|
return "🚀";
|
||||||
|
case InputRpsPick.P:
|
||||||
|
return "📎";
|
||||||
|
default:
|
||||||
|
return "✂️";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await CheckBetOptional(amount) || amount == 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var res = await _gs.RpsAsync(ctx.User.Id, amount, (byte)pick);
|
||||||
|
|
||||||
|
if (!res.TryPickT0(out var result, out _))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var embed = _sender.CreateEmbed();
|
||||||
|
|
||||||
|
string msg;
|
||||||
|
if (result.Result == RpsResultType.Draw)
|
||||||
|
{
|
||||||
|
msg = GetText(strs.rps_draw(GetRpsPick(pick)));
|
||||||
|
}
|
||||||
|
else if (result.Result == RpsResultType.Win)
|
||||||
|
{
|
||||||
|
if ((long)result.Won > 0)
|
||||||
|
embed.AddField(GetText(strs.won), N((long)result.Won));
|
||||||
|
|
||||||
|
msg = GetText(strs.rps_win(ctx.User.Mention,
|
||||||
|
GetRpsPick(pick),
|
||||||
|
GetRpsPick((InputRpsPick)result.ComputerPick)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
msg = GetText(strs.rps_win(ctx.Client.CurrentUser.Mention,
|
||||||
|
GetRpsPick((InputRpsPick)result.ComputerPick),
|
||||||
|
GetRpsPick(pick)));
|
||||||
|
}
|
||||||
|
|
||||||
|
embed
|
||||||
|
.WithOkColor()
|
||||||
|
.WithDescription(msg);
|
||||||
|
|
||||||
|
await Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly ImmutableArray<string> _emojis =
|
||||||
|
new[] { "⬆", "↖", "⬅", "↙", "⬇", "↘", "➡", "↗" }.ToImmutableArray();
|
||||||
|
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task LuckyLadder([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
|
||||||
|
{
|
||||||
|
if (!await CheckBetMandatory(amount))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var res = await _gs.LulaAsync(ctx.User.Id, amount);
|
||||||
|
if (!res.TryPickT0(out var result, out _))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var multis = result.Multipliers;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var multi in multis)
|
||||||
|
{
|
||||||
|
sb.Append($"╠══╣");
|
||||||
|
|
||||||
|
if (multi == result.Multiplier)
|
||||||
|
sb.Append($"{Format.Bold($"x{multi:0.##}")} ⬅️");
|
||||||
|
else
|
||||||
|
sb.Append($"||x{multi:0.##}||");
|
||||||
|
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
var eb = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithDescription(sb.ToString())
|
||||||
|
.AddField(GetText(strs.multiplier), $"{result.Multiplier:0.##}x", true)
|
||||||
|
.AddField(GetText(strs.won), $"{(long)result.Won}", true)
|
||||||
|
.WithAuthor(ctx.User);
|
||||||
|
|
||||||
|
|
||||||
|
await Response().Embed(eb).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public enum GambleTestTarget
|
||||||
|
{
|
||||||
|
Slot,
|
||||||
|
Betroll,
|
||||||
|
Betflip,
|
||||||
|
BetflipT,
|
||||||
|
BetDraw,
|
||||||
|
BetDrawHL,
|
||||||
|
BetDrawRB,
|
||||||
|
Lula,
|
||||||
|
Rps,
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
public async Task BetTest()
|
||||||
|
{
|
||||||
|
var values = Enum.GetValues<GambleTestTarget>()
|
||||||
|
.Select(x => $"`{x}`")
|
||||||
|
.Join(", ");
|
||||||
|
|
||||||
|
await Response().Confirm(GetText(strs.available_tests), values).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
public async Task BetTest(GambleTestTarget target, int tests = 1000)
|
||||||
|
{
|
||||||
|
if (tests <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await ctx.Channel.TriggerTypingAsync();
|
||||||
|
|
||||||
|
var streak = 0;
|
||||||
|
var maxW = 0;
|
||||||
|
var maxL = 0;
|
||||||
|
|
||||||
|
var dict = new Dictionary<decimal, int>();
|
||||||
|
for (var i = 0; i < tests; i++)
|
||||||
|
{
|
||||||
|
var multi = target switch
|
||||||
|
{
|
||||||
|
GambleTestTarget.BetDraw => (await _gs.BetDrawAsync(ctx.User.Id, 0, 1, 0)).AsT0.Multiplier,
|
||||||
|
GambleTestTarget.BetDrawRB => (await _gs.BetDrawAsync(ctx.User.Id, 0, null, 1)).AsT0.Multiplier,
|
||||||
|
GambleTestTarget.BetDrawHL => (await _gs.BetDrawAsync(ctx.User.Id, 0, 0, null)).AsT0.Multiplier,
|
||||||
|
GambleTestTarget.Slot => (await _gs.SlotAsync(ctx.User.Id, 0)).AsT0.Multiplier,
|
||||||
|
GambleTestTarget.Betflip => (await _gs.BetFlipAsync(ctx.User.Id, 0, 0)).AsT0.Multiplier,
|
||||||
|
GambleTestTarget.BetflipT => (await _gs.BetFlipAsync(ctx.User.Id, 0, 1)).AsT0.Multiplier,
|
||||||
|
GambleTestTarget.Lula => (await _gs.LulaAsync(ctx.User.Id, 0)).AsT0.Multiplier,
|
||||||
|
GambleTestTarget.Rps => (await _gs.RpsAsync(ctx.User.Id, 0, (byte)(i % 3))).AsT0.Multiplier,
|
||||||
|
GambleTestTarget.Betroll => (await _gs.BetRollAsync(ctx.User.Id, 0)).AsT0.Multiplier,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(target))
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dict.ContainsKey(multi))
|
||||||
|
dict[multi] += 1;
|
||||||
|
else
|
||||||
|
dict.Add(multi, 1);
|
||||||
|
|
||||||
|
if (multi < 1)
|
||||||
|
{
|
||||||
|
if (streak <= 0)
|
||||||
|
--streak;
|
||||||
|
else
|
||||||
|
streak = -1;
|
||||||
|
|
||||||
|
maxL = Math.Max(maxL, -streak);
|
||||||
|
}
|
||||||
|
else if (multi > 1)
|
||||||
|
{
|
||||||
|
if (streak >= 0)
|
||||||
|
++streak;
|
||||||
|
else
|
||||||
|
streak = 1;
|
||||||
|
|
||||||
|
maxW = Math.Max(maxW, streak);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
decimal payout = 0;
|
||||||
|
foreach (var key in dict.Keys.OrderByDescending(x => x))
|
||||||
|
{
|
||||||
|
sb.AppendLine($"x**{key}** occured `{dict[key]}` times. {dict[key] * 1.0f / tests * 100}%");
|
||||||
|
payout += key * dict[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Longest win streak: `{maxW}`");
|
||||||
|
sb.AppendLine($"Longest lose streak: `{maxL}`");
|
||||||
|
|
||||||
|
await Response()
|
||||||
|
.Confirm(GetText(strs.test_results_for(target)),
|
||||||
|
sb.ToString(),
|
||||||
|
footer: $"Total Bet: {tests} | Payout: {payout:F0} | {payout * 1.0M / tests * 100}%")
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
}
|
411
src/EllieBot/Modules/Gambling/GamblingConfig.cs
Normal file
411
src/EllieBot/Modules/Gambling/GamblingConfig.cs
Normal file
|
@ -0,0 +1,411 @@
|
||||||
|
#nullable disable
|
||||||
|
using Cloneable;
|
||||||
|
using EllieBot.Common.Yml;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
using YamlDotNet.Serialization;
|
||||||
|
using Color = SixLabors.ImageSharp.Color;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Common;
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public sealed partial class GamblingConfig : ICloneable<GamblingConfig>
|
||||||
|
{
|
||||||
|
[Comment("""DO NOT CHANGE""")]
|
||||||
|
public int Version { get; set; } = 8;
|
||||||
|
|
||||||
|
[Comment("""Currency settings""")]
|
||||||
|
public CurrencyConfig Currency { get; set; }
|
||||||
|
|
||||||
|
[Comment("""Minimum amount users can bet (>=0)""")]
|
||||||
|
public int MinBet { get; set; } = 0;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
Maximum amount users can bet
|
||||||
|
Set 0 for unlimited
|
||||||
|
""")]
|
||||||
|
public int MaxBet { get; set; } = 0;
|
||||||
|
|
||||||
|
[Comment("""Settings for betflip command""")]
|
||||||
|
public BetFlipConfig BetFlip { get; set; }
|
||||||
|
|
||||||
|
[Comment("""Settings for betroll command""")]
|
||||||
|
public BetRollConfig BetRoll { get; set; }
|
||||||
|
|
||||||
|
[Comment("""Automatic currency generation settings.""")]
|
||||||
|
public GenerationConfig Generation { get; set; }
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
Settings for timely command
|
||||||
|
(letting people claim X amount of currency every Y hours)
|
||||||
|
""")]
|
||||||
|
public TimelyConfig Timely { get; set; }
|
||||||
|
|
||||||
|
[Comment("""How much will each user's owned currency decay over time.""")]
|
||||||
|
public DecayConfig Decay { get; set; }
|
||||||
|
|
||||||
|
[Comment("""What is the bot's cut on some transactions""")]
|
||||||
|
public BotCutConfig BotCuts { get; set; }
|
||||||
|
|
||||||
|
[Comment("""Settings for LuckyLadder command""")]
|
||||||
|
public LuckyLadderSettings LuckyLadder { get; set; }
|
||||||
|
|
||||||
|
[Comment("""Settings related to waifus""")]
|
||||||
|
public WaifuConfig Waifu { get; set; }
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
Amount of currency selfhosters will get PER pledged dollar CENT.
|
||||||
|
1 = 100 currency per $. Used almost exclusively on public nadeko.
|
||||||
|
""")]
|
||||||
|
public decimal PatreonCurrencyPerCent { get; set; } = 1;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
Currency reward per vote.
|
||||||
|
This will work only if you've set up VotesApi and correct credentials for topgg and/or discords voting
|
||||||
|
""")]
|
||||||
|
public long VoteReward { get; set; } = 100;
|
||||||
|
|
||||||
|
[Comment("""Slot config""")]
|
||||||
|
public SlotsConfig Slots { get; set; }
|
||||||
|
|
||||||
|
public GamblingConfig()
|
||||||
|
{
|
||||||
|
BetRoll = new();
|
||||||
|
Waifu = new();
|
||||||
|
Currency = new();
|
||||||
|
BetFlip = new();
|
||||||
|
Generation = new();
|
||||||
|
Timely = new();
|
||||||
|
Decay = new();
|
||||||
|
Slots = new();
|
||||||
|
LuckyLadder = new();
|
||||||
|
BotCuts = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CurrencyConfig
|
||||||
|
{
|
||||||
|
[Comment("""What is the emoji/character which represents the currency""")]
|
||||||
|
public string Sign { get; set; } = "💵";
|
||||||
|
|
||||||
|
[Comment("""What is the name of the currency""")]
|
||||||
|
public string Name { get; set; } = "Ellie Money";
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
For how long (in days) will the transactions be kept in the database (curtrs)
|
||||||
|
Set 0 to disable cleanup (keep transactions forever)
|
||||||
|
""")]
|
||||||
|
public int TransactionsLifetime { get; set; } = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public partial class TimelyConfig
|
||||||
|
{
|
||||||
|
[Comment("""
|
||||||
|
How much currency will the users get every time they run .timely command
|
||||||
|
setting to 0 or less will disable this feature
|
||||||
|
""")]
|
||||||
|
public int Amount { get; set; } = 0;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
How often (in hours) can users claim currency with .timely command
|
||||||
|
setting to 0 or less will disable this feature
|
||||||
|
""")]
|
||||||
|
public int Cooldown { get; set; } = 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public partial class BetFlipConfig
|
||||||
|
{
|
||||||
|
[Comment("""Bet multiplier if user guesses correctly""")]
|
||||||
|
public decimal Multiplier { get; set; } = 1.95M;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public partial class BetRollConfig
|
||||||
|
{
|
||||||
|
[Comment("""
|
||||||
|
When betroll is played, user will roll a number 0-100.
|
||||||
|
This setting will describe which multiplier is used for when the roll is higher than the given number.
|
||||||
|
Doesn't have to be ordered.
|
||||||
|
""")]
|
||||||
|
public BetRollPair[] Pairs { get; set; } = Array.Empty<BetRollPair>();
|
||||||
|
|
||||||
|
public BetRollConfig()
|
||||||
|
=> Pairs =
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
WhenAbove = 99,
|
||||||
|
MultiplyBy = 10
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
WhenAbove = 90,
|
||||||
|
MultiplyBy = 4
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
WhenAbove = 66,
|
||||||
|
MultiplyBy = 2
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public partial class GenerationConfig
|
||||||
|
{
|
||||||
|
[Comment("""
|
||||||
|
when currency is generated, should it also have a random password
|
||||||
|
associated with it which users have to type after the .pick command
|
||||||
|
in order to get it
|
||||||
|
""")]
|
||||||
|
public bool HasPassword { get; set; } = true;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
Every message sent has a certain % chance to generate the currency
|
||||||
|
specify the percentage here (1 being 100%, 0 being 0% - for example
|
||||||
|
default is 0.02, which is 2%
|
||||||
|
""")]
|
||||||
|
public decimal Chance { get; set; } = 0.02M;
|
||||||
|
|
||||||
|
[Comment("""How many seconds have to pass for the next message to have a chance to spawn currency""")]
|
||||||
|
public int GenCooldown { get; set; } = 10;
|
||||||
|
|
||||||
|
[Comment("""Minimum amount of currency that can spawn""")]
|
||||||
|
public int MinAmount { get; set; } = 1;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
Maximum amount of currency that can spawn.
|
||||||
|
Set to the same value as MinAmount to always spawn the same amount
|
||||||
|
""")]
|
||||||
|
public int MaxAmount { get; set; } = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public partial class DecayConfig
|
||||||
|
{
|
||||||
|
[Comment("""
|
||||||
|
Percentage of user's current currency which will be deducted every 24h.
|
||||||
|
0 - 1 (1 is 100%, 0.5 50%, 0 disabled)
|
||||||
|
""")]
|
||||||
|
public decimal Percent { get; set; } = 0;
|
||||||
|
|
||||||
|
[Comment("""Maximum amount of user's currency that can decay at each interval. 0 for unlimited.""")]
|
||||||
|
public int MaxDecay { get; set; } = 0;
|
||||||
|
|
||||||
|
[Comment("""Only users who have more than this amount will have their currency decay.""")]
|
||||||
|
public int MinThreshold { get; set; } = 99;
|
||||||
|
|
||||||
|
[Comment("""How often, in hours, does the decay run. Default is 24 hours""")]
|
||||||
|
public int HourInterval { get; set; } = 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public partial class LuckyLadderSettings
|
||||||
|
{
|
||||||
|
[Comment("""Self-Explanatory. Has to have 8 values, otherwise the command won't work.""")]
|
||||||
|
public decimal[] Multipliers { get; set; }
|
||||||
|
|
||||||
|
public LuckyLadderSettings()
|
||||||
|
=> Multipliers = [2.4M, 1.7M, 1.5M, 1.2M, 0.5M, 0.3M, 0.2M, 0.1M];
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public sealed partial class WaifuConfig
|
||||||
|
{
|
||||||
|
[Comment("""Minimum price a waifu can have""")]
|
||||||
|
public long MinPrice { get; set; } = 50;
|
||||||
|
|
||||||
|
public MultipliersData Multipliers { get; set; } = new();
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
Settings for periodic waifu price decay.
|
||||||
|
Waifu price decays only if the waifu has no claimer.
|
||||||
|
""")]
|
||||||
|
public WaifuDecayConfig Decay { get; set; } = new();
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
List of items available for gifting.
|
||||||
|
If negative is true, gift will instead reduce waifu value.
|
||||||
|
""")]
|
||||||
|
public List<WaifuItemModel> Items { get; set; } = [];
|
||||||
|
|
||||||
|
public WaifuConfig()
|
||||||
|
=> Items =
|
||||||
|
[
|
||||||
|
new("🥔", 5, "Potato"),
|
||||||
|
new("🍪", 10, "Cookie"),
|
||||||
|
new("🥖", 20, "Bread"),
|
||||||
|
new("🍭", 30, "Lollipop"),
|
||||||
|
new("🌹", 50, "Rose"),
|
||||||
|
new("🍺", 70, "Beer"),
|
||||||
|
new("🌮", 85, "Taco"),
|
||||||
|
new("💌", 100, "LoveLetter"),
|
||||||
|
new("🥛", 125, "Milk"),
|
||||||
|
new("🍕", 150, "Pizza"),
|
||||||
|
new("🍫", 200, "Chocolate"),
|
||||||
|
new("🍦", 250, "Icecream"),
|
||||||
|
new("🍣", 300, "Sushi"),
|
||||||
|
new("🍚", 400, "Rice"),
|
||||||
|
new("🍉", 500, "Watermelon"),
|
||||||
|
new("🍱", 600, "Bento"),
|
||||||
|
new("🎟", 800, "MovieTicket"),
|
||||||
|
new("🍰", 1000, "Cake"),
|
||||||
|
new("📔", 1500, "Book"),
|
||||||
|
new("🐱", 2000, "Cat"),
|
||||||
|
new("🐶", 2001, "Dog"),
|
||||||
|
new("🐼", 2500, "Panda"),
|
||||||
|
new("💄", 3000, "Lipstick"),
|
||||||
|
new("👛", 3500, "Purse"),
|
||||||
|
new("📱", 4000, "iPhone"),
|
||||||
|
new("👗", 4500, "Dress"),
|
||||||
|
new("💻", 5000, "Laptop"),
|
||||||
|
new("🎻", 7500, "Violin"),
|
||||||
|
new("🎹", 8000, "Piano"),
|
||||||
|
new("🚗", 9000, "Car"),
|
||||||
|
new("💍", 10000, "Ring"),
|
||||||
|
new("🛳", 12000, "Ship"),
|
||||||
|
new("🏠", 15000, "House"),
|
||||||
|
new("🚁", 20000, "Helicopter"),
|
||||||
|
new("🚀", 30000, "Spaceship"),
|
||||||
|
new("🌕", 50000, "Moon")
|
||||||
|
];
|
||||||
|
|
||||||
|
public class WaifuDecayConfig
|
||||||
|
{
|
||||||
|
[Comment("""
|
||||||
|
Unclaimed waifus will decay by this percentage (0 - 100).
|
||||||
|
Default is 0 (disabled)
|
||||||
|
For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$)
|
||||||
|
""")]
|
||||||
|
public int UnclaimedDecayPercent { get; set; } = 0;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
Claimed waifus will decay by this percentage (0 - 100).
|
||||||
|
Default is 0 (disabled)
|
||||||
|
For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$)
|
||||||
|
""")]
|
||||||
|
public int ClaimedDecayPercent { get; set; } = 0;
|
||||||
|
|
||||||
|
[Comment("""How often to decay waifu values, in hours""")]
|
||||||
|
public int HourInterval { get; set; } = 24;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
Minimum waifu price required for the decay to be applied.
|
||||||
|
For example if this value is set to 300, any waifu with the price 300 or less will not experience decay.
|
||||||
|
""")]
|
||||||
|
public long MinPrice { get; set; } = 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public sealed partial class MultipliersData
|
||||||
|
{
|
||||||
|
[Comment("""
|
||||||
|
Multiplier for waifureset. Default 150.
|
||||||
|
Formula (at the time of writing this):
|
||||||
|
price = (waifu_price * 1.25f) + ((number_of_divorces + changes_of_heart + 2) * WaifuReset) rounded up
|
||||||
|
""")]
|
||||||
|
public int WaifuReset { get; set; } = 150;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
The minimum amount of currency that you have to pay
|
||||||
|
in order to buy a waifu who doesn't have a crush on you.
|
||||||
|
Default is 1.1
|
||||||
|
Example: If a waifu is worth 100, you will have to pay at least 100 * NormalClaim currency to claim her.
|
||||||
|
(100 * 1.1 = 110)
|
||||||
|
""")]
|
||||||
|
public decimal NormalClaim { get; set; } = 1.1m;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
The minimum amount of currency that you have to pay
|
||||||
|
in order to buy a waifu that has a crush on you.
|
||||||
|
Default is 0.88
|
||||||
|
Example: If a waifu is worth 100, you will have to pay at least 100 * CrushClaim currency to claim her.
|
||||||
|
(100 * 0.88 = 88)
|
||||||
|
""")]
|
||||||
|
public decimal CrushClaim { get; set; } = 0.88M;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
When divorcing a waifu, her new value will be her current value multiplied by this number.
|
||||||
|
Default 0.75 (meaning will lose 25% of her value)
|
||||||
|
""")]
|
||||||
|
public decimal DivorceNewValue { get; set; } = 0.75M;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
All gift prices will be multiplied by this number.
|
||||||
|
Default 1 (meaning no effect)
|
||||||
|
""")]
|
||||||
|
public decimal AllGiftPrices { get; set; } = 1.0M;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
What percentage of the value of the gift will a waifu gain when she's gifted.
|
||||||
|
Default 0.95 (meaning 95%)
|
||||||
|
Example: If a waifu is worth 1000, and she receives a gift worth 100, her new value will be 1095)
|
||||||
|
""")]
|
||||||
|
public decimal GiftEffect { get; set; } = 0.95M;
|
||||||
|
|
||||||
|
[Comment("""
|
||||||
|
What percentage of the value of the gift will a waifu lose when she's gifted a gift marked as 'negative'.
|
||||||
|
Default 0.5 (meaning 50%)
|
||||||
|
Example: If a waifu is worth 1000, and she receives a negative gift worth 100, her new value will be 950)
|
||||||
|
""")]
|
||||||
|
public decimal NegativeGiftEffect { get; set; } = 0.50M;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SlotsConfig
|
||||||
|
{
|
||||||
|
[Comment("""Hex value of the color which the numbers on the slot image will have.""")]
|
||||||
|
public Rgba32 CurrencyFontColor { get; set; } = Color.Red;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public sealed partial class WaifuItemModel
|
||||||
|
{
|
||||||
|
public string ItemEmoji { get; set; }
|
||||||
|
public long Price { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
[YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)]
|
||||||
|
public bool Negative { get; set; }
|
||||||
|
|
||||||
|
public WaifuItemModel()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public WaifuItemModel(
|
||||||
|
string itemEmoji,
|
||||||
|
long price,
|
||||||
|
string name,
|
||||||
|
bool negative = false)
|
||||||
|
{
|
||||||
|
ItemEmoji = itemEmoji;
|
||||||
|
Price = price;
|
||||||
|
Name = name;
|
||||||
|
Negative = negative;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
=> Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public sealed partial class BetRollPair
|
||||||
|
{
|
||||||
|
public int WhenAbove { get; set; }
|
||||||
|
public float MultiplyBy { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public sealed partial class BotCutConfig
|
||||||
|
{
|
||||||
|
[Comment("""
|
||||||
|
Shop sale cut percentage.
|
||||||
|
Whenever a user buys something from the shop, bot will take a cut equal to this percentage.
|
||||||
|
The rest goes to the user who posted the item/role/whatever to the shop.
|
||||||
|
This is a good way to reduce the amount of currency in circulation therefore keeping the inflation in check.
|
||||||
|
Default 0.1 (10%).
|
||||||
|
""")]
|
||||||
|
public decimal ShopSaleCut { get; set; } = 0.1m;
|
||||||
|
}
|
203
src/EllieBot/Modules/Gambling/GamblingConfigService.cs
Normal file
203
src/EllieBot/Modules/Gambling/GamblingConfigService.cs
Normal file
|
@ -0,0 +1,203 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Common.Configs;
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
public sealed class GamblingConfigService : ConfigServiceBase<GamblingConfig>
|
||||||
|
{
|
||||||
|
private const string FILE_PATH = "data/gambling.yml";
|
||||||
|
private static readonly TypedKey<GamblingConfig> _changeKey = new("config.gambling.updated");
|
||||||
|
|
||||||
|
public override string Name
|
||||||
|
=> "gambling";
|
||||||
|
|
||||||
|
private readonly IEnumerable<WaifuItemModel> _antiGiftSeed = new[]
|
||||||
|
{
|
||||||
|
new WaifuItemModel("🥀", 100, "WiltedRose", true), new WaifuItemModel("✂️", 1000, "Haircut", true),
|
||||||
|
new WaifuItemModel("🧻", 10000, "ToiletPaper", true)
|
||||||
|
};
|
||||||
|
|
||||||
|
public GamblingConfigService(IConfigSeria serializer, IPubSub pubSub)
|
||||||
|
: base(FILE_PATH, serializer, pubSub, _changeKey)
|
||||||
|
{
|
||||||
|
AddParsedProp("currency.name",
|
||||||
|
gs => gs.Currency.Name,
|
||||||
|
ConfigParsers.String,
|
||||||
|
ConfigPrinters.ToString);
|
||||||
|
|
||||||
|
AddParsedProp("currency.sign",
|
||||||
|
gs => gs.Currency.Sign,
|
||||||
|
ConfigParsers.String,
|
||||||
|
ConfigPrinters.ToString);
|
||||||
|
|
||||||
|
AddParsedProp("minbet",
|
||||||
|
gs => gs.MinBet,
|
||||||
|
int.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val >= 0);
|
||||||
|
|
||||||
|
AddParsedProp("maxbet",
|
||||||
|
gs => gs.MaxBet,
|
||||||
|
int.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val >= 0);
|
||||||
|
|
||||||
|
AddParsedProp("gen.min",
|
||||||
|
gs => gs.Generation.MinAmount,
|
||||||
|
int.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val >= 1);
|
||||||
|
|
||||||
|
AddParsedProp("gen.max",
|
||||||
|
gs => gs.Generation.MaxAmount,
|
||||||
|
int.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val >= 1);
|
||||||
|
|
||||||
|
AddParsedProp("gen.cd",
|
||||||
|
gs => gs.Generation.GenCooldown,
|
||||||
|
int.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val > 0);
|
||||||
|
|
||||||
|
AddParsedProp("gen.chance",
|
||||||
|
gs => gs.Generation.Chance,
|
||||||
|
decimal.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val is >= 0 and <= 1);
|
||||||
|
|
||||||
|
AddParsedProp("gen.has_pw",
|
||||||
|
gs => gs.Generation.HasPassword,
|
||||||
|
bool.TryParse,
|
||||||
|
ConfigPrinters.ToString);
|
||||||
|
|
||||||
|
AddParsedProp("bf.multi",
|
||||||
|
gs => gs.BetFlip.Multiplier,
|
||||||
|
decimal.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val >= 1);
|
||||||
|
|
||||||
|
AddParsedProp("waifu.min_price",
|
||||||
|
gs => gs.Waifu.MinPrice,
|
||||||
|
long.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val >= 0);
|
||||||
|
|
||||||
|
AddParsedProp("waifu.multi.reset",
|
||||||
|
gs => gs.Waifu.Multipliers.WaifuReset,
|
||||||
|
int.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val >= 0);
|
||||||
|
|
||||||
|
AddParsedProp("waifu.multi.crush_claim",
|
||||||
|
gs => gs.Waifu.Multipliers.CrushClaim,
|
||||||
|
decimal.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val >= 0);
|
||||||
|
|
||||||
|
AddParsedProp("waifu.multi.normal_claim",
|
||||||
|
gs => gs.Waifu.Multipliers.NormalClaim,
|
||||||
|
decimal.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val > 0);
|
||||||
|
|
||||||
|
AddParsedProp("waifu.multi.divorce_value",
|
||||||
|
gs => gs.Waifu.Multipliers.DivorceNewValue,
|
||||||
|
decimal.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val > 0);
|
||||||
|
|
||||||
|
AddParsedProp("waifu.multi.all_gifts",
|
||||||
|
gs => gs.Waifu.Multipliers.AllGiftPrices,
|
||||||
|
decimal.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val > 0);
|
||||||
|
|
||||||
|
AddParsedProp("waifu.multi.gift_effect",
|
||||||
|
gs => gs.Waifu.Multipliers.GiftEffect,
|
||||||
|
decimal.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val >= 0);
|
||||||
|
|
||||||
|
AddParsedProp("waifu.multi.negative_gift_effect",
|
||||||
|
gs => gs.Waifu.Multipliers.NegativeGiftEffect,
|
||||||
|
decimal.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val >= 0);
|
||||||
|
|
||||||
|
AddParsedProp("decay.percent",
|
||||||
|
gs => gs.Decay.Percent,
|
||||||
|
decimal.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val is >= 0 and <= 1);
|
||||||
|
|
||||||
|
AddParsedProp("decay.maxdecay",
|
||||||
|
gs => gs.Decay.MaxDecay,
|
||||||
|
int.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val >= 0);
|
||||||
|
|
||||||
|
AddParsedProp("decay.threshold",
|
||||||
|
gs => gs.Decay.MinThreshold,
|
||||||
|
int.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val >= 0);
|
||||||
|
|
||||||
|
Migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Migrate()
|
||||||
|
{
|
||||||
|
if (data.Version < 2)
|
||||||
|
{
|
||||||
|
ModifyConfig(c =>
|
||||||
|
{
|
||||||
|
c.Waifu.Items = c.Waifu.Items.Concat(_antiGiftSeed).ToList();
|
||||||
|
c.Version = 2;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.Version < 3)
|
||||||
|
{
|
||||||
|
ModifyConfig(c =>
|
||||||
|
{
|
||||||
|
c.Version = 3;
|
||||||
|
c.VoteReward = 100;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.Version < 5)
|
||||||
|
{
|
||||||
|
ModifyConfig(c =>
|
||||||
|
{
|
||||||
|
c.Version = 5;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.Version < 6)
|
||||||
|
{
|
||||||
|
ModifyConfig(c =>
|
||||||
|
{
|
||||||
|
c.Version = 6;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.Version < 7)
|
||||||
|
{
|
||||||
|
ModifyConfig(c =>
|
||||||
|
{
|
||||||
|
c.Version = 7;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.Version < 8)
|
||||||
|
{
|
||||||
|
ModifyConfig(c =>
|
||||||
|
{
|
||||||
|
c.Version = 8;
|
||||||
|
c.Waifu.Decay.UnclaimedDecayPercent = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
187
src/EllieBot/Modules/Gambling/GamblingService.cs
Normal file
187
src/EllieBot/Modules/Gambling/GamblingService.cs
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
#nullable disable
|
||||||
|
using LinqToDB;
|
||||||
|
using LinqToDB.EntityFrameworkCore;
|
||||||
|
using EllieBot.Common.ModuleBehaviors;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Common.Connect4;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
public class GamblingService : IEService, IReadyExecutor
|
||||||
|
{
|
||||||
|
public ConcurrentDictionary<(ulong, ulong), RollDuelGame> Duels { get; } = new();
|
||||||
|
public ConcurrentDictionary<ulong, Connect4Game> Connect4Games { get; } = new();
|
||||||
|
private readonly DbService _db;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly IBotCache _cache;
|
||||||
|
private readonly GamblingConfigService _gss;
|
||||||
|
|
||||||
|
private static readonly TypedKey<long> _curDecayKey = new("currency:last_decay");
|
||||||
|
|
||||||
|
public GamblingService(
|
||||||
|
DbService db,
|
||||||
|
DiscordSocketClient client,
|
||||||
|
IBotCache cache,
|
||||||
|
GamblingConfigService gss)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_client = client;
|
||||||
|
_cache = cache;
|
||||||
|
_gss = gss;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task OnReadyAsync()
|
||||||
|
=> Task.WhenAll(CurrencyDecayLoopAsync(), TransactionClearLoopAsync());
|
||||||
|
|
||||||
|
private async Task TransactionClearLoopAsync()
|
||||||
|
{
|
||||||
|
if (_client.ShardId != 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
|
||||||
|
while (await timer.WaitForNextTickAsync())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lifetime = _gss.Data.Currency.TransactionsLifetime;
|
||||||
|
if (lifetime <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var days = TimeSpan.FromDays(lifetime);
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
await uow.Set<CurrencyTransaction>()
|
||||||
|
.DeleteAsync(ct => ct.DateAdded == null || now - ct.DateAdded < days);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex,
|
||||||
|
"An unexpected error occurred in transactions cleanup loop: {ErrorMessage}",
|
||||||
|
ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CurrencyDecayLoopAsync()
|
||||||
|
{
|
||||||
|
if (_client.ShardId != 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
|
||||||
|
while (await timer.WaitForNextTickAsync())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = _gss.Data;
|
||||||
|
var maxDecay = config.Decay.MaxDecay;
|
||||||
|
if (config.Decay.Percent is <= 0 or > 1 || maxDecay < 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var result = await _cache.GetAsync(_curDecayKey);
|
||||||
|
|
||||||
|
if (result.TryPickT0(out var bin, out _)
|
||||||
|
&& (now - DateTime.FromBinary(bin) < TimeSpan.FromHours(config.Decay.HourInterval)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("""
|
||||||
|
--- Decaying users' currency ---
|
||||||
|
| decay: {ConfigDecayPercent}%
|
||||||
|
| max: {MaxDecay}
|
||||||
|
| threshold: {DecayMinTreshold}
|
||||||
|
""",
|
||||||
|
config.Decay.Percent * 100,
|
||||||
|
maxDecay,
|
||||||
|
config.Decay.MinThreshold);
|
||||||
|
|
||||||
|
if (maxDecay == 0)
|
||||||
|
maxDecay = int.MaxValue;
|
||||||
|
|
||||||
|
var decay = (double)config.Decay.Percent;
|
||||||
|
await uow.Set<DiscordUser>()
|
||||||
|
.Where(x => x.CurrencyAmount > config.Decay.MinThreshold && x.UserId != _client.CurrentUser.Id)
|
||||||
|
.UpdateAsync(old => new()
|
||||||
|
{
|
||||||
|
CurrencyAmount =
|
||||||
|
maxDecay > Sql.Round((old.CurrencyAmount * decay) - 0.5)
|
||||||
|
? (long)(old.CurrencyAmount - Sql.Round((old.CurrencyAmount * decay) - 0.5))
|
||||||
|
: old.CurrencyAmount - maxDecay
|
||||||
|
});
|
||||||
|
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
|
||||||
|
await _cache.AddAsync(_curDecayKey, now.ToBinary());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex,
|
||||||
|
"An unexpected error occurred in currency decay loop: {ErrorMessage}",
|
||||||
|
ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly TypedKey<EconomyResult> _ecoKey = new("nadeko:economy");
|
||||||
|
|
||||||
|
private static readonly SemaphoreSlim _timelyLock = new(1, 1);
|
||||||
|
|
||||||
|
private static TypedKey<Dictionary<ulong, long>> _timelyKey
|
||||||
|
= new("timely:claims");
|
||||||
|
|
||||||
|
public async Task<TimeSpan?> ClaimTimelyAsync(ulong userId, int period)
|
||||||
|
{
|
||||||
|
if (period == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
await _timelyLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// get the dictionary from the cache or get a new one
|
||||||
|
var dict = (await _cache.GetOrAddAsync(_timelyKey,
|
||||||
|
() => Task.FromResult(new Dictionary<ulong, long>())))!;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var nowB = now.ToBinary();
|
||||||
|
|
||||||
|
// try to get users last claim
|
||||||
|
if (!dict.TryGetValue(userId, out var lastB))
|
||||||
|
lastB = dict[userId] = now.ToBinary();
|
||||||
|
|
||||||
|
var diff = now - DateTime.FromBinary(lastB);
|
||||||
|
|
||||||
|
// if its now, or too long ago => success
|
||||||
|
if (lastB == nowB || diff > period.Hours())
|
||||||
|
{
|
||||||
|
// update the cache
|
||||||
|
dict[userId] = nowB;
|
||||||
|
await _cache.AddAsync(_timelyKey, dict);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// otherwise return the remaining time
|
||||||
|
return period.Hours() - diff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_timelyLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool UserHasTimelyReminder(ulong userId)
|
||||||
|
{
|
||||||
|
var db = _db.GetDbContext();
|
||||||
|
return db.GetTable<Reminder>().Any(x => x.UserId == userId
|
||||||
|
&& x.Type == ReminderType.Timely);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveAllTimelyClaimsAsync()
|
||||||
|
=> await _cache.RemoveAsync(_timelyKey);
|
||||||
|
}
|
68
src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs
Normal file
68
src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Common;
|
||||||
|
|
||||||
|
public abstract class GamblingModule<TService> : EllieModule<TService>
|
||||||
|
{
|
||||||
|
protected GamblingConfig Config
|
||||||
|
=> _lazyConfig.Value;
|
||||||
|
|
||||||
|
protected string CurrencySign
|
||||||
|
=> Config.Currency.Sign;
|
||||||
|
|
||||||
|
protected string CurrencyName
|
||||||
|
=> Config.Currency.Name;
|
||||||
|
|
||||||
|
private readonly Lazy<GamblingConfig> _lazyConfig;
|
||||||
|
|
||||||
|
protected GamblingModule(GamblingConfigService gambService)
|
||||||
|
=> _lazyConfig = new(() => gambService.Data);
|
||||||
|
|
||||||
|
private async Task<bool> InternalCheckBet(long amount)
|
||||||
|
{
|
||||||
|
if (amount < 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (amount < Config.MinBet)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.min_bet_limit(Format.Bold(Config.MinBet.ToString()) + CurrencySign)).SendAsync();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Config.MaxBet > 0 && amount > Config.MaxBet)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.max_bet_limit(Format.Bold(Config.MaxBet.ToString()) + CurrencySign)).SendAsync();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected string N<T>(T cur)
|
||||||
|
where T : INumber<T>
|
||||||
|
=> CurrencyHelper.N(cur, Culture, CurrencySign);
|
||||||
|
|
||||||
|
protected Task<bool> CheckBetMandatory(long amount)
|
||||||
|
{
|
||||||
|
if (amount < 1)
|
||||||
|
return Task.FromResult(false);
|
||||||
|
return InternalCheckBet(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Task<bool> CheckBetOptional(long amount)
|
||||||
|
{
|
||||||
|
if (amount == 0)
|
||||||
|
return Task.FromResult(true);
|
||||||
|
return InternalCheckBet(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class GamblingSubmodule<TService> : GamblingModule<TService>
|
||||||
|
{
|
||||||
|
protected GamblingSubmodule(GamblingConfigService gamblingConfService)
|
||||||
|
: base(gamblingConfService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
3
src/EllieBot/Modules/Gambling/InputRpsPick.cs
Normal file
3
src/EllieBot/Modules/Gambling/InputRpsPick.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
114
src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs
Normal file
114
src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Common.TypeReaders;
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public partial class Gambling
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class PlantPickCommands : GamblingSubmodule<PlantPickService>
|
||||||
|
{
|
||||||
|
private readonly ILogCommandService _logService;
|
||||||
|
|
||||||
|
public PlantPickCommands(ILogCommandService logService, GamblingConfigService gss)
|
||||||
|
: base(gss)
|
||||||
|
=> _logService = logService;
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Pick(string pass = null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var picked = await _service.PickAsync(ctx.Guild.Id, (ITextChannel)ctx.Channel, ctx.User.Id, pass);
|
||||||
|
|
||||||
|
if (picked > 0)
|
||||||
|
{
|
||||||
|
var msg = await Response().NoReply().Confirm(strs.picked(N(picked), ctx.User)).SendAsync();
|
||||||
|
msg.DeleteAfter(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logService.AddDeleteIgnore(ctx.Message.Id);
|
||||||
|
await ctx.Message.DeleteAsync();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Plant([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, string pass = null)
|
||||||
|
{
|
||||||
|
if (amount < 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric())
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages)
|
||||||
|
{
|
||||||
|
_logService.AddDeleteIgnore(ctx.Message.Id);
|
||||||
|
await ctx.Message.DeleteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var success = await _service.PlantAsync(ctx.Guild.Id,
|
||||||
|
ctx.Channel,
|
||||||
|
ctx.User.Id,
|
||||||
|
ctx.User.ToString(),
|
||||||
|
amount,
|
||||||
|
pass);
|
||||||
|
|
||||||
|
if (!success)
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.ManageMessages)]
|
||||||
|
#if GLOBAL_NADEKO
|
||||||
|
[OwnerOnly]
|
||||||
|
#endif
|
||||||
|
public async Task GenCurrency()
|
||||||
|
{
|
||||||
|
var enabled = _service.ToggleCurrencyGeneration(ctx.Guild.Id, ctx.Channel.Id);
|
||||||
|
if (enabled)
|
||||||
|
await Response().Confirm(strs.curgen_enabled).SendAsync();
|
||||||
|
else
|
||||||
|
await Response().Confirm(strs.curgen_disabled).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.ManageMessages)]
|
||||||
|
[OwnerOnly]
|
||||||
|
public Task GenCurList(int page = 1)
|
||||||
|
{
|
||||||
|
if (--page < 0)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
var enabledIn = _service.GetAllGeneratingChannels();
|
||||||
|
|
||||||
|
return Response()
|
||||||
|
.Paginated()
|
||||||
|
.Items(enabledIn.ToList())
|
||||||
|
.PageSize(9)
|
||||||
|
.CurrentPage(page)
|
||||||
|
.Page((items, _) =>
|
||||||
|
{
|
||||||
|
if (!items.Any())
|
||||||
|
return _sender.CreateEmbed().WithErrorColor().WithDescription("-");
|
||||||
|
|
||||||
|
return items.Aggregate(_sender.CreateEmbed().WithOkColor(),
|
||||||
|
(eb, i) => eb.AddField(i.GuildId.ToString(), i.ChannelId));
|
||||||
|
})
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
390
src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs
Normal file
390
src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs
Normal file
|
@ -0,0 +1,390 @@
|
||||||
|
#nullable disable
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EllieBot.Common.ModuleBehaviors;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using SixLabors.Fonts;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Drawing.Processing;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
using Color = SixLabors.ImageSharp.Color;
|
||||||
|
using Image = SixLabors.ImageSharp.Image;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
public class PlantPickService : IEService, IExecNoCommand
|
||||||
|
{
|
||||||
|
//channelId/last generation
|
||||||
|
public ConcurrentDictionary<ulong, long> LastGenerations { get; } = new();
|
||||||
|
private readonly DbService _db;
|
||||||
|
private readonly IBotStrings _strings;
|
||||||
|
private readonly IImageCache _images;
|
||||||
|
private readonly FontProvider _fonts;
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
private readonly CommandHandler _cmdHandler;
|
||||||
|
private readonly EllieRandom _rng;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly GamblingConfigService _gss;
|
||||||
|
|
||||||
|
private readonly ConcurrentHashSet<ulong> _generationChannels;
|
||||||
|
private readonly SemaphoreSlim _pickLock = new(1, 1);
|
||||||
|
|
||||||
|
public PlantPickService(
|
||||||
|
DbService db,
|
||||||
|
IBotStrings strings,
|
||||||
|
IImageCache images,
|
||||||
|
FontProvider fonts,
|
||||||
|
ICurrencyService cs,
|
||||||
|
CommandHandler cmdHandler,
|
||||||
|
DiscordSocketClient client,
|
||||||
|
GamblingConfigService gss)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_strings = strings;
|
||||||
|
_images = images;
|
||||||
|
_fonts = fonts;
|
||||||
|
_cs = cs;
|
||||||
|
_cmdHandler = cmdHandler;
|
||||||
|
_rng = new();
|
||||||
|
_client = client;
|
||||||
|
_gss = gss;
|
||||||
|
|
||||||
|
using var uow = db.GetDbContext();
|
||||||
|
var guildIds = client.Guilds.Select(x => x.Id).ToList();
|
||||||
|
var configs = uow.Set<GuildConfig>()
|
||||||
|
.AsQueryable()
|
||||||
|
.Include(x => x.GenerateCurrencyChannelIds)
|
||||||
|
.Where(x => guildIds.Contains(x.GuildId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_generationChannels = new(configs.SelectMany(c => c.GenerateCurrencyChannelIds.Select(obj => obj.ChannelId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
|
||||||
|
=> PotentialFlowerGeneration(msg);
|
||||||
|
|
||||||
|
private string GetText(ulong gid, LocStr str)
|
||||||
|
=> _strings.GetText(str, gid);
|
||||||
|
|
||||||
|
public bool ToggleCurrencyGeneration(ulong gid, ulong cid)
|
||||||
|
{
|
||||||
|
bool enabled;
|
||||||
|
using var uow = _db.GetDbContext();
|
||||||
|
var guildConfig = uow.GuildConfigsForId(gid, set => set.Include(gc => gc.GenerateCurrencyChannelIds));
|
||||||
|
|
||||||
|
var toAdd = new GCChannelId
|
||||||
|
{
|
||||||
|
ChannelId = cid
|
||||||
|
};
|
||||||
|
if (!guildConfig.GenerateCurrencyChannelIds.Contains(toAdd))
|
||||||
|
{
|
||||||
|
guildConfig.GenerateCurrencyChannelIds.Add(toAdd);
|
||||||
|
_generationChannels.Add(cid);
|
||||||
|
enabled = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var toDelete = guildConfig.GenerateCurrencyChannelIds.FirstOrDefault(x => x.Equals(toAdd));
|
||||||
|
if (toDelete is not null)
|
||||||
|
uow.Remove(toDelete);
|
||||||
|
_generationChannels.TryRemove(cid);
|
||||||
|
enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uow.SaveChanges();
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<GuildConfigExtensions.GeneratingChannel> GetAllGeneratingChannels()
|
||||||
|
{
|
||||||
|
using var uow = _db.GetDbContext();
|
||||||
|
var chs = uow.Set<GuildConfig>().GetGeneratingChannels();
|
||||||
|
return chs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a random currency image stream, with an optional password sticked onto it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pass">Optional password to add to top left corner.</param>
|
||||||
|
/// <returns>Stream of the currency image</returns>
|
||||||
|
public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass)
|
||||||
|
{
|
||||||
|
var curImg = await _images.GetCurrencyImageAsync();
|
||||||
|
|
||||||
|
if (curImg is null)
|
||||||
|
return (new MemoryStream(), null);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(pass))
|
||||||
|
{
|
||||||
|
// determine the extension
|
||||||
|
using var load = Image.Load(curImg);
|
||||||
|
|
||||||
|
var format = load.Metadata.DecodedImageFormat;
|
||||||
|
// return the image
|
||||||
|
return (curImg.ToStream(), format?.FileExtensions.FirstOrDefault() ?? "png");
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the image stream and extension
|
||||||
|
return AddPassword(curImg, pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a password to the image.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="curImg">Image to add password to.</param>
|
||||||
|
/// <param name="pass">Password to add to top left corner.</param>
|
||||||
|
/// <returns>Image with the password in the top left corner.</returns>
|
||||||
|
private (Stream, string) AddPassword(byte[] curImg, string pass)
|
||||||
|
{
|
||||||
|
// draw lower, it looks better
|
||||||
|
pass = pass.TrimTo(10, true).ToLowerInvariant();
|
||||||
|
using var img = Image.Load<Rgba32>(curImg);
|
||||||
|
// choose font size based on the image height, so that it's visible
|
||||||
|
var font = _fonts.NotoSans.CreateFont(img.Height / 12.0f, FontStyle.Bold);
|
||||||
|
img.Mutate(x =>
|
||||||
|
{
|
||||||
|
// measure the size of the text to be drawing
|
||||||
|
var size = TextMeasurer.MeasureSize(pass,
|
||||||
|
new RichTextOptions(font)
|
||||||
|
{
|
||||||
|
Origin = new PointF(0, 0)
|
||||||
|
});
|
||||||
|
|
||||||
|
// fill the background with black, add 5 pixels on each side to make it look better
|
||||||
|
x.FillPolygon(Color.ParseHex("00000080"),
|
||||||
|
new PointF(0, 0),
|
||||||
|
new PointF(size.Width + 5, 0),
|
||||||
|
new PointF(size.Width + 5, size.Height + 10),
|
||||||
|
new PointF(0, size.Height + 10));
|
||||||
|
|
||||||
|
// draw the password over the background
|
||||||
|
x.DrawText(pass, font, Color.White, new(0, 0));
|
||||||
|
});
|
||||||
|
// return image as a stream for easy sending
|
||||||
|
var format = img.Metadata.DecodedImageFormat;
|
||||||
|
return (img.ToStream(format), format?.FileExtensions.FirstOrDefault() ?? "png");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task PotentialFlowerGeneration(IUserMessage imsg)
|
||||||
|
{
|
||||||
|
if (imsg is not SocketUserMessage msg || msg.Author.IsBot)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
if (imsg.Channel is not ITextChannel channel)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
if (!_generationChannels.Contains(channel.Id))
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = _gss.Data;
|
||||||
|
var lastGeneration = LastGenerations.GetOrAdd(channel.Id, DateTime.MinValue.ToBinary());
|
||||||
|
var rng = new EllieRandom();
|
||||||
|
|
||||||
|
if (DateTime.UtcNow - TimeSpan.FromSeconds(config.Generation.GenCooldown)
|
||||||
|
< DateTime.FromBinary(lastGeneration)) //recently generated in this channel, don't generate again
|
||||||
|
return;
|
||||||
|
|
||||||
|
var num = rng.Next(1, 101) + (config.Generation.Chance * 100);
|
||||||
|
if (num > 100 && LastGenerations.TryUpdate(channel.Id, DateTime.UtcNow.ToBinary(), lastGeneration))
|
||||||
|
{
|
||||||
|
var dropAmount = config.Generation.MinAmount;
|
||||||
|
var dropAmountMax = config.Generation.MaxAmount;
|
||||||
|
|
||||||
|
if (dropAmountMax > dropAmount)
|
||||||
|
dropAmount = new EllieRandom().Next(dropAmount, dropAmountMax + 1);
|
||||||
|
|
||||||
|
if (dropAmount > 0)
|
||||||
|
{
|
||||||
|
var prefix = _cmdHandler.GetPrefix(channel.Guild.Id);
|
||||||
|
var toSend = dropAmount == 1
|
||||||
|
? GetText(channel.GuildId, strs.curgen_sn(config.Currency.Sign))
|
||||||
|
+ " "
|
||||||
|
+ GetText(channel.GuildId, strs.pick_sn(prefix))
|
||||||
|
: GetText(channel.GuildId, strs.curgen_pl(dropAmount, config.Currency.Sign))
|
||||||
|
+ " "
|
||||||
|
+ GetText(channel.GuildId, strs.pick_pl(prefix));
|
||||||
|
|
||||||
|
var pw = config.Generation.HasPassword ? GenerateCurrencyPassword().ToUpperInvariant() : null;
|
||||||
|
|
||||||
|
IUserMessage sent;
|
||||||
|
var (stream, ext) = await GetRandomCurrencyImageAsync(pw);
|
||||||
|
|
||||||
|
await using (stream)
|
||||||
|
sent = await channel.SendFileAsync(stream, $"currency_image.{ext}", toSend);
|
||||||
|
|
||||||
|
await AddPlantToDatabase(channel.GuildId,
|
||||||
|
channel.Id,
|
||||||
|
_client.CurrentUser.Id,
|
||||||
|
sent.Id,
|
||||||
|
dropAmount,
|
||||||
|
pw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generate a hexadecimal string from 1000 to ffff.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A hexadecimal string from 1000 to ffff</returns>
|
||||||
|
private string GenerateCurrencyPassword()
|
||||||
|
{
|
||||||
|
// generate a number from 1000 to ffff
|
||||||
|
var num = _rng.Next(4096, 65536);
|
||||||
|
// convert it to hexadecimal
|
||||||
|
return num.ToString("x4");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> PickAsync(
|
||||||
|
ulong gid,
|
||||||
|
ITextChannel ch,
|
||||||
|
ulong uid,
|
||||||
|
string pass)
|
||||||
|
{
|
||||||
|
await _pickLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
long amount;
|
||||||
|
ulong[] ids;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
// this method will sum all plants with that password,
|
||||||
|
// remove them, and get messageids of the removed plants
|
||||||
|
|
||||||
|
pass = pass?.Trim().TrimTo(10, true).ToUpperInvariant();
|
||||||
|
// gets all plants in this channel with the same password
|
||||||
|
var entries = uow.Set<PlantedCurrency>()
|
||||||
|
.AsQueryable()
|
||||||
|
.Where(x => x.ChannelId == ch.Id && pass == x.Password)
|
||||||
|
.ToList();
|
||||||
|
// sum how much currency that is, and get all of the message ids (so that i can delete them)
|
||||||
|
amount = entries.Sum(x => x.Amount);
|
||||||
|
ids = entries.Select(x => x.MessageId).ToArray();
|
||||||
|
// remove them from the database
|
||||||
|
uow.RemoveRange(entries);
|
||||||
|
|
||||||
|
|
||||||
|
if (amount > 0)
|
||||||
|
// give the picked currency to the user
|
||||||
|
await _cs.AddAsync(uid, amount, new("currency", "collect"));
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// delete all of the plant messages which have just been picked
|
||||||
|
_ = ch.DeleteMessagesAsync(ids);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
// return the amount of currency the user picked
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_pickLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ulong?> SendPlantMessageAsync(
|
||||||
|
ulong gid,
|
||||||
|
IMessageChannel ch,
|
||||||
|
string user,
|
||||||
|
long amount,
|
||||||
|
string pass)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// get the text
|
||||||
|
var prefix = _cmdHandler.GetPrefix(gid);
|
||||||
|
var msgToSend = GetText(gid, strs.planted(Format.Bold(user), amount + _gss.Data.Currency.Sign));
|
||||||
|
|
||||||
|
if (amount > 1)
|
||||||
|
msgToSend += " " + GetText(gid, strs.pick_pl(prefix));
|
||||||
|
else
|
||||||
|
msgToSend += " " + GetText(gid, strs.pick_sn(prefix));
|
||||||
|
|
||||||
|
//get the image
|
||||||
|
var (stream, ext) = await GetRandomCurrencyImageAsync(pass);
|
||||||
|
// send it
|
||||||
|
await using (stream)
|
||||||
|
{
|
||||||
|
var msg = await ch.SendFileAsync(stream, $"img.{ext}", msgToSend);
|
||||||
|
// return sent message's id (in order to be able to delete it when it's picked)
|
||||||
|
return msg.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// if sending fails, return null as message id
|
||||||
|
Log.Warning(ex, "Sending plant message failed: {Message}", ex.Message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> PlantAsync(
|
||||||
|
ulong gid,
|
||||||
|
IMessageChannel ch,
|
||||||
|
ulong uid,
|
||||||
|
string user,
|
||||||
|
long amount,
|
||||||
|
string pass)
|
||||||
|
{
|
||||||
|
// normalize it - no more than 10 chars, uppercase
|
||||||
|
pass = pass?.Trim().TrimTo(10, true).ToUpperInvariant();
|
||||||
|
// has to be either null or alphanumeric
|
||||||
|
if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// remove currency from the user who's planting
|
||||||
|
if (await _cs.RemoveAsync(uid, amount, new("put/collect", "put")))
|
||||||
|
{
|
||||||
|
// try to send the message with the currency image
|
||||||
|
var msgId = await SendPlantMessageAsync(gid, ch, user, amount, pass);
|
||||||
|
if (msgId is null)
|
||||||
|
{
|
||||||
|
// if it fails it will return null, if it returns null, refund
|
||||||
|
await _cs.AddAsync(uid, amount, new("put/collect", "refund"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if it doesn't fail, put the plant in the database for other people to pick
|
||||||
|
await AddPlantToDatabase(gid, ch.Id, uid, msgId.Value, amount, pass);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if user doesn't have enough currency, fail
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddPlantToDatabase(
|
||||||
|
ulong gid,
|
||||||
|
ulong cid,
|
||||||
|
ulong uid,
|
||||||
|
ulong mid,
|
||||||
|
long amount,
|
||||||
|
string pass)
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
uow.Set<PlantedCurrency>()
|
||||||
|
.Add(new()
|
||||||
|
{
|
||||||
|
Amount = amount,
|
||||||
|
GuildId = gid,
|
||||||
|
ChannelId = cid,
|
||||||
|
Password = pass,
|
||||||
|
UserId = uid,
|
||||||
|
MessageId = mid
|
||||||
|
});
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
46
src/EllieBot/Modules/Gambling/Shop/IShopService.cs
Normal file
46
src/EllieBot/Modules/Gambling/Shop/IShopService.cs
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
public interface IShopService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Changes the price of a shop item
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guildId">Id of the guild in which the shop is</param>
|
||||||
|
/// <param name="index">Index of the item</param>
|
||||||
|
/// <param name="newPrice">New item price</param>
|
||||||
|
/// <returns>Success status</returns>
|
||||||
|
Task<bool> ChangeEntryPriceAsync(ulong guildId, int index, int newPrice);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes the name of a shop item
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guildId">Id of the guild in which the shop is</param>
|
||||||
|
/// <param name="index">Index of the item</param>
|
||||||
|
/// <param name="newName">New item name</param>
|
||||||
|
/// <returns>Success status</returns>
|
||||||
|
Task<bool> ChangeEntryNameAsync(ulong guildId, int index, string newName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Swaps indexes of 2 items in the shop
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guildId">Id of the guild in which the shop is</param>
|
||||||
|
/// <param name="index1">First entry's index</param>
|
||||||
|
/// <param name="index2">Second entry's index</param>
|
||||||
|
/// <returns>Whether swap was successful</returns>
|
||||||
|
Task<bool> SwapEntriesAsync(ulong guildId, int index1, int index2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Swaps indexes of 2 items in the shop
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guildId">Id of the guild in which the shop is</param>
|
||||||
|
/// <param name="fromIndex">Current index of the entry to move</param>
|
||||||
|
/// <param name="toIndex">Destination index of the entry</param>
|
||||||
|
/// <returns>Whether swap was successful</returns>
|
||||||
|
Task<bool> MoveEntryAsync(ulong guildId, int fromIndex, int toIndex);
|
||||||
|
|
||||||
|
Task<bool> SetItemRoleRequirementAsync(ulong guildId, int index, ulong? roleId);
|
||||||
|
Task<ShopEntry> AddShopCommandAsync(ulong guildId, ulong userId, int price, string command);
|
||||||
|
}
|
597
src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs
Normal file
597
src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs
Normal file
|
@ -0,0 +1,597 @@
|
||||||
|
#nullable disable
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using EllieBot.Modules.Administration;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public partial class Gambling
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class ShopCommands : GamblingSubmodule<IShopService>
|
||||||
|
{
|
||||||
|
public enum List
|
||||||
|
{
|
||||||
|
List
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Role
|
||||||
|
{
|
||||||
|
Role
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Command
|
||||||
|
{
|
||||||
|
Command,
|
||||||
|
Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly DbService _db;
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
|
||||||
|
public ShopCommands(DbService db, ICurrencyService cs, GamblingConfigService gamblingConf)
|
||||||
|
: base(gamblingConf)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_cs = cs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task ShopInternalAsync(int page = 0)
|
||||||
|
{
|
||||||
|
if (page < 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(page));
|
||||||
|
|
||||||
|
using var uow = _db.GetDbContext();
|
||||||
|
var entries = uow.GuildConfigsForId(ctx.Guild.Id,
|
||||||
|
set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items))
|
||||||
|
.ShopEntries.ToIndexed();
|
||||||
|
|
||||||
|
return Response()
|
||||||
|
.Paginated()
|
||||||
|
.Items(entries.ToList())
|
||||||
|
.PageSize(9)
|
||||||
|
.CurrentPage(page)
|
||||||
|
.Page((items, curPage) =>
|
||||||
|
{
|
||||||
|
if (!items.Any())
|
||||||
|
return _sender.CreateEmbed().WithErrorColor().WithDescription(GetText(strs.shop_none));
|
||||||
|
var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.shop));
|
||||||
|
|
||||||
|
for (var i = 0; i < items.Count; i++)
|
||||||
|
{
|
||||||
|
var entry = items[i];
|
||||||
|
embed.AddField($"#{(curPage * 9) + i + 1} - {N(entry.Price)}",
|
||||||
|
EntryToString(entry),
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
})
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public Task Shop(int page = 1)
|
||||||
|
{
|
||||||
|
if (--page < 0)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
return ShopInternalAsync(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Buy(int index)
|
||||||
|
{
|
||||||
|
index -= 1;
|
||||||
|
if (index < 0)
|
||||||
|
return;
|
||||||
|
ShopEntry entry;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
var config = uow.GuildConfigsForId(ctx.Guild.Id,
|
||||||
|
set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items));
|
||||||
|
var entries = new IndexedCollection<ShopEntry>(config.ShopEntries);
|
||||||
|
entry = entries.ElementAtOrDefault(index);
|
||||||
|
uow.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.shop_item_not_found).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.RoleRequirement is ulong reqRoleId)
|
||||||
|
{
|
||||||
|
var role = ctx.Guild.GetRole(reqRoleId);
|
||||||
|
if (role is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.shop_item_req_role_not_found).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var guser = (IGuildUser)ctx.User;
|
||||||
|
if (!guser.RoleIds.Contains(reqRoleId))
|
||||||
|
{
|
||||||
|
await Response()
|
||||||
|
.Error(strs.shop_item_req_role_unfulfilled(Format.Bold(role.ToString())))
|
||||||
|
.SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Type == ShopEntryType.Role)
|
||||||
|
{
|
||||||
|
var guser = (IGuildUser)ctx.User;
|
||||||
|
var role = ctx.Guild.GetRole(entry.RoleId);
|
||||||
|
|
||||||
|
if (role is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.shop_role_not_found).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guser.RoleIds.Any(id => id == role.Id))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.shop_role_already_bought).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString())))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await guser.AddRoleAsync(role);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error adding shop role");
|
||||||
|
await _cs.AddAsync(ctx.User.Id, entry.Price, new("shop", "error-refund"));
|
||||||
|
await Response().Error(strs.shop_role_purchase_error).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var profit = GetProfitAmount(entry.Price);
|
||||||
|
await _cs.AddAsync(entry.AuthorId, profit, new("shop", "sell", $"Shop sell item - {entry.Type}"));
|
||||||
|
await _cs.AddAsync(ctx.Client.CurrentUser.Id, entry.Price - profit, new("shop", "cut"));
|
||||||
|
await Response().Confirm(strs.shop_role_purchase(Format.Bold(role.Name))).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (entry.Type == ShopEntryType.List)
|
||||||
|
{
|
||||||
|
if (entry.Items.Count == 0)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.out_of_stock).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = entry.Items.ToArray()[new EllieRandom().Next(0, entry.Items.Count)];
|
||||||
|
|
||||||
|
if (await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString())))
|
||||||
|
{
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
uow.Set<ShopEntryItem>().Remove(item);
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Response()
|
||||||
|
.User(ctx.User)
|
||||||
|
.Embed(_sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithTitle(GetText(strs.shop_purchase(ctx.Guild.Name)))
|
||||||
|
.AddField(GetText(strs.item), item.Text)
|
||||||
|
.AddField(GetText(strs.price), entry.Price.ToString(), true)
|
||||||
|
.AddField(GetText(strs.name), entry.Name, true))
|
||||||
|
.SendAsync();
|
||||||
|
|
||||||
|
await _cs.AddAsync(entry.AuthorId,
|
||||||
|
GetProfitAmount(entry.Price),
|
||||||
|
new("shop", "sell", entry.Name));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _cs.AddAsync(ctx.User.Id, entry.Price, new("shop", "error-refund", entry.Name));
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
var entries = new IndexedCollection<ShopEntry>(uow.GuildConfigsForId(ctx.Guild.Id,
|
||||||
|
set => set.Include(x => x.ShopEntries)
|
||||||
|
.ThenInclude(x => x.Items))
|
||||||
|
.ShopEntries);
|
||||||
|
entry = entries.ElementAtOrDefault(index);
|
||||||
|
if (entry is not null)
|
||||||
|
{
|
||||||
|
if (entry.Items.Add(item))
|
||||||
|
uow.SaveChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Error(strs.shop_buy_error).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Confirm(strs.shop_item_purchase).SendAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
}
|
||||||
|
else if (entry.Type == ShopEntryType.Command)
|
||||||
|
{
|
||||||
|
var guild = ctx.Guild as SocketGuild;
|
||||||
|
var channel = ctx.Channel as ISocketMessageChannel;
|
||||||
|
var msg = ctx.Message as SocketUserMessage;
|
||||||
|
var user = await ctx.Guild.GetUserAsync(entry.AuthorId);
|
||||||
|
|
||||||
|
if (guild is null || channel is null || msg is null || user is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.shop_command_invalid_context).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString())))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var buyer = (IGuildUser)ctx.User;
|
||||||
|
var cmd = entry.Command
|
||||||
|
.Replace("%you%", buyer.Mention)
|
||||||
|
.Replace("%you.mention%", buyer.Mention)
|
||||||
|
.Replace("%you.username%", buyer.Username)
|
||||||
|
.Replace("%you.name%", buyer.GlobalName ?? buyer.Username)
|
||||||
|
.Replace("%you.nick%", buyer.DisplayName);
|
||||||
|
|
||||||
|
var eb = _sender.CreateEmbed()
|
||||||
|
.WithPendingColor()
|
||||||
|
.WithTitle("Executing shop command")
|
||||||
|
.WithDescription(cmd);
|
||||||
|
|
||||||
|
var msgTask = Response().Embed(eb).SendAsync();
|
||||||
|
|
||||||
|
await _cs.AddAsync(entry.AuthorId,
|
||||||
|
GetProfitAmount(entry.Price),
|
||||||
|
new("shop", "sell", entry.Name));
|
||||||
|
|
||||||
|
await Task.Delay(250);
|
||||||
|
await _cmdHandler.TryRunCommand(guild,
|
||||||
|
channel,
|
||||||
|
new DoAsUserMessage(
|
||||||
|
msg,
|
||||||
|
user,
|
||||||
|
cmd
|
||||||
|
));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pendingMsg = await msgTask;
|
||||||
|
await pendingMsg.EditAsync(
|
||||||
|
SmartEmbedText.FromEmbed(eb
|
||||||
|
.WithOkColor()
|
||||||
|
.WithTitle("Shop command executed")
|
||||||
|
.Build()));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetProfitAmount(int price)
|
||||||
|
=> (int)Math.Ceiling((1.0m - Config.BotCuts.ShopSaleCut) * price);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.Administrator)]
|
||||||
|
[BotPerm(GuildPerm.ManageRoles)]
|
||||||
|
public async Task ShopAdd(Command _, int price, [Leftover] string command)
|
||||||
|
{
|
||||||
|
if (price < 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
|
||||||
|
var entry = await _service.AddShopCommandAsync(ctx.Guild.Id, ctx.User.Id, price, command);
|
||||||
|
|
||||||
|
await Response().Embed(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.Administrator)]
|
||||||
|
[BotPerm(GuildPerm.ManageRoles)]
|
||||||
|
public async Task ShopAdd(Role _, int price, [Leftover] IRole role)
|
||||||
|
{
|
||||||
|
if (price < 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var entry = new ShopEntry
|
||||||
|
{
|
||||||
|
Name = "-",
|
||||||
|
Price = price,
|
||||||
|
Type = ShopEntryType.Role,
|
||||||
|
AuthorId = ctx.User.Id,
|
||||||
|
RoleId = role.Id,
|
||||||
|
RoleName = role.Name
|
||||||
|
};
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
var entries = new IndexedCollection<ShopEntry>(uow.GuildConfigsForId(ctx.Guild.Id,
|
||||||
|
set => set.Include(x => x.ShopEntries)
|
||||||
|
.ThenInclude(x => x.Items))
|
||||||
|
.ShopEntries)
|
||||||
|
{
|
||||||
|
entry
|
||||||
|
};
|
||||||
|
uow.GuildConfigsForId(ctx.Guild.Id, set => set).ShopEntries = entries;
|
||||||
|
uow.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Embed(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.Administrator)]
|
||||||
|
public async Task ShopAdd(List _, int price, [Leftover] string name)
|
||||||
|
{
|
||||||
|
if (price < 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var entry = new ShopEntry
|
||||||
|
{
|
||||||
|
Name = name.TrimTo(100),
|
||||||
|
Price = price,
|
||||||
|
Type = ShopEntryType.List,
|
||||||
|
AuthorId = ctx.User.Id,
|
||||||
|
Items = new()
|
||||||
|
};
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
var entries = new IndexedCollection<ShopEntry>(uow.GuildConfigsForId(ctx.Guild.Id,
|
||||||
|
set => set.Include(x => x.ShopEntries)
|
||||||
|
.ThenInclude(x => x.Items))
|
||||||
|
.ShopEntries)
|
||||||
|
{
|
||||||
|
entry
|
||||||
|
};
|
||||||
|
uow.GuildConfigsForId(ctx.Guild.Id, set => set).ShopEntries = entries;
|
||||||
|
uow.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Embed(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.Administrator)]
|
||||||
|
public async Task ShopListAdd(int index, [Leftover] string itemText)
|
||||||
|
{
|
||||||
|
index -= 1;
|
||||||
|
if (index < 0)
|
||||||
|
return;
|
||||||
|
var item = new ShopEntryItem
|
||||||
|
{
|
||||||
|
Text = itemText
|
||||||
|
};
|
||||||
|
ShopEntry entry;
|
||||||
|
var rightType = false;
|
||||||
|
var added = false;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
var entries = new IndexedCollection<ShopEntry>(uow.GuildConfigsForId(ctx.Guild.Id,
|
||||||
|
set => set.Include(x => x.ShopEntries)
|
||||||
|
.ThenInclude(x => x.Items))
|
||||||
|
.ShopEntries);
|
||||||
|
entry = entries.ElementAtOrDefault(index);
|
||||||
|
if (entry is not null && (rightType = entry.Type == ShopEntryType.List))
|
||||||
|
{
|
||||||
|
if (entry.Items.Add(item))
|
||||||
|
{
|
||||||
|
added = true;
|
||||||
|
uow.SaveChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry is null)
|
||||||
|
await Response().Error(strs.shop_item_not_found).SendAsync();
|
||||||
|
else if (!rightType)
|
||||||
|
await Response().Error(strs.shop_item_wrong_type).SendAsync();
|
||||||
|
else if (added == false)
|
||||||
|
await Response().Error(strs.shop_list_item_not_unique).SendAsync();
|
||||||
|
else
|
||||||
|
await Response().Confirm(strs.shop_list_item_added).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.Administrator)]
|
||||||
|
public async Task ShopRemove(int index)
|
||||||
|
{
|
||||||
|
index -= 1;
|
||||||
|
if (index < 0)
|
||||||
|
return;
|
||||||
|
ShopEntry removed;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
var config = uow.GuildConfigsForId(ctx.Guild.Id,
|
||||||
|
set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items));
|
||||||
|
|
||||||
|
var entries = new IndexedCollection<ShopEntry>(config.ShopEntries);
|
||||||
|
removed = entries.ElementAtOrDefault(index);
|
||||||
|
if (removed is not null)
|
||||||
|
{
|
||||||
|
uow.RemoveRange(removed.Items);
|
||||||
|
uow.Remove(removed);
|
||||||
|
uow.SaveChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed is null)
|
||||||
|
await Response().Error(strs.shop_item_not_found).SendAsync();
|
||||||
|
else
|
||||||
|
await Response().Embed(EntryToEmbed(removed).WithTitle(GetText(strs.shop_item_rm))).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.Administrator)]
|
||||||
|
public async Task ShopChangePrice(int index, int price)
|
||||||
|
{
|
||||||
|
if (--index < 0 || price <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var succ = await _service.ChangeEntryPriceAsync(ctx.Guild.Id, index, price);
|
||||||
|
if (succ)
|
||||||
|
{
|
||||||
|
await ShopInternalAsync(index / 9);
|
||||||
|
await ctx.OkAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await ctx.ErrorAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.Administrator)]
|
||||||
|
public async Task ShopChangeName(int index, [Leftover] string newName)
|
||||||
|
{
|
||||||
|
if (--index < 0 || string.IsNullOrWhiteSpace(newName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var succ = await _service.ChangeEntryNameAsync(ctx.Guild.Id, index, newName);
|
||||||
|
if (succ)
|
||||||
|
{
|
||||||
|
await ShopInternalAsync(index / 9);
|
||||||
|
await ctx.OkAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await ctx.ErrorAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.Administrator)]
|
||||||
|
public async Task ShopSwap(int index1, int index2)
|
||||||
|
{
|
||||||
|
if (--index1 < 0 || --index2 < 0 || index1 == index2)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var succ = await _service.SwapEntriesAsync(ctx.Guild.Id, index1, index2);
|
||||||
|
if (succ)
|
||||||
|
{
|
||||||
|
await ShopInternalAsync(index1 / 9);
|
||||||
|
await ctx.OkAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await ctx.ErrorAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.Administrator)]
|
||||||
|
public async Task ShopMove(int fromIndex, int toIndex)
|
||||||
|
{
|
||||||
|
if (--fromIndex < 0 || --toIndex < 0 || fromIndex == toIndex)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var succ = await _service.MoveEntryAsync(ctx.Guild.Id, fromIndex, toIndex);
|
||||||
|
if (succ)
|
||||||
|
{
|
||||||
|
await ShopInternalAsync(toIndex / 9);
|
||||||
|
await ctx.OkAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await ctx.ErrorAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.Administrator)]
|
||||||
|
public async Task ShopReq(int itemIndex, [Leftover] IRole role = null)
|
||||||
|
{
|
||||||
|
if (--itemIndex < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var succ = await _service.SetItemRoleRequirementAsync(ctx.Guild.Id, itemIndex, role?.Id);
|
||||||
|
if (!succ)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.shop_item_not_found).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role is null)
|
||||||
|
await Response().Confirm(strs.shop_item_role_no_req(itemIndex)).SendAsync();
|
||||||
|
else
|
||||||
|
await Response().Confirm(strs.shop_item_role_req(itemIndex + 1, role)).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public EmbedBuilder EntryToEmbed(ShopEntry entry)
|
||||||
|
{
|
||||||
|
var embed = _sender.CreateEmbed().WithOkColor();
|
||||||
|
|
||||||
|
if (entry.Type == ShopEntryType.Role)
|
||||||
|
{
|
||||||
|
return embed
|
||||||
|
.AddField(GetText(strs.name),
|
||||||
|
GetText(strs.shop_role(Format.Bold(ctx.Guild.GetRole(entry.RoleId)?.Name
|
||||||
|
?? "MISSING_ROLE"))),
|
||||||
|
true)
|
||||||
|
.AddField(GetText(strs.price), N(entry.Price), true)
|
||||||
|
.AddField(GetText(strs.type), entry.Type.ToString(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Type == ShopEntryType.List)
|
||||||
|
{
|
||||||
|
return embed.AddField(GetText(strs.name), entry.Name, true)
|
||||||
|
.AddField(GetText(strs.price), N(entry.Price), true)
|
||||||
|
.AddField(GetText(strs.type), GetText(strs.random_unique_item), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (entry.Type == ShopEntryType.Command)
|
||||||
|
{
|
||||||
|
return embed
|
||||||
|
.AddField(GetText(strs.name), Format.Code(entry.Command), true)
|
||||||
|
.AddField(GetText(strs.price), N(entry.Price), true)
|
||||||
|
.AddField(GetText(strs.type), entry.Type.ToString(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
//else if (entry.Type == ShopEntryType.Infinite_List)
|
||||||
|
// return embed.AddField(GetText(strs.name), GetText(strs.shop_role(Format.Bold(entry.RoleName)), true))
|
||||||
|
// .AddField(GetText(strs.price), entry.Price.ToString(), true)
|
||||||
|
// .AddField(GetText(strs.type), entry.Type.ToString(), true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string EntryToString(ShopEntry entry)
|
||||||
|
{
|
||||||
|
var prepend = string.Empty;
|
||||||
|
if (entry.RoleRequirement is not null)
|
||||||
|
prepend = Format.Italics(GetText(strs.shop_item_requires_role($"<@&{entry.RoleRequirement}>")))
|
||||||
|
+ Environment.NewLine;
|
||||||
|
|
||||||
|
if (entry.Type == ShopEntryType.Role)
|
||||||
|
return prepend
|
||||||
|
+ GetText(strs.shop_role(Format.Bold(ctx.Guild.GetRole(entry.RoleId)?.Name ?? "MISSING_ROLE")));
|
||||||
|
if (entry.Type == ShopEntryType.List)
|
||||||
|
return prepend + GetText(strs.unique_items_left(entry.Items.Count)) + "\n" + entry.Name;
|
||||||
|
|
||||||
|
if (entry.Type == ShopEntryType.Command)
|
||||||
|
return prepend + Format.Code(entry.Command);
|
||||||
|
return prepend;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
126
src/EllieBot/Modules/Gambling/Shop/ShopService.cs
Normal file
126
src/EllieBot/Modules/Gambling/Shop/ShopService.cs
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
#nullable disable
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
public class ShopService : IShopService, IEService
|
||||||
|
{
|
||||||
|
private readonly DbService _db;
|
||||||
|
|
||||||
|
public ShopService(DbService db)
|
||||||
|
=> _db = db;
|
||||||
|
|
||||||
|
private IndexedCollection<ShopEntry> GetEntriesInternal(DbContext uow, ulong guildId)
|
||||||
|
=> uow.GuildConfigsForId(guildId,
|
||||||
|
set => set.Include(x => x.ShopEntries)
|
||||||
|
.ThenInclude(x => x.Items))
|
||||||
|
.ShopEntries.ToIndexed();
|
||||||
|
|
||||||
|
public async Task<bool> ChangeEntryPriceAsync(ulong guildId, int index, int newPrice)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(newPrice);
|
||||||
|
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var entries = GetEntriesInternal(uow, guildId);
|
||||||
|
|
||||||
|
if (index >= entries.Count)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
entries[index].Price = newPrice;
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ChangeEntryNameAsync(ulong guildId, int index, string newName)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(newName))
|
||||||
|
throw new ArgumentNullException(nameof(newName));
|
||||||
|
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var entries = GetEntriesInternal(uow, guildId);
|
||||||
|
|
||||||
|
if (index >= entries.Count)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
entries[index].Name = newName.TrimTo(100);
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SwapEntriesAsync(ulong guildId, int index1, int index2)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(index1);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(index2);
|
||||||
|
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var entries = GetEntriesInternal(uow, guildId);
|
||||||
|
|
||||||
|
if (index1 >= entries.Count || index2 >= entries.Count || index1 == index2)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
entries[index1].Index = index2;
|
||||||
|
entries[index2].Index = index1;
|
||||||
|
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> MoveEntryAsync(ulong guildId, int fromIndex, int toIndex)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(fromIndex);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(toIndex);
|
||||||
|
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var entries = GetEntriesInternal(uow, guildId);
|
||||||
|
|
||||||
|
if (fromIndex >= entries.Count || toIndex >= entries.Count || fromIndex == toIndex)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var entry = entries[fromIndex];
|
||||||
|
entries.RemoveAt(fromIndex);
|
||||||
|
entries.Insert(toIndex, entry);
|
||||||
|
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetItemRoleRequirementAsync(ulong guildId, int index, ulong? roleId)
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var entries = GetEntriesInternal(uow, guildId);
|
||||||
|
|
||||||
|
if (index >= entries.Count)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var entry = entries[index];
|
||||||
|
|
||||||
|
entry.RoleRequirement = roleId;
|
||||||
|
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ShopEntry> AddShopCommandAsync(ulong guildId, ulong userId, int price, string command)
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
|
||||||
|
var entries = GetEntriesInternal(uow, guildId);
|
||||||
|
var entry = new ShopEntry()
|
||||||
|
{
|
||||||
|
AuthorId = userId,
|
||||||
|
Command = command,
|
||||||
|
Type = ShopEntryType.Command,
|
||||||
|
Price = price,
|
||||||
|
};
|
||||||
|
entries.Add(entry);
|
||||||
|
uow.GuildConfigsForId(guildId, set => set).ShopEntries = entries;
|
||||||
|
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
223
src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs
Normal file
223
src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs
Normal file
|
@ -0,0 +1,223 @@
|
||||||
|
#nullable disable warnings
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using SixLabors.Fonts;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Drawing.Processing;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
using EllieBot.Common.TypeReaders;
|
||||||
|
using Color = SixLabors.ImageSharp.Color;
|
||||||
|
using Image = SixLabors.ImageSharp.Image;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public enum GamblingError
|
||||||
|
{
|
||||||
|
InsufficientFunds,
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class Gambling
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class SlotCommands : GamblingSubmodule<IGamblingService>
|
||||||
|
{
|
||||||
|
private readonly IImageCache _images;
|
||||||
|
private readonly FontProvider _fonts;
|
||||||
|
private readonly DbService _db;
|
||||||
|
private object _slotStatsLock = new();
|
||||||
|
|
||||||
|
public SlotCommands(
|
||||||
|
IImageCache images,
|
||||||
|
FontProvider fonts,
|
||||||
|
DbService db,
|
||||||
|
GamblingConfigService gamb)
|
||||||
|
: base(gamb)
|
||||||
|
{
|
||||||
|
_images = images;
|
||||||
|
_fonts = fonts;
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Test()
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task Slot([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
|
||||||
|
{
|
||||||
|
if (!await CheckBetMandatory(amount))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// var slotInteraction = CreateSlotInteractionIntenal(amount);
|
||||||
|
|
||||||
|
await ctx.Channel.TriggerTypingAsync();
|
||||||
|
|
||||||
|
if (await InternalSlotAsync(amount) is not SlotResult result)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = GetSlotMessageTextInternal(result);
|
||||||
|
|
||||||
|
using var image = await GenerateSlotImageAsync(amount, result);
|
||||||
|
await using var imgStream = await image.ToStreamAsync();
|
||||||
|
|
||||||
|
|
||||||
|
var eb = _sender.CreateEmbed()
|
||||||
|
.WithAuthor(ctx.User)
|
||||||
|
.WithDescription(Format.Bold(text))
|
||||||
|
.WithImageUrl($"attachment://result.png")
|
||||||
|
.WithOkColor();
|
||||||
|
|
||||||
|
var bb = new ButtonBuilder(emote: Emoji.Parse("🔁"), customId: "slot:again", label: "Pull Again");
|
||||||
|
var inter = _inter.Create(ctx.User.Id,
|
||||||
|
bb,
|
||||||
|
smc =>
|
||||||
|
{
|
||||||
|
smc.DeferAsync();
|
||||||
|
return Slot(amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
var msg = await ctx.Channel.SendFileAsync(imgStream,
|
||||||
|
"result.png",
|
||||||
|
embed: eb.Build(),
|
||||||
|
components: inter.CreateComponent()
|
||||||
|
);
|
||||||
|
await inter.RunAsync(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// private SlotInteraction CreateSlotInteractionIntenal(long amount)
|
||||||
|
// {
|
||||||
|
// return new SlotInteraction((DiscordSocketClient)ctx.Client,
|
||||||
|
// ctx.User.Id,
|
||||||
|
// async (smc) =>
|
||||||
|
// {
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// if (await InternalSlotAsync(amount) is not SlotResult result)
|
||||||
|
// {
|
||||||
|
// await smc.RespondErrorAsync(_eb, GetText(strs.not_enough(CurrencySign)), true);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// var msg = GetSlotMessageInternal(result);
|
||||||
|
//
|
||||||
|
// using var image = await GenerateSlotImageAsync(amount, result);
|
||||||
|
// await using var imgStream = await image.ToStreamAsync();
|
||||||
|
//
|
||||||
|
// var guid = Guid.NewGuid();
|
||||||
|
// var imgName = $"result_{guid}.png";
|
||||||
|
//
|
||||||
|
// var slotInteraction = CreateSlotInteractionIntenal(amount).GetInteraction();
|
||||||
|
//
|
||||||
|
// await smc.Message.ModifyAsync(m =>
|
||||||
|
// {
|
||||||
|
// m.Content = msg;
|
||||||
|
// m.Attachments = new[]
|
||||||
|
// {
|
||||||
|
// new FileAttachment(imgStream, imgName)
|
||||||
|
// };
|
||||||
|
// m.Components = slotInteraction.CreateComponent();
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// _ = slotInteraction.RunAsync(smc.Message);
|
||||||
|
// }
|
||||||
|
// catch (Exception ex)
|
||||||
|
// {
|
||||||
|
// Log.Error(ex, "Error pulling slot again");
|
||||||
|
// }
|
||||||
|
// // finally
|
||||||
|
// // {
|
||||||
|
// // await Task.Delay(1000);
|
||||||
|
// // _runningUsers.TryRemove(ctx.User.Id);
|
||||||
|
// // }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
private string GetSlotMessageTextInternal(SlotResult result)
|
||||||
|
{
|
||||||
|
var multi = result.Multiplier.ToString("0.##");
|
||||||
|
var msg = result.WinType switch
|
||||||
|
{
|
||||||
|
SlotWinType.SingleJoker => GetText(strs.slot_single(CurrencySign, multi)),
|
||||||
|
SlotWinType.DoubleJoker => GetText(strs.slot_two(CurrencySign, multi)),
|
||||||
|
SlotWinType.TrippleNormal => GetText(strs.slot_three(multi)),
|
||||||
|
SlotWinType.TrippleJoker => GetText(strs.slot_jackpot(multi)),
|
||||||
|
_ => GetText(strs.better_luck),
|
||||||
|
};
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SlotResult?> InternalSlotAsync(long amount)
|
||||||
|
{
|
||||||
|
var maybeResult = await _service.SlotAsync(ctx.User.Id, amount);
|
||||||
|
|
||||||
|
if (!maybeResult.TryPickT0(out var result, out var error))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Image<Rgba32>> GenerateSlotImageAsync(long amount, SlotResult result)
|
||||||
|
{
|
||||||
|
long ownedAmount;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
ownedAmount = uow.Set<DiscordUser>()
|
||||||
|
.FirstOrDefault(x => x.UserId == ctx.User.Id)?.CurrencyAmount
|
||||||
|
?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var slotBg = await _images.GetSlotBgAsync();
|
||||||
|
var bgImage = Image.Load<Rgba32>(slotBg);
|
||||||
|
var numbers = new int[3];
|
||||||
|
result.Rolls.CopyTo(numbers, 0);
|
||||||
|
|
||||||
|
Color fontColor = Config.Slots.CurrencyFontColor;
|
||||||
|
|
||||||
|
bgImage.Mutate<Rgba32>(x => x.DrawText(new RichTextOptions(_fonts.DottyFont.CreateFont(65))
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
WrappingLength = 140,
|
||||||
|
Origin = new(298, 100)
|
||||||
|
},
|
||||||
|
((long)result.Won).ToString(),
|
||||||
|
fontColor));
|
||||||
|
|
||||||
|
var bottomFont = _fonts.DottyFont.CreateFont(50);
|
||||||
|
|
||||||
|
bgImage.Mutate(x => x.DrawText(new RichTextOptions(bottomFont)
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
WrappingLength = 135,
|
||||||
|
Origin = new(196, 480)
|
||||||
|
},
|
||||||
|
amount.ToString(),
|
||||||
|
fontColor));
|
||||||
|
|
||||||
|
bgImage.Mutate(x => x.DrawText(new(bottomFont)
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Origin = new(393, 480)
|
||||||
|
},
|
||||||
|
ownedAmount.ToString(),
|
||||||
|
fontColor));
|
||||||
|
//sw.PrintLap("drew red text");
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
using var img = Image.Load(await _images.GetSlotEmojiAsync(numbers[i]));
|
||||||
|
bgImage.Mutate(x => x.DrawImage(img, new Point(148 + (105 * i), 217), 1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
return bgImage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
106
src/EllieBot/Modules/Gambling/VoteRewardService.cs
Normal file
106
src/EllieBot/Modules/Gambling/VoteRewardService.cs
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Common.ModuleBehaviors;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
public class VoteModel
|
||||||
|
{
|
||||||
|
[JsonPropertyName("userId")]
|
||||||
|
public ulong UserId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VoteRewardService : IEService, IReadyExecutor
|
||||||
|
{
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly IBotCredentials _creds;
|
||||||
|
private readonly ICurrencyService _currencyService;
|
||||||
|
private readonly GamblingConfigService _gamb;
|
||||||
|
|
||||||
|
public VoteRewardService(
|
||||||
|
DiscordSocketClient client,
|
||||||
|
IBotCredentials creds,
|
||||||
|
ICurrencyService currencyService,
|
||||||
|
GamblingConfigService gamb)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_creds = creds;
|
||||||
|
_currencyService = currencyService;
|
||||||
|
_gamb = gamb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnReadyAsync()
|
||||||
|
{
|
||||||
|
if (_client.ShardId != 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var http = new HttpClient(new HttpClientHandler
|
||||||
|
{
|
||||||
|
AllowAutoRedirect = false,
|
||||||
|
ServerCertificateCustomValidationCallback = delegate { return true; }
|
||||||
|
});
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
await Task.Delay(30000);
|
||||||
|
|
||||||
|
var topggKey = _creds.Votes?.TopggKey;
|
||||||
|
var topggServiceUrl = _creds.Votes?.TopggServiceUrl;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(topggKey) && !string.IsNullOrWhiteSpace(topggServiceUrl))
|
||||||
|
{
|
||||||
|
http.DefaultRequestHeaders.Authorization = new(topggKey);
|
||||||
|
var uri = new Uri(new(topggServiceUrl), "topgg/new");
|
||||||
|
var res = await http.GetStringAsync(uri);
|
||||||
|
var data = JsonSerializer.Deserialize<List<VoteModel>>(res);
|
||||||
|
|
||||||
|
if (data is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var ids = data.Select(x => x.UserId).ToList();
|
||||||
|
|
||||||
|
await _currencyService.AddBulkAsync(ids,
|
||||||
|
_gamb.Data.VoteReward,
|
||||||
|
new("vote", "top.gg", "top.gg vote reward"));
|
||||||
|
|
||||||
|
Log.Information("Rewarding {Count} top.gg voters", ids.Count());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Critical error loading top.gg vote rewards");
|
||||||
|
}
|
||||||
|
|
||||||
|
var discordsKey = _creds.Votes?.DiscordsKey;
|
||||||
|
var discordsServiceUrl = _creds.Votes?.DiscordsServiceUrl;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(discordsKey) && !string.IsNullOrWhiteSpace(discordsServiceUrl))
|
||||||
|
{
|
||||||
|
http.DefaultRequestHeaders.Authorization = new(discordsKey);
|
||||||
|
var res = await http.GetStringAsync(new Uri(new(discordsServiceUrl), "discords/new"));
|
||||||
|
var data = JsonSerializer.Deserialize<List<VoteModel>>(res);
|
||||||
|
|
||||||
|
if (data is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var ids = data.Select(x => x.UserId).ToList();
|
||||||
|
|
||||||
|
await _currencyService.AddBulkAsync(ids,
|
||||||
|
_gamb.Data.VoteReward,
|
||||||
|
new("vote", "discords", "discords.com vote reward"));
|
||||||
|
|
||||||
|
Log.Information("Rewarding {Count} discords.com voters", ids.Count());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Critical error loading discords.com vote rewards");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
406
src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs
Normal file
406
src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs
Normal file
|
@ -0,0 +1,406 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Common.Waifu;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public partial class Gambling
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class WaifuClaimCommands : GamblingSubmodule<WaifuService>
|
||||||
|
{
|
||||||
|
public WaifuClaimCommands(GamblingConfigService gamblingConfService)
|
||||||
|
: base(gamblingConfService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task WaifuReset()
|
||||||
|
{
|
||||||
|
var price = _service.GetResetPrice(ctx.User);
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithTitle(GetText(strs.waifu_reset_confirm))
|
||||||
|
.WithDescription(GetText(strs.waifu_reset_price(Format.Bold(N(price)))));
|
||||||
|
|
||||||
|
if (!await PromptUserConfirmAsync(embed))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (await _service.TryReset(ctx.User))
|
||||||
|
{
|
||||||
|
await Response().Confirm(strs.waifu_reset).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Error(strs.waifu_reset_fail).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task WaifuClaim(long amount, [Leftover] IUser target)
|
||||||
|
{
|
||||||
|
if (amount < Config.Waifu.MinPrice)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.waifu_isnt_cheap(Config.Waifu.MinPrice + CurrencySign)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.Id == ctx.User.Id)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.waifu_not_yourself).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (w, isAffinity, result) = await _service.ClaimWaifuAsync(ctx.User, target, amount);
|
||||||
|
|
||||||
|
if (result == WaifuClaimResult.InsufficientAmount)
|
||||||
|
{
|
||||||
|
await Response()
|
||||||
|
.Error(
|
||||||
|
strs.waifu_not_enough(N((long)Math.Ceiling(w.Price * (isAffinity ? 0.88f : 1.1f)))))
|
||||||
|
.SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result == WaifuClaimResult.NotEnoughFunds)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg = GetText(strs.waifu_claimed(
|
||||||
|
Format.Bold(ctx.User.ToString()),
|
||||||
|
Format.Bold(target.ToString()),
|
||||||
|
N(amount)));
|
||||||
|
|
||||||
|
if (w.Affinity?.UserId == ctx.User.Id)
|
||||||
|
msg += "\n" + GetText(strs.waifu_fulfilled(target, N(w.Price)));
|
||||||
|
else
|
||||||
|
msg = " " + msg;
|
||||||
|
await Response().Confirm(ctx.User.Mention + msg).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(0)]
|
||||||
|
public async Task WaifuTransfer(ulong waifuId, IUser newOwner)
|
||||||
|
{
|
||||||
|
if (!await _service.WaifuTransfer(ctx.User, waifuId, newOwner))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.waifu_transfer_fail).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response()
|
||||||
|
.Confirm(strs.waifu_transfer_success(Format.Bold(waifuId.ToString()),
|
||||||
|
Format.Bold(ctx.User.ToString()),
|
||||||
|
Format.Bold(newOwner.ToString())))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(1)]
|
||||||
|
public async Task WaifuTransfer(IUser waifu, IUser newOwner)
|
||||||
|
{
|
||||||
|
if (!await _service.WaifuTransfer(ctx.User, waifu.Id, newOwner))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.waifu_transfer_fail).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response()
|
||||||
|
.Confirm(strs.waifu_transfer_success(Format.Bold(waifu.ToString()),
|
||||||
|
Format.Bold(ctx.User.ToString()),
|
||||||
|
Format.Bold(newOwner.ToString())))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(-1)]
|
||||||
|
public Task Divorce([Leftover] string target)
|
||||||
|
{
|
||||||
|
var waifuUserId = _service.GetWaifuUserId(ctx.User.Id, target);
|
||||||
|
if (waifuUserId == default)
|
||||||
|
return Response().Error(strs.waifu_not_yours).SendAsync();
|
||||||
|
|
||||||
|
return Divorce(waifuUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(0)]
|
||||||
|
public Task Divorce([Leftover] IGuildUser target)
|
||||||
|
=> Divorce(target.Id);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(1)]
|
||||||
|
public async Task Divorce([Leftover] ulong targetId)
|
||||||
|
{
|
||||||
|
if (targetId == ctx.User.Id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var (w, result, amount, remaining) = await _service.DivorceWaifuAsync(ctx.User, targetId);
|
||||||
|
|
||||||
|
if (result == DivorceResult.SucessWithPenalty)
|
||||||
|
{
|
||||||
|
await Response()
|
||||||
|
.Confirm(strs.waifu_divorced_like(Format.Bold(w.Waifu.ToString()),
|
||||||
|
N(amount)))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
else if (result == DivorceResult.Success)
|
||||||
|
await Response().Confirm(strs.waifu_divorced_notlike(N(amount))).SendAsync();
|
||||||
|
else if (result == DivorceResult.NotYourWife)
|
||||||
|
await Response().Error(strs.waifu_not_yours).SendAsync();
|
||||||
|
else if (remaining is { } rem)
|
||||||
|
{
|
||||||
|
await Response()
|
||||||
|
.Error(strs.waifu_recent_divorce(
|
||||||
|
Format.Bold(((int)rem.TotalHours).ToString()),
|
||||||
|
Format.Bold(rem.Minutes.ToString())))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Affinity([Leftover] IGuildUser user = null)
|
||||||
|
{
|
||||||
|
if (user?.Id == ctx.User.Id)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.waifu_egomaniac).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (oldAff, sucess, remaining) = await _service.ChangeAffinityAsync(ctx.User, user);
|
||||||
|
if (!sucess)
|
||||||
|
{
|
||||||
|
if (remaining is not null)
|
||||||
|
{
|
||||||
|
await Response()
|
||||||
|
.Error(strs.waifu_affinity_cooldown(
|
||||||
|
Format.Bold(((int)remaining?.TotalHours).ToString()),
|
||||||
|
Format.Bold(remaining?.Minutes.ToString())))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await Response().Error(strs.waifu_affinity_already).SendAsync();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
await Response().Confirm(strs.waifu_affinity_reset).SendAsync();
|
||||||
|
}
|
||||||
|
else if (oldAff is null)
|
||||||
|
{
|
||||||
|
await Response()
|
||||||
|
.Confirm(strs.waifu_affinity_set(Format.Bold(ctx.User.ToString()), Format.Bold(user.ToString())))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Response()
|
||||||
|
.Confirm(strs.waifu_affinity_changed(
|
||||||
|
Format.Bold(ctx.User.ToString()),
|
||||||
|
Format.Bold(oldAff.ToString()),
|
||||||
|
Format.Bold(user.ToString())))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task WaifuLb(int page = 1)
|
||||||
|
{
|
||||||
|
page--;
|
||||||
|
|
||||||
|
if (page < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (page > 100)
|
||||||
|
page = 100;
|
||||||
|
|
||||||
|
var waifus = _service.GetTopWaifusAtPage(page).ToList();
|
||||||
|
|
||||||
|
if (waifus.Count == 0)
|
||||||
|
{
|
||||||
|
await Response().Confirm(strs.waifus_none).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var embed = _sender.CreateEmbed().WithTitle(GetText(strs.waifus_top_waifus)).WithOkColor();
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
foreach (var w in waifus)
|
||||||
|
{
|
||||||
|
var j = i++;
|
||||||
|
embed.AddField("#" + ((page * 9) + j + 1) + " - " + N(w.Price), GetLbString(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetLbString(WaifuLbResult w)
|
||||||
|
{
|
||||||
|
var claimer = "no one";
|
||||||
|
string status;
|
||||||
|
|
||||||
|
var waifuUsername = w.Username.TrimTo(20);
|
||||||
|
var claimerUsername = w.Claimer?.TrimTo(20);
|
||||||
|
|
||||||
|
if (w.Claimer is not null)
|
||||||
|
claimer = $"{claimerUsername}#{w.ClaimerDiscrim}";
|
||||||
|
if (w.Affinity is null)
|
||||||
|
status = $"... but {waifuUsername}'s heart is empty";
|
||||||
|
else if (w.Affinity + w.AffinityDiscrim == w.Claimer + w.ClaimerDiscrim)
|
||||||
|
status = $"... and {waifuUsername} likes {claimerUsername} too <3";
|
||||||
|
else
|
||||||
|
status = $"... but {waifuUsername}'s heart belongs to {w.Affinity.TrimTo(20)}#{w.AffinityDiscrim}";
|
||||||
|
return $"**{waifuUsername}#{w.Discrim}** - claimed by **{claimer}**\n\t{status}";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(1)]
|
||||||
|
public Task WaifuInfo([Leftover] IUser target = null)
|
||||||
|
{
|
||||||
|
if (target is null)
|
||||||
|
target = ctx.User;
|
||||||
|
|
||||||
|
return InternalWaifuInfo(target.Id, target.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(0)]
|
||||||
|
public Task WaifuInfo(ulong targetId)
|
||||||
|
=> InternalWaifuInfo(targetId);
|
||||||
|
|
||||||
|
private async Task InternalWaifuInfo(ulong targetId, string name = null)
|
||||||
|
{
|
||||||
|
var wi = await _service.GetFullWaifuInfoAsync(targetId);
|
||||||
|
var affInfo = _service.GetAffinityTitle(wi.AffinityCount);
|
||||||
|
|
||||||
|
var waifuItems = _service.GetWaifuItems().ToDictionary(x => x.ItemEmoji, x => x);
|
||||||
|
|
||||||
|
var nobody = GetText(strs.nobody);
|
||||||
|
var itemList = await _service.GetItems(wi.WaifuId);
|
||||||
|
var itemsStr = !itemList.Any()
|
||||||
|
? "-"
|
||||||
|
: string.Join("\n",
|
||||||
|
itemList.Where(x => waifuItems.TryGetValue(x.ItemEmoji, out _))
|
||||||
|
.OrderByDescending(x => waifuItems[x.ItemEmoji].Price)
|
||||||
|
.GroupBy(x => x.ItemEmoji)
|
||||||
|
.Take(60)
|
||||||
|
.Select(x => $"{x.Key} x{x.Count(),-3}")
|
||||||
|
.Chunk(2)
|
||||||
|
.Select(x => string.Join(" ", x)));
|
||||||
|
|
||||||
|
var claimsNames = (await _service.GetClaimNames(wi.WaifuId));
|
||||||
|
var claimsStr = claimsNames
|
||||||
|
.Shuffle()
|
||||||
|
.Take(30)
|
||||||
|
.Join('\n');
|
||||||
|
|
||||||
|
var fansList = await _service.GetFansNames(wi.WaifuId);
|
||||||
|
var fansStr = fansList
|
||||||
|
.Shuffle()
|
||||||
|
.Take(30)
|
||||||
|
.Select((x) => claimsNames.Contains(x) ? $"{x} 💞" : x)
|
||||||
|
.Join('\n');
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(fansStr))
|
||||||
|
fansStr = "-";
|
||||||
|
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithTitle(GetText(strs.waifu)
|
||||||
|
+ " "
|
||||||
|
+ (wi.FullName ?? name ?? targetId.ToString())
|
||||||
|
+ " - \"the "
|
||||||
|
+ _service.GetClaimTitle(wi.ClaimCount)
|
||||||
|
+ "\"")
|
||||||
|
.AddField(GetText(strs.price), N(wi.Price), true)
|
||||||
|
.AddField(GetText(strs.claimed_by), wi.ClaimerName ?? nobody, true)
|
||||||
|
.AddField(GetText(strs.likes), wi.AffinityName ?? nobody, true)
|
||||||
|
.AddField(GetText(strs.changes_of_heart),
|
||||||
|
$"{wi.AffinityCount} - \"the {affInfo}\"",
|
||||||
|
true)
|
||||||
|
.AddField(GetText(strs.divorces), wi.DivorceCount.ToString(), true)
|
||||||
|
.AddField("\u200B", "\u200B", true)
|
||||||
|
.AddField(GetText(strs.fans(fansList.Count)), fansStr, true)
|
||||||
|
.AddField($"Waifus ({wi.ClaimCount})",
|
||||||
|
wi.ClaimCount == 0 ? nobody : claimsStr,
|
||||||
|
true)
|
||||||
|
.AddField(GetText(strs.gifts), itemsStr, true);
|
||||||
|
|
||||||
|
await Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(1)]
|
||||||
|
public async Task WaifuGift(int page = 1)
|
||||||
|
{
|
||||||
|
if (--page < 0 || page > (Config.Waifu.Items.Count - 1) / 9)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var waifuItems = _service.GetWaifuItems();
|
||||||
|
await Response()
|
||||||
|
.Paginated()
|
||||||
|
.Items(waifuItems.OrderBy(x => x.Negative)
|
||||||
|
.ThenBy(x => x.Price)
|
||||||
|
.ToList())
|
||||||
|
.PageSize(9)
|
||||||
|
.CurrentPage(page)
|
||||||
|
.Page((items, _) =>
|
||||||
|
{
|
||||||
|
var embed = _sender.CreateEmbed().WithTitle(GetText(strs.waifu_gift_shop)).WithOkColor();
|
||||||
|
|
||||||
|
items
|
||||||
|
.ToList()
|
||||||
|
.ForEach(x => embed.AddField(
|
||||||
|
$"{(!x.Negative ? string.Empty : "\\💔")} {x.ItemEmoji} {x.Name}",
|
||||||
|
Format.Bold(N(x.Price)),
|
||||||
|
true));
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
})
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(0)]
|
||||||
|
public async Task WaifuGift(MultipleWaifuItems items, [Leftover] IUser waifu)
|
||||||
|
{
|
||||||
|
if (waifu.Id == ctx.User.Id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var sucess = await _service.GiftWaifuAsync(ctx.User, waifu, items.Item, items.Count);
|
||||||
|
|
||||||
|
if (sucess)
|
||||||
|
{
|
||||||
|
await Response()
|
||||||
|
.Confirm(strs.waifu_gift(
|
||||||
|
Format.Bold($"{GetCountString(items)}{items.Item} {items.Item.ItemEmoji}"),
|
||||||
|
Format.Bold(waifu.ToString())))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCountString(MultipleWaifuItems items)
|
||||||
|
=> items.Count > 1
|
||||||
|
? $"{items.Count}x "
|
||||||
|
: string.Empty;
|
||||||
|
}
|
||||||
|
}
|
633
src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs
Normal file
633
src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs
Normal file
|
@ -0,0 +1,633 @@
|
||||||
|
#nullable disable
|
||||||
|
using LinqToDB;
|
||||||
|
using LinqToDB.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EllieBot.Common.ModuleBehaviors;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
using EllieBot.Modules.Gambling.Common.Waifu;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
public class WaifuService : IEService, IReadyExecutor
|
||||||
|
{
|
||||||
|
private readonly DbService _db;
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
private readonly IBotCache _cache;
|
||||||
|
private readonly GamblingConfigService _gss;
|
||||||
|
private readonly IBotCredentials _creds;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
|
||||||
|
public WaifuService(
|
||||||
|
DbService db,
|
||||||
|
ICurrencyService cs,
|
||||||
|
IBotCache cache,
|
||||||
|
GamblingConfigService gss,
|
||||||
|
IBotCredentials creds,
|
||||||
|
DiscordSocketClient client)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_cs = cs;
|
||||||
|
_cache = cache;
|
||||||
|
_gss = gss;
|
||||||
|
_creds = creds;
|
||||||
|
_client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> WaifuTransfer(IUser owner, ulong waifuId, IUser newOwner)
|
||||||
|
{
|
||||||
|
if (owner.Id == newOwner.Id || waifuId == newOwner.Id)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var settings = _gss.Data;
|
||||||
|
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var waifu = uow.Set<WaifuInfo>().ByWaifuUserId(waifuId);
|
||||||
|
var ownerUser = uow.GetOrCreateUser(owner);
|
||||||
|
|
||||||
|
// owner has to be the owner of the waifu
|
||||||
|
if (waifu is null || waifu.ClaimerId != ownerUser.Id)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// if waifu likes the person, gotta pay the penalty
|
||||||
|
if (waifu.AffinityId == ownerUser.Id)
|
||||||
|
{
|
||||||
|
if (!await _cs.RemoveAsync(owner.Id, (long)(waifu.Price * 0.6), new("waifu", "affinity-penalty")))
|
||||||
|
// unable to pay 60% penalty
|
||||||
|
return false;
|
||||||
|
|
||||||
|
waifu.Price = (long)(waifu.Price * 0.7); // half of 60% = 30% price reduction
|
||||||
|
if (waifu.Price < settings.Waifu.MinPrice)
|
||||||
|
waifu.Price = settings.Waifu.MinPrice;
|
||||||
|
}
|
||||||
|
else // if not, pay 10% fee
|
||||||
|
{
|
||||||
|
if (!await _cs.RemoveAsync(owner.Id, waifu.Price / 10, new("waifu", "transfer")))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
waifu.Price = (long)(waifu.Price * 0.95); // half of 10% = 5% price reduction
|
||||||
|
if (waifu.Price < settings.Waifu.MinPrice)
|
||||||
|
waifu.Price = settings.Waifu.MinPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
//new claimerId is the id of the new owner
|
||||||
|
var newOwnerUser = uow.GetOrCreateUser(newOwner);
|
||||||
|
waifu.ClaimerId = newOwnerUser.Id;
|
||||||
|
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long GetResetPrice(IUser user)
|
||||||
|
{
|
||||||
|
var settings = _gss.Data;
|
||||||
|
using var uow = _db.GetDbContext();
|
||||||
|
var waifu = uow.Set<WaifuInfo>().ByWaifuUserId(user.Id);
|
||||||
|
|
||||||
|
if (waifu is null)
|
||||||
|
return settings.Waifu.MinPrice;
|
||||||
|
|
||||||
|
var divorces = uow.Set<WaifuUpdate>()
|
||||||
|
.Count(x
|
||||||
|
=> x.Old != null
|
||||||
|
&& x.Old.UserId == user.Id
|
||||||
|
&& x.UpdateType == WaifuUpdateType.Claimed
|
||||||
|
&& x.New == null);
|
||||||
|
var affs = uow.Set<WaifuUpdate>()
|
||||||
|
.AsQueryable()
|
||||||
|
.Where(w => w.User.UserId == user.Id
|
||||||
|
&& w.UpdateType == WaifuUpdateType.AffinityChanged
|
||||||
|
&& w.New != null)
|
||||||
|
.ToList()
|
||||||
|
.GroupBy(x => x.New)
|
||||||
|
.Count();
|
||||||
|
|
||||||
|
return (long)Math.Ceiling(waifu.Price * 1.25f)
|
||||||
|
+ ((divorces + affs + 2) * settings.Waifu.Multipliers.WaifuReset);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> TryReset(IUser user)
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var price = GetResetPrice(user);
|
||||||
|
if (!await _cs.RemoveAsync(user.Id, price, new("waifu", "reset")))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var affs = uow.Set<WaifuUpdate>()
|
||||||
|
.AsQueryable()
|
||||||
|
.Where(w => w.User.UserId == user.Id
|
||||||
|
&& w.UpdateType == WaifuUpdateType.AffinityChanged
|
||||||
|
&& w.New != null);
|
||||||
|
|
||||||
|
var divorces = uow.Set<WaifuUpdate>()
|
||||||
|
.AsQueryable()
|
||||||
|
.Where(x => x.Old != null
|
||||||
|
&& x.Old.UserId == user.Id
|
||||||
|
&& x.UpdateType == WaifuUpdateType.Claimed
|
||||||
|
&& x.New == null);
|
||||||
|
|
||||||
|
//reset changes of heart to 0
|
||||||
|
uow.Set<WaifuUpdate>().RemoveRange(affs);
|
||||||
|
//reset divorces to 0
|
||||||
|
uow.Set<WaifuUpdate>().RemoveRange(divorces);
|
||||||
|
var waifu = uow.Set<WaifuInfo>().ByWaifuUserId(user.Id);
|
||||||
|
//reset price, remove items
|
||||||
|
//remove owner, remove affinity
|
||||||
|
waifu.Price = 50;
|
||||||
|
waifu.Items.Clear();
|
||||||
|
waifu.ClaimerId = null;
|
||||||
|
waifu.AffinityId = null;
|
||||||
|
|
||||||
|
//wives stay though
|
||||||
|
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(WaifuInfo, bool, WaifuClaimResult)> ClaimWaifuAsync(IUser user, IUser target, long amount)
|
||||||
|
{
|
||||||
|
var settings = _gss.Data;
|
||||||
|
WaifuClaimResult result;
|
||||||
|
WaifuInfo w;
|
||||||
|
bool isAffinity;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
w = uow.Set<WaifuInfo>().ByWaifuUserId(target.Id);
|
||||||
|
isAffinity = w?.Affinity?.UserId == user.Id;
|
||||||
|
if (w is null)
|
||||||
|
{
|
||||||
|
var claimer = uow.GetOrCreateUser(user);
|
||||||
|
var waifu = uow.GetOrCreateUser(target);
|
||||||
|
if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim")))
|
||||||
|
result = WaifuClaimResult.NotEnoughFunds;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
uow.Set<WaifuInfo>()
|
||||||
|
.Add(w = new()
|
||||||
|
{
|
||||||
|
Waifu = waifu,
|
||||||
|
Claimer = claimer,
|
||||||
|
Affinity = null,
|
||||||
|
Price = amount
|
||||||
|
});
|
||||||
|
uow.Set<WaifuUpdate>()
|
||||||
|
.Add(new()
|
||||||
|
{
|
||||||
|
User = waifu,
|
||||||
|
Old = null,
|
||||||
|
New = claimer,
|
||||||
|
UpdateType = WaifuUpdateType.Claimed
|
||||||
|
});
|
||||||
|
result = WaifuClaimResult.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (isAffinity && amount > w.Price * settings.Waifu.Multipliers.CrushClaim)
|
||||||
|
{
|
||||||
|
if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim")))
|
||||||
|
result = WaifuClaimResult.NotEnoughFunds;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var oldClaimer = w.Claimer;
|
||||||
|
w.Claimer = uow.GetOrCreateUser(user);
|
||||||
|
w.Price = amount + (amount / 4);
|
||||||
|
result = WaifuClaimResult.Success;
|
||||||
|
|
||||||
|
uow.Set<WaifuUpdate>()
|
||||||
|
.Add(new()
|
||||||
|
{
|
||||||
|
User = w.Waifu,
|
||||||
|
Old = oldClaimer,
|
||||||
|
New = w.Claimer,
|
||||||
|
UpdateType = WaifuUpdateType.Claimed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (amount >= w.Price * settings.Waifu.Multipliers.NormalClaim) // if no affinity
|
||||||
|
{
|
||||||
|
if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim")))
|
||||||
|
result = WaifuClaimResult.NotEnoughFunds;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var oldClaimer = w.Claimer;
|
||||||
|
w.Claimer = uow.GetOrCreateUser(user);
|
||||||
|
w.Price = amount;
|
||||||
|
result = WaifuClaimResult.Success;
|
||||||
|
|
||||||
|
uow.Set<WaifuUpdate>()
|
||||||
|
.Add(new()
|
||||||
|
{
|
||||||
|
User = w.Waifu,
|
||||||
|
Old = oldClaimer,
|
||||||
|
New = w.Claimer,
|
||||||
|
UpdateType = WaifuUpdateType.Claimed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
result = WaifuClaimResult.InsufficientAmount;
|
||||||
|
|
||||||
|
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (w, isAffinity, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(DiscordUser, bool, TimeSpan?)> ChangeAffinityAsync(IUser user, IGuildUser target)
|
||||||
|
{
|
||||||
|
DiscordUser oldAff = null;
|
||||||
|
var success = false;
|
||||||
|
TimeSpan? remaining = null;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
var w = uow.Set<WaifuInfo>().ByWaifuUserId(user.Id);
|
||||||
|
var newAff = target is null ? null : uow.GetOrCreateUser(target);
|
||||||
|
if (w?.Affinity?.UserId == target?.Id)
|
||||||
|
{
|
||||||
|
return (null, false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = await _cache.GetRatelimitAsync(GetAffinityKey(user.Id),
|
||||||
|
30.Minutes());
|
||||||
|
|
||||||
|
if (remaining is not null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
else if (w is null)
|
||||||
|
{
|
||||||
|
var thisUser = uow.GetOrCreateUser(user);
|
||||||
|
uow.Set<WaifuInfo>()
|
||||||
|
.Add(new()
|
||||||
|
{
|
||||||
|
Affinity = newAff,
|
||||||
|
Waifu = thisUser,
|
||||||
|
Price = 1,
|
||||||
|
Claimer = null
|
||||||
|
});
|
||||||
|
success = true;
|
||||||
|
|
||||||
|
uow.Set<WaifuUpdate>()
|
||||||
|
.Add(new()
|
||||||
|
{
|
||||||
|
User = thisUser,
|
||||||
|
Old = null,
|
||||||
|
New = newAff,
|
||||||
|
UpdateType = WaifuUpdateType.AffinityChanged
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (w.Affinity is not null)
|
||||||
|
oldAff = w.Affinity;
|
||||||
|
w.Affinity = newAff;
|
||||||
|
success = true;
|
||||||
|
|
||||||
|
uow.Set<WaifuUpdate>()
|
||||||
|
.Add(new()
|
||||||
|
{
|
||||||
|
User = w.Waifu,
|
||||||
|
Old = oldAff,
|
||||||
|
New = newAff,
|
||||||
|
UpdateType = WaifuUpdateType.AffinityChanged
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (oldAff, success, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<WaifuLbResult> GetTopWaifusAtPage(int page, int perPage = 9)
|
||||||
|
{
|
||||||
|
using var uow = _db.GetDbContext();
|
||||||
|
return uow.Set<WaifuInfo>().GetTop(perPage, page * perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ulong GetWaifuUserId(ulong ownerId, string name)
|
||||||
|
{
|
||||||
|
using var uow = _db.GetDbContext();
|
||||||
|
return uow.Set<WaifuInfo>().GetWaifuUserId(ownerId, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TypedKey<long> GetDivorceKey(ulong userId)
|
||||||
|
=> new($"waifu:divorce_cd:{userId}");
|
||||||
|
|
||||||
|
private static TypedKey<long> GetAffinityKey(ulong userId)
|
||||||
|
=> new($"waifu:affinity:{userId}");
|
||||||
|
|
||||||
|
public async Task<(WaifuInfo, DivorceResult, long, TimeSpan?)> DivorceWaifuAsync(IUser user, ulong targetId)
|
||||||
|
{
|
||||||
|
DivorceResult result;
|
||||||
|
TimeSpan? remaining = null;
|
||||||
|
long amount = 0;
|
||||||
|
WaifuInfo w;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
w = uow.Set<WaifuInfo>().ByWaifuUserId(targetId);
|
||||||
|
if (w?.Claimer is null || w.Claimer.UserId != user.Id)
|
||||||
|
result = DivorceResult.NotYourWife;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
remaining = await _cache.GetRatelimitAsync(GetDivorceKey(user.Id), 6.Hours());
|
||||||
|
if (remaining is TimeSpan rem)
|
||||||
|
{
|
||||||
|
result = DivorceResult.Cooldown;
|
||||||
|
return (w, result, amount, rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
amount = w.Price / 2;
|
||||||
|
|
||||||
|
if (w.Affinity?.UserId == user.Id)
|
||||||
|
{
|
||||||
|
await _cs.AddAsync(w.Waifu.UserId, amount, new("waifu", "compensation"));
|
||||||
|
w.Price = (long)Math.Floor(w.Price * _gss.Data.Waifu.Multipliers.DivorceNewValue);
|
||||||
|
result = DivorceResult.SucessWithPenalty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _cs.AddAsync(user.Id, amount, new("waifu", "refund"));
|
||||||
|
|
||||||
|
result = DivorceResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldClaimer = w.Claimer;
|
||||||
|
w.Claimer = null;
|
||||||
|
|
||||||
|
uow.Set<WaifuUpdate>()
|
||||||
|
.Add(new()
|
||||||
|
{
|
||||||
|
User = w.Waifu,
|
||||||
|
Old = oldClaimer,
|
||||||
|
New = null,
|
||||||
|
UpdateType = WaifuUpdateType.Claimed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (w, result, amount, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> GiftWaifuAsync(
|
||||||
|
IUser from,
|
||||||
|
IUser giftedWaifu,
|
||||||
|
WaifuItemModel itemObj,
|
||||||
|
int count)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(count, 1, nameof(count));
|
||||||
|
|
||||||
|
if (!await _cs.RemoveAsync(from, itemObj.Price * count, new("waifu", "item")))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var totalValue = itemObj.Price * count;
|
||||||
|
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var w = uow.Set<WaifuInfo>()
|
||||||
|
.ByWaifuUserId(giftedWaifu.Id,
|
||||||
|
set => set
|
||||||
|
.Include(x => x.Items)
|
||||||
|
.Include(x => x.Claimer));
|
||||||
|
if (w is null)
|
||||||
|
{
|
||||||
|
uow.Set<WaifuInfo>()
|
||||||
|
.Add(w = new()
|
||||||
|
{
|
||||||
|
Affinity = null,
|
||||||
|
Claimer = null,
|
||||||
|
Price = 1,
|
||||||
|
Waifu = uow.GetOrCreateUser(giftedWaifu)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemObj.Negative)
|
||||||
|
{
|
||||||
|
w.Items.AddRange(Enumerable.Range(0, count)
|
||||||
|
.Select((_) => new WaifuItem()
|
||||||
|
{
|
||||||
|
Name = itemObj.Name.ToLowerInvariant(),
|
||||||
|
ItemEmoji = itemObj.ItemEmoji
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (w.Claimer?.UserId == from.Id)
|
||||||
|
w.Price += (long)(totalValue * _gss.Data.Waifu.Multipliers.GiftEffect);
|
||||||
|
else
|
||||||
|
w.Price += totalValue / 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
w.Price -= (long)(totalValue * _gss.Data.Waifu.Multipliers.NegativeGiftEffect);
|
||||||
|
if (w.Price < 1)
|
||||||
|
w.Price = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WaifuInfoStats> GetFullWaifuInfoAsync(ulong targetId)
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var wi = await uow.GetWaifuInfoAsync(targetId);
|
||||||
|
if (wi is null)
|
||||||
|
{
|
||||||
|
wi = new()
|
||||||
|
{
|
||||||
|
AffinityCount = 0,
|
||||||
|
AffinityName = null,
|
||||||
|
ClaimCount = 0,
|
||||||
|
ClaimerName = null,
|
||||||
|
DivorceCount = 0,
|
||||||
|
FullName = null,
|
||||||
|
Price = 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return wi;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetClaimTitle(int count)
|
||||||
|
{
|
||||||
|
ClaimTitle title;
|
||||||
|
if (count == 0)
|
||||||
|
title = ClaimTitle.Lonely;
|
||||||
|
else if (count == 1)
|
||||||
|
title = ClaimTitle.Devoted;
|
||||||
|
else if (count < 3)
|
||||||
|
title = ClaimTitle.Rookie;
|
||||||
|
else if (count < 6)
|
||||||
|
title = ClaimTitle.Schemer;
|
||||||
|
else if (count < 10)
|
||||||
|
title = ClaimTitle.Dilettante;
|
||||||
|
else if (count < 17)
|
||||||
|
title = ClaimTitle.Intermediate;
|
||||||
|
else if (count < 25)
|
||||||
|
title = ClaimTitle.Seducer;
|
||||||
|
else if (count < 35)
|
||||||
|
title = ClaimTitle.Expert;
|
||||||
|
else if (count < 50)
|
||||||
|
title = ClaimTitle.Veteran;
|
||||||
|
else if (count < 75)
|
||||||
|
title = ClaimTitle.Incubis;
|
||||||
|
else if (count < 100)
|
||||||
|
title = ClaimTitle.Harem_King;
|
||||||
|
else
|
||||||
|
title = ClaimTitle.Harem_God;
|
||||||
|
|
||||||
|
return title.ToString().Replace('_', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetAffinityTitle(int count)
|
||||||
|
{
|
||||||
|
AffinityTitle title;
|
||||||
|
if (count < 1)
|
||||||
|
title = AffinityTitle.Pure;
|
||||||
|
else if (count < 2)
|
||||||
|
title = AffinityTitle.Faithful;
|
||||||
|
else if (count < 4)
|
||||||
|
title = AffinityTitle.Playful;
|
||||||
|
else if (count < 8)
|
||||||
|
title = AffinityTitle.Cheater;
|
||||||
|
else if (count < 11)
|
||||||
|
title = AffinityTitle.Tainted;
|
||||||
|
else if (count < 15)
|
||||||
|
title = AffinityTitle.Corrupted;
|
||||||
|
else if (count < 20)
|
||||||
|
title = AffinityTitle.Lewd;
|
||||||
|
else if (count < 25)
|
||||||
|
title = AffinityTitle.Sloot;
|
||||||
|
else if (count < 35)
|
||||||
|
title = AffinityTitle.Depraved;
|
||||||
|
else
|
||||||
|
title = AffinityTitle.Harlot;
|
||||||
|
|
||||||
|
return title.ToString().Replace('_', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<WaifuItemModel> GetWaifuItems()
|
||||||
|
{
|
||||||
|
var conf = _gss.Data;
|
||||||
|
return conf.Waifu.Items.Select(x
|
||||||
|
=> new WaifuItemModel(x.ItemEmoji,
|
||||||
|
(long)(x.Price * conf.Waifu.Multipliers.AllGiftPrices),
|
||||||
|
x.Name,
|
||||||
|
x.Negative))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly TypedKey<long> _waifuDecayKey = $"waifu:last_decay";
|
||||||
|
|
||||||
|
public async Task OnReadyAsync()
|
||||||
|
{
|
||||||
|
// only decay waifu values from shard 0
|
||||||
|
if (_client.ShardId != 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var decay = _gss.Data.Waifu.Decay;
|
||||||
|
|
||||||
|
var unclaimedMulti = 1 - (decay.UnclaimedDecayPercent / 100f);
|
||||||
|
var claimedMulti = 1 - (decay.ClaimedDecayPercent / 100f);
|
||||||
|
|
||||||
|
var minPrice = decay.MinPrice;
|
||||||
|
var decayInterval = decay.HourInterval;
|
||||||
|
|
||||||
|
if (decayInterval <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if ((unclaimedMulti < 0 || unclaimedMulti > 1) && (claimedMulti < 0 || claimedMulti > 1))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var nowB = now.ToBinary();
|
||||||
|
|
||||||
|
var result = await _cache.GetAsync(_waifuDecayKey);
|
||||||
|
|
||||||
|
if (result.TryGetValue(out var val))
|
||||||
|
{
|
||||||
|
var lastDecay = DateTime.FromBinary(val);
|
||||||
|
var toWait = decayInterval.Hours() - (DateTime.UtcNow - lastDecay);
|
||||||
|
|
||||||
|
if (toWait > 0.Hours())
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _cache.AddAsync(_waifuDecayKey, nowB);
|
||||||
|
|
||||||
|
if (unclaimedMulti is > 0 and <= 1)
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
|
||||||
|
await uow.GetTable<WaifuInfo>()
|
||||||
|
.Where(x => x.Price > minPrice && x.ClaimerId == null)
|
||||||
|
.UpdateAsync(old => new()
|
||||||
|
{
|
||||||
|
Price = (long)(old.Price * unclaimedMulti)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claimedMulti is > 0 and <= 1)
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
await uow.GetTable<WaifuInfo>()
|
||||||
|
.Where(x => x.Price > minPrice && x.ClaimerId == null)
|
||||||
|
.UpdateAsync(old => new()
|
||||||
|
{
|
||||||
|
Price = (long)(old.Price * claimedMulti)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Unexpected error occured in waifu decay loop: {ErrorMessage}", ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await Task.Delay(1.Hours());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyCollection<string>> GetClaimNames(int waifuId)
|
||||||
|
{
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
return await ctx.GetTable<DiscordUser>()
|
||||||
|
.Where(x => ctx.GetTable<WaifuInfo>()
|
||||||
|
.Where(wi => wi.ClaimerId == waifuId)
|
||||||
|
.Select(wi => wi.WaifuId)
|
||||||
|
.Contains(x.Id))
|
||||||
|
.Select(x => $"{x.Username}#{x.Discriminator}")
|
||||||
|
.ToListAsyncEF();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyCollection<string>> GetFansNames(int waifuId)
|
||||||
|
{
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
return await ctx.GetTable<DiscordUser>()
|
||||||
|
.Where(x => ctx.GetTable<WaifuInfo>()
|
||||||
|
.Where(wi => wi.AffinityId == waifuId)
|
||||||
|
.Select(wi => wi.WaifuId)
|
||||||
|
.Contains(x.Id))
|
||||||
|
.Select(x => $"{x.Username}#{x.Discriminator}")
|
||||||
|
.ToListAsyncEF();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyCollection<WaifuItem>> GetItems(int waifuId)
|
||||||
|
{
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
return await ctx.GetTable<WaifuItem>()
|
||||||
|
.Where(x => x.WaifuInfoId
|
||||||
|
== ctx.GetTable<WaifuInfo>()
|
||||||
|
.Where(x => x.WaifuId == waifuId)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.FirstOrDefault())
|
||||||
|
.ToListAsyncEF();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.Waifu;
|
||||||
|
|
||||||
|
public enum AffinityTitle
|
||||||
|
{
|
||||||
|
Pure,
|
||||||
|
Faithful,
|
||||||
|
Playful,
|
||||||
|
Cheater,
|
||||||
|
Tainted,
|
||||||
|
Corrupted,
|
||||||
|
Lewd,
|
||||||
|
Sloot,
|
||||||
|
Depraved,
|
||||||
|
Harlot
|
||||||
|
}
|
18
src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs
Normal file
18
src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.Waifu;
|
||||||
|
|
||||||
|
public enum ClaimTitle
|
||||||
|
{
|
||||||
|
Lonely,
|
||||||
|
Devoted,
|
||||||
|
Rookie,
|
||||||
|
Schemer,
|
||||||
|
Dilettante,
|
||||||
|
Intermediate,
|
||||||
|
Seducer,
|
||||||
|
Expert,
|
||||||
|
Veteran,
|
||||||
|
Incubis,
|
||||||
|
Harem_King,
|
||||||
|
Harem_God
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.Waifu;
|
||||||
|
|
||||||
|
public enum DivorceResult
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
SucessWithPenalty,
|
||||||
|
NotYourWife,
|
||||||
|
Cooldown
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.Waifu;
|
||||||
|
|
||||||
|
public class Extensions
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Gambling.Common;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public record class MultipleWaifuItems(int Count, WaifuItemModel Item);
|
|
@ -0,0 +1,47 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Common.TypeReaders;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public partial class MultipleWaifuItemsTypeReader : EllieTypeReader<MultipleWaifuItems>
|
||||||
|
{
|
||||||
|
private readonly WaifuService _service;
|
||||||
|
|
||||||
|
[GeneratedRegex(@"(?:(?<count>\d+)[x*])?(?<item>.+)")]
|
||||||
|
private static partial Regex ItemRegex();
|
||||||
|
|
||||||
|
public MultipleWaifuItemsTypeReader(WaifuService service)
|
||||||
|
{
|
||||||
|
_service = service;
|
||||||
|
}
|
||||||
|
public override ValueTask<TypeReaderResult<MultipleWaifuItems>> ReadAsync(ICommandContext ctx, string input)
|
||||||
|
{
|
||||||
|
input = input.ToLowerInvariant();
|
||||||
|
var match = ItemRegex().Match(input);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
return new(Discord.Commands.TypeReaderResult.FromError(CommandError.ParseFailed, "Invalid input."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = 1;
|
||||||
|
if (match.Groups["count"].Success)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(match.Groups["count"].Value, out count) || count < 1)
|
||||||
|
{
|
||||||
|
return new(Discord.Commands.TypeReaderResult.FromError(CommandError.ParseFailed, "Invalid count."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemName = match.Groups["item"].Value?.ToLowerInvariant();
|
||||||
|
var allItems = _service.GetWaifuItems();
|
||||||
|
var item = allItems.FirstOrDefault(x => x.Name.ToLowerInvariant() == itemName);
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
return new(Discord.Commands.TypeReaderResult.FromError(CommandError.ParseFailed, "Waifu gift does not exist."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(Discord.Commands.TypeReaderResult.FromSuccess(new MultipleWaifuItems(count, item)));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Gambling.Common.Waifu;
|
||||||
|
|
||||||
|
public enum WaifuClaimResult
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
NotEnoughFunds,
|
||||||
|
InsufficientAmount
|
||||||
|
}
|
17
src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs
Normal file
17
src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Db.Models;
|
||||||
|
|
||||||
|
public class WaifuInfo : DbEntity
|
||||||
|
{
|
||||||
|
public int WaifuId { get; set; }
|
||||||
|
public DiscordUser Waifu { get; set; }
|
||||||
|
|
||||||
|
public int? ClaimerId { get; set; }
|
||||||
|
public DiscordUser Claimer { get; set; }
|
||||||
|
|
||||||
|
public int? AffinityId { get; set; }
|
||||||
|
public DiscordUser Affinity { get; set; }
|
||||||
|
|
||||||
|
public long Price { get; set; }
|
||||||
|
public List<WaifuItem> Items { get; set; } = new();
|
||||||
|
}
|
134
src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs
Normal file
134
src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
#nullable disable
|
||||||
|
using LinqToDB;
|
||||||
|
using LinqToDB.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Db;
|
||||||
|
|
||||||
|
public static class WaifuExtensions
|
||||||
|
{
|
||||||
|
public static WaifuInfo ByWaifuUserId(
|
||||||
|
this DbSet<WaifuInfo> waifus,
|
||||||
|
ulong userId,
|
||||||
|
Func<DbSet<WaifuInfo>, IQueryable<WaifuInfo>> includes = null)
|
||||||
|
{
|
||||||
|
if (includes is null)
|
||||||
|
{
|
||||||
|
return waifus.Include(wi => wi.Waifu)
|
||||||
|
.Include(wi => wi.Affinity)
|
||||||
|
.Include(wi => wi.Claimer)
|
||||||
|
.Include(wi => wi.Items)
|
||||||
|
.FirstOrDefault(wi => wi.Waifu.UserId == userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return includes(waifus).AsQueryable().FirstOrDefault(wi => wi.Waifu.UserId == userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<WaifuLbResult> GetTop(this DbSet<WaifuInfo> waifus, int count, int skip = 0)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(count);
|
||||||
|
|
||||||
|
if (count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return waifus.Include(wi => wi.Waifu)
|
||||||
|
.Include(wi => wi.Affinity)
|
||||||
|
.Include(wi => wi.Claimer)
|
||||||
|
.OrderByDescending(wi => wi.Price)
|
||||||
|
.Skip(skip)
|
||||||
|
.Take(count)
|
||||||
|
.Select(x => new WaifuLbResult
|
||||||
|
{
|
||||||
|
Affinity = x.Affinity == null ? null : x.Affinity.Username,
|
||||||
|
AffinityDiscrim = x.Affinity == null ? null : x.Affinity.Discriminator,
|
||||||
|
Claimer = x.Claimer == null ? null : x.Claimer.Username,
|
||||||
|
ClaimerDiscrim = x.Claimer == null ? null : x.Claimer.Discriminator,
|
||||||
|
Username = x.Waifu.Username,
|
||||||
|
Discrim = x.Waifu.Discriminator,
|
||||||
|
Price = x.Price
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static decimal GetTotalValue(this DbSet<WaifuInfo> waifus)
|
||||||
|
=> waifus.AsQueryable().Where(x => x.ClaimerId != null).Sum(x => x.Price);
|
||||||
|
|
||||||
|
public static ulong GetWaifuUserId(this DbSet<WaifuInfo> waifus, ulong ownerId, string name)
|
||||||
|
=> waifus.AsQueryable()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.Claimer.UserId == ownerId && x.Waifu.Username + "#" + x.Waifu.Discriminator == name)
|
||||||
|
.Select(x => x.Waifu.UserId)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
public static async Task<WaifuInfoStats> GetWaifuInfoAsync(this DbContext ctx, ulong userId)
|
||||||
|
{
|
||||||
|
await ctx.EnsureUserCreatedAsync(userId);
|
||||||
|
|
||||||
|
await ctx.Set<WaifuInfo>()
|
||||||
|
.ToLinqToDBTable()
|
||||||
|
.InsertOrUpdateAsync(() => new()
|
||||||
|
{
|
||||||
|
AffinityId = null,
|
||||||
|
ClaimerId = null,
|
||||||
|
Price = 1,
|
||||||
|
WaifuId = ctx.Set<DiscordUser>().Where(x => x.UserId == userId).Select(x => x.Id).First()
|
||||||
|
},
|
||||||
|
_ => new(),
|
||||||
|
() => new()
|
||||||
|
{
|
||||||
|
WaifuId = ctx.Set<DiscordUser>().Where(x => x.UserId == userId).Select(x => x.Id).First()
|
||||||
|
});
|
||||||
|
|
||||||
|
var toReturn = ctx.Set<WaifuInfo>()
|
||||||
|
.AsQueryable()
|
||||||
|
.Where(w => w.WaifuId
|
||||||
|
== ctx.Set<DiscordUser>()
|
||||||
|
.AsQueryable()
|
||||||
|
.Where(u => u.UserId == userId)
|
||||||
|
.Select(u => u.Id)
|
||||||
|
.FirstOrDefault())
|
||||||
|
.Select(w => new WaifuInfoStats
|
||||||
|
{
|
||||||
|
WaifuId = w.WaifuId,
|
||||||
|
FullName =
|
||||||
|
ctx.Set<DiscordUser>()
|
||||||
|
.AsQueryable()
|
||||||
|
.Where(u => u.UserId == userId)
|
||||||
|
.Select(u => u.Username + "#" + u.Discriminator)
|
||||||
|
.FirstOrDefault(),
|
||||||
|
AffinityCount =
|
||||||
|
ctx.Set<WaifuUpdate>()
|
||||||
|
.AsQueryable()
|
||||||
|
.Count(x => x.UserId == w.WaifuId
|
||||||
|
&& x.UpdateType == WaifuUpdateType.AffinityChanged
|
||||||
|
&& x.NewId != null),
|
||||||
|
AffinityName =
|
||||||
|
ctx.Set<DiscordUser>()
|
||||||
|
.AsQueryable()
|
||||||
|
.Where(u => u.Id == w.AffinityId)
|
||||||
|
.Select(u => u.Username + "#" + u.Discriminator)
|
||||||
|
.FirstOrDefault(),
|
||||||
|
ClaimCount = ctx.Set<WaifuInfo>().AsQueryable().Count(x => x.ClaimerId == w.WaifuId),
|
||||||
|
ClaimerName =
|
||||||
|
ctx.Set<DiscordUser>()
|
||||||
|
.AsQueryable()
|
||||||
|
.Where(u => u.Id == w.ClaimerId)
|
||||||
|
.Select(u => u.Username + "#" + u.Discriminator)
|
||||||
|
.FirstOrDefault(),
|
||||||
|
DivorceCount =
|
||||||
|
ctx.Set<WaifuUpdate>()
|
||||||
|
.AsQueryable()
|
||||||
|
.Count(x => x.OldId == w.WaifuId
|
||||||
|
&& x.NewId == null
|
||||||
|
&& x.UpdateType == WaifuUpdateType.Claimed),
|
||||||
|
Price = w.Price,
|
||||||
|
})
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (toReturn is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return toReturn;
|
||||||
|
}
|
||||||
|
}
|
14
src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs
Normal file
14
src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Db;
|
||||||
|
|
||||||
|
public class WaifuInfoStats
|
||||||
|
{
|
||||||
|
public int WaifuId { get; init; }
|
||||||
|
public string FullName { get; init; }
|
||||||
|
public long Price { get; init; }
|
||||||
|
public string ClaimerName { get; init; }
|
||||||
|
public string AffinityName { get; init; }
|
||||||
|
public int AffinityCount { get; init; }
|
||||||
|
public int DivorceCount { get; init; }
|
||||||
|
public int ClaimCount { get; init; }
|
||||||
|
}
|
10
src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs
Normal file
10
src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Db.Models;
|
||||||
|
|
||||||
|
public class WaifuItem : DbEntity
|
||||||
|
{
|
||||||
|
public WaifuInfo WaifuInfo { get; set; }
|
||||||
|
public int? WaifuInfoId { get; set; }
|
||||||
|
public string ItemEmoji { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
}
|
16
src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs
Normal file
16
src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Db.Models;
|
||||||
|
|
||||||
|
public class WaifuLbResult
|
||||||
|
{
|
||||||
|
public string Username { get; set; }
|
||||||
|
public string Discrim { get; set; }
|
||||||
|
|
||||||
|
public string Claimer { get; set; }
|
||||||
|
public string ClaimerDiscrim { get; set; }
|
||||||
|
|
||||||
|
public string Affinity { get; set; }
|
||||||
|
public string AffinityDiscrim { get; set; }
|
||||||
|
|
||||||
|
public long Price { get; set; }
|
||||||
|
}
|
15
src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs
Normal file
15
src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Db.Models;
|
||||||
|
|
||||||
|
public class WaifuUpdate : DbEntity
|
||||||
|
{
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public DiscordUser User { get; set; }
|
||||||
|
public WaifuUpdateType UpdateType { get; set; }
|
||||||
|
|
||||||
|
public int? OldId { get; set; }
|
||||||
|
public DiscordUser Old { get; set; }
|
||||||
|
|
||||||
|
public int? NewId { get; set; }
|
||||||
|
public DiscordUser New { get; set; }
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Db.Models;
|
||||||
|
|
||||||
|
public enum WaifuUpdateType
|
||||||
|
{
|
||||||
|
AffinityChanged,
|
||||||
|
Claimed
|
||||||
|
}
|
19
src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs
Normal file
19
src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
using Ellie.Econ;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling.Common;
|
||||||
|
|
||||||
|
public class QuadDeck : Deck
|
||||||
|
{
|
||||||
|
protected override void RefillPool()
|
||||||
|
{
|
||||||
|
CardPool = new(52 * 4);
|
||||||
|
for (var j = 1; j < 14; j++)
|
||||||
|
for (var i = 1; i < 5; i++)
|
||||||
|
{
|
||||||
|
CardPool.Add(new((CardSuit)i, j));
|
||||||
|
CardPool.Add(new((CardSuit)i, j));
|
||||||
|
CardPool.Add(new((CardSuit)i, j));
|
||||||
|
CardPool.Add(new((CardSuit)i, j));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
using LinqToDB;
|
||||||
|
using LinqToDB.EntityFrameworkCore;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public class GamblingCleanupService : IGamblingCleanupService, IEService
|
||||||
|
{
|
||||||
|
private readonly DbService _db;
|
||||||
|
|
||||||
|
public GamblingCleanupService(DbService db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteWaifus()
|
||||||
|
{
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
await ctx.GetTable<WaifuInfo>().DeleteAsync();
|
||||||
|
await ctx.GetTable<WaifuItem>().DeleteAsync();
|
||||||
|
await ctx.GetTable<WaifuUpdate>().DeleteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteWaifu(ulong userId)
|
||||||
|
{
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
await ctx.GetTable<WaifuUpdate>()
|
||||||
|
.Where(x => x.User.UserId == userId)
|
||||||
|
.DeleteAsync();
|
||||||
|
await ctx.GetTable<WaifuItem>()
|
||||||
|
.Where(x => x.WaifuInfo.Waifu.UserId == userId)
|
||||||
|
.DeleteAsync();
|
||||||
|
await ctx.GetTable<WaifuInfo>()
|
||||||
|
.Where(x => x.Claimer.UserId == userId)
|
||||||
|
.UpdateAsync(old => new WaifuInfo()
|
||||||
|
{
|
||||||
|
ClaimerId = null,
|
||||||
|
});
|
||||||
|
await ctx.GetTable<WaifuInfo>()
|
||||||
|
.Where(x => x.Waifu.UserId == userId)
|
||||||
|
.DeleteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteCurrency()
|
||||||
|
{
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
await ctx.GetTable<DiscordUser>().UpdateAsync(_ => new DiscordUser()
|
||||||
|
{
|
||||||
|
CurrencyAmount = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.GetTable<CurrencyTransaction>().DeleteAsync();
|
||||||
|
await ctx.GetTable<PlantedCurrency>().DeleteAsync();
|
||||||
|
await ctx.GetTable<BankUser>().DeleteAsync();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public interface IGamblingCleanupService
|
||||||
|
{
|
||||||
|
Task DeleteWaifus();
|
||||||
|
Task DeleteWaifu(ulong userId);
|
||||||
|
Task DeleteCurrency();
|
||||||
|
}
|
17
src/EllieBot/Modules/Gambling/_common/IGamblingService.cs
Normal file
17
src/EllieBot/Modules/Gambling/_common/IGamblingService.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Gambling.Betdraw;
|
||||||
|
using EllieBot.Modules.Gambling.Rps;
|
||||||
|
using OneOf;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public interface IGamblingService
|
||||||
|
{
|
||||||
|
Task<OneOf<LuLaResult, GamblingError>> LulaAsync(ulong userId, long amount);
|
||||||
|
Task<OneOf<BetrollResult, GamblingError>> BetRollAsync(ulong userId, long amount);
|
||||||
|
Task<OneOf<BetflipResult, GamblingError>> BetFlipAsync(ulong userId, long amount, byte guess);
|
||||||
|
Task<OneOf<SlotResult, GamblingError>> SlotAsync(ulong userId, long amount);
|
||||||
|
Task<FlipResult[]> FlipAsync(int count);
|
||||||
|
Task<OneOf<RpsResult, GamblingError>> RpsAsync(ulong userId, long amount, byte pick);
|
||||||
|
Task<OneOf<BetdrawResult, GamblingError>> BetDrawAsync(ulong userId, long amount, byte? maybeGuessValue, byte? maybeGuessColor);
|
||||||
|
}
|
268
src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs
Normal file
268
src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs
Normal file
|
@ -0,0 +1,268 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Gambling.Betdraw;
|
||||||
|
using EllieBot.Modules.Gambling.Rps;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using OneOf;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Gambling;
|
||||||
|
|
||||||
|
public sealed class NewGamblingService : IGamblingService, IEService
|
||||||
|
{
|
||||||
|
private readonly GamblingConfigService _bcs;
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
|
||||||
|
public NewGamblingService(GamblingConfigService bcs, ICurrencyService cs)
|
||||||
|
{
|
||||||
|
_bcs = bcs;
|
||||||
|
_cs = cs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OneOf<LuLaResult, GamblingError>> LulaAsync(ulong userId, long amount)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(amount);
|
||||||
|
|
||||||
|
if (amount > 0)
|
||||||
|
{
|
||||||
|
var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("lula", "bet"));
|
||||||
|
|
||||||
|
if (!isTakeSuccess)
|
||||||
|
{
|
||||||
|
return GamblingError.InsufficientFunds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var game = new LulaGame(_bcs.Data.LuckyLadder.Multipliers);
|
||||||
|
var result = game.Spin(amount);
|
||||||
|
|
||||||
|
var won = (long)result.Won;
|
||||||
|
if (won > 0)
|
||||||
|
{
|
||||||
|
await _cs.AddAsync(userId, won, new("lula", "win"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OneOf<BetrollResult, GamblingError>> BetRollAsync(ulong userId, long amount)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(amount);
|
||||||
|
|
||||||
|
if (amount > 0)
|
||||||
|
{
|
||||||
|
var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betroll", "bet"));
|
||||||
|
|
||||||
|
if (!isTakeSuccess)
|
||||||
|
{
|
||||||
|
return GamblingError.InsufficientFunds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var game = new BetrollGame(_bcs.Data.BetRoll.Pairs
|
||||||
|
.Select(x => (x.WhenAbove, (decimal)x.MultiplyBy))
|
||||||
|
.ToList());
|
||||||
|
|
||||||
|
var result = game.Roll(amount);
|
||||||
|
|
||||||
|
var won = (long)result.Won;
|
||||||
|
if (won > 0)
|
||||||
|
{
|
||||||
|
await _cs.AddAsync(userId, won, new("betroll", "win"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OneOf<BetflipResult, GamblingError>> BetFlipAsync(ulong userId, long amount, byte guess)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(amount);
|
||||||
|
|
||||||
|
ArgumentOutOfRangeException.ThrowIfGreaterThan(guess, 1);
|
||||||
|
|
||||||
|
if (amount > 0)
|
||||||
|
{
|
||||||
|
var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betflip", "bet"));
|
||||||
|
|
||||||
|
if (!isTakeSuccess)
|
||||||
|
{
|
||||||
|
return GamblingError.InsufficientFunds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var game = new BetflipGame(_bcs.Data.BetFlip.Multiplier);
|
||||||
|
var result = game.Flip(guess, amount);
|
||||||
|
|
||||||
|
var won = (long)result.Won;
|
||||||
|
if (won > 0)
|
||||||
|
{
|
||||||
|
await _cs.AddAsync(userId, won, new("betflip", "win"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OneOf<BetdrawResult, GamblingError>> BetDrawAsync(ulong userId, long amount, byte? maybeGuessValue, byte? maybeGuessColor)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(amount);
|
||||||
|
|
||||||
|
if (maybeGuessColor is null && maybeGuessValue is null)
|
||||||
|
throw new ArgumentNullException();
|
||||||
|
|
||||||
|
if (maybeGuessColor > 1)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maybeGuessColor));
|
||||||
|
|
||||||
|
if (maybeGuessValue > 1)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maybeGuessValue));
|
||||||
|
|
||||||
|
if (amount > 0)
|
||||||
|
{
|
||||||
|
var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betdraw", "bet"));
|
||||||
|
|
||||||
|
if (!isTakeSuccess)
|
||||||
|
{
|
||||||
|
return GamblingError.InsufficientFunds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var game = new BetdrawGame();
|
||||||
|
var result = game.Draw((BetdrawValueGuess?)maybeGuessValue, (BetdrawColorGuess?)maybeGuessColor, amount);
|
||||||
|
|
||||||
|
var won = (long)result.Won;
|
||||||
|
if (won > 0)
|
||||||
|
{
|
||||||
|
await _cs.AddAsync(userId, won, new("betdraw", "win"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OneOf<SlotResult, GamblingError>> SlotAsync(ulong userId, long amount)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(amount);
|
||||||
|
|
||||||
|
if (amount > 0)
|
||||||
|
{
|
||||||
|
var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("slot", "bet"));
|
||||||
|
|
||||||
|
if (!isTakeSuccess)
|
||||||
|
{
|
||||||
|
return GamblingError.InsufficientFunds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var game = new SlotGame();
|
||||||
|
var result = game.Spin(amount);
|
||||||
|
|
||||||
|
var won = (long)result.Won;
|
||||||
|
if (won > 0)
|
||||||
|
{
|
||||||
|
await _cs.AddAsync(userId, won, new("slot", "won"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<FlipResult[]> FlipAsync(int count)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(count, 1);
|
||||||
|
|
||||||
|
var game = new BetflipGame(0);
|
||||||
|
|
||||||
|
var results = new FlipResult[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
results[i] = new()
|
||||||
|
{
|
||||||
|
Side = game.Flip(0, 0).Side
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// private readonly ConcurrentDictionary<ulong, Deck> _decks = new ConcurrentDictionary<ulong, Deck>();
|
||||||
|
//
|
||||||
|
// public override Task<DeckShuffleReply> DeckShuffle(DeckShuffleRequest request, ServerCallContext context)
|
||||||
|
// {
|
||||||
|
// _decks.AddOrUpdate(request.Id, new Deck(), (key, old) => new Deck());
|
||||||
|
// return Task.FromResult(new DeckShuffleReply { });
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public override Task<DeckDrawReply> DeckDraw(DeckDrawRequest request, ServerCallContext context)
|
||||||
|
// {
|
||||||
|
// if (request.Count < 1 || request.Count > 10)
|
||||||
|
// throw new ArgumentOutOfRangeException(nameof(request.Id));
|
||||||
|
//
|
||||||
|
// var deck = request.UseNew
|
||||||
|
// ? new Deck()
|
||||||
|
// : _decks.GetOrAdd(request.Id, new Deck());
|
||||||
|
//
|
||||||
|
// var list = new List<Deck.Card>(request.Count);
|
||||||
|
// for (int i = 0; i < request.Count; i++)
|
||||||
|
// {
|
||||||
|
// var card = deck.DrawNoRestart();
|
||||||
|
// if (card is null)
|
||||||
|
// {
|
||||||
|
// if (i == 0)
|
||||||
|
// {
|
||||||
|
// deck.Restart();
|
||||||
|
// list.Add(deck.DrawNoRestart());
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// list.Add(card);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// var cards = list
|
||||||
|
// .Select(x => new Card
|
||||||
|
// {
|
||||||
|
// Name = x.ToString().ToLowerInvariant().Replace(' ', '_'),
|
||||||
|
// Number = x.Number,
|
||||||
|
// Suit = (CardSuit) x.Suit
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// var toReturn = new DeckDrawReply();
|
||||||
|
// toReturn.Cards.AddRange(cards);
|
||||||
|
//
|
||||||
|
// return Task.FromResult(toReturn);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
|
||||||
|
public async Task<OneOf<RpsResult, GamblingError>> RpsAsync(ulong userId, long amount, byte pick)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(amount);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfGreaterThan(pick, 2);
|
||||||
|
|
||||||
|
if (amount > 0)
|
||||||
|
{
|
||||||
|
var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("rps", "bet"));
|
||||||
|
|
||||||
|
if (!isTakeSuccess)
|
||||||
|
{
|
||||||
|
return GamblingError.InsufficientFunds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rps = new RpsGame();
|
||||||
|
var result = rps.Play((RpsPick)pick, amount);
|
||||||
|
|
||||||
|
var won = (long)result.Won;
|
||||||
|
if (won > 0)
|
||||||
|
{
|
||||||
|
var extra = result.Result switch
|
||||||
|
{
|
||||||
|
RpsResultType.Draw => "draw",
|
||||||
|
RpsResultType.Win => "win",
|
||||||
|
_ => "lose"
|
||||||
|
};
|
||||||
|
|
||||||
|
await _cs.AddAsync(userId, won, new("rps", extra));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
139
src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs
Normal file
139
src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Gambling.Common;
|
||||||
|
|
||||||
|
public class RollDuelGame
|
||||||
|
{
|
||||||
|
public enum Reason
|
||||||
|
{
|
||||||
|
Normal,
|
||||||
|
NoFunds,
|
||||||
|
Timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum State
|
||||||
|
{
|
||||||
|
Waiting,
|
||||||
|
Running,
|
||||||
|
Ended
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Func<RollDuelGame, Task> OnGameTick;
|
||||||
|
public event Func<RollDuelGame, Reason, Task> OnEnded;
|
||||||
|
|
||||||
|
public ulong P1 { get; }
|
||||||
|
public ulong P2 { get; }
|
||||||
|
|
||||||
|
public long Amount { get; }
|
||||||
|
|
||||||
|
public List<(int, int)> Rolls { get; } = new();
|
||||||
|
public State CurrentState { get; private set; }
|
||||||
|
public ulong Winner { get; private set; }
|
||||||
|
|
||||||
|
private readonly ulong _botId;
|
||||||
|
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
|
||||||
|
private readonly Timer _timeoutTimer;
|
||||||
|
private readonly NadekoRandom _rng = new();
|
||||||
|
private readonly SemaphoreSlim _locker = new(1, 1);
|
||||||
|
|
||||||
|
public RollDuelGame(
|
||||||
|
ICurrencyService cs,
|
||||||
|
ulong botId,
|
||||||
|
ulong p1,
|
||||||
|
ulong p2,
|
||||||
|
long amount)
|
||||||
|
{
|
||||||
|
P1 = p1;
|
||||||
|
P2 = p2;
|
||||||
|
_botId = botId;
|
||||||
|
Amount = amount;
|
||||||
|
_cs = cs;
|
||||||
|
|
||||||
|
_timeoutTimer = new(async delegate
|
||||||
|
{
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (CurrentState != State.Waiting)
|
||||||
|
return;
|
||||||
|
CurrentState = State.Ended;
|
||||||
|
await OnEnded?.Invoke(this, Reason.Timeout);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_locker.Release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
TimeSpan.FromSeconds(15),
|
||||||
|
TimeSpan.FromMilliseconds(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartGame()
|
||||||
|
{
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (CurrentState != State.Waiting)
|
||||||
|
return;
|
||||||
|
_timeoutTimer.Change(Timeout.Infinite, Timeout.Infinite);
|
||||||
|
CurrentState = State.Running;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_locker.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await _cs.RemoveAsync(P1, Amount, new("rollduel", "bet")))
|
||||||
|
{
|
||||||
|
await OnEnded?.Invoke(this, Reason.NoFunds);
|
||||||
|
CurrentState = State.Ended;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await _cs.RemoveAsync(P2, Amount, new("rollduel", "bet")))
|
||||||
|
{
|
||||||
|
await _cs.AddAsync(P1, Amount, new("rollduel", "refund"));
|
||||||
|
await OnEnded?.Invoke(this, Reason.NoFunds);
|
||||||
|
CurrentState = State.Ended;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int n1, n2;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
n1 = _rng.Next(0, 5);
|
||||||
|
n2 = _rng.Next(0, 5);
|
||||||
|
Rolls.Add((n1, n2));
|
||||||
|
if (n1 != n2)
|
||||||
|
{
|
||||||
|
if (n1 > n2)
|
||||||
|
Winner = P1;
|
||||||
|
else
|
||||||
|
Winner = P2;
|
||||||
|
var won = (long)(Amount * 2 * 0.98f);
|
||||||
|
await _cs.AddAsync(Winner, won, new("rollduel", "win"));
|
||||||
|
|
||||||
|
await _cs.AddAsync(_botId, (Amount * 2) - won, new("rollduel", "fee"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try { await OnGameTick?.Invoke(this); }
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
await Task.Delay(2500);
|
||||||
|
if (n1 != n2)
|
||||||
|
break;
|
||||||
|
} while (true);
|
||||||
|
|
||||||
|
CurrentState = State.Ended;
|
||||||
|
await OnEnded?.Invoke(this, Reason.Normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RollDuelChallenge
|
||||||
|
{
|
||||||
|
public ulong Player1 { get; set; }
|
||||||
|
public ulong Player2 { get; set; }
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
using NCalc;
|
||||||
|
using OneOf;
|
||||||
|
|
||||||
|
namespace EllieBot.Common.TypeReaders;
|
||||||
|
|
||||||
|
public class BaseShmartInputAmountReader
|
||||||
|
{
|
||||||
|
private static readonly Regex _percentRegex = new(@"^((?<num>100|\d{1,2})%)$", RegexOptions.Compiled);
|
||||||
|
protected readonly DbService _db;
|
||||||
|
protected readonly GamblingConfigService _gambling;
|
||||||
|
|
||||||
|
public BaseShmartInputAmountReader(DbService db, GamblingConfigService gambling)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_gambling = gambling;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<OneOf<long, OneOf.Types.Error<string>>> ReadAsync(ICommandContext context, string input)
|
||||||
|
{
|
||||||
|
var i = input.Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
i = i.Replace("K", "000");
|
||||||
|
|
||||||
|
//can't add m because it will conflict with max atm
|
||||||
|
|
||||||
|
if (await TryHandlePercentage(context, i) is long num)
|
||||||
|
{
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var expr = new Expression(i, EvaluateOptions.IgnoreCase);
|
||||||
|
expr.EvaluateParameter += (str, ev) => EvaluateParam(str, ev, context).GetAwaiter().GetResult();
|
||||||
|
return (long)decimal.Parse(expr.Evaluate().ToString()!);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return new OneOf.Types.Error<string>($"Invalid input: {input}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EvaluateParam(string name, ParameterArgs args, ICommandContext ctx)
|
||||||
|
{
|
||||||
|
switch (name.ToUpperInvariant())
|
||||||
|
{
|
||||||
|
case "PI":
|
||||||
|
args.Result = Math.PI;
|
||||||
|
break;
|
||||||
|
case "E":
|
||||||
|
args.Result = Math.E;
|
||||||
|
break;
|
||||||
|
case "ALL":
|
||||||
|
case "ALLIN":
|
||||||
|
args.Result = await Cur(ctx);
|
||||||
|
break;
|
||||||
|
case "HALF":
|
||||||
|
args.Result = await Cur(ctx) / 2;
|
||||||
|
break;
|
||||||
|
case "MAX":
|
||||||
|
args.Result = await Max(ctx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual async Task<long> Cur(ICommandContext ctx)
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
return await uow.Set<DiscordUser>().GetUserCurrencyAsync(ctx.User.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual async Task<long> Max(ICommandContext ctx)
|
||||||
|
{
|
||||||
|
var settings = _gambling.Data;
|
||||||
|
var max = settings.MaxBet;
|
||||||
|
return max == 0 ? await Cur(ctx) : max;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<long?> TryHandlePercentage(ICommandContext ctx, string input)
|
||||||
|
{
|
||||||
|
var m = _percentRegex.Match(input);
|
||||||
|
|
||||||
|
if (m.Captures.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!long.TryParse(m.Groups["num"].ToString(), out var percent))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return (long)(await Cur(ctx) * (percent / 100.0f));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
using EllieBot.Modules.Gambling.Bank;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Common.TypeReaders;
|
||||||
|
|
||||||
|
public sealed class ShmartBankInputAmountReader : BaseShmartInputAmountReader
|
||||||
|
{
|
||||||
|
private readonly IBankService _bank;
|
||||||
|
|
||||||
|
public ShmartBankInputAmountReader(IBankService bank, DbService db, GamblingConfigService gambling)
|
||||||
|
: base(db, gambling)
|
||||||
|
{
|
||||||
|
_bank = bank;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<long> Cur(ICommandContext ctx)
|
||||||
|
=> _bank.GetBalanceAsync(ctx.User.Id);
|
||||||
|
|
||||||
|
protected override Task<long> Max(ICommandContext ctx)
|
||||||
|
=> Cur(ctx);
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Gambling.Bank;
|
||||||
|
using EllieBot.Modules.Gambling.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Common.TypeReaders;
|
||||||
|
|
||||||
|
public sealed class BalanceTypeReader : TypeReader
|
||||||
|
{
|
||||||
|
private readonly BaseShmartInputAmountReader _tr;
|
||||||
|
|
||||||
|
public BalanceTypeReader(DbService db, GamblingConfigService gambling)
|
||||||
|
{
|
||||||
|
_tr = new BaseShmartInputAmountReader(db, gambling);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<Discord.Commands.TypeReaderResult> ReadAsync(
|
||||||
|
ICommandContext context,
|
||||||
|
string input,
|
||||||
|
IServiceProvider services)
|
||||||
|
{
|
||||||
|
|
||||||
|
var result = await _tr.ReadAsync(context, input);
|
||||||
|
|
||||||
|
if (result.TryPickT0(out var val, out var err))
|
||||||
|
{
|
||||||
|
return Discord.Commands.TypeReaderResult.FromSuccess(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, err.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BankBalanceTypeReader : TypeReader
|
||||||
|
{
|
||||||
|
private readonly ShmartBankInputAmountReader _tr;
|
||||||
|
|
||||||
|
public BankBalanceTypeReader(IBankService bank, DbService db, GamblingConfigService gambling)
|
||||||
|
{
|
||||||
|
_tr = new ShmartBankInputAmountReader(bank, db, gambling);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<Discord.Commands.TypeReaderResult> ReadAsync(
|
||||||
|
ICommandContext context,
|
||||||
|
string input,
|
||||||
|
IServiceProvider services)
|
||||||
|
{
|
||||||
|
|
||||||
|
var result = await _tr.ReadAsync(context, input);
|
||||||
|
|
||||||
|
if (result.TryPickT0(out var val, out var err))
|
||||||
|
{
|
||||||
|
return Discord.Commands.TypeReaderResult.FromSuccess(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, err.Value);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue