Added Gambling module

This commit is contained in:
Toastie 2024-06-18 23:50:45 +12:00
parent d9096a07a8
commit 922f726a6e
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
68 changed files with 8765 additions and 0 deletions

View 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 EllieRandom();
while (!_users.All(x => x.Progress >= 60))
{
foreach (var user in _users)
{
user.Progress += rng.Next(1, 11);
if (user.Progress >= 60)
user.Progress = 60;
}
var finished = _users.Where(x => x.Progress >= 60 && !FinishedUsers.Contains(x)).Shuffle();
FinishedUsers.AddRange(finished);
_ = OnStateUpdate?.Invoke(this);
await Task.Delay(2500);
}
if (FinishedUsers[0].Bet > 0)
{
await _currency.AddAsync(FinishedUsers[0].UserId,
FinishedUsers[0].Bet * (_users.Count - 1),
new("animalrace", "win"));
}
_ = OnEnded?.Invoke(this);
});
}
public void Dispose()
{
CurrentPhase = Phase.Ended;
OnStarted = null;
OnEnded = null;
OnStartingFailed = null;
OnStateUpdate = null;
_locker.Dispose();
_users.Clear();
}
}

View file

@ -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();
}

View file

@ -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();
}
}
}
}

View file

@ -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();
}

View file

@ -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)
{
}
}

View file

@ -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)
{
}
}

View file

@ -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)
{
}
}

View file

@ -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)
{
}
}

View 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;
}
}

View file

@ -0,0 +1,118 @@
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();
}
}
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);
}
}

View 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;
}
}

View 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();
}
}
}

View file

@ -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();
}

View 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);
}
}

View 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;
}
}

View file

@ -0,0 +1,409 @@
#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 ImmutableArray<Field> GameState
=> _gameState.ToImmutableArray();
public ImmutableArray<(ulong UserId, string Username)?> Players
=> _players.ToImmutableArray();
public (ulong UserId, string Username) CurrentPlayer
=> CurrentPhase == Phase.P1Move ? _players[0].Value : _players[1].Value;
public (ulong UserId, string Username) OtherPlayer
=> CurrentPhase == Phase.P2Move ? _players[0].Value : _players[1].Value;
//state is bottom to top, left to right
private readonly Field[] _gameState = new Field[NUMBER_OF_ROWS * NUMBER_OF_COLUMNS];
private readonly (ulong UserId, string Username)?[] _players = new (ulong, string)?[2];
private readonly SemaphoreSlim _locker = new(1, 1);
private readonly Options _options;
private readonly ICurrencyService _cs;
private readonly EllieRandom _rng;
private Timer playerTimeoutTimer;
/* [ ][ ][ ][ ][ ][ ]
* [ ][ ][ ][ ][ ][ ]
* [ ][ ][ ][ ][ ][ ]
* [ ][ ][ ][ ][ ][ ]
* [ ][ ][ ][ ][ ][ ]
* [ ][ ][ ][ ][ ][ ]
* [ ][ ][ ][ ][ ][ ]
*/
public Connect4Game(
ulong userId,
string userName,
Options options,
ICurrencyService cs)
{
_players[0] = (userId, userName);
_options = options;
_cs = cs;
_rng = new();
for (var i = 0; i < NUMBER_OF_COLUMNS * NUMBER_OF_ROWS; i++)
_gameState[i] = Field.Empty;
}
public void Initialize()
{
if (CurrentPhase != Phase.Joining)
return;
_ = Task.Run(async () =>
{
await Task.Delay(15000);
await _locker.WaitAsync();
try
{
if (_players[1] is null)
{
_ = OnGameFailedToStart?.Invoke(this);
CurrentPhase = Phase.Ended;
await _cs.AddAsync(_players[0].Value.UserId, _options.Bet, new("connect4", "refund"));
}
}
finally { _locker.Release(); }
});
}
public async Task<bool> Join(ulong userId, string userName, int bet)
{
await _locker.WaitAsync();
try
{
if (CurrentPhase != Phase.Joining) //can't join if its not a joining phase
return false;
if (_players[0].Value.UserId == userId) // same user can't join own game
return false;
if (bet != _options.Bet) // can't join if bet amount is not the same
return false;
if (!await _cs.RemoveAsync(userId, bet, new("connect4", "bet"))) // user doesn't have enough money to gamble
return false;
if (_rng.Next(0, 2) == 0) //rolling from 0-1, if number is 0, join as first player
{
_players[1] = _players[0];
_players[0] = (userId, userName);
}
else //else join as a second player
_players[1] = (userId, userName);
CurrentPhase = Phase.P1Move; //start the game
playerTimeoutTimer = new(async _ =>
{
await _locker.WaitAsync();
try
{
EndGame(Result.OtherPlayerWon, OtherPlayer.UserId);
}
finally { _locker.Release(); }
},
null,
TimeSpan.FromSeconds(_options.TurnTimer),
TimeSpan.FromSeconds(_options.TurnTimer));
_ = OnGameStateUpdated?.Invoke(this);
return true;
}
finally { _locker.Release(); }
}
public async Task<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)
{
_cs.AddAsync(CurrentPlayer.UserId, _options.Bet, new("connect4", "draw"));
_cs.AddAsync(OtherPlayer.UserId, _options.Bet, new("connect4", "draw"));
return;
}
if (winId is not null)
_cs.AddAsync(winId.Value, (long)(_options.Bet * 1.98), new("connect4", "win"));
}
private Field GetPlayerPiece(ulong userId)
=> _players[0].Value.UserId == userId ? Field.P1 : Field.P2;
//column is full if there are no empty fields
private bool IsColumnFull(int column)
{
var start = NUMBER_OF_ROWS * column;
for (var i = start; i < start + NUMBER_OF_ROWS; i++)
{
if (_gameState[i] == Field.Empty)
return false;
}
return true;
}
public void Dispose()
{
OnGameFailedToStart = null;
OnGameStateUpdated = null;
OnGameEnded = null;
playerTimeoutTimer?.Change(Timeout.Infinite, Timeout.Infinite);
}
public class Options : IEllieCommandOptions
{
[Option('t',
"turn-timer",
Required = false,
Default = 15,
HelpText = "Turn time in seconds. It has to be between 5 and 60. Default 15.")]
public int TurnTimer { get; set; } = 15;
[Option('b', "bet", Required = false, Default = 0, HelpText = "Amount you bet. Default 0.")]
public int Bet { get; set; }
public void NormalizeOptions()
{
if (TurnTimer is < 5 or > 60)
TurnTimer = 15;
if (Bet < 0)
Bet = 0;
}
}
}

View file

@ -0,0 +1,205 @@
#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 readonly ICurrencyService _cs;
private IUserMessage msg;
private int repostCounter;
public Connect4Commands(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService gamb)
: base(gamb)
{
_client = client;
_cs = cs;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[EllieOptions<Connect4Game.Options>]
public async Task Connect4(params string[] args)
{
var (options, _) = OptionsParser.ParseFrom(new Connect4Game.Options(), args);
if (!await CheckBetOptional(options.Bet))
return;
var newGame = new Connect4Game(ctx.User.Id, ctx.User.ToString(), options, _cs);
Connect4Game game;
if ((game = _service.Connect4Games.GetOrAdd(ctx.Channel.Id, newGame)) != newGame)
{
if (game.CurrentPhase != Connect4Game.Phase.Joining)
return;
newGame.Dispose();
//means game already exists, try to join
await game.Join(ctx.User.Id, ctx.User.ToString(), options.Bet);
return;
}
if (options.Bet > 0)
{
if (!await _cs.RemoveAsync(ctx.User.Id, options.Bet, new("connect4", "bet")))
{
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
_service.Connect4Games.TryRemove(ctx.Channel.Id, out _);
game.Dispose();
return;
}
}
game.OnGameStateUpdated += Game_OnGameStateUpdated;
game.OnGameFailedToStart += GameOnGameFailedToStart;
game.OnGameEnded += GameOnGameEnded;
_client.MessageReceived += ClientMessageReceived;
game.Initialize();
if (options.Bet == 0)
await Response().Confirm(strs.connect4_created).SendAsync();
else
await Response().Error(strs.connect4_created_bet(N(options.Bet))).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();
}
}
}

View 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;
}

View 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));
}
}
}

View file

@ -0,0 +1,234 @@
#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 (amount <= 0)
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,
}
}
}

View 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; }
}

View file

@ -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));
}
}
}

View file

@ -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;
}
}

View 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;
}
}

View file

@ -0,0 +1,198 @@
#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 char[] _sneakyGameStatusChars = Enumerable.Range(48, 10)
.Concat(Enumerable.Range(65, 26))
.Concat(Enumerable.Range(97, 26))
.Select(x => (char)x)
.ToArray();
private readonly object _stopLock = new();
private readonly object _potLock = new();
private readonly IMessageSenderService _sender;
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(_sneakyGameStatusChars.Shuffle().Take(5).ToArray());
_t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2));
if (_opts.Hours > 0)
_timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan);
}
private void EventTimeout(object state)
=> _ = StopEvent();
private async void OnTimerTick(object state)
{
var potEmpty = PotEmptied;
var toAward = new List<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;
}
}

View file

@ -0,0 +1,9 @@
#nullable disable
namespace EllieBot.Modules.Gambling.Common;
public interface ICurrencyEvent
{
event Func<ulong, Task> OnEnded;
Task StopEvent();
Task StartEvent();
}

View 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;
}
}

View 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();
}
}
}

View file

@ -0,0 +1,7 @@
namespace EllieBot.Modules.Gambling;
public readonly struct FlipResult
{
public long Won { get; init; }
public int Side { get; init; }
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,404 @@
#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; } = 2;
[Comment("""Currency settings""")]
public CurrencyConfig Currency { get; set; }
[Comment("""Minimum amount users can bet (>=0)""")]
public int MinBet { get; set; } = 0;
[Comment("""
Maximum amount users can bet
Set 0 for unlimited
""")]
public int MaxBet { get; set; } = 0;
[Comment("""Settings for betflip command""")]
public BetFlipConfig BetFlip { get; set; }
[Comment("""Settings for betroll command""")]
public BetRollConfig BetRoll { get; set; }
[Comment("""Automatic currency generation settings.""")]
public GenerationConfig Generation { get; set; }
[Comment("""
Settings for timely command
(letting people claim X amount of currency every Y hours)
""")]
public TimelyConfig Timely { get; set; }
[Comment("""How much will each user's owned currency decay over time.""")]
public DecayConfig Decay { get; set; }
[Comment("""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 ellie.
""")]
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("""
Percentage (0 - 100) of the waifu value to reduce.
Set 0 to disable
For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$)
""")]
public int Percent { get; set; } = 0;
[Comment("""How often to decay waifu values, in hours""")]
public int HourInterval { get; set; } = 24;
[Comment("""
Minimum waifu price required for the decay to be applied.
For example if this value is set to 300, any waifu with the price 300 or less will not experience decay.
""")]
public long MinPrice { get; set; } = 300;
}
}
[Cloneable]
public sealed partial class MultipliersData
{
[Comment("""
Multiplier for waifureset. Default 150.
Formula (at the time of writing this):
price = (waifu_price * 1.25f) + ((number_of_divorces + changes_of_heart + 2) * WaifuReset) rounded up
""")]
public int WaifuReset { get; set; } = 150;
[Comment("""
The minimum amount of currency that you have to pay
in order to buy a waifu who doesn't have a crush on you.
Default is 1.1
Example: If a waifu is worth 100, you will have to pay at least 100 * NormalClaim currency to claim her.
(100 * 1.1 = 110)
""")]
public decimal NormalClaim { get; set; } = 1.1m;
[Comment("""
The minimum amount of currency that you have to pay
in order to buy a waifu that has a crush on you.
Default is 0.88
Example: If a waifu is worth 100, you will have to pay at least 100 * CrushClaim currency to claim her.
(100 * 0.88 = 88)
""")]
public decimal CrushClaim { get; set; } = 0.88M;
[Comment("""
When divorcing a waifu, her new value will be her current value multiplied by this number.
Default 0.75 (meaning will lose 25% of her value)
""")]
public decimal DivorceNewValue { get; set; } = 0.75M;
[Comment("""
All gift prices will be multiplied by this number.
Default 1 (meaning no effect)
""")]
public decimal AllGiftPrices { get; set; } = 1.0M;
[Comment("""
What percentage of the value of the gift will a waifu gain when she's gifted.
Default 0.95 (meaning 95%)
Example: If a waifu is worth 1000, and she receives a gift worth 100, her new value will be 1095)
""")]
public decimal GiftEffect { get; set; } = 0.95M;
[Comment("""
What percentage of the value of the gift will a waifu lose when she's gifted a gift marked as 'negative'.
Default 0.5 (meaning 50%)
Example: If a waifu is worth 1000, and she receives a negative gift worth 100, her new value will be 950)
""")]
public decimal NegativeGiftEffect { get; set; } = 0.50M;
}
public sealed class SlotsConfig
{
[Comment("""Hex value of the color which the numbers on the slot image will have.""")]
public Rgba32 CurrencyFontColor { get; set; } = Color.Red;
}
[Cloneable]
public sealed partial class WaifuItemModel
{
public string ItemEmoji { get; set; }
public long Price { get; set; }
public string Name { get; set; }
[YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)]
public bool Negative { get; set; }
public WaifuItemModel()
{
}
public WaifuItemModel(
string itemEmoji,
long price,
string name,
bool negative = false)
{
ItemEmoji = itemEmoji;
Price = price;
Name = name;
Negative = negative;
}
public override string ToString()
=> Name;
}
[Cloneable]
public sealed partial class BetRollPair
{
public int WhenAbove { get; set; }
public float MultiplyBy { get; set; }
}
[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;
}

View file

@ -0,0 +1,194 @@
#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;
});
}
}
}

View file

@ -0,0 +1,220 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db;
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("ellie:economy");
public async Task<EconomyResult> GetEconomyAsync()
{
var data = await _cache.GetOrAddAsync(_ecoKey,
async () =>
{
await using var uow = _db.GetDbContext();
var cash = uow.Set<DiscordUser>().GetTotalCurrency();
var onePercent = uow.Set<DiscordUser>().GetTopOnePercentCurrency(_client.CurrentUser.Id);
decimal planted = uow.Set<PlantedCurrency>().AsQueryable().Sum(x => x.Amount);
var waifus = uow.Set<WaifuInfo>().GetTotalValue();
var bot = await uow.Set<DiscordUser>().GetUserCurrencyAsync(_client.CurrentUser.Id);
decimal bank = await uow.GetTable<BankUser>()
.SumAsyncLinqToDB(x => x.Balance);
var result = new EconomyResult
{
Cash = cash,
Planted = planted,
Bot = bot,
Waifus = waifus,
OnePercent = onePercent,
Bank = bank
};
return result;
},
TimeSpan.FromMinutes(3));
return data;
}
private static readonly SemaphoreSlim _timelyLock = new(1, 1);
private static TypedKey<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);
}

View 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)
{
}
}

View file

@ -0,0 +1,3 @@
#nullable disable
namespace EllieBot.Modules.Gambling;

View 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_ELLIE
[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();
}
}
}

View file

@ -0,0 +1,385 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db;
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,
CommandHandler cmd,
IBotStrings strings,
IImageCache images,
FontProvider fonts,
ICurrencyService cs,
CommandHandler cmdHandler,
DiscordSocketClient client,
GamblingConfigService gss)
{
_db = db;
_strings = strings;
_images = images;
_fonts = fonts;
_cs = cs;
_cmdHandler = cmdHandler;
_rng = new();
_client = client;
_gss = gss;
using var uow = db.GetDbContext();
var guildIds = client.Guilds.Select(x => x.Id).ToList();
var configs = uow.Set<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>
/// <param name="extension">Extension of the file, defaults to png</param>
/// <returns>Stream of the currency image</returns>
public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass)
{
var curImg = await _images.GetCurrencyImageAsync();
if (string.IsNullOrWhiteSpace(pass))
{
// determine the extension
using var load = _ = Image.Load(curImg, out var format);
// return the image
return (curImg.ToStream(), format.FileExtensions.FirstOrDefault() ?? "png");
}
// get the image stream and extension
return AddPassword(curImg, pass);
}
/// <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, out var format);
// choose font size based on the image height, so that it's visible
var font = _fonts.NotoSans.CreateFont(img.Height / 12.0f, FontStyle.Bold);
img.Mutate(x =>
{
// measure the size of the text to be drawing
var size = TextMeasurer.Measure(pass, new TextOptions(font)
{
Origin = new PointF(0, 0)
});
// fill the background with black, add 5 pixels on each side to make it look better
x.FillPolygon(Color.ParseHex("00000080"),
new PointF(0, 0),
new PointF(size.Width + 5, 0),
new PointF(size.Width + 5, size.Height + 10),
new PointF(0, size.Height + 10));
// draw the password over the background
x.DrawText(pass, font, Color.White, new(0, 0));
});
// return image as a stream for easy sending
return (img.ToStream(format), format.FileExtensions.FirstOrDefault() ?? "png");
}
private Task PotentialFlowerGeneration(IUserMessage imsg)
{
if (imsg is not SocketUserMessage msg || msg.Author.IsBot)
return Task.CompletedTask;
if (imsg.Channel is not ITextChannel channel)
return Task.CompletedTask;
if (!_generationChannels.Contains(channel.Id))
return Task.CompletedTask;
_ = Task.Run(async () =>
{
try
{
var config = _gss.Data;
var lastGeneration = LastGenerations.GetOrAdd(channel.Id, DateTime.MinValue.ToBinary());
var rng = new EllieRandom();
if (DateTime.UtcNow - TimeSpan.FromSeconds(config.Generation.GenCooldown)
< DateTime.FromBinary(lastGeneration)) //recently generated in this channel, don't generate again
return;
var num = rng.Next(1, 101) + (config.Generation.Chance * 100);
if (num > 100 && LastGenerations.TryUpdate(channel.Id, DateTime.UtcNow.ToBinary(), lastGeneration))
{
var dropAmount = config.Generation.MinAmount;
var dropAmountMax = config.Generation.MaxAmount;
if (dropAmountMax > dropAmount)
dropAmount = new EllieRandom().Next(dropAmount, dropAmountMax + 1);
if (dropAmount > 0)
{
var prefix = _cmdHandler.GetPrefix(channel.Guild.Id);
var toSend = dropAmount == 1
? GetText(channel.GuildId, strs.curgen_sn(config.Currency.Sign))
+ " "
+ GetText(channel.GuildId, strs.pick_sn(prefix))
: GetText(channel.GuildId, strs.curgen_pl(dropAmount, config.Currency.Sign))
+ " "
+ GetText(channel.GuildId, strs.pick_pl(prefix));
var pw = config.Generation.HasPassword ? GenerateCurrencyPassword().ToUpperInvariant() : null;
IUserMessage sent;
var (stream, ext) = await GetRandomCurrencyImageAsync(pw);
await using (stream)
sent = await channel.SendFileAsync(stream, $"currency_image.{ext}", toSend);
await AddPlantToDatabase(channel.GuildId,
channel.Id,
_client.CurrentUser.Id,
sent.Id,
dropAmount,
pw);
}
}
}
catch
{
}
});
return Task.CompletedTask;
}
/// <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();
}
}

View file

@ -0,0 +1,61 @@
#nullable disable
using EllieBot.Common.TypeReaders;
using EllieBot.Modules.Gambling.Common;
using EllieBot.Modules.Gambling.Services;
namespace EllieBot.Modules.Gambling;
public partial class Gambling
{
public partial class CurrencyRaffleCommands : GamblingSubmodule<CurrencyRaffleService>
{
public enum Mixed { Mixed }
public CurrencyRaffleCommands(GamblingConfigService gamblingConfService)
: base(gamblingConfService)
{
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(0)]
public Task RaffleCur(Mixed _, [OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
=> RaffleCur(amount, true);
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(1)]
public async Task RaffleCur([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, bool mixed = false)
{
if (!await CheckBetMandatory(amount))
return;
async Task OnEnded(IUser arg, long won)
{
await Response()
.Confirm(GetText(strs.rafflecur_ended(CurrencyName,
Format.Bold(arg.ToString()),
won + CurrencySign)))
.SendAsync();
}
var res = await _service.JoinOrCreateGame(ctx.Channel.Id, ctx.User, amount, mixed, OnEnded);
if (res.Item1 is not null)
{
await Response()
.Confirm(GetText(strs.rafflecur(res.Item1.GameType.ToString())),
string.Join("\n", res.Item1.Users.Select(x => $"{x.DiscordUser} ({N(x.Amount)})")),
footer: GetText(strs.rafflecur_joined(ctx.User.ToString())))
.SendAsync();
}
else
{
if (res.Item2 == CurrencyRaffleService.JoinErrorType.AlreadyJoinedOrInvalidAmount)
await Response().Error(strs.rafflecur_already_joined).SendAsync();
else if (res.Item2 == CurrencyRaffleService.JoinErrorType.NotEnoughCurrency)
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
}
}
}
}

View file

@ -0,0 +1,69 @@
#nullable disable
namespace EllieBot.Modules.Gambling.Common;
public class CurrencyRaffleGame
{
public enum Type
{
Mixed,
Normal
}
public IEnumerable<User> Users
=> _users;
public Type GameType { get; }
private readonly HashSet<User> _users = new();
public CurrencyRaffleGame(Type type)
=> GameType = type;
public bool AddUser(IUser usr, long amount)
{
// if game type is normal, and someone already joined the game
// (that's the user who created it)
if (GameType == Type.Normal && _users.Count > 0 && _users.First().Amount != amount)
return false;
if (!_users.Add(new()
{
DiscordUser = usr,
Amount = amount
}))
return false;
return true;
}
public User GetWinner()
{
var rng = new EllieRandom();
if (GameType == Type.Mixed)
{
var num = rng.NextLong(0L, Users.Sum(x => x.Amount));
var sum = 0L;
foreach (var u in Users)
{
sum += u.Amount;
if (sum > num)
return u;
}
}
var usrs = _users.ToArray();
return usrs[rng.Next(0, usrs.Length)];
}
public class User
{
public IUser DiscordUser { get; set; }
public long Amount { get; set; }
public override int GetHashCode()
=> DiscordUser.GetHashCode();
public override bool Equals(object obj)
=> obj is User u ? u.DiscordUser == DiscordUser : false;
}
}

View file

@ -0,0 +1,81 @@
#nullable disable
using EllieBot.Modules.Gambling.Common;
namespace EllieBot.Modules.Gambling.Services;
public class CurrencyRaffleService : IEService
{
public enum JoinErrorType
{
NotEnoughCurrency,
AlreadyJoinedOrInvalidAmount
}
public Dictionary<ulong, CurrencyRaffleGame> Games { get; } = new();
private readonly SemaphoreSlim _locker = new(1, 1);
private readonly ICurrencyService _cs;
public CurrencyRaffleService(ICurrencyService cs)
=> _cs = cs;
public async Task<(CurrencyRaffleGame, JoinErrorType?)> JoinOrCreateGame(
ulong channelId,
IUser user,
long amount,
bool mixed,
Func<IUser, long, Task> onEnded)
{
await _locker.WaitAsync();
try
{
var newGame = false;
if (!Games.TryGetValue(channelId, out var crg))
{
newGame = true;
crg = new(mixed ? CurrencyRaffleGame.Type.Mixed : CurrencyRaffleGame.Type.Normal);
Games.Add(channelId, crg);
}
//remove money, and stop the game if this
// user created it and doesn't have the money
if (!await _cs.RemoveAsync(user.Id, amount, new("raffle", "join")))
{
if (newGame)
Games.Remove(channelId);
return (null, JoinErrorType.NotEnoughCurrency);
}
if (!crg.AddUser(user, amount))
{
await _cs.AddAsync(user.Id, amount, new("raffle", "refund"));
return (null, JoinErrorType.AlreadyJoinedOrInvalidAmount);
}
if (newGame)
{
_ = Task.Run(async () =>
{
await Task.Delay(60000);
await _locker.WaitAsync();
try
{
var winner = crg.GetWinner();
var won = crg.Users.Sum(x => x.Amount);
await _cs.AddAsync(winner.DiscordUser.Id, won, new("raffle", "win"));
Games.Remove(channelId, out _);
_ = onEnded(winner.DiscordUser, won);
}
catch { }
finally { _locker.Release(); }
});
}
return (crg, null);
}
finally
{
_locker.Release();
}
}
}

View 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);
}

View file

@ -0,0 +1,590 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using EllieBot.Db;
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 cmd = entry.Command.Replace("%you%", ctx.User.Id.ToString());
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 _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;
}
}
}

View file

@ -0,0 +1,127 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using EllieBot.Db;
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;
}
}

View file

@ -0,0 +1,230 @@
#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.Modules.Gambling;
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 static decimal totalBet;
private static decimal totalPaidOut;
private readonly IImageCache _images;
private readonly FontProvider _fonts;
private readonly DbService _db;
private object _slotStatsLock = new();
public SlotCommands(
IImageCache images,
FontProvider fonts,
DbService db,
GamblingConfigService gamb)
: base(gamb)
{
_images = images;
_fonts = fonts;
_db = db;
}
public Task Test()
=> Task.CompletedTask;
[Cmd]
public async Task Slot([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
{
if (!await CheckBetMandatory(amount))
return;
// var slotInteraction = CreateSlotInteractionIntenal(amount);
await ctx.Channel.TriggerTypingAsync();
if (await InternalSlotAsync(amount) is not SlotResult result)
{
await 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;
}
lock (_slotStatsLock)
{
totalBet += amount;
totalPaidOut += result.Won;
}
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, out _);
var numbers = new int[3];
result.Rolls.CopyTo(numbers, 0);
Color fontColor = Config.Slots.CurrencyFontColor;
bgImage.Mutate(x => x.DrawText(new TextOptions(_fonts.DottyFont.CreateFont(65))
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
WrappingLength = 140,
Origin = new(298, 100)
},
((long)result.Won).ToString(),
fontColor));
var bottomFont = _fonts.DottyFont.CreateFont(50);
bgImage.Mutate(x => x.DrawText(new TextOptions(bottomFont)
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
WrappingLength = 135,
Origin = new(196, 480)
},
amount.ToString(),
fontColor));
bgImage.Mutate(x => x.DrawText(new(bottomFont)
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Origin = new(393, 480)
},
ownedAmount.ToString(),
fontColor));
//sw.PrintLap("drew red text");
for (var i = 0; i < 3; i++)
{
using var img = Image.Load(await _images.GetSlotEmojiAsync(numbers[i]));
bgImage.Mutate(x => x.DrawImage(img, new Point(148 + (105 * i), 217), 1f));
}
return bgImage;
}
}
}

View 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");
}
}
}
}

View file

@ -0,0 +1,393 @@
#nullable disable
using EllieBot.Modules.Gambling.Common;
using EllieBot.Modules.Gambling.Common.Waifu;
using EllieBot.Modules.Gambling.Services;
using EllieBot.Db.Models;
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(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
{
await Response()
.Error(strs.waifu_recent_divorce(
Format.Bold(((int)remaining?.TotalHours).ToString()),
Format.Bold(remaining?.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(user.ToString()))).SendAsync();
else
{
await Response()
.Confirm(strs.waifu_affinity_changed(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";
var status = string.Empty;
var waifuUsername = w.Username.TrimTo(20);
var claimerUsername = w.Claimer?.TrimTo(20);
if (w.Claimer is not null)
claimer = $"{claimerUsername}#{w.ClaimerDiscrim}";
if (w.Affinity is null)
status = $"... but {waifuUsername}'s heart is empty";
else if (w.Affinity + w.AffinityDiscrim == w.Claimer + w.ClaimerDiscrim)
status = $"... and {waifuUsername} likes {claimerUsername} too <3";
else
status = $"... but {waifuUsername}'s heart belongs to {w.Affinity.TrimTo(20)}#{w.AffinityDiscrim}";
return $"**{waifuUsername}#{w.Discrim}** - claimed by **{claimer}**\n\t{status}";
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(1)]
public Task WaifuInfo([Leftover] IUser target = null)
{
if (target is null)
target = ctx.User;
return InternalWaifuInfo(target.Id, target.ToString());
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(0)]
public Task WaifuInfo(ulong targetId)
=> InternalWaifuInfo(targetId);
private async Task InternalWaifuInfo(ulong targetId, string name = null)
{
var wi = await _service.GetFullWaifuInfoAsync(targetId);
var affInfo = _service.GetAffinityTitle(wi.AffinityCount);
var waifuItems = _service.GetWaifuItems().ToDictionary(x => x.ItemEmoji, x => x);
var nobody = GetText(strs.nobody);
var itemList = await _service.GetItems(wi.WaifuId);
var itemsStr = !itemList.Any()
? "-"
: string.Join("\n",
itemList.Where(x => waifuItems.TryGetValue(x.ItemEmoji, out _))
.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(string itemName, [Leftover] IUser waifu)
{
if (waifu.Id == ctx.User.Id)
return;
var allItems = _service.GetWaifuItems();
var item = allItems.FirstOrDefault(x => x.Name.ToLowerInvariant() == itemName.ToLowerInvariant());
if (item is null)
{
await Response().Error(strs.waifu_gift_not_exist).SendAsync();
return;
}
var sucess = await _service.GiftWaifuAsync(ctx.User, waifu, item);
if (sucess)
{
await Response()
.Confirm(strs.waifu_gift(Format.Bold(item + " " + item.ItemEmoji),
Format.Bold(waifu.ToString())))
.SendAsync();
}
else
await Response().Error(strs.not_enough(CurrencySign)).SendAsync();
}
}
}

View file

@ -0,0 +1,582 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db;
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)
{
if (!await _cs.RemoveAsync(from, itemObj.Price, new("waifu", "item")))
return false;
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.Add(new()
{
Name = itemObj.Name.ToLowerInvariant(),
ItemEmoji = itemObj.ItemEmoji
});
if (w.Claimer?.UserId == from.Id)
w.Price += (long)(itemObj.Price * _gss.Data.Waifu.Multipliers.GiftEffect);
else
w.Price += itemObj.Price / 2;
}
else
{
w.Price -= (long)(itemObj.Price * _gss.Data.Waifu.Multipliers.NegativeGiftEffect);
if (w.Price < 1)
w.Price = 1;
}
await uow.SaveChangesAsync();
return true;
}
public async Task<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 multi = _gss.Data.Waifu.Decay.Percent / 100f;
var minPrice = _gss.Data.Waifu.Decay.MinPrice;
var decayInterval = _gss.Data.Waifu.Decay.HourInterval;
if (multi is < 0f or > 1f || decayInterval < 0)
continue;
var now = DateTime.UtcNow;
var nowB = now.ToBinary();
var result = await _cache.GetAsync(_waifuDecayKey);
if (result.TryGetValue(out var val))
{
var lastDecay = DateTime.FromBinary(val);
var toWait = decayInterval.Hours() - (DateTime.UtcNow - lastDecay);
if (toWait > 0.Hours())
continue;
}
await _cache.AddAsync(_waifuDecayKey, nowB);
await using var uow = _db.GetDbContext();
await uow.GetTable<WaifuInfo>()
.Where(x => x.Price > minPrice && x.ClaimerId == null)
.UpdateAsync(old => new()
{
Price = (long)(old.Price * multi)
});
}
catch (Exception ex)
{
Log.Error(ex, "Unexpected error occured in waifu decay loop: {ErrorMessage}", ex.Message);
}
finally
{
await Task.Delay(1.Hours());
}
}
}
public async Task<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();
}
}

View file

@ -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
}

View 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
}

View file

@ -0,0 +1,10 @@
#nullable disable
namespace EllieBot.Modules.Gambling.Common.Waifu;
public enum DivorceResult
{
Success,
SucessWithPenalty,
NotYourWife,
Cooldown
}

View file

@ -0,0 +1,6 @@
namespace EllieBot.Modules.Gambling.Common.Waifu;
public class Extensions
{
}

View file

@ -0,0 +1,9 @@
#nullable disable
namespace EllieBot.Modules.Gambling.Common.Waifu;
public enum WaifuClaimResult
{
Success,
NotEnoughFunds,
InsufficientAmount
}

View 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();
}

View file

@ -0,0 +1,131 @@
#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.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;
}
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View file

@ -0,0 +1,8 @@
#nullable disable
namespace EllieBot.Db.Models;
public enum WaifuUpdateType
{
AffinityChanged,
Claimed
}

View 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));
}
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,8 @@
namespace EllieBot.Modules.Gambling;
public interface IGamblingCleanupService
{
Task DeleteWaifus();
Task DeleteWaifu(ulong userId);
Task DeleteCurrency();
}

View file

@ -0,0 +1,18 @@
#nullable disable
using EllieBot.Modules.Gambling;
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);
}

View file

@ -0,0 +1,269 @@
#nullable disable
using EllieBot.Modules.Gambling;
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;
}
}

View 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 EllieRandom _rng = new();
private readonly SemaphoreSlim _locker = new(1, 1);
public RollDuelGame(
ICurrencyService cs,
ulong botId,
ulong p1,
ulong p2,
long amount)
{
P1 = p1;
P2 = p2;
_botId = botId;
Amount = amount;
_cs = cs;
_timeoutTimer = new(async delegate
{
await _locker.WaitAsync();
try
{
if (CurrentState != State.Waiting)
return;
CurrentState = State.Ended;
await OnEnded?.Invoke(this, Reason.Timeout);
}
catch { }
finally
{
_locker.Release();
}
},
null,
TimeSpan.FromSeconds(15),
TimeSpan.FromMilliseconds(-1));
}
public async Task StartGame()
{
await _locker.WaitAsync();
try
{
if (CurrentState != State.Waiting)
return;
_timeoutTimer.Change(Timeout.Infinite, Timeout.Infinite);
CurrentState = State.Running;
}
finally
{
_locker.Release();
}
if (!await _cs.RemoveAsync(P1, Amount, new("rollduel", "bet")))
{
await OnEnded?.Invoke(this, Reason.NoFunds);
CurrentState = State.Ended;
return;
}
if (!await _cs.RemoveAsync(P2, Amount, new("rollduel", "bet")))
{
await _cs.AddAsync(P1, Amount, new("rollduel", "refund"));
await OnEnded?.Invoke(this, Reason.NoFunds);
CurrentState = State.Ended;
return;
}
int n1, n2;
do
{
n1 = _rng.Next(0, 5);
n2 = _rng.Next(0, 5);
Rolls.Add((n1, n2));
if (n1 != n2)
{
if (n1 > n2)
Winner = P1;
else
Winner = P2;
var won = (long)(Amount * 2 * 0.98f);
await _cs.AddAsync(Winner, won, new("rollduel", "win"));
await _cs.AddAsync(_botId, (Amount * 2) - won, new("rollduel", "fee"));
}
try { await OnGameTick?.Invoke(this); }
catch { }
await Task.Delay(2500);
if (n1 != n2)
break;
} while (true);
CurrentState = State.Ended;
await OnEnded?.Invoke(this, Reason.Normal);
}
}
public struct RollDuelChallenge
{
public ulong Player1 { get; set; }
public ulong Player2 { get; set; }
}

View file

@ -0,0 +1,95 @@
using System.Text.RegularExpressions;
using EllieBot.Db;
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));
}
}

View file

@ -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);
}

View file

@ -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);
}
}