Added Ellie.Bot.Modules.Gambling

This commit is contained in:
Emotion 2023-08-03 21:33:06 +12:00
parent bd61b0bf72
commit aaaafbda4c
No known key found for this signature in database
GPG key ID: D7D3E4C27A98C37B
111 changed files with 12399 additions and 0 deletions

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Ellie.Bot.Common\Ellie.Bot.Common.csproj"/>
<ProjectReference Include="..\Ellie.Econ\Ellie.Econ.csproj"/>
<ProjectReference Include="..\Ellie.Bot.Generators.Cloneable\Ellie.Bot.Generators.Cloneable.csproj" OutputItemType="Analyzer"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="CoreCLR-NCalc" Version="2.2.110"/>
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,154 @@
#nullable disable
using Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions;
using Ellie.Modules.Games.Common;
namespace Ellie.Modules.Gambling.Common.AnimalRacing;
public sealed class AnimalRace : IDisposable
{
public enum Phase
{
WaitingForPlayers,
Running,
Ended
}
public event Func<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)
{
if (bet < 0)
throw new ArgumentOutOfRangeException(nameof(bet));
var user = new AnimalRacingUser(userName, userId, bet);
await _locker.WaitAsync();
try
{
if (_users.Count == MaxUsers)
throw new AnimalRaceFullException();
if (CurrentPhase != Phase.WaitingForPlayers)
throw new AlreadyStartedException();
if (!await _currency.RemoveAsync(userId, bet, new("animalrace", "bet")))
throw new NotEnoughFundsException();
if (_users.Contains(user))
throw new AlreadyJoinedException();
var animal = _animalsQueue.Dequeue();
user.Animal = animal;
_users.Add(user);
if (_animalsQueue.Count == 0) //start if no more spots left
await Start();
return user;
}
finally { _locker.Release(); }
}
private async Task Start()
{
CurrentPhase = Phase.Running;
if (_users.Count <= 1)
{
foreach (var user in _users)
{
if (user.Bet > 0)
await _currency.AddAsync(user.UserId, user.Bet, new("animalrace", "refund"));
}
_ = OnStartingFailed?.Invoke(this);
CurrentPhase = Phase.Ended;
return;
}
_ = OnStarted?.Invoke(this);
_ = Task.Run(async () =>
{
var rng = new EllieRandom();
while (!_users.All(x => x.Progress >= 60))
{
foreach (var user in _users)
{
user.Progress += rng.Next(1, 11);
if (user.Progress >= 60)
user.Progress = 60;
}
var finished = _users.Where(x => x.Progress >= 60 && !FinishedUsers.Contains(x)).Shuffle();
FinishedUsers.AddRange(finished);
_ = OnStateUpdate?.Invoke(this);
await Task.Delay(2500);
}
if (FinishedUsers[0].Bet > 0)
{
await _currency.AddAsync(FinishedUsers[0].UserId,
FinishedUsers[0].Bet * (_users.Count - 1),
new("animalrace", "win"));
}
_ = OnEnded?.Invoke(this);
});
}
public void Dispose()
{
CurrentPhase = Phase.Ended;
OnStarted = null;
OnEnded = null;
OnStartingFailed = null;
OnStateUpdate = null;
_locker.Dispose();
_users.Clear();
}
}

View file

@ -0,0 +1,9 @@
#nullable disable
using Ellie.Modules.Gambling.Common.AnimalRacing;
namespace Ellie.Modules.Gambling.Services;
public class AnimalRaceService : IEService
{
public ConcurrentDictionary<ulong, AnimalRace> AnimalRaces { get; } = new();
}

View file

@ -0,0 +1,183 @@
#nullable disable
using Ellie.Common.TypeReaders;
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Common.AnimalRacing;
using Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions;
using Ellie.Modules.Gambling.Services;
using Ellie.Modules.Games.Services;
namespace Ellie.Modules.Gambling;
// wth is this, needs full rewrite
public partial class Gambling
{
[Group]
public partial class AnimalRacingCommands : GamblingSubmodule<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 SendErrorAsync(GetText(strs.animal_race), GetText(strs.animal_race_already_started));
ar.Initialize();
var count = 0;
Task ClientMessageReceived(SocketMessage arg)
{
_ = Task.Run(() =>
{
try
{
if (arg.Channel.Id == ctx.Channel.Id)
{
if (ar.CurrentPhase == AnimalRace.Phase.Running && ++count % 9 == 0)
raceMessage = null;
}
}
catch { }
});
return Task.CompletedTask;
}
Task ArOnEnded(AnimalRace race)
{
_client.MessageReceived -= ClientMessageReceived;
_service.AnimalRaces.TryRemove(ctx.Guild.Id, out _);
var winner = race.FinishedUsers[0];
if (race.FinishedUsers[0].Bet > 0)
{
return SendConfirmAsync(GetText(strs.animal_race),
GetText(strs.animal_race_won_money(Format.Bold(winner.Username),
winner.Animal.Icon,
(race.FinishedUsers[0].Bet * (race.Users.Count - 1)) + CurrencySign)));
}
ar.Dispose();
return SendConfirmAsync(GetText(strs.animal_race),
GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon)));
}
ar.OnStartingFailed += Ar_OnStartingFailed;
ar.OnStateUpdate += Ar_OnStateUpdate;
ar.OnEnded += ArOnEnded;
ar.OnStarted += Ar_OnStarted;
_client.MessageReceived += ClientMessageReceived;
return SendConfirmAsync(GetText(strs.animal_race),
GetText(strs.animal_race_starting(options.StartTime)),
footer: GetText(strs.animal_race_join_instr(prefix)));
}
private Task Ar_OnStarted(AnimalRace race)
{
if (race.Users.Count == race.MaxUsers)
return SendConfirmAsync(GetText(strs.animal_race), GetText(strs.animal_race_full));
return SendConfirmAsync(GetText(strs.animal_race),
GetText(strs.animal_race_starting_with_x(race.Users.Count)));
}
private async Task Ar_OnStateUpdate(AnimalRace race)
{
var text = $@"|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|
{string.Join("\n", race.Users.Select(p =>
{
var index = race.FinishedUsers.IndexOf(p);
var extra = index == -1 ? "" : $"#{index + 1} {(index == 0 ? "🏆" : "")}";
return $"{(int)(p.Progress / 60f * 100),-2}%|{new string('‣', p.Progress) + p.Animal.Icon + extra}";
}))}
|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|";
var msg = raceMessage;
if (msg is null)
raceMessage = await SendConfirmAsync(text);
else
{
await msg.ModifyAsync(x => x.Embed = _eb.Create()
.WithTitle(GetText(strs.animal_race))
.WithDescription(text)
.WithOkColor()
.Build());
}
}
private Task Ar_OnStartingFailed(AnimalRace race)
{
_service.AnimalRaces.TryRemove(ctx.Guild.Id, out _);
race.Dispose();
return ReplyErrorLocalizedAsync(strs.animal_race_failed);
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task JoinRace([OverrideTypeReader(typeof(BalanceTypeReader))] long amount = default)
{
if (!await CheckBetOptional(amount))
return;
if (!_service.AnimalRaces.TryGetValue(ctx.Guild.Id, out var ar))
{
await ReplyErrorLocalizedAsync(strs.race_not_exist);
return;
}
try
{
var user = await ar.JoinRace(ctx.User.Id, ctx.User.ToString(), amount);
if (amount > 0)
{
await SendConfirmAsync(GetText(strs.animal_race_join_bet(ctx.User.Mention,
user.Animal.Icon,
amount + CurrencySign)));
}
else
await SendConfirmAsync(GetText(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon)));
}
catch (ArgumentOutOfRangeException)
{
//ignore if user inputed an invalid amount
}
catch (AlreadyJoinedException)
{
// just ignore this
}
catch (AlreadyStartedException)
{
//ignore
}
catch (AnimalRaceFullException)
{
await SendConfirmAsync(GetText(strs.animal_race), GetText(strs.animal_race_full));
}
catch (NotEnoughFundsException)
{
await SendErrorAsync(GetText(strs.not_enough(CurrencySign)));
}
}
}
}

View file

@ -0,0 +1,26 @@
#nullable disable
using Ellie.Modules.Games.Common;
namespace Ellie.Modules.Gambling.Common.AnimalRacing;
public class AnimalRacingUser
{
public long Bet { get; }
public string Username { get; }
public ulong UserId { get; }
public RaceAnimal Animal { get; set; }
public int Progress { get; set; }
public AnimalRacingUser(string username, ulong userId, long bet)
{
Bet = bet;
Username = username;
UserId = userId;
}
public override bool Equals(object obj)
=> obj is AnimalRacingUser x ? x.UserId == UserId : false;
public override int GetHashCode()
=> UserId.GetHashCode();
}

View file

@ -0,0 +1,19 @@
#nullable disable
namespace Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions;
public class AlreadyJoinedException : Exception
{
public AlreadyJoinedException()
{
}
public AlreadyJoinedException(string message)
: base(message)
{
}
public AlreadyJoinedException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,19 @@
#nullable disable
namespace Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions;
public class AlreadyStartedException : Exception
{
public AlreadyStartedException()
{
}
public AlreadyStartedException(string message)
: base(message)
{
}
public AlreadyStartedException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,19 @@
#nullable disable
namespace Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions;
public class AnimalRaceFullException : Exception
{
public AnimalRaceFullException()
{
}
public AnimalRaceFullException(string message)
: base(message)
{
}
public AnimalRaceFullException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,19 @@
#nullable disable
namespace Ellie.Modules.Gambling.Common.AnimalRacing.Exceptions;
public class NotEnoughFundsException : Exception
{
public NotEnoughFundsException()
{
}
public NotEnoughFundsException(string message)
: base(message)
{
}
public NotEnoughFundsException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,16 @@
#nullable disable
using CommandLine;
namespace Ellie.Modules.Gambling.Common.AnimalRacing;
public class RaceOptions : IEllieCommandOptions
{
[Option('s', "start-time", Default = 20, Required = false)]
public int StartTime { get; set; } = 20;
public void NormalizeOptions()
{
if (StartTime is < 10 or > 120)
StartTime = 20;
}
}

View file

@ -0,0 +1,118 @@
using Ellie.Common.TypeReaders;
using Ellie.Modules.Gambling.Bank;
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Services;
namespace Ellie.Modules.Gambling;
public partial class Gambling
{
[Name("Bank")]
[Group("bank")]
public partial class BankCommands : GamblingModule<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 ReplyConfirmLocalizedAsync(strs.bank_deposited(N(amount)));
}
else
{
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
}
}
[Cmd]
public async Task BankWithdraw([OverrideTypeReader(typeof(BankBalanceTypeReader))] long amount)
{
if (amount <= 0)
return;
if (await _bank.WithdrawAsync(ctx.User.Id, amount))
{
await ReplyConfirmLocalizedAsync(strs.bank_withdrew(N(amount)));
}
else
{
await ReplyErrorLocalizedAsync(strs.bank_withdraw_insuff(CurrencySign));
}
}
[Cmd]
public async Task BankBalance()
{
var bal = await _bank.GetBalanceAsync(ctx.User.Id);
var eb = _eb.Create(ctx)
.WithOkColor()
.WithDescription(GetText(strs.bank_balance(N(bal))));
try
{
await ctx.User.EmbedAsync(eb);
await ctx.OkAsync();
}
catch
{
await ReplyErrorLocalizedAsync(strs.cant_dm);
}
}
private async Task BankTakeInternalAsync(long amount, ulong userId)
{
if (await _bank.TakeAsync(userId, amount))
{
await ctx.OkAsync();
return;
}
await ReplyErrorLocalizedAsync(strs.take_fail(N(amount),
_client.GetUser(userId)?.ToString()
?? userId.ToString(),
CurrencySign));
}
private async Task BankAwardInternalAsync(long amount, ulong userId)
{
if (await _bank.AwardAsync(userId, amount))
{
await ctx.OkAsync();
return;
}
}
[Cmd]
[OwnerOnly]
[Priority(1)]
public async Task BankTake(long amount, [Leftover] IUser user)
=> await BankTakeInternalAsync(amount, user.Id);
[Cmd]
[OwnerOnly]
[Priority(0)]
public async Task BankTake(long amount, ulong userId)
=> await BankTakeInternalAsync(amount, userId);
[Cmd]
[OwnerOnly]
public async Task BankAward(long amount, [Leftover] IUser user)
=> await BankAwardInternalAsync(amount, user.Id);
}
}

View file

@ -0,0 +1,119 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Ellie.Db.Models;
namespace Ellie.Modules.Gambling.Bank;
public sealed class BankService : IBankService, IEService
{
private readonly ICurrencyService _cur;
private readonly DbService _db;
public BankService(ICurrencyService cur, DbService db)
{
_cur = cur;
_db = db;
}
public async Task<bool> AwardAsync(ulong userId, long amount)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(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)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(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)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount));
if (!await _cur.RemoveAsync(userId, amount, new("bank", "deposit")))
return false;
await using var ctx = _db.GetDbContext();
await ctx.Set<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)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(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 Ellie.Common.TypeReaders;
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Common.Blackjack;
using Ellie.Modules.Gambling.Services;
namespace Ellie.Modules.Gambling;
public partial class Gambling
{
public partial class BlackJackCommands : GamblingSubmodule<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 ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
return;
}
bj.StateUpdated += Bj_StateUpdated;
bj.GameEnded += Bj_GameEnded;
bj.Start();
await ReplyConfirmLocalizedAsync(strs.bj_created);
}
else
{
if (await bj.Join(ctx.User, amount))
await ReplyConfirmLocalizedAsync(strs.bj_joined);
else
{
Log.Information("{User} can't join a blackjack game as it's in {BlackjackState} state already",
ctx.User,
bj.State);
}
}
await ctx.Message.DeleteAsync();
}
private Task Bj_GameEnded(Blackjack arg)
{
_service.Games.TryRemove(ctx.Channel.Id, out _);
return Task.CompletedTask;
}
private async Task Bj_StateUpdated(Blackjack bj)
{
try
{
if (msg is not null)
_ = msg.DeleteAsync();
var c = bj.Dealer.Cards.Select(x => x.GetEmojiString())
.ToList();
var dealerIcon = "❔ ";
if (bj.State == Blackjack.GameState.Ended)
{
if (bj.Dealer.GetHandValue() == 21)
dealerIcon = "💰 ";
else if (bj.Dealer.GetHandValue() > 21)
dealerIcon = "💥 ";
else
dealerIcon = "🏁 ";
}
var cStr = string.Concat(c.Select(x => x[..^1] + " "));
cStr += "\n" + string.Concat(c.Select(x => x.Last() + " "));
var embed = _eb.Create()
.WithOkColor()
.WithTitle("BlackJack")
.AddField($"{dealerIcon} Dealer's Hand | Value: {bj.Dealer.GetHandValue()}", cStr);
if (bj.CurrentUser is not null)
embed.WithFooter($"Player to make a choice: {bj.CurrentUser.DiscordUser}");
foreach (var p in bj.Players)
{
c = p.Cards.Select(x => x.GetEmojiString()).ToList();
cStr = "-\t" + string.Concat(c.Select(x => x[..^1] + " "));
cStr += "\n-\t" + string.Concat(c.Select(x => x.Last() + " "));
var full = $"{p.DiscordUser.ToString().TrimTo(20)} | Bet: {N(p.Bet)} | Value: {p.GetHandValue()}";
if (bj.State == Blackjack.GameState.Ended)
{
if (p.State == User.UserState.Lost)
full = "❌ " + full;
else
full = "✅ " + full;
}
else if (p == bj.CurrentUser)
full = "▶ " + full;
else if (p.State == User.UserState.Stand)
full = "⏹ " + full;
else if (p.State == User.UserState.Bust)
full = "💥 " + full;
else if (p.State == User.UserState.Blackjack)
full = "💰 " + full;
embed.AddField(full, cStr);
}
msg = await ctx.Channel.EmbedAsync(embed);
}
catch
{
}
}
private string UserToString(User x)
{
var playerName = x.State == User.UserState.Bust
? Format.Strikethrough(x.DiscordUser.ToString().TrimTo(30))
: x.DiscordUser.ToString();
// var hand = $"{string.Concat(x.Cards.Select(y => "〖" + y.GetEmojiString() + "〗"))}";
return $"{playerName} | Bet: {x.Bet}\n";
}
[Cmd]
[RequireContext(ContextType.Guild)]
public Task Hit()
=> InternalBlackJack(BjAction.Hit);
[Cmd]
[RequireContext(ContextType.Guild)]
public Task Stand()
=> InternalBlackJack(BjAction.Stand);
[Cmd]
[RequireContext(ContextType.Guild)]
public Task Double()
=> InternalBlackJack(BjAction.Double);
private async Task InternalBlackJack(BjAction a)
{
if (!_service.Games.TryGetValue(ctx.Channel.Id, out var bj))
return;
if (a == BjAction.Hit)
await bj.Hit(ctx.User);
else if (a == BjAction.Stand)
await bj.Stand(ctx.User);
else if (a == BjAction.Double)
{
if (!await bj.Double(ctx.User))
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
}
await ctx.Message.DeleteAsync();
}
}
}

View file

@ -0,0 +1,9 @@
#nullable disable
using Ellie.Modules.Gambling.Common.Blackjack;
namespace Ellie.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 Ellie.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 #NadekoLog SERVER PLEASE");
State = GameState.Ended;
_ = GameEnded?.Invoke(this);
}
}
private async Task PromptUserMove(User usr)
{
using var cts = new CancellationTokenSource();
var pause = Task.Delay(20000, cts.Token); //10 seconds to decide
CurrentUser = usr;
currentUserMove = new();
await PrintState();
// either wait for the user to make an action and
// if he doesn't - stand
var finished = await Task.WhenAny(pause, currentUserMove.Task);
if (finished == pause)
await Stand(usr);
else
cts.Cancel();
CurrentUser = null;
currentUserMove = null;
}
public async Task<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,58 @@
#nullable disable
using Ellie.Econ;
namespace Ellie.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)
{
if (bet <= 0)
throw new ArgumentOutOfRangeException(nameof(bet));
Bet = bet;
DiscordUser = user;
}
}

View file

@ -0,0 +1,409 @@
#nullable disable
using CommandLine;
using System.Collections.Immutable;
namespace Ellie.Modules.Gambling.Common.Connect4;
public sealed class Connect4Game : IDisposable
{
public enum Field //temporary most likely
{
Empty,
P1,
P2
}
public enum Phase
{
Joining, // waiting for second player to join
P1Move,
P2Move,
Ended
}
public enum Result
{
Draw,
CurrentPlayerWon,
OtherPlayerWon
}
public const int NUMBER_OF_COLUMNS = 7;
public const int NUMBER_OF_ROWS = 6;
//public event Func<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,204 @@
#nullable disable
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Common.Connect4;
using Ellie.Modules.Gambling.Services;
using System.Text;
namespace Ellie.Modules.Gambling;
public partial class Gambling
{
[Group]
public partial class Connect4Commands : GamblingSubmodule<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 ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
_service.Connect4Games.TryRemove(ctx.Channel.Id, out _);
game.Dispose();
return;
}
}
game.OnGameStateUpdated += Game_OnGameStateUpdated;
game.OnGameFailedToStart += GameOnGameFailedToStart;
game.OnGameEnded += GameOnGameEnded;
_client.MessageReceived += ClientMessageReceived;
game.Initialize();
if (options.Bet == 0)
await ReplyConfirmLocalizedAsync(strs.connect4_created);
else
await ReplyErrorLocalizedAsync(strs.connect4_created_bet(N(options.Bet)));
Task ClientMessageReceived(SocketMessage arg)
{
if (ctx.Channel.Id != arg.Channel.Id)
return Task.CompletedTask;
_ = Task.Run(async () =>
{
var success = false;
if (int.TryParse(arg.Content, out var col))
success = await game.Input(arg.Author.Id, col);
if (success)
{
try { await arg.DeleteAsync(); }
catch { }
}
else
{
if (game.CurrentPhase is Connect4Game.Phase.Joining or Connect4Game.Phase.Ended)
return;
RepostCounter++;
if (RepostCounter == 0)
{
try { msg = await ctx.Channel.SendMessageAsync("", embed: (Embed)msg.Embeds.First()); }
catch { }
}
}
});
return Task.CompletedTask;
}
Task GameOnGameFailedToStart(Connect4Game arg)
{
if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose))
{
_client.MessageReceived -= ClientMessageReceived;
toDispose.Dispose();
}
return ErrorLocalizedAsync(strs.connect4_failed_to_start);
}
Task GameOnGameEnded(Connect4Game arg, Connect4Game.Result result)
{
if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose))
{
_client.MessageReceived -= ClientMessageReceived;
toDispose.Dispose();
}
string title;
if (result == Connect4Game.Result.CurrentPlayerWon)
{
title = GetText(strs.connect4_won(Format.Bold(arg.CurrentPlayer.Username),
Format.Bold(arg.OtherPlayer.Username)));
}
else if (result == Connect4Game.Result.OtherPlayerWon)
{
title = GetText(strs.connect4_won(Format.Bold(arg.OtherPlayer.Username),
Format.Bold(arg.CurrentPlayer.Username)));
}
else
title = GetText(strs.connect4_draw);
return msg.ModifyAsync(x => x.Embed = _eb.Create()
.WithTitle(title)
.WithDescription(GetGameStateText(game))
.WithOkColor()
.Build());
}
}
private async Task Game_OnGameStateUpdated(Connect4Game game)
{
var embed = _eb.Create()
.WithTitle($"{game.CurrentPlayer.Username} vs {game.OtherPlayer.Username}")
.WithDescription(GetGameStateText(game))
.WithOkColor();
if (msg is null)
msg = await ctx.Channel.EmbedAsync(embed);
else
await msg.ModifyAsync(x => x.Embed = embed.Build());
}
private string GetGameStateText(Connect4Game game)
{
var sb = new StringBuilder();
if (game.CurrentPhase is Connect4Game.Phase.P1Move or Connect4Game.Phase.P2Move)
sb.AppendLine(GetText(strs.connect4_player_to_move(Format.Bold(game.CurrentPlayer.Username))));
for (var i = Connect4Game.NUMBER_OF_ROWS; i > 0; i--)
{
for (var j = 0; j < Connect4Game.NUMBER_OF_COLUMNS; j++)
{
var cur = game.GameState[i + (j * Connect4Game.NUMBER_OF_ROWS) - 1];
if (cur == Connect4Game.Field.Empty)
sb.Append("⚫"); //black circle
else if (cur == Connect4Game.Field.P1)
sb.Append("🔴"); //red circle
else
sb.Append("🔵"); //blue circle
}
sb.AppendLine();
}
for (var i = 0; i < Connect4Game.NUMBER_OF_COLUMNS; i++)
sb.Append(_numbers[i]);
return sb.ToString();
}
}
}

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 Ellie.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 = _eb.Create(ctx)
.WithOkColor()
.WithAuthor(ctx.User)
.AddField(GetText(strs.roll2), gen)
.WithImageUrl($"attachment://{fileName}");
await ctx.Channel.SendFileAsync(ms,
fileName,
embed: eb.Build());
}
[Cmd]
[Priority(1)]
public async Task Roll(int num)
=> await InternalRoll(num, true);
[Cmd]
[Priority(1)]
public async Task Rolluo(int num = 1)
=> await InternalRoll(num, false);
[Cmd]
[Priority(0)]
public async Task Roll(string arg)
=> await InternallDndRoll(arg, true);
[Cmd]
[Priority(0)]
public async Task Rolluo(string arg)
=> await InternallDndRoll(arg, false);
private async Task InternalRoll(int num, bool ordered)
{
if (num is < 1 or > 30)
{
await ReplyErrorLocalizedAsync(strs.dice_invalid_number(1, 30));
return;
}
var rng = new EllieRandom();
var dice = new List<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 = _eb.Create(ctx)
.WithOkColor()
.WithAuthor(ctx.User)
.AddField(GetText(strs.rolls), values.Select(x => Format.Code(x.ToString())).Join(' '), true)
.AddField(GetText(strs.total), values.Sum(), true)
.WithDescription(GetText(strs.dice_rolled_num(Format.Bold(values.Count.ToString()))))
.WithImageUrl($"attachment://{imageName}");
await ctx.Channel.SendFileAsync(ms,
imageName,
embed: eb.Build());
}
private async Task InternallDndRoll(string arg, bool ordered)
{
Match match;
if ((match = _fudgeRegex.Match(arg)).Length != 0
&& int.TryParse(match.Groups["n1"].ToString(), out var n1)
&& n1 is > 0 and < 500)
{
var rng = new EllieRandom();
var rolls = new List<char>();
for (var i = 0; i < n1; i++)
rolls.Add(_fateRolls[rng.Next(0, _fateRolls.Length)]);
var embed = _eb.Create()
.WithOkColor()
.WithAuthor(ctx.User)
.WithDescription(GetText(strs.dice_rolled_num(Format.Bold(n1.ToString()))))
.AddField(Format.Bold("Result"),
string.Join(" ", rolls.Select(c => Format.Code($"[{c}]"))));
await ctx.Channel.EmbedAsync(embed);
}
else if ((match = _dndRegex.Match(arg)).Length != 0)
{
var rng = new EllieRandom();
if (int.TryParse(match.Groups["n1"].ToString(), out n1)
&& int.TryParse(match.Groups["n2"].ToString(), out var n2)
&& n1 <= 50
&& n2 <= 100000
&& n1 > 0
&& n2 > 0)
{
if (!int.TryParse(match.Groups["add"].Value, out var add))
add = 0;
if (!int.TryParse(match.Groups["sub"].Value, out var sub))
sub = 0;
var arr = new int[n1];
for (var i = 0; i < n1; i++)
arr[i] = rng.Next(1, n2 + 1);
var sum = arr.Sum();
var embed = _eb.Create()
.WithOkColor()
.WithAuthor(ctx.User)
.WithDescription(GetText(strs.dice_rolled_num(n1 + $"`1 - {n2}`")))
.AddField(Format.Bold(GetText(strs.rolls)),
string.Join(" ",
(ordered ? arr.OrderBy(x => x).AsEnumerable() : arr).Select(x
=> Format.Code(x.ToString()))))
.AddField(Format.Bold("Sum"),
sum + " + " + add + " - " + sub + " = " + (sum + add - sub));
await ctx.Channel.EmbedAsync(embed);
}
}
}
[Cmd]
public async Task NRoll([Leftover] string range)
{
int rolled;
if (range.Contains("-"))
{
var arr = range.Split('-').Take(2).Select(int.Parse).ToArray();
if (arr[0] > arr[1])
{
await ReplyErrorLocalizedAsync(strs.second_larger_than_first);
return;
}
rolled = new EllieRandom().Next(arr[0], arr[1] + 1);
}
else
rolled = new EllieRandom().Next(0, int.Parse(range) + 1);
await ReplyConfirmLocalizedAsync(strs.dice_rolled(Format.Bold(rolled.ToString())));
}
private async Task<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 Ellie.Common.TypeReaders;
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Services;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Image = SixLabors.ImageSharp.Image;
namespace Ellie.Modules.Gambling;
public partial class Gambling
{
[Group]
public partial class DrawCommands : GamblingSubmodule<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 ReplyErrorLocalizedAsync(strs.no_more_cards);
}
catch
{
// ignored
}
break;
}
var currentCard = cards.Draw();
cardObjects.Add(currentCard);
var image = await GetCardImageAsync(currentCard);
images.Add(image);
}
var imgName = "cards.jpg";
using var img = images.Merge();
foreach (var i in images)
i.Dispose();
var eb = _eb.Create(ctx)
.WithOkColor();
var toSend = string.Empty;
if (cardObjects.Count == 5)
eb.AddField(GetText(strs.hand_value), Deck.GetHandValue(cardObjects), true);
if (guildId is not null)
toSend += GetText(strs.cards_left(Format.Bold(cards.CardPool.Count.ToString())));
eb.WithDescription(toSend)
.WithAuthor(ctx.User)
.WithImageUrl($"attachment://{imgName}");
if (count > 1)
eb.AddField(GetText(strs.cards), count.ToString(), true);
await using var imageStream = await img.ToStreamAsync();
await ctx.Channel.SendFileAsync(imageStream,
imgName,
embed: eb.Build());
}
private async Task<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 ReplyConfirmLocalizedAsync(strs.deck_reshuffled);
}
[Cmd]
[RequireContext(ContextType.Guild)]
public Task BetDraw([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, InputValueGuess val, InputColorGuess? col = null)
=> BetDrawInternal(amount, val, col);
[Cmd]
[RequireContext(ContextType.Guild)]
public Task BetDraw([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, InputColorGuess col, InputValueGuess? val = null)
=> BetDrawInternal(amount, val, col);
public async Task BetDrawInternal(long amount, InputValueGuess? val, InputColorGuess? col)
{
if (amount <= 0)
return;
var res = await _service.BetDrawAsync(ctx.User.Id,
amount,
(byte?)val,
(byte?)col);
if (!res.TryPickT0(out var result, out _))
{
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
return;
}
var eb = _eb.Create(ctx)
.WithOkColor()
.WithAuthor(ctx.User)
.WithDescription(result.Card.GetEmoji())
.AddField(GetText(strs.guess), GetGuessInfo(val, col), true)
.AddField(GetText(strs.card), GetCardInfo(result.Card), true)
.AddField(GetText(strs.won), N((long)result.Won), false)
.WithImageUrl("attachment://card.png");
using var img = await GetCardImageAsync(result.Card);
await using var imgStream = await img.ToStreamAsync();
await ctx.Channel.SendFileAsync(imgStream, "card.png", embed: eb.Build());
}
private string GetGuessInfo(InputValueGuess? valG, InputColorGuess? colG)
{
var val = valG switch
{
InputValueGuess.H => "Hi ⬆️",
InputValueGuess.L => "Lo ⬇️",
_ => "❓"
};
var col = colG switch
{
InputColorGuess.Red => "R 🔴",
InputColorGuess.Black => "B ⚫",
_ => "❓"
};
return $"{val} / {col}";
}
private string GetCardInfo(RegularCard card)
{
var val = (int)card.Value switch
{
< 7 => "Lo ⬇️",
> 7 => "Hi ⬆️",
_ => "7 💀"
};
var col = card.Value == RegularValue.Seven
? "7 💀"
: card.Suit switch
{
RegularSuit.Diamonds or RegularSuit.Hearts => "R 🔴",
_ => "B ⚫"
};
return $"{val} / {col}";
}
public enum InputValueGuess
{
High = 0,
H = 0,
Hi = 0,
Low = 1,
L = 1,
Lo = 1,
}
public enum InputColorGuess
{
R = 0,
Red = 0,
B = 1,
Bl = 1,
Black = 1,
}
}
}

View file

@ -0,0 +1,12 @@
#nullable disable
namespace Ellie.Modules.Gambling.Services;
public sealed class EconomyResult
{
public decimal Cash { get; init; }
public decimal Planted { get; init; }
public decimal Waifus { get; init; }
public decimal OnePercent { get; init; }
public decimal Bank { get; init; }
public long Bot { get; init; }
}

View file

@ -0,0 +1,60 @@
#nullable disable
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Common.Events;
using Ellie.Modules.Gambling.Services;
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Gambling;
public partial class Gambling
{
[Group]
public partial class CurrencyEventsCommands : GamblingSubmodule<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 ReplyErrorLocalizedAsync(strs.start_event_fail);
}
private IEmbedBuilder GetEmbed(CurrencyEvent.Type type, EventOptions opts, long currentPot)
=> type switch
{
CurrencyEvent.Type.Reaction => _eb.Create()
.WithOkColor()
.WithTitle(GetText(strs.event_title(type.ToString())))
.WithDescription(GetReactionDescription(opts.Amount, currentPot))
.WithFooter(GetText(strs.event_duration_footer(opts.Hours))),
CurrencyEvent.Type.GameStatus => _eb.Create()
.WithOkColor()
.WithTitle(GetText(strs.event_title(type.ToString())))
.WithDescription(GetGameStatusDescription(opts.Amount, currentPot))
.WithFooter(GetText(strs.event_duration_footer(opts.Hours))),
_ => throw new ArgumentOutOfRangeException(nameof(type))
};
private string GetReactionDescription(long amount, long potSize)
{
var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : N(potSize));
return GetText(strs.new_reaction_event(CurrencySign, Format.Bold(N(amount)), potSizeStr));
}
private string GetGameStatusDescription(long amount, long potSize)
{
var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : potSize + CurrencySign);
return GetText(strs.new_gamestatus_event(CurrencySign, Format.Bold(N(amount)), potSizeStr));
}
}
}

View file

@ -0,0 +1,67 @@
#nullable disable
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Common.Events;
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Gambling.Services;
public class CurrencyEventsService : IEService
{
private readonly DiscordSocketClient _client;
private readonly ICurrencyService _cs;
private readonly GamblingConfigService _configService;
private readonly ConcurrentDictionary<ulong, ICurrencyEvent> _events = new();
public CurrencyEventsService(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService configService)
{
_client = client;
_cs = cs;
_configService = configService;
}
public async Task<bool> TryCreateEventAsync(
ulong guildId,
ulong channelId,
CurrencyEvent.Type type,
EventOptions opts,
Func<CurrencyEvent.Type, EventOptions, long, IEmbedBuilder> embed)
{
var g = _client.GetGuild(guildId);
if (g?.GetChannel(channelId) is not ITextChannel ch)
return false;
ICurrencyEvent ce;
if (type == CurrencyEvent.Type.Reaction)
ce = new ReactionEvent(_client, _cs, g, ch, opts, _configService.Data, embed);
else if (type == CurrencyEvent.Type.GameStatus)
ce = new GameStatusEvent(_client, _cs, g, ch, opts, embed);
else
return false;
var added = _events.TryAdd(guildId, ce);
if (added)
{
try
{
ce.OnEnded += OnEventEnded;
await ce.StartEvent();
}
catch (Exception ex)
{
Log.Warning(ex, "Error starting event");
_events.TryRemove(guildId, out ce);
return false;
}
}
return added;
}
private Task OnEventEnded(ulong gid)
{
_events.TryRemove(gid, out _);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,39 @@
#nullable disable
using CommandLine;
namespace Ellie.Modules.Gambling.Common.Events;
public class EventOptions : IEllieCommandOptions
{
[Option('a', "amount", Required = false, Default = 100, HelpText = "Amount of currency each user receives.")]
public long Amount { get; set; } = 100;
[Option('p',
"pot-size",
Required = false,
Default = 0,
HelpText = "The maximum amount of currency that can be rewarded. 0 means no limit.")]
public long PotSize { get; set; }
//[Option('t', "type", Required = false, Default = "reaction", HelpText = "Type of the event. reaction, gamestatus or joinserver.")]
//public string TypeString { get; set; } = "reaction";
[Option('d',
"duration",
Required = false,
Default = 24,
HelpText = "Number of hours the event should run for. Default 24.")]
public int Hours { get; set; } = 24;
public void NormalizeOptions()
{
if (Amount < 0)
Amount = 100;
if (PotSize < 0)
PotSize = 0;
if (Hours <= 0)
Hours = 24;
if (PotSize != 0 && PotSize < Amount)
PotSize = 0;
}
}

View file

@ -0,0 +1,195 @@
#nullable disable
using Ellie.Services.Database.Models;
using System.Collections.Concurrent;
namespace Ellie.Modules.Gambling.Common.Events;
public class GameStatusEvent : ICurrencyEvent
{
public event Func<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, IEmbedBuilder> _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();
public GameStatusEvent(
DiscordSocketClient client,
ICurrencyService cs,
SocketGuild g,
ITextChannel ch,
EventOptions opt,
Func<CurrencyEvent.Type, EventOptions, long, IEmbedBuilder> embedFunc)
{
_client = client;
_guild = g;
_cs = cs;
_amount = opt.Amount;
PotSize = opt.PotSize;
_embedFunc = embedFunc;
_isPotLimited = PotSize > 0;
_channel = ch;
_opts = opt;
// generate code
_code = new(_sneakyGameStatusChars.Shuffle().Take(5).ToArray());
_t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2));
if (_opts.Hours > 0)
_timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan);
}
private void EventTimeout(object state)
=> _ = StopEvent();
private async void OnTimerTick(object state)
{
var potEmpty = PotEmptied;
var toAward = new List<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 _channel.EmbedAsync(GetEmbed(_opts.PotSize));
await _client.SetGameAsync(_code);
_client.MessageDeleted += OnMessageDeleted;
_client.MessageReceived += HandleMessage;
_t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
}
private IEmbedBuilder GetEmbed(long pot)
=> _embedFunc(CurrencyEvent.Type.GameStatus, _opts, pot);
private async Task OnMessageDeleted(Cacheable<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 Ellie.Modules.Gambling.Common;
public interface ICurrencyEvent
{
event Func<ulong, Task> OnEnded;
Task StopEvent();
Task StartEvent();
}

View file

@ -0,0 +1,194 @@
#nullable disable
using Ellie.Services.Database.Models;
namespace Ellie.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, IEmbedBuilder> _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();
public ReactionEvent(
DiscordSocketClient client,
ICurrencyService cs,
SocketGuild g,
ITextChannel ch,
EventOptions opt,
GamblingConfig config,
Func<CurrencyEvent.Type, EventOptions, long, IEmbedBuilder> embedFunc)
{
_client = client;
_guild = g;
_cs = cs;
_amount = opt.Amount;
PotSize = opt.PotSize;
_embedFunc = embedFunc;
_isPotLimited = PotSize > 0;
_channel = ch;
_noRecentlyJoinedServer = false;
_opts = opt;
_config = config;
_t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2));
if (_opts.Hours > 0)
_timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan);
}
private void EventTimeout(object state)
=> _ = StopEvent();
private async void OnTimerTick(object state)
{
var potEmpty = PotEmptied;
var toAward = new List<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 _channel.EmbedAsync(GetEmbed(_opts.PotSize));
await msg.AddReactionAsync(emote);
_client.MessageDeleted += OnMessageDeleted;
_client.ReactionAdded += HandleReaction;
_t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
}
private IEmbedBuilder GetEmbed(long pot)
=> _embedFunc(CurrencyEvent.Type.Reaction, _opts, pot);
private async Task OnMessageDeleted(Cacheable<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 Ellie.Common.TypeReaders;
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Services;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Image = SixLabors.ImageSharp.Image;
namespace Ellie.Modules.Gambling;
public partial class Gambling
{
[Group]
public partial class FlipCoinCommands : GamblingSubmodule<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 ReplyErrorLocalizedAsync(strs.flip_invalid(10));
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 = _eb.Create(ctx)
.WithOkColor()
.WithAuthor(ctx.User)
.WithDescription(msg)
.WithImageUrl($"attachment://{imgName}");
await ctx.Channel.SendFileAsync(stream,
imgName,
embed: eb.Build());
}
[Cmd]
public async Task Betflip([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, BetFlipGuess guess)
{
if (!await CheckBetMandatory(amount) || amount == 1)
return;
var res = await _service.BetFlipAsync(ctx.User.Id, amount, (byte)guess);
if (!res.TryPickT0(out var result, out _))
{
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
return;
}
Uri imageToSend;
var coins = _ic.Data.Coins;
if (result.Side == 0)
{
imageToSend = coins.Heads[_rng.Next(0, coins.Heads.Length)];
}
else
{
imageToSend = coins.Tails[_rng.Next(0, coins.Tails.Length)];
}
string str;
var won = (long)result.Won;
if (won > 0)
{
str = Format.Bold(GetText(strs.flip_guess(N(won))));
}
else
{
str = Format.Bold(GetText(strs.better_luck));
}
await ctx.Channel.EmbedAsync(_eb.Create()
.WithAuthor(ctx.User)
.WithDescription(str)
.WithOkColor()
.WithImageUrl(imageToSend.ToString()));
}
}
}

View file

@ -0,0 +1,7 @@
namespace Ellie.Econ.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,387 @@
#nullable disable
using Cloneable;
using Ellie.Common.Yml;
using SixLabors.ImageSharp.PixelFormats;
using YamlDotNet.Serialization;
using Color = SixLabors.ImageSharp.Color;
namespace Ellie.Modules.Gambling.Common;
[Cloneable]
public sealed partial class GamblingConfig : ICloneable<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("""Settings for LuckyLadder command""")]
public LuckyLadderSettings LuckyLadder { get; set; }
[Comment("""Settings related to waifus""")]
public WaifuConfig Waifu { get; set; }
[Comment("""
Amount of currency selfhosters will get PER pledged dollar CENT.
1 = 100 currency per $. Used almost exclusively on public nadeko.
""")]
public decimal PatreonCurrencyPerCent { get; set; } = 1;
[Comment("""
Currency reward per vote.
This will work only if you've set up VotesApi and correct credentials for topgg and/or discords voting
""")]
public long VoteReward { get; set; } = 100;
[Comment("""Slot config""")]
public SlotsConfig Slots { get; set; }
public GamblingConfig()
{
BetRoll = new();
Waifu = new();
Currency = new();
BetFlip = new();
Generation = new();
Timely = new();
Decay = new();
Slots = new();
LuckyLadder = new();
}
}
public class CurrencyConfig
{
[Comment("""What is the emoji/character which represents the currency""")]
public string Sign { get; set; } = "🌸";
[Comment("""What is the name of the currency""")]
public string Name { get; set; } = "Nadeko Flower";
[Comment("""
For how long (in days) will the transactions be kept in the database (curtrs)
Set 0 to disable cleanup (keep transactions forever)
""")]
public int TransactionsLifetime { get; set; } = 0;
}
[Cloneable]
public partial class TimelyConfig
{
[Comment("""
How much currency will the users get every time they run .timely command
setting to 0 or less will disable this feature
""")]
public int Amount { get; set; } = 0;
[Comment("""
How often (in hours) can users claim currency with .timely command
setting to 0 or less will disable this feature
""")]
public int Cooldown { get; set; } = 24;
}
[Cloneable]
public partial class BetFlipConfig
{
[Comment("""Bet multiplier if user guesses correctly""")]
public decimal Multiplier { get; set; } = 1.95M;
}
[Cloneable]
public partial class BetRollConfig
{
[Comment("""
When betroll is played, user will roll a number 0-100.
This setting will describe which multiplier is used for when the roll is higher than the given number.
Doesn't have to be ordered.
""")]
public BetRollPair[] Pairs { get; set; } = Array.Empty<BetRollPair>();
public BetRollConfig()
=> Pairs = new BetRollPair[]
{
new()
{
WhenAbove = 99,
MultiplyBy = 10
},
new()
{
WhenAbove = 90,
MultiplyBy = 4
},
new()
{
WhenAbove = 66,
MultiplyBy = 2
}
};
}
[Cloneable]
public partial class GenerationConfig
{
[Comment("""
when currency is generated, should it also have a random password
associated with it which users have to type after the .pick command
in order to get it
""")]
public bool HasPassword { get; set; } = true;
[Comment("""
Every message sent has a certain % chance to generate the currency
specify the percentage here (1 being 100%, 0 being 0% - for example
default is 0.02, which is 2%
""")]
public decimal Chance { get; set; } = 0.02M;
[Comment("""How many seconds have to pass for the next message to have a chance to spawn currency""")]
public int GenCooldown { get; set; } = 10;
[Comment("""Minimum amount of currency that can spawn""")]
public int MinAmount { get; set; } = 1;
[Comment("""
Maximum amount of currency that can spawn.
Set to the same value as MinAmount to always spawn the same amount
""")]
public int MaxAmount { get; set; } = 1;
}
[Cloneable]
public partial class DecayConfig
{
[Comment("""
Percentage of user's current currency which will be deducted every 24h.
0 - 1 (1 is 100%, 0.5 50%, 0 disabled)
""")]
public decimal Percent { get; set; } = 0;
[Comment("""Maximum amount of user's currency that can decay at each interval. 0 for unlimited.""")]
public int MaxDecay { get; set; } = 0;
[Comment("""Only users who have more than this amount will have their currency decay.""")]
public int MinThreshold { get; set; } = 99;
[Comment("""How often, in hours, does the decay run. Default is 24 hours""")]
public int HourInterval { get; set; } = 24;
}
[Cloneable]
public partial class LuckyLadderSettings
{
[Comment("""Self-Explanatory. Has to have 8 values, otherwise the command won't work.""")]
public decimal[] Multipliers { get; set; }
public LuckyLadderSettings()
=> Multipliers = new[] { 2.4M, 1.7M, 1.5M, 1.2M, 0.5M, 0.3M, 0.2M, 0.1M };
}
[Cloneable]
public sealed partial class WaifuConfig
{
[Comment("""Minimum price a waifu can have""")]
public long MinPrice { get; set; } = 50;
public MultipliersData Multipliers { get; set; } = new();
[Comment("""
Settings for periodic waifu price decay.
Waifu price decays only if the waifu has no claimer.
""")]
public WaifuDecayConfig Decay { get; set; } = new();
[Comment("""
List of items available for gifting.
If negative is true, gift will instead reduce waifu value.
""")]
public List<WaifuItemModel> Items { get; set; } = new();
public WaifuConfig()
=> Items = new()
{
new("🥔", 5, "Potato"),
new("🍪", 10, "Cookie"),
new("🥖", 20, "Bread"),
new("🍭", 30, "Lollipop"),
new("🌹", 50, "Rose"),
new("🍺", 70, "Beer"),
new("🌮", 85, "Taco"),
new("💌", 100, "LoveLetter"),
new("🥛", 125, "Milk"),
new("🍕", 150, "Pizza"),
new("🍫", 200, "Chocolate"),
new("🍦", 250, "Icecream"),
new("🍣", 300, "Sushi"),
new("🍚", 400, "Rice"),
new("🍉", 500, "Watermelon"),
new("🍱", 600, "Bento"),
new("🎟", 800, "MovieTicket"),
new("🍰", 1000, "Cake"),
new("📔", 1500, "Book"),
new("🐱", 2000, "Cat"),
new("🐶", 2001, "Dog"),
new("🐼", 2500, "Panda"),
new("💄", 3000, "Lipstick"),
new("👛", 3500, "Purse"),
new("📱", 4000, "iPhone"),
new("👗", 4500, "Dress"),
new("💻", 5000, "Laptop"),
new("🎻", 7500, "Violin"),
new("🎹", 8000, "Piano"),
new("🚗", 9000, "Car"),
new("💍", 10000, "Ring"),
new("🛳", 12000, "Ship"),
new("🏠", 15000, "House"),
new("🚁", 20000, "Helicopter"),
new("🚀", 30000, "Spaceship"),
new("🌕", 50000, "Moon")
};
public class WaifuDecayConfig
{
[Comment("""
Percentage (0 - 100) of the waifu value to reduce.
Set 0 to disable
For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$)
""")]
public int Percent { get; set; } = 0;
[Comment("""How often to decay waifu values, in hours""")]
public int HourInterval { get; set; } = 24;
[Comment("""
Minimum waifu price required for the decay to be applied.
For example if this value is set to 300, any waifu with the price 300 or less will not experience decay.
""")]
public long MinPrice { get; set; } = 300;
}
}
[Cloneable]
public sealed partial class MultipliersData
{
[Comment("""
Multiplier for waifureset. Default 150.
Formula (at the time of writing this):
price = (waifu_price * 1.25f) + ((number_of_divorces + changes_of_heart + 2) * WaifuReset) rounded up
""")]
public int WaifuReset { get; set; } = 150;
[Comment("""
The minimum amount of currency that you have to pay
in order to buy a waifu who doesn't have a crush on you.
Default is 1.1
Example: If a waifu is worth 100, you will have to pay at least 100 * NormalClaim currency to claim her.
(100 * 1.1 = 110)
""")]
public decimal NormalClaim { get; set; } = 1.1m;
[Comment("""
The minimum amount of currency that you have to pay
in order to buy a waifu that has a crush on you.
Default is 0.88
Example: If a waifu is worth 100, you will have to pay at least 100 * CrushClaim currency to claim her.
(100 * 0.88 = 88)
""")]
public decimal CrushClaim { get; set; } = 0.88M;
[Comment("""
When divorcing a waifu, her new value will be her current value multiplied by this number.
Default 0.75 (meaning will lose 25% of her value)
""")]
public decimal DivorceNewValue { get; set; } = 0.75M;
[Comment("""
All gift prices will be multiplied by this number.
Default 1 (meaning no effect)
""")]
public decimal AllGiftPrices { get; set; } = 1.0M;
[Comment("""
What percentage of the value of the gift will a waifu gain when she's gifted.
Default 0.95 (meaning 95%)
Example: If a waifu is worth 1000, and she receives a gift worth 100, her new value will be 1095)
""")]
public decimal GiftEffect { get; set; } = 0.95M;
[Comment("""
What percentage of the value of the gift will a waifu lose when she's gifted a gift marked as 'negative'.
Default 0.5 (meaning 50%)
Example: If a waifu is worth 1000, and she receives a negative gift worth 100, her new value will be 950)
""")]
public decimal NegativeGiftEffect { get; set; } = 0.50M;
}
public sealed class SlotsConfig
{
[Comment("""Hex value of the color which the numbers on the slot image will have.""")]
public Rgba32 CurrencyFontColor { get; set; } = Color.Red;
}
[Cloneable]
public sealed partial class WaifuItemModel
{
public string ItemEmoji { get; set; }
public long Price { get; set; }
public string Name { get; set; }
[YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)]
public bool Negative { get; set; }
public WaifuItemModel()
{
}
public WaifuItemModel(
string itemEmoji,
long price,
string name,
bool negative = false)
{
ItemEmoji = itemEmoji;
Price = price;
Name = name;
Negative = negative;
}
public override string ToString()
=> Name;
}
[Cloneable]
public sealed partial class BetRollPair
{
public int WhenAbove { get; set; }
public float MultiplyBy { get; set; }
}

View file

@ -0,0 +1,186 @@
#nullable disable
using Ellie.Common.Configs;
using Ellie.Modules.Gambling.Common;
namespace Ellie.Modules.Gambling.Services;
public sealed class GamblingConfigService : ConfigServiceBase<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;
});
}
}
}

View file

@ -0,0 +1,214 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Ellie.Common.ModuleBehaviors;
using Ellie.Db;
using Ellie.Db.Models;
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Common.Connect4;
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Gambling.Services;
public class GamblingService : IEService, IReadyExecutor
{
public ConcurrentDictionary<(ulong, ulong), RollDuelGame> Duels { get; } = new();
public ConcurrentDictionary<ulong, Connect4Game> Connect4Games { get; } = new();
private readonly DbService _db;
private readonly DiscordSocketClient _client;
private readonly IBotCache _cache;
private readonly GamblingConfigService _gss;
private static readonly TypedKey<long> _curDecayKey = new("currency:last_decay");
public GamblingService(
DbService db,
DiscordSocketClient client,
IBotCache cache,
GamblingConfigService gss)
{
_db = db;
_client = client;
_cache = cache;
_gss = gss;
}
public Task OnReadyAsync()
=> Task.WhenAll(CurrencyDecayLoopAsync(), TransactionClearLoopAsync());
private async Task TransactionClearLoopAsync()
{
if (_client.ShardId != 0)
return;
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync())
{
try
{
var lifetime = _gss.Data.Currency.TransactionsLifetime;
if (lifetime <= 0)
continue;
var now = DateTime.UtcNow;
var days = TimeSpan.FromDays(lifetime);
await using var uow = _db.GetDbContext();
await uow.Set<CurrencyTransaction>()
.DeleteAsync(ct => ct.DateAdded == null || now - ct.DateAdded < days);
}
catch (Exception ex)
{
Log.Warning(ex,
"An unexpected error occurred in transactions cleanup loop: {ErrorMessage}",
ex.Message);
}
}
}
private async Task CurrencyDecayLoopAsync()
{
if (_client.ShardId != 0)
return;
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync())
{
try
{
var config = _gss.Data;
var maxDecay = config.Decay.MaxDecay;
if (config.Decay.Percent is <= 0 or > 1 || maxDecay < 0)
continue;
var now = DateTime.UtcNow;
await using var uow = _db.GetDbContext();
var result = await _cache.GetAsync(_curDecayKey);
if (result.TryPickT0(out var bin, out _)
&& (now - DateTime.FromBinary(bin) < TimeSpan.FromHours(config.Decay.HourInterval)))
{
continue;
}
Log.Information("""
--- Decaying users' currency ---
| decay: {ConfigDecayPercent}%
| max: {MaxDecay}
| threshold: {DecayMinTreshold}
""",
config.Decay.Percent * 100,
maxDecay,
config.Decay.MinThreshold);
if (maxDecay == 0)
maxDecay = int.MaxValue;
var decay = (double)config.Decay.Percent;
await uow.Set<DiscordUser>()
.Where(x => x.CurrencyAmount > config.Decay.MinThreshold && x.UserId != _client.CurrentUser.Id)
.UpdateAsync(old => new()
{
CurrencyAmount =
maxDecay > Sql.Round((old.CurrencyAmount * decay) - 0.5)
? (long)(old.CurrencyAmount - Sql.Round((old.CurrencyAmount * decay) - 0.5))
: old.CurrencyAmount - maxDecay
});
await uow.SaveChangesAsync();
await _cache.AddAsync(_curDecayKey, now.ToBinary());
}
catch (Exception ex)
{
Log.Warning(ex,
"An unexpected error occurred in currency decay loop: {ErrorMessage}",
ex.Message);
}
}
}
private static readonly TypedKey<EconomyResult> _ecoKey = new("nadeko:economy");
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 async Task RemoveAllTimelyClaimsAsync()
=> await _cache.RemoveAsync(_timelyKey);
}

View file

@ -0,0 +1,68 @@
#nullable disable
using Ellie.Modules.Gambling.Services;
using System.Numerics;
using Ellie.Bot.Common;
namespace Ellie.Modules.Gambling.Common;
public abstract class GamblingModule<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 ReplyErrorLocalizedAsync(strs.min_bet_limit(Format.Bold(Config.MinBet.ToString()) + CurrencySign));
return false;
}
if (Config.MaxBet > 0 && amount > Config.MaxBet)
{
await ReplyErrorLocalizedAsync(strs.max_bet_limit(Format.Bold(Config.MaxBet.ToString()) + CurrencySign));
return false;
}
return true;
}
protected string N<T>(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,31 @@
// global using System.Collections.Generic
global using NonBlocking;
// packages
global using Serilog;
global using Humanizer;
// ellie
global using Ellie;
global using Ellie.Services;
global using Ellise.Common; // new project
global using Ellie.Common; // old + ellie specific things
global using Ellie.Common.Attributes;
global using Ellie.Extensions;
global using Ellie.Marmalade;
// discord
global using Discord;
global using Discord.Commands;
global using Discord.Net;
global using Discord.WebSocket;
// aliases
global using GuildPerm = Discord.GuildPermission;
global using ChannelPerm = Discord.ChannelPermission;
global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute;
global using LeftoverAttribute = Discord.Commands.RemainderAttribute;
global using TypeReaderResult = Ellie.Common.TypeReaders.TypeReaderResult;
// non-essential
global using JetBrains.Annotations;

View file

@ -0,0 +1,2 @@
#nullable disable
namespace Ellie.Modules.Gambling;

View file

@ -0,0 +1,112 @@
#nullable disable
using Ellie.Common.TypeReaders;
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Services;
namespace Ellie.Modules.Gambling;
public partial class Gambling
{
[Group]
public partial class PlantPickCommands : GamblingSubmodule<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 ReplyConfirmLocalizedAsync(strs.picked(N(picked)));
msg.DeleteAfter(10);
}
if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages)
{
try
{
_logService.AddDeleteIgnore(ctx.Message.Id);
await ctx.Message.DeleteAsync();
}
catch { }
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Plant([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, string pass = null)
{
if (amount < 1)
return;
if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric())
return;
if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages)
{
_logService.AddDeleteIgnore(ctx.Message.Id);
await ctx.Message.DeleteAsync();
}
var success = await _service.PlantAsync(ctx.Guild.Id,
ctx.Channel,
ctx.User.Id,
ctx.User.ToString(),
amount,
pass);
if (!success)
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageMessages)]
#if GLOBAL_NADEKO
[OwnerOnly]
#endif
public async Task GenCurrency()
{
var enabled = _service.ToggleCurrencyGeneration(ctx.Guild.Id, ctx.Channel.Id);
if (enabled)
await ReplyConfirmLocalizedAsync(strs.curgen_enabled);
else
await ReplyConfirmLocalizedAsync(strs.curgen_disabled);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageMessages)]
[OwnerOnly]
public Task GenCurList(int page = 1)
{
if (--page < 0)
return Task.CompletedTask;
var enabledIn = _service.GetAllGeneratingChannels();
return ctx.SendPaginatedConfirmAsync(page,
_ =>
{
var items = enabledIn.Skip(page * 9).Take(9).ToList();
if (!items.Any())
return _eb.Create().WithErrorColor().WithDescription("-");
return items.Aggregate(_eb.Create().WithOkColor(),
(eb, i) => eb.AddField(i.GuildId.ToString(), i.ChannelId));
},
enabledIn.Count(),
9);
}
}
}

View file

@ -0,0 +1,385 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using Ellie.Common.ModuleBehaviors;
using Ellie.Db;
using Ellie.Services.Database.Models;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Color = SixLabors.ImageSharp.Color;
using Image = SixLabors.ImageSharp.Image;
namespace Ellie.Modules.Gambling.Services;
public class PlantPickService : IEService, IExecNoCommand
{
//channelId/last generation
public ConcurrentDictionary<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,57 @@
#nullable disable
using Ellie.Common.TypeReaders;
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Services;
namespace Ellie.Modules.Gambling;
public partial class Gambling
{
public partial class CurrencyRaffleCommands : GamblingSubmodule<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 SendConfirmAsync(GetText(strs.rafflecur_ended(CurrencyName,
Format.Bold(arg.ToString()),
won + CurrencySign)));
}
var res = await _service.JoinOrCreateGame(ctx.Channel.Id, ctx.User, amount, mixed, OnEnded);
if (res.Item1 is not null)
{
await SendConfirmAsync(GetText(strs.rafflecur(res.Item1.GameType.ToString())),
string.Join("\n", res.Item1.Users.Select(x => $"{x.DiscordUser} ({N(x.Amount)})")),
footer: GetText(strs.rafflecur_joined(ctx.User.ToString())));
}
else
{
if (res.Item2 == CurrencyRaffleService.JoinErrorType.AlreadyJoinedOrInvalidAmount)
await ReplyErrorLocalizedAsync(strs.rafflecur_already_joined);
else if (res.Item2 == CurrencyRaffleService.JoinErrorType.NotEnoughCurrency)
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
}
}
}
}

View file

@ -0,0 +1,69 @@
#nullable disable
namespace Ellie.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 Ellie.Modules.Gambling.Common;
namespace Ellie.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,43 @@
#nullable disable
namespace Ellie.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);
}

View file

@ -0,0 +1,496 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using Ellie.Db;
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Services;
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Gambling;
public partial class Gambling
{
[Group]
public partial class ShopCommands : GamblingSubmodule<IShopService>
{
public enum List
{
List
}
public enum Role
{
Role
}
private readonly DbService _db;
private readonly ICurrencyService _cs;
public ShopCommands(DbService db, ICurrencyService cs, GamblingConfigService gamblingConf)
: base(gamblingConf)
{
_db = db;
_cs = cs;
}
private Task ShopInternalAsync(int page = 0)
{
if (page < 0)
throw new ArgumentOutOfRangeException(nameof(page));
using var uow = _db.GetDbContext();
var entries = uow.GuildConfigsForId(ctx.Guild.Id,
set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items))
.ShopEntries.ToIndexed();
return ctx.SendPaginatedConfirmAsync(page,
curPage =>
{
var theseEntries = entries.Skip(curPage * 9).Take(9).ToArray();
if (!theseEntries.Any())
return _eb.Create().WithErrorColor().WithDescription(GetText(strs.shop_none));
var embed = _eb.Create().WithOkColor().WithTitle(GetText(strs.shop));
for (var i = 0; i < theseEntries.Length; i++)
{
var entry = theseEntries[i];
embed.AddField($"#{(curPage * 9) + i + 1} - {N(entry.Price)}",
EntryToString(entry),
true);
}
return embed;
},
entries.Count,
9);
}
[Cmd]
[RequireContext(ContextType.Guild)]
public Task Shop(int page = 1)
{
if (--page < 0)
return Task.CompletedTask;
return ShopInternalAsync(page);
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Buy(int index)
{
index -= 1;
if (index < 0)
return;
ShopEntry entry;
await using (var uow = _db.GetDbContext())
{
var config = uow.GuildConfigsForId(ctx.Guild.Id,
set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items));
var entries = new IndexedCollection<ShopEntry>(config.ShopEntries);
entry = entries.ElementAtOrDefault(index);
uow.SaveChanges();
}
if (entry is null)
{
await ReplyErrorLocalizedAsync(strs.shop_item_not_found);
return;
}
if (entry.RoleRequirement is ulong reqRoleId)
{
var role = ctx.Guild.GetRole(reqRoleId);
if (role is null)
{
await ReplyErrorLocalizedAsync(strs.shop_item_req_role_not_found);
return;
}
var guser = (IGuildUser)ctx.User;
if (!guser.RoleIds.Contains(reqRoleId))
{
await ReplyErrorLocalizedAsync(strs.shop_item_req_role_unfulfilled(Format.Bold(role.ToString())));
return;
}
}
if (entry.Type == ShopEntryType.Role)
{
var guser = (IGuildUser)ctx.User;
var role = ctx.Guild.GetRole(entry.RoleId);
if (role is null)
{
await ReplyErrorLocalizedAsync(strs.shop_role_not_found);
return;
}
if (guser.RoleIds.Any(id => id == role.Id))
{
await ReplyErrorLocalizedAsync(strs.shop_role_already_bought);
return;
}
if (await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString())))
{
try
{
await guser.AddRoleAsync(role);
}
catch (Exception ex)
{
Log.Warning(ex, "Error adding shop role");
await _cs.AddAsync(ctx.User.Id, entry.Price, new("shop", "error-refund"));
await ReplyErrorLocalizedAsync(strs.shop_role_purchase_error);
return;
}
var profit = GetProfitAmount(entry.Price);
await _cs.AddAsync(entry.AuthorId, profit, new("shop", "sell", $"Shop sell item - {entry.Type}"));
await _cs.AddAsync(ctx.Client.CurrentUser.Id, entry.Price - profit, new("shop", "cut"));
await ReplyConfirmLocalizedAsync(strs.shop_role_purchase(Format.Bold(role.Name)));
return;
}
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
return;
}
if (entry.Type == ShopEntryType.List)
{
if (entry.Items.Count == 0)
{
await ReplyErrorLocalizedAsync(strs.out_of_stock);
return;
}
var item = entry.Items.ToArray()[new EllieRandom().Next(0, entry.Items.Count)];
if (await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString())))
{
await using (var uow = _db.GetDbContext())
{
uow.Set<ShopEntryItem>().Remove(item);
uow.SaveChanges();
}
try
{
await ctx.User.EmbedAsync(_eb.Create()
.WithOkColor()
.WithTitle(GetText(strs.shop_purchase(ctx.Guild.Name)))
.AddField(GetText(strs.item), item.Text)
.AddField(GetText(strs.price), entry.Price.ToString(), true)
.AddField(GetText(strs.name), entry.Name, true));
await _cs.AddAsync(entry.AuthorId,
GetProfitAmount(entry.Price),
new("shop", "sell", entry.Name));
}
catch
{
await _cs.AddAsync(ctx.User.Id, entry.Price, new("shop", "error-refund", entry.Name));
await using (var uow = _db.GetDbContext())
{
var entries = new IndexedCollection<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 ReplyErrorLocalizedAsync(strs.shop_buy_error);
return;
}
await ReplyConfirmLocalizedAsync(strs.shop_item_purchase);
}
else
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
}
}
private static long GetProfitAmount(int price)
=> (int)Math.Ceiling(0.90 * price);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task ShopAdd(Role _, int price, [Leftover] IRole role)
{
if (price < 1)
return;
var entry = new ShopEntry
{
Name = "-",
Price = price,
Type = ShopEntryType.Role,
AuthorId = ctx.User.Id,
RoleId = role.Id,
RoleName = role.Name
};
await using (var uow = _db.GetDbContext())
{
var entries = new IndexedCollection<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 ctx.Channel.EmbedAsync(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add)));
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task ShopAdd(List _, int price, [Leftover] string name)
{
if (price < 1)
return;
var entry = new ShopEntry
{
Name = name.TrimTo(100),
Price = price,
Type = ShopEntryType.List,
AuthorId = ctx.User.Id,
Items = new()
};
await using (var uow = _db.GetDbContext())
{
var entries = new IndexedCollection<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 ctx.Channel.EmbedAsync(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add)));
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task ShopListAdd(int index, [Leftover] string itemText)
{
index -= 1;
if (index < 0)
return;
var item = new ShopEntryItem
{
Text = itemText
};
ShopEntry entry;
var rightType = false;
var added = false;
await using (var uow = _db.GetDbContext())
{
var entries = new IndexedCollection<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 ReplyErrorLocalizedAsync(strs.shop_item_not_found);
else if (!rightType)
await ReplyErrorLocalizedAsync(strs.shop_item_wrong_type);
else if (added == false)
await ReplyErrorLocalizedAsync(strs.shop_list_item_not_unique);
else
await ReplyConfirmLocalizedAsync(strs.shop_list_item_added);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task ShopRemove(int index)
{
index -= 1;
if (index < 0)
return;
ShopEntry removed;
await using (var uow = _db.GetDbContext())
{
var config = uow.GuildConfigsForId(ctx.Guild.Id,
set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items));
var entries = new IndexedCollection<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 ReplyErrorLocalizedAsync(strs.shop_item_not_found);
else
await ctx.Channel.EmbedAsync(EntryToEmbed(removed).WithTitle(GetText(strs.shop_item_rm)));
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task ShopChangePrice(int index, int price)
{
if (--index < 0 || price <= 0)
return;
var succ = await _service.ChangeEntryPriceAsync(ctx.Guild.Id, index, price);
if (succ)
{
await ShopInternalAsync(index / 9);
await ctx.OkAsync();
}
else
await ctx.ErrorAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task ShopChangeName(int index, [Leftover] string newName)
{
if (--index < 0 || string.IsNullOrWhiteSpace(newName))
return;
var succ = await _service.ChangeEntryNameAsync(ctx.Guild.Id, index, newName);
if (succ)
{
await ShopInternalAsync(index / 9);
await ctx.OkAsync();
}
else
await ctx.ErrorAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task ShopSwap(int index1, int index2)
{
if (--index1 < 0 || --index2 < 0 || index1 == index2)
return;
var succ = await _service.SwapEntriesAsync(ctx.Guild.Id, index1, index2);
if (succ)
{
await ShopInternalAsync(index1 / 9);
await ctx.OkAsync();
}
else
await ctx.ErrorAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task ShopMove(int fromIndex, int toIndex)
{
if (--fromIndex < 0 || --toIndex < 0 || fromIndex == toIndex)
return;
var succ = await _service.MoveEntryAsync(ctx.Guild.Id, fromIndex, toIndex);
if (succ)
{
await ShopInternalAsync(toIndex / 9);
await ctx.OkAsync();
}
else
await ctx.ErrorAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task ShopReq(int itemIndex, [Leftover] IRole role = null)
{
if (--itemIndex < 0)
return;
var succ = await _service.SetItemRoleRequirementAsync(ctx.Guild.Id, itemIndex, role?.Id);
if (!succ)
{
await ReplyErrorLocalizedAsync(strs.shop_item_not_found);
return;
}
if (role is null)
await ReplyConfirmLocalizedAsync(strs.shop_item_role_no_req(itemIndex));
else
await ReplyConfirmLocalizedAsync(strs.shop_item_role_req(itemIndex + 1, role));
}
public IEmbedBuilder EntryToEmbed(ShopEntry entry)
{
var embed = _eb.Create().WithOkColor();
if (entry.Type == ShopEntryType.Role)
{
return embed
.AddField(GetText(strs.name),
GetText(strs.shop_role(Format.Bold(ctx.Guild.GetRole(entry.RoleId)?.Name
?? "MISSING_ROLE"))),
true)
.AddField(GetText(strs.price), N(entry.Price), true)
.AddField(GetText(strs.type), entry.Type.ToString(), true);
}
if (entry.Type == ShopEntryType.List)
{
return embed.AddField(GetText(strs.name), entry.Name, true)
.AddField(GetText(strs.price), N(entry.Price), true)
.AddField(GetText(strs.type), GetText(strs.random_unique_item), true);
}
//else if (entry.Type == ShopEntryType.Infinite_List)
// return embed.AddField(GetText(strs.name), GetText(strs.shop_role(Format.Bold(entry.RoleName)), true))
// .AddField(GetText(strs.price), entry.Price.ToString(), true)
// .AddField(GetText(strs.type), entry.Type.ToString(), true);
return null;
}
public string EntryToString(ShopEntry entry)
{
var prepend = string.Empty;
if (entry.RoleRequirement is not null)
prepend = Format.Italics(GetText(strs.shop_item_requires_role($"<@&{entry.RoleRequirement}>")))
+ Environment.NewLine;
if (entry.Type == ShopEntryType.Role)
return prepend
+ GetText(strs.shop_role(Format.Bold(ctx.Guild.GetRole(entry.RoleId)?.Name ?? "MISSING_ROLE")));
if (entry.Type == ShopEntryType.List)
return prepend + GetText(strs.unique_items_left(entry.Items.Count)) + "\n" + entry.Name;
return prepend;
}
}
}

View file

@ -0,0 +1,112 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using Ellie.Db;
using Ellie.Services.Database;
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Gambling.Services;
public class ShopService : IShopService, IEService
{
private readonly DbService _db;
public ShopService(DbService db)
=> _db = db;
private IndexedCollection<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)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
if (newPrice <= 0)
throw new ArgumentOutOfRangeException(nameof(newPrice));
await using var uow = _db.GetDbContext();
var entries = GetEntriesInternal(uow, guildId);
if (index >= entries.Count)
return false;
entries[index].Price = newPrice;
await uow.SaveChangesAsync();
return true;
}
public async Task<bool> ChangeEntryNameAsync(ulong guildId, int index, string newName)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
if (string.IsNullOrWhiteSpace(newName))
throw new ArgumentNullException(nameof(newName));
await using var uow = _db.GetDbContext();
var entries = GetEntriesInternal(uow, guildId);
if (index >= entries.Count)
return false;
entries[index].Name = newName.TrimTo(100);
await uow.SaveChangesAsync();
return true;
}
public async Task<bool> SwapEntriesAsync(ulong guildId, int index1, int index2)
{
if (index1 < 0)
throw new ArgumentOutOfRangeException(nameof(index1));
if (index2 < 0)
throw new ArgumentOutOfRangeException(nameof(index2));
await using var uow = _db.GetDbContext();
var entries = GetEntriesInternal(uow, guildId);
if (index1 >= entries.Count || index2 >= entries.Count || index1 == index2)
return false;
entries[index1].Index = index2;
entries[index2].Index = index1;
await uow.SaveChangesAsync();
return true;
}
public async Task<bool> MoveEntryAsync(ulong guildId, int fromIndex, int toIndex)
{
if (fromIndex < 0)
throw new ArgumentOutOfRangeException(nameof(fromIndex));
if (toIndex < 0)
throw new ArgumentOutOfRangeException(nameof(toIndex));
await using var uow = _db.GetDbContext();
var entries = GetEntriesInternal(uow, guildId);
if (fromIndex >= entries.Count || toIndex >= entries.Count || fromIndex == toIndex)
return false;
var entry = entries[fromIndex];
entries.RemoveAt(fromIndex);
entries.Insert(toIndex, entry);
await uow.SaveChangesAsync();
return true;
}
public async Task<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;
}
}

View file

@ -0,0 +1,227 @@
#nullable disable warnings
using Ellie.Db.Models;
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Services;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Ellie.Econ.Gambling;
using Ellie.Common.TypeReaders;
using Color = SixLabors.ImageSharp.Color;
using Image = SixLabors.ImageSharp.Image;
namespace Ellie.Modules.Gambling;
public enum GamblingError
{
InsufficientFunds,
}
public partial class Gambling
{
[Group]
public partial class SlotCommands : GamblingSubmodule<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 ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
return;
}
var text = GetSlotMessageTextInternal(result);
using var image = await GenerateSlotImageAsync(amount, result);
await using var imgStream = await image.ToStreamAsync();
var eb = _eb.Create(ctx)
.WithAuthor(ctx.User)
.WithDescription(Format.Bold(text))
.WithImageUrl($"attachment://result.png")
.WithOkColor();
var bb = new ButtonBuilder(emote: Emoji.Parse("🔁"), customId: "slot:again", label: "Pull Again");
var si = new SimpleInteraction<long>(bb, (_, amount) => Slot(amount), amount);
var inter = _inter.Create(ctx.User.Id, si);
var msg = await ctx.Channel.SendFileAsync(imgStream,
"result.png",
embed: eb.Build(),
components: inter.CreateComponent()
);
await inter.RunAsync(msg);
}
// private SlotInteraction CreateSlotInteractionIntenal(long amount)
// {
// return new SlotInteraction((DiscordSocketClient)ctx.Client,
// ctx.User.Id,
// async (smc) =>
// {
// try
// {
// if (await InternalSlotAsync(amount) is not SlotResult result)
// {
// await smc.RespondErrorAsync(_eb, GetText(strs.not_enough(CurrencySign)), true);
// return;
// }
//
// var msg = GetSlotMessageInternal(result);
//
// using var image = await GenerateSlotImageAsync(amount, result);
// await using var imgStream = await image.ToStreamAsync();
//
// var guid = Guid.NewGuid();
// var imgName = $"result_{guid}.png";
//
// var slotInteraction = CreateSlotInteractionIntenal(amount).GetInteraction();
//
// await smc.Message.ModifyAsync(m =>
// {
// m.Content = msg;
// m.Attachments = new[]
// {
// new FileAttachment(imgStream, imgName)
// };
// m.Components = slotInteraction.CreateComponent();
// });
//
// _ = slotInteraction.RunAsync(smc.Message);
// }
// catch (Exception ex)
// {
// Log.Error(ex, "Error pulling slot again");
// }
// // finally
// // {
// // await Task.Delay(1000);
// // _runningUsers.TryRemove(ctx.User.Id);
// // }
// });
// }
private string GetSlotMessageTextInternal(SlotResult result)
{
var multi = result.Multiplier.ToString("0.##");
var msg = result.WinType switch
{
SlotWinType.SingleJoker => GetText(strs.slot_single(CurrencySign, multi)),
SlotWinType.DoubleJoker => GetText(strs.slot_two(CurrencySign, multi)),
SlotWinType.TrippleNormal => GetText(strs.slot_three(multi)),
SlotWinType.TrippleJoker => GetText(strs.slot_jackpot(multi)),
_ => GetText(strs.better_luck),
};
return msg;
}
private async Task<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 Ellie.Common.ModuleBehaviors;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Ellie.Modules.Gambling.Services;
public class VoteModel
{
[JsonPropertyName("userId")]
public ulong UserId { get; set; }
}
public class VoteRewardService : IEService, IReadyExecutor
{
private readonly DiscordSocketClient _client;
private readonly IBotCredentials _creds;
private readonly ICurrencyService _currencyService;
private readonly GamblingConfigService _gamb;
public VoteRewardService(
DiscordSocketClient client,
IBotCredentials creds,
ICurrencyService currencyService,
GamblingConfigService gamb)
{
_client = client;
_creds = creds;
_currencyService = currencyService;
_gamb = gamb;
}
public async Task OnReadyAsync()
{
if (_client.ShardId != 0)
return;
using var http = new HttpClient(new HttpClientHandler
{
AllowAutoRedirect = false,
ServerCertificateCustomValidationCallback = delegate { return true; }
});
while (true)
{
await Task.Delay(30000);
var topggKey = _creds.Votes?.TopggKey;
var topggServiceUrl = _creds.Votes?.TopggServiceUrl;
try
{
if (!string.IsNullOrWhiteSpace(topggKey) && !string.IsNullOrWhiteSpace(topggServiceUrl))
{
http.DefaultRequestHeaders.Authorization = new(topggKey);
var uri = new Uri(new(topggServiceUrl), "topgg/new");
var res = await http.GetStringAsync(uri);
var data = JsonSerializer.Deserialize<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,373 @@
#nullable disable
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Common.Waifu;
using Ellie.Modules.Gambling.Services;
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Gambling;
public partial class Gambling
{
[Group]
public partial class WaifuClaimCommands : GamblingSubmodule<WaifuService>
{
public WaifuClaimCommands(GamblingConfigService gamblingConfService)
: base(gamblingConfService)
{
}
[Cmd]
public async Task WaifuReset()
{
var price = _service.GetResetPrice(ctx.User);
var embed = _eb.Create()
.WithTitle(GetText(strs.waifu_reset_confirm))
.WithDescription(GetText(strs.waifu_reset_price(Format.Bold(N(price)))));
if (!await PromptUserConfirmAsync(embed))
return;
if (await _service.TryReset(ctx.User))
{
await ReplyConfirmLocalizedAsync(strs.waifu_reset);
return;
}
await ReplyErrorLocalizedAsync(strs.waifu_reset_fail);
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task WaifuClaim(long amount, [Leftover] IUser target)
{
if (amount < Config.Waifu.MinPrice)
{
await ReplyErrorLocalizedAsync(strs.waifu_isnt_cheap(Config.Waifu.MinPrice + CurrencySign));
return;
}
if (target.Id == ctx.User.Id)
{
await ReplyErrorLocalizedAsync(strs.waifu_not_yourself);
return;
}
var (w, isAffinity, result) = await _service.ClaimWaifuAsync(ctx.User, target, amount);
if (result == WaifuClaimResult.InsufficientAmount)
{
await ReplyErrorLocalizedAsync(
strs.waifu_not_enough(N((long)Math.Ceiling(w.Price * (isAffinity ? 0.88f : 1.1f)))));
return;
}
if (result == WaifuClaimResult.NotEnoughFunds)
{
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
return;
}
var msg = GetText(strs.waifu_claimed(Format.Bold(target.ToString()), N(amount)));
if (w.Affinity?.UserId == ctx.User.Id)
msg += "\n" + GetText(strs.waifu_fulfilled(target, N(w.Price)));
else
msg = " " + msg;
await SendConfirmAsync(ctx.User.Mention + msg);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(0)]
public async Task WaifuTransfer(ulong waifuId, IUser newOwner)
{
if (!await _service.WaifuTransfer(ctx.User, waifuId, newOwner))
{
await ReplyErrorLocalizedAsync(strs.waifu_transfer_fail);
return;
}
await ReplyConfirmLocalizedAsync(strs.waifu_transfer_success(Format.Bold(waifuId.ToString()),
Format.Bold(ctx.User.ToString()),
Format.Bold(newOwner.ToString())));
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(1)]
public async Task WaifuTransfer(IUser waifu, IUser newOwner)
{
if (!await _service.WaifuTransfer(ctx.User, waifu.Id, newOwner))
{
await ReplyErrorLocalizedAsync(strs.waifu_transfer_fail);
return;
}
await ReplyConfirmLocalizedAsync(strs.waifu_transfer_success(Format.Bold(waifu.ToString()),
Format.Bold(ctx.User.ToString()),
Format.Bold(newOwner.ToString())));
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(-1)]
public Task Divorce([Leftover] string target)
{
var waifuUserId = _service.GetWaifuUserId(ctx.User.Id, target);
if (waifuUserId == default)
return ReplyErrorLocalizedAsync(strs.waifu_not_yours);
return Divorce(waifuUserId);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(0)]
public Task Divorce([Leftover] IGuildUser target)
=> Divorce(target.Id);
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(1)]
public async Task Divorce([Leftover] ulong targetId)
{
if (targetId == ctx.User.Id)
return;
var (w, result, amount, remaining) = await _service.DivorceWaifuAsync(ctx.User, targetId);
if (result == DivorceResult.SucessWithPenalty)
{
await ReplyConfirmLocalizedAsync(strs.waifu_divorced_like(Format.Bold(w.Waifu.ToString()),
N(amount)));
}
else if (result == DivorceResult.Success)
await ReplyConfirmLocalizedAsync(strs.waifu_divorced_notlike(N(amount)));
else if (result == DivorceResult.NotYourWife)
await ReplyErrorLocalizedAsync(strs.waifu_not_yours);
else
{
await ReplyErrorLocalizedAsync(strs.waifu_recent_divorce(
Format.Bold(((int)remaining?.TotalHours).ToString()),
Format.Bold(remaining?.Minutes.ToString())));
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Affinity([Leftover] IGuildUser user = null)
{
if (user?.Id == ctx.User.Id)
{
await ReplyErrorLocalizedAsync(strs.waifu_egomaniac);
return;
}
var (oldAff, sucess, remaining) = await _service.ChangeAffinityAsync(ctx.User, user);
if (!sucess)
{
if (remaining is not null)
{
await ReplyErrorLocalizedAsync(strs.waifu_affinity_cooldown(
Format.Bold(((int)remaining?.TotalHours).ToString()),
Format.Bold(remaining?.Minutes.ToString())));
}
else
await ReplyErrorLocalizedAsync(strs.waifu_affinity_already);
return;
}
if (user is null)
await ReplyConfirmLocalizedAsync(strs.waifu_affinity_reset);
else if (oldAff is null)
await ReplyConfirmLocalizedAsync(strs.waifu_affinity_set(Format.Bold(user.ToString())));
else
{
await ReplyConfirmLocalizedAsync(strs.waifu_affinity_changed(Format.Bold(oldAff.ToString()),
Format.Bold(user.ToString())));
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task WaifuLb(int page = 1)
{
page--;
if (page < 0)
return;
if (page > 100)
page = 100;
var waifus = _service.GetTopWaifusAtPage(page).ToList();
if (waifus.Count == 0)
{
await ReplyConfirmLocalizedAsync(strs.waifus_none);
return;
}
var embed = _eb.Create().WithTitle(GetText(strs.waifus_top_waifus)).WithOkColor();
var i = 0;
foreach (var w in waifus)
{
var j = i++;
embed.AddField("#" + ((page * 9) + j + 1) + " - " + N(w.Price), GetLbString(w));
}
await ctx.Channel.EmbedAsync(embed);
}
private string GetLbString(WaifuLbResult w)
{
var claimer = "no one";
var status = string.Empty;
var waifuUsername = w.Username.TrimTo(20);
var claimerUsername = w.Claimer?.TrimTo(20);
if (w.Claimer is not null)
claimer = $"{claimerUsername}#{w.ClaimerDiscrim}";
if (w.Affinity is null)
status = $"... but {waifuUsername}'s heart is empty";
else if (w.Affinity + w.AffinityDiscrim == w.Claimer + w.ClaimerDiscrim)
status = $"... and {waifuUsername} likes {claimerUsername} too <3";
else
status = $"... but {waifuUsername}'s heart belongs to {w.Affinity.TrimTo(20)}#{w.AffinityDiscrim}";
return $"**{waifuUsername}#{w.Discrim}** - claimed by **{claimer}**\n\t{status}";
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(1)]
public Task WaifuInfo([Leftover] IUser target = null)
{
if (target is null)
target = ctx.User;
return InternalWaifuInfo(target.Id, target.ToString());
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(0)]
public Task WaifuInfo(ulong targetId)
=> InternalWaifuInfo(targetId);
private async Task InternalWaifuInfo(ulong targetId, string name = null)
{
var wi = await _service.GetFullWaifuInfoAsync(targetId);
var affInfo = _service.GetAffinityTitle(wi.AffinityCount);
var waifuItems = _service.GetWaifuItems().ToDictionary(x => x.ItemEmoji, x => x);
var nobody = GetText(strs.nobody);
var itemList = await _service.GetItems(wi.WaifuId);
var itemsStr = !itemList.Any()
? "-"
: string.Join("\n",
itemList.Where(x => waifuItems.TryGetValue(x.ItemEmoji, out _))
.OrderBy(x => waifuItems[x.ItemEmoji].Price)
.GroupBy(x => x.ItemEmoji)
.Select(x => $"{x.Key} x{x.Count(),-3}")
.Chunk(2)
.Select(x => string.Join(" ", x)));
var claimsNames = (await _service.GetClaimNames(wi.WaifuId));
var claimsStr = claimsNames
.Shuffle()
.Take(30)
.Join('\n');
var fansList = await _service.GetFansNames(wi.WaifuId);
var fansStr = fansList
.Select((x) => claimsNames.Contains(x) ? $"{x} 💞" : x)
.Join('\n');
if (string.IsNullOrWhiteSpace(fansStr))
fansStr = "-";
var embed = _eb.Create()
.WithOkColor()
.WithTitle(GetText(strs.waifu)
+ " "
+ (wi.FullName ?? name ?? targetId.ToString())
+ " - \"the "
+ _service.GetClaimTitle(wi.ClaimCount)
+ "\"")
.AddField(GetText(strs.price), N(wi.Price), true)
.AddField(GetText(strs.claimed_by), wi.ClaimerName ?? nobody, true)
.AddField(GetText(strs.likes), wi.AffinityName ?? nobody, true)
.AddField(GetText(strs.changes_of_heart), $"{wi.AffinityCount} - \"the {affInfo}\"", true)
.AddField(GetText(strs.divorces), wi.DivorceCount.ToString(), true)
.AddField("\u200B", "\u200B", true)
.AddField(GetText(strs.fans(fansList.Count)), fansStr, true)
.AddField($"Waifus ({wi.ClaimCount})",
wi.ClaimCount == 0 ? nobody : claimsStr,
true)
.AddField(GetText(strs.gifts), itemsStr, true);
await ctx.Channel.EmbedAsync(embed);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(1)]
public async Task WaifuGift(int page = 1)
{
if (--page < 0 || page > (Config.Waifu.Items.Count - 1) / 9)
return;
var waifuItems = _service.GetWaifuItems();
await ctx.SendPaginatedConfirmAsync(page,
cur =>
{
var embed = _eb.Create().WithTitle(GetText(strs.waifu_gift_shop)).WithOkColor();
waifuItems.OrderBy(x => x.Negative)
.ThenBy(x => x.Price)
.Skip(9 * cur)
.Take(9)
.ToList()
.ForEach(x => embed.AddField(
$"{(!x.Negative ? string.Empty : "\\💔")} {x.ItemEmoji} {x.Name}",
Format.Bold(N(x.Price)),
true));
return embed;
},
waifuItems.Count,
9);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(0)]
public async Task WaifuGift(string itemName, [Leftover] IUser waifu)
{
if (waifu.Id == ctx.User.Id)
return;
var allItems = _service.GetWaifuItems();
var item = allItems.FirstOrDefault(x => x.Name.ToLowerInvariant() == itemName.ToLowerInvariant());
if (item is null)
{
await ReplyErrorLocalizedAsync(strs.waifu_gift_not_exist);
return;
}
var sucess = await _service.GiftWaifuAsync(ctx.User, waifu, item);
if (sucess)
{
await ReplyConfirmLocalizedAsync(strs.waifu_gift(Format.Bold(item + " " + item.ItemEmoji),
Format.Bold(waifu.ToString())));
}
else
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
}
}
}

View file

@ -0,0 +1,583 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Ellie.Common.ModuleBehaviors;
using Ellie.Db;
using Ellie.Db.Models;
using Ellie.Modules.Gambling.Common;
using Ellie.Modules.Gambling.Common.Waifu;
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Gambling.Services;
public class WaifuService : IEService, IReadyExecutor
{
private readonly DbService _db;
private readonly ICurrencyService _cs;
private readonly IBotCache _cache;
private readonly GamblingConfigService _gss;
private readonly IBotCredentials _creds;
private readonly DiscordSocketClient _client;
public WaifuService(
DbService db,
ICurrencyService cs,
IBotCache cache,
GamblingConfigService gss,
IBotCredentials creds,
DiscordSocketClient client)
{
_db = db;
_cs = cs;
_cache = cache;
_gss = gss;
_creds = creds;
_client = client;
}
public async Task<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)
{
using var uow = _db.GetDbContext();
return uow.Set<WaifuInfo>().GetTop(9, page * 9);
}
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 Ellie.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 Ellie.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 Ellie.Modules.Gambling.Common.Waifu;
public enum DivorceResult
{
Success,
SucessWithPenalty,
NotYourWife,
Cooldown
}

View file

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

View file

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

View file

@ -0,0 +1,19 @@
#nullable disable
using Ellie.Db.Models;
namespace Ellie.Services.Database.Models;
public class WaifuInfo : DbEntity
{
public int WaifuId { get; set; }
public DiscordUser Waifu { get; set; }
public int? ClaimerId { get; set; }
public DiscordUser Claimer { get; set; }
public int? AffinityId { get; set; }
public DiscordUser Affinity { get; set; }
public long Price { get; set; }
public List<WaifuItem> Items { get; set; } = new();
}

View file

@ -0,0 +1,133 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Ellie.Db.Models;
using Ellie.Services.Database;
using Ellie.Services.Database.Models;
namespace Ellie.Db;
public static class WaifuExtensions
{
public static WaifuInfo ByWaifuUserId(
this DbSet<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)
{
if (count < 0)
throw new ArgumentOutOfRangeException(nameof(count));
if (count == 0)
return new List<WaifuLbResult>();
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 Ellie.Db;
public class WaifuInfoStats
{
public int WaifuId { get; init; }
public string FullName { get; init; }
public long Price { get; init; }
public string ClaimerName { get; init; }
public string AffinityName { get; init; }
public int AffinityCount { get; init; }
public int DivorceCount { get; init; }
public int ClaimCount { get; init; }
}

View file

@ -0,0 +1,10 @@
#nullable disable
namespace Ellie.Services.Database.Models;
public class WaifuItem : DbEntity
{
public WaifuInfo WaifuInfo { get; set; }
public int? WaifuInfoId { get; set; }
public string ItemEmoji { get; set; }
public string Name { get; set; }
}

View file

@ -0,0 +1,16 @@
#nullable disable
namespace Ellie.Services.Database.Models;
public class WaifuLbResult
{
public string Username { get; set; }
public string Discrim { get; set; }
public string Claimer { get; set; }
public string ClaimerDiscrim { get; set; }
public string Affinity { get; set; }
public string AffinityDiscrim { get; set; }
public long Price { get; set; }
}

View file

@ -0,0 +1,17 @@
#nullable disable
using Ellie.Db.Models;
namespace Ellie.Services.Database.Models;
public class WaifuUpdate : DbEntity
{
public int UserId { get; set; }
public DiscordUser User { get; set; }
public WaifuUpdateType UpdateType { get; set; }
public int? OldId { get; set; }
public DiscordUser Old { get; set; }
public int? NewId { get; set; }
public DiscordUser New { get; set; }
}

View file

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

View file

@ -0,0 +1,19 @@
using Ellie.Econ;
namespace Ellie.Modules.Gambling.Common;
public class QuadDeck : Deck
{
protected override void RefillPool()
{
CardPool = new(52 * 4);
for (var j = 1; j < 14; j++)
for (var i = 1; i < 5; i++)
{
CardPool.Add(new((CardSuit)i, j));
CardPool.Add(new((CardSuit)i, j));
CardPool.Add(new((CardSuit)i, j));
CardPool.Add(new((CardSuit)i, j));
}
}
}

View file

@ -0,0 +1,67 @@
using LinqToDB;
using Ellie.Db.Models;
using Ellie.Services.Database.Models;
namespace Ellie.Bot.Modules.Gambling.Gambling._Common;
public interface IGamblingCleanupService
{
Task DeleteWaifus();
Task DeleteWaifu(ulong userId);
Task DeleteCurrency();
}
public class GamblingCleanupService : IGamblingCleanupService
{
private readonly DbService _db;
public GamblingCleanupService(DbService db)
{
_db = db;
}
public async Task DeleteWaifus()
{
await using var ctx = _db.GetDbContext();
await ctx.Set<WaifuInfo>().DeleteAsync();
await ctx.Set<WaifuItem>().DeleteAsync();
await ctx.Set<WaifuUpdate>().DeleteAsync();
await ctx.SaveChangesAsync();
}
public async Task DeleteWaifu(ulong userId)
{
await using var ctx = _db.GetDbContext();
await ctx.Set<WaifuUpdate>()
.Where(x => x.User.UserId == userId)
.DeleteAsync();
await ctx.Set<WaifuItem>()
.Where(x => x.WaifuInfo.Waifu.UserId == userId)
.DeleteAsync();
await ctx.Set<WaifuInfo>()
.Where(x => x.Claimer.UserId == userId)
.UpdateAsync(old => new WaifuInfo()
{
ClaimerId = null,
});
await ctx.Set<WaifuInfo>()
.Where(x => x.Waifu.UserId == userId)
.DeleteAsync();
await ctx.SaveChangesAsync();
}
public async Task DeleteCurrency()
{
await using var uow = _db.GetDbContext();
await uow.Set<DiscordUser>().UpdateAsync(_ => new DiscordUser()
{
CurrencyAmount = 0
});
await uow.Set<CurrencyTransaction>().DeleteAsync();
await uow.Set<PlantedCurrency>().DeleteAsync();
await uow.Set<BankUser>().DeleteAsync();
await uow.SaveChangesAsync();
}
}

View file

@ -0,0 +1,18 @@
#nullable disable
using Ellie.Econ.Gambling;
using Ellie.Econ.Gambling.Betdraw;
using Ellie.Econ.Gambling.Rps;
using OneOf;
namespace Ellie.Modules.Gambling;
public interface IGamblingService
{
Task<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? guessValue, byte? guessColor);
}

View file

@ -0,0 +1,279 @@
#nullable disable
using Ellie.Econ.Gambling;
using Ellie.Econ.Gambling.Betdraw;
using Ellie.Econ.Gambling.Rps;
using Ellie.Modules.Gambling.Services;
using OneOf;
namespace Ellie.Modules.Gambling;
public sealed class NewGamblingService : IGamblingService, IEService
{
private readonly GamblingConfigService _bcs;
private readonly ICurrencyService _cs;
public NewGamblingService(GamblingConfigService bcs, ICurrencyService cs)
{
_bcs = bcs;
_cs = cs;
}
public async Task<OneOf<LuLaResult, GamblingError>> LulaAsync(ulong userId, long amount)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount));
if (amount > 0)
{
var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("lula", "bet"));
if (!isTakeSuccess)
{
return GamblingError.InsufficientFunds;
}
}
var game = new LulaGame(_bcs.Data.LuckyLadder.Multipliers);
var result = game.Spin(amount);
var won = (long)result.Won;
if (won > 0)
{
await _cs.AddAsync(userId, won, new("lula", "win"));
}
return result;
}
public async Task<OneOf<BetrollResult, GamblingError>> BetRollAsync(ulong userId, long amount)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount));
if (amount > 0)
{
var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betroll", "bet"));
if (!isTakeSuccess)
{
return GamblingError.InsufficientFunds;
}
}
var game = new BetrollGame(_bcs.Data.BetRoll.Pairs
.Select(x => (x.WhenAbove, (decimal)x.MultiplyBy))
.ToList());
var result = game.Roll(amount);
var won = (long)result.Won;
if (won > 0)
{
await _cs.AddAsync(userId, won, new("betroll", "win"));
}
return result;
}
public async Task<OneOf<BetflipResult, GamblingError>> BetFlipAsync(ulong userId, long amount, byte guess)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount));
if (guess > 1)
throw new ArgumentOutOfRangeException(nameof(guess));
if (amount > 0)
{
var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betflip", "bet"));
if (!isTakeSuccess)
{
return GamblingError.InsufficientFunds;
}
}
var game = new BetflipGame(_bcs.Data.BetFlip.Multiplier);
var result = game.Flip(guess, amount);
var won = (long)result.Won;
if (won > 0)
{
await _cs.AddAsync(userId, won, new("betflip", "win"));
}
return result;
}
public async Task<OneOf<BetdrawResult, GamblingError>> BetDrawAsync(ulong userId, long amount, byte? guessValue, byte? guessColor)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount));
if (guessColor is null && guessValue is null)
throw new ArgumentNullException();
if (guessColor > 1)
throw new ArgumentOutOfRangeException(nameof(guessColor));
if (guessValue > 1)
throw new ArgumentOutOfRangeException(nameof(guessValue));
if (amount > 0)
{
var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betdraw", "bet"));
if (!isTakeSuccess)
{
return GamblingError.InsufficientFunds;
}
}
var game = new BetdrawGame();
var result = game.Draw((BetdrawValueGuess?)guessValue, (BetdrawColorGuess?)guessColor, amount);
var won = (long)result.Won;
if (won > 0)
{
await _cs.AddAsync(userId, won, new("betdraw", "win"));
}
return result;
}
public async Task<OneOf<SlotResult, GamblingError>> SlotAsync(ulong userId, long amount)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount));
if (amount > 0)
{
var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("slot", "bet"));
if (!isTakeSuccess)
{
return GamblingError.InsufficientFunds;
}
}
var game = new SlotGame();
var result = game.Spin(amount);
var won = (long)result.Won;
if (won > 0)
{
await _cs.AddAsync(userId, won, new("slot", "won"));
}
return result;
}
public Task<FlipResult[]> FlipAsync(int count)
{
if (count < 1)
throw new ArgumentOutOfRangeException(nameof(count));
var game = new BetflipGame(0);
var results = new FlipResult[count];
for (var i = 0; i < count; i++)
{
results[i] = new()
{
Side = game.Flip(0, 0).Side
};
}
return Task.FromResult(results);
}
//
//
// private readonly ConcurrentDictionary<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)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount));
if (pick > 2)
throw new ArgumentOutOfRangeException(nameof(pick));
if (amount > 0)
{
var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("rps", "bet"));
if (!isTakeSuccess)
{
return GamblingError.InsufficientFunds;
}
}
var rps = new RpsGame();
var result = rps.Play((RpsPick)pick, amount);
var won = (long)result.Won;
if (won > 0)
{
var extra = result.Result switch
{
RpsResultType.Draw => "draw",
RpsResultType.Win => "win",
_ => "lose"
};
await _cs.AddAsync(userId, won, new("rps", extra));
}
return result;
}
}

View file

@ -0,0 +1,139 @@
#nullable disable
namespace Ellie.Modules.Gambling.Common;
public class RollDuelGame
{
public enum Reason
{
Normal,
NoFunds,
Timeout
}
public enum State
{
Waiting,
Running,
Ended
}
public event Func<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 Ellie.Db;
using Ellie.Db.Models;
using Ellie.Modules.Gambling.Services;
using NCalc;
using OneOf;
namespace Ellie.Common.TypeReaders;
public class BaseShmartInputAmountReader
{
private static readonly Regex _percentRegex = new(@"^((?<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 Ellie.Modules.Gambling.Bank;
using Ellie.Modules.Gambling.Services;
namespace Ellie.Common.TypeReaders;
public sealed class ShmartBankInputAmountReader : BaseShmartInputAmountReader
{
private readonly IBankService _bank;
public ShmartBankInputAmountReader(IBankService bank, DbService db, GamblingConfigService gambling)
: base(db, gambling)
{
_bank = bank;
}
protected override Task<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 Ellie.Modules.Gambling.Bank;
using Ellie.Modules.Gambling.Services;
namespace Ellie.Common.TypeReaders;
public sealed class BalanceTypeReader : TypeReader
{
private readonly BaseShmartInputAmountReader _tr;
public BalanceTypeReader(DbService db, GamblingConfigService gambling)
{
_tr = new BaseShmartInputAmountReader(db, gambling);
}
public override async Task<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);
}
}

View file

@ -0,0 +1,200 @@
#nullable disable
using CommandLine;
using System.Collections.Immutable;
namespace Ellie.Modules.Games.Common.Acrophobia;
public sealed class AcrophobiaGame : IDisposable
{
public enum Phase
{
Submission,
Voting,
Ended
}
public enum UserInputResult
{
Submitted,
SubmissionFailed,
Voted,
VotingFailed,
Failed
}
public event Func<AcrophobiaGame, Task> OnStarted = delegate { return Task.CompletedTask; };
public event Func<AcrophobiaGame, ImmutableArray<KeyValuePair<AcrophobiaUser, int>>, Task> OnVotingStarted =
delegate { return Task.CompletedTask; };
public event Func<string, Task> OnUserVoted = delegate { return Task.CompletedTask; };
public event Func<AcrophobiaGame, ImmutableArray<KeyValuePair<AcrophobiaUser, int>>, Task> OnEnded = delegate
{
return Task.CompletedTask;
};
public Phase CurrentPhase { get; private set; } = Phase.Submission;
public ImmutableArray<char> StartingLetters { get; private set; }
public Options Opts { get; }
private readonly Dictionary<AcrophobiaUser, int> _submissions = new();
private readonly SemaphoreSlim _locker = new(1, 1);
private readonly EllieRandom _rng;
private readonly HashSet<ulong> _usersWhoVoted = new();
public AcrophobiaGame(Options options)
{
Opts = options;
_rng = new();
InitializeStartingLetters();
}
public async Task Run()
{
await OnStarted(this);
await Task.Delay(Opts.SubmissionTime * 1000);
await _locker.WaitAsync();
try
{
if (_submissions.Count == 0)
{
CurrentPhase = Phase.Ended;
await OnVotingStarted(this, ImmutableArray.Create<KeyValuePair<AcrophobiaUser, int>>());
return;
}
if (_submissions.Count == 1)
{
CurrentPhase = Phase.Ended;
await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray());
return;
}
CurrentPhase = Phase.Voting;
await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray());
}
finally { _locker.Release(); }
await Task.Delay(Opts.VoteTime * 1000);
await _locker.WaitAsync();
try
{
CurrentPhase = Phase.Ended;
await OnEnded(this, _submissions.ToArray().ToImmutableArray());
}
finally { _locker.Release(); }
}
private void InitializeStartingLetters()
{
var wordCount = _rng.Next(3, 6);
var lettersArr = new char[wordCount];
for (var i = 0; i < wordCount; i++)
{
var randChar = (char)_rng.Next(65, 91);
lettersArr[i] = randChar == 'X' ? (char)_rng.Next(65, 88) : randChar;
}
StartingLetters = lettersArr.ToImmutableArray();
}
public async Task<bool> UserInput(ulong userId, string userName, string input)
{
var user = new AcrophobiaUser(userId, userName, input.ToLowerInvariant().ToTitleCase());
await _locker.WaitAsync();
try
{
switch (CurrentPhase)
{
case Phase.Submission:
if (_submissions.ContainsKey(user) || !IsValidAnswer(input))
break;
_submissions.Add(user, 0);
return true;
case Phase.Voting:
AcrophobiaUser toVoteFor;
if (!int.TryParse(input, out var index)
|| --index < 0
|| index >= _submissions.Count
|| (toVoteFor = _submissions.ToArray()[index].Key).UserId == user.UserId
|| !_usersWhoVoted.Add(userId))
break;
++_submissions[toVoteFor];
_ = Task.Run(() => OnUserVoted(userName));
return true;
}
return false;
}
finally
{
_locker.Release();
}
}
private bool IsValidAnswer(string input)
{
input = input.ToUpperInvariant();
var inputWords = input.Split(' ');
if (inputWords.Length
!= StartingLetters.Length) // number of words must be the same as the number of the starting letters
return false;
for (var i = 0; i < StartingLetters.Length; i++)
{
var letter = StartingLetters[i];
if (!inputWords[i]
.StartsWith(letter.ToString(), StringComparison.InvariantCulture)) // all first letters must match
return false;
}
return true;
}
public void Dispose()
{
CurrentPhase = Phase.Ended;
OnStarted = null;
OnEnded = null;
OnUserVoted = null;
OnVotingStarted = null;
_usersWhoVoted.Clear();
_submissions.Clear();
_locker.Dispose();
}
public class Options : IEllieCommandOptions
{
[Option('s',
"submission-time",
Required = false,
Default = 60,
HelpText = "Time after which the submissions are closed and voting starts.")]
public int SubmissionTime { get; set; } = 60;
[Option('v',
"vote-time",
Required = false,
Default = 60,
HelpText = "Time after which the voting is closed and the winner is declared.")]
public int VoteTime { get; set; } = 30;
public void NormalizeOptions()
{
if (SubmissionTime is < 15 or > 300)
SubmissionTime = 60;
if (VoteTime is < 15 or > 120)
VoteTime = 30;
}
}
}

View file

@ -0,0 +1,22 @@
#nullable disable
namespace Ellie.Modules.Games.Common.Acrophobia;
public class AcrophobiaUser
{
public string UserName { get; }
public ulong UserId { get; }
public string Input { get; }
public AcrophobiaUser(ulong userId, string userName, string input)
{
UserName = userName;
UserId = userId;
Input = input;
}
public override int GetHashCode()
=> UserId.GetHashCode();
public override bool Equals(object obj)
=> obj is AcrophobiaUser x ? x.UserId == UserId : false;
}

View file

@ -0,0 +1,140 @@
#nullable disable
using Ellie.Modules.Games.Common.Acrophobia;
using Ellie.Modules.Games.Services;
using System.Collections.Immutable;
namespace Ellie.Modules.Games;
public partial class Games
{
[Group]
public partial class AcropobiaCommands : EllieModule<GamesService>
{
private readonly DiscordSocketClient _client;
public AcropobiaCommands(DiscordSocketClient client)
=> _client = client;
[Cmd]
[RequireContext(ContextType.Guild)]
[EllieOptions<AcrophobiaGame.Options>]
public async Task Acrophobia(params string[] args)
{
var (options, _) = OptionsParser.ParseFrom(new AcrophobiaGame.Options(), args);
var channel = (ITextChannel)ctx.Channel;
var game = new AcrophobiaGame(options);
if (_service.AcrophobiaGames.TryAdd(channel.Id, game))
{
try
{
game.OnStarted += Game_OnStarted;
game.OnEnded += Game_OnEnded;
game.OnVotingStarted += Game_OnVotingStarted;
game.OnUserVoted += Game_OnUserVoted;
_client.MessageReceived += ClientMessageReceived;
await game.Run();
}
finally
{
_client.MessageReceived -= ClientMessageReceived;
_service.AcrophobiaGames.TryRemove(channel.Id, out game);
game?.Dispose();
}
}
else
await ReplyErrorLocalizedAsync(strs.acro_running);
Task ClientMessageReceived(SocketMessage msg)
{
if (msg.Channel.Id != ctx.Channel.Id)
return Task.CompletedTask;
_ = Task.Run(async () =>
{
try
{
var success = await game.UserInput(msg.Author.Id, msg.Author.ToString(), msg.Content);
if (success)
await msg.DeleteAsync();
}
catch { }
});
return Task.CompletedTask;
}
}
private Task Game_OnStarted(AcrophobiaGame game)
{
var embed = _eb.Create()
.WithOkColor()
.WithTitle(GetText(strs.acrophobia))
.WithDescription(
GetText(strs.acro_started(Format.Bold(string.Join(".", game.StartingLetters)))))
.WithFooter(GetText(strs.acro_started_footer(game.Opts.SubmissionTime)));
return ctx.Channel.EmbedAsync(embed);
}
private Task Game_OnUserVoted(string user)
=> SendConfirmAsync(GetText(strs.acrophobia), GetText(strs.acro_vote_cast(Format.Bold(user))));
private async Task Game_OnVotingStarted(
AcrophobiaGame game,
ImmutableArray<KeyValuePair<AcrophobiaUser, int>> submissions)
{
if (submissions.Length == 0)
{
await SendErrorAsync(GetText(strs.acrophobia), GetText(strs.acro_ended_no_sub));
return;
}
if (submissions.Length == 1)
{
await ctx.Channel.EmbedAsync(_eb.Create()
.WithOkColor()
.WithDescription(GetText(
strs.acro_winner_only(
Format.Bold(submissions.First().Key.UserName))))
.WithFooter(submissions.First().Key.Input));
return;
}
var i = 0;
var embed = _eb.Create()
.WithOkColor()
.WithTitle(GetText(strs.acrophobia) + " - " + GetText(strs.submissions_closed))
.WithDescription(GetText(strs.acro_nym_was(
Format.Bold(string.Join(".", game.StartingLetters))
+ "\n"
+ $@"--
{submissions.Aggregate("", (agg, cur) => agg + $"`{++i}.` **{cur.Key.Input}**\n")}
--")))
.WithFooter(GetText(strs.acro_vote));
await ctx.Channel.EmbedAsync(embed);
}
private async Task Game_OnEnded(AcrophobiaGame game, ImmutableArray<KeyValuePair<AcrophobiaUser, int>> votes)
{
if (!votes.Any() || votes.All(x => x.Value == 0))
{
await SendErrorAsync(GetText(strs.acrophobia), GetText(strs.acro_no_votes_cast));
return;
}
var table = votes.OrderByDescending(v => v.Value);
var winner = table.First();
var embed = _eb.Create()
.WithOkColor()
.WithTitle(GetText(strs.acrophobia))
.WithDescription(GetText(strs.acro_winner(Format.Bold(winner.Key.UserName),
Format.Bold(winner.Value.ToString()))))
.WithFooter(winner.Key.Input);
await ctx.Channel.EmbedAsync(embed);
}
}
}

View file

@ -0,0 +1,217 @@
#nullable disable
using Ellie.Bot.Common;
using Ellie.Common.ModuleBehaviors;
using Ellie.Db.Models;
using Ellie.Modules.Games.Common;
using Ellie.Modules.Games.Common.ChatterBot;
using Ellie.Modules.Patronage;
using Ellie.Modules.Permissions;
namespace Ellie.Modules.Games.Services;
public class ChatterBotService : IExecOnMessage
{
public ConcurrentDictionary<ulong, Lazy<IChatterBotSession>> ChatterBotGuilds { get; }
public int Priority
=> 1;
private readonly FeatureLimitKey _flKey;
private readonly DiscordSocketClient _client;
private readonly IPermissionChecker _perms;
private readonly CommandHandler _cmd;
private readonly IBotStrings _strings;
private readonly IBotCredentials _creds;
private readonly IEmbedBuilderService _eb;
private readonly IHttpClientFactory _httpFactory;
private readonly IPatronageService _ps;
private readonly GamesConfigService _gcs;
public ChatterBotService(
DiscordSocketClient client,
IPermissionChecker perms,
IBot bot,
CommandHandler cmd,
IBotStrings strings,
IHttpClientFactory factory,
IBotCredentials creds,
IEmbedBuilderService eb,
IPatronageService ps,
GamesConfigService gcs)
{
_client = client;
_perms = perms;
_cmd = cmd;
_strings = strings;
_creds = creds;
_eb = eb;
_httpFactory = factory;
_ps = ps;
_perms = perms;
_gcs = gcs;
_flKey = new FeatureLimitKey()
{
Key = CleverBotResponseStr.CLEVERBOT_RESPONSE,
PrettyName = "Cleverbot Replies"
};
ChatterBotGuilds = new(bot.AllGuildConfigs
.Where(gc => gc.CleverbotEnabled)
.ToDictionary(gc => gc.GuildId,
_ => new Lazy<IChatterBotSession>(() => CreateSession(), true)));
}
public IChatterBotSession CreateSession()
{
switch (_gcs.Data.ChatBot)
{
case ChatBotImplementation.Cleverbot:
if (!string.IsNullOrWhiteSpace(_creds.CleverbotApiKey))
return new OfficialCleverbotSession(_creds.CleverbotApiKey, _httpFactory);
Log.Information("Cleverbot will not work as the api key is missing.");
return null;
case ChatBotImplementation.Gpt3:
if (!string.IsNullOrWhiteSpace(_creds.Gpt3ApiKey))
return new OfficialGpt3Session(_creds.Gpt3ApiKey,
_gcs.Data.ChatGpt.Model,
_gcs.Data.ChatGpt.MaxTokens,
_httpFactory);
Log.Information("Gpt3 will not work as the api key is missing.");
return null;
default:
return null;
}
}
public string PrepareMessage(IUserMessage msg, out IChatterBotSession cleverbot)
{
var channel = msg.Channel as ITextChannel;
cleverbot = null;
if (channel is null)
return null;
if (!ChatterBotGuilds.TryGetValue(channel.Guild.Id, out var lazyCleverbot))
return null;
cleverbot = lazyCleverbot.Value;
var nadekoId = _client.CurrentUser.Id;
var normalMention = $"<@{nadekoId}> ";
var nickMention = $"<@!{nadekoId}> ";
string message;
if (msg.Content.StartsWith(normalMention, StringComparison.InvariantCulture))
message = msg.Content[normalMention.Length..].Trim();
else if (msg.Content.StartsWith(nickMention, StringComparison.InvariantCulture))
message = msg.Content[nickMention.Length..].Trim();
else
return null;
return message;
}
public async Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg)
{
if (guild is not SocketGuild sg)
return false;
try
{
var message = PrepareMessage(usrMsg, out var cbs);
if (message is null || cbs is null)
return false;
var res = await _perms.CheckAsync(sg,
usrMsg.Channel,
usrMsg.Author,
"games",
CleverBotResponseStr.CLEVERBOT_RESPONSE);
// todo this needs checking, this might block all messages in a channel if cleverbot is enabled but blocked
// need to check what kind of block it is
// might be the case for other classes using permission checker
if (!res.IsT0)
return true;
var channel = (ITextChannel)usrMsg.Channel;
var conf = _ps.GetConfig();
if (!_creds.IsOwner(sg.OwnerId) && conf.IsEnabled)
{
var quota = await _ps.TryGetFeatureLimitAsync(_flKey, sg.OwnerId, 0);
uint? daily = quota.Quota is int dVal and < 0
? (uint)-dVal
: null;
uint? monthly = quota.Quota is int mVal and >= 0
? (uint)mVal
: null;
var maybeLimit = await _ps.TryIncrementQuotaCounterAsync(sg.OwnerId,
sg.OwnerId == usrMsg.Author.Id,
FeatureType.Limit,
_flKey.Key,
null,
daily,
monthly);
if (maybeLimit.TryPickT1(out var ql, out var counters))
{
if (ql.Quota == 0)
{
await channel.SendErrorAsync(_eb,
null!,
text:
"In order to use the cleverbot feature, the owner of this server should be [Patron Tier X](https://patreon.com/join/emotionchild) on patreon.",
footer:
"You may disable the cleverbot feature, and this message via '.cleverbot' command");
return true;
}
await channel.SendErrorAsync(_eb,
null!,
$"You've reached your quota limit of **{ql.Quota}** responses {ql.QuotaPeriod.ToFullName()} for the cleverbot feature.",
footer: "You may wait for the quota reset or .");
return true;
}
}
_ = channel.TriggerTypingAsync();
var response = await cbs.Think(message);
await channel.SendConfirmAsync(_eb,
title: null,
response.SanitizeMentions(true)
// , footer: counter > 0 ? counter.ToString() : null
);
Log.Information("""
CleverBot Executed
Server: {GuildName} [{GuildId}]
Channel: {ChannelName} [{ChannelId}]
UserId: {Author} [{AuthorId}]
Message: {Content}
""",
guild.Name,
guild.Id,
usrMsg.Channel?.Name,
usrMsg.Channel?.Id,
usrMsg.Author,
usrMsg.Author.Id,
usrMsg.Content);
return true;
}
catch (Exception ex)
{
Log.Warning(ex, "Error in cleverbot");
}
return false;
}
}

View file

@ -0,0 +1,48 @@
#nullable disable
using Ellie.Db;
using Ellie.Modules.Games.Services;
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Games;
public partial class Games
{
[Group]
public partial class ChatterBotCommands : EllieModule<ChatterBotService>
{
private readonly DbService _db;
public ChatterBotCommands(DbService db)
=> _db = db;
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageMessages)]
public async Task CleverBot()
{
var channel = (ITextChannel)ctx.Channel;
if (_service.ChatterBotGuilds.TryRemove(channel.Guild.Id, out _))
{
await using (var uow = _db.GetDbContext())
{
uow.Set<GuildConfig>().SetCleverbotEnabled(ctx.Guild.Id, false);
await uow.SaveChangesAsync();
}
await ReplyConfirmLocalizedAsync(strs.cleverbot_disabled);
return;
}
_service.ChatterBotGuilds.TryAdd(channel.Guild.Id, new(() => _service.CreateSession(), true));
await using (var uow = _db.GetDbContext())
{
uow.Set<GuildConfig>().SetCleverbotEnabled(ctx.Guild.Id, true);
await uow.SaveChangesAsync();
}
await ReplyConfirmLocalizedAsync(strs.cleverbot_enabled);
}
}
}

View file

@ -0,0 +1,8 @@
#nullable disable
namespace Ellie.Modules.Games.Common.ChatterBot;
public class CleverbotResponse
{
public string Cs { get; set; }
public string Output { get; set; }
}

View file

@ -0,0 +1,30 @@
#nullable disable
using System.Text.Json.Serialization;
namespace Ellie.Modules.Games.Common.ChatterBot;
public class Gpt3Response
{
[JsonPropertyName("choices")]
public Choice[] Choices { get; set; }
}
public class Choice
{
public string Text { get; set; }
}
public class Gpt3ApiRequest
{
[JsonPropertyName("model")]
public string Model { get; init; }
[JsonPropertyName("prompt")]
public string Prompt { get; init; }
[JsonPropertyName("temperature")]
public int Temperature { get; init; }
[JsonPropertyName("max_tokens")]
public int MaxTokens { get; init; }
}

View file

@ -0,0 +1,7 @@
#nullable disable
namespace Ellie.Modules.Games.Common.ChatterBot;
public interface IChatterBotSession
{
Task<string> Think(string input);
}

View file

@ -0,0 +1,38 @@
#nullable disable
using Newtonsoft.Json;
namespace Ellie.Modules.Games.Common.ChatterBot;
public class OfficialCleverbotSession : IChatterBotSession
{
private string QueryString
=> $"https://www.cleverbot.com/getreply?key={_apiKey}" + "&wrapper=ellie" + "&input={0}" + "&cs={1}";
private readonly string _apiKey;
private readonly IHttpClientFactory _httpFactory;
private string cs;
public OfficialCleverbotSession(string apiKey, IHttpClientFactory factory)
{
_apiKey = apiKey;
_httpFactory = factory;
}
public async Task<string> Think(string input)
{
using var http = _httpFactory.CreateClient();
var dataString = await http.GetStringAsync(string.Format(QueryString, input, cs ?? ""));
try
{
var data = JsonConvert.DeserializeObject<CleverbotResponse>(dataString);
cs = data?.Cs;
return data?.Output;
}
catch
{
Log.Warning("Unexpected cleverbot response received: {ResponseString}", dataString);
return null;
}
}
}

View file

@ -0,0 +1,68 @@
#nullable disable
using Newtonsoft.Json;
using System.Net.Http.Json;
namespace Ellie.Modules.Games.Common.ChatterBot;
public class OfficialGpt3Session : IChatterBotSession
{
private string Uri
=> $"https://api.openai.com/v1/completions";
private readonly string _apiKey;
private readonly string _model;
private readonly int _maxTokens;
private readonly IHttpClientFactory _httpFactory;
public OfficialGpt3Session(
string apiKey,
Gpt3Model model,
int maxTokens,
IHttpClientFactory factory)
{
_apiKey = apiKey;
_httpFactory = factory;
switch (model)
{
case Gpt3Model.Ada001:
_model = "text-ada-001";
break;
case Gpt3Model.Babbage001:
_model = "text-babbage-001";
break;
case Gpt3Model.Curie001:
_model = "text-curie-001";
break;
case Gpt3Model.Davinci003:
_model = "text-davinci-003";
break;
}
_maxTokens = maxTokens;
}
public async Task<string> Think(string input)
{
using var http = _httpFactory.CreateClient();
http.DefaultRequestHeaders.Authorization = new("Bearer", _apiKey);
var data = await http.PostAsJsonAsync(Uri, new Gpt3ApiRequest()
{
Model = _model,
Prompt = input,
MaxTokens = _maxTokens,
Temperature = 1,
});
var dataString = await data.Content.ReadAsStringAsync();
try
{
var response = JsonConvert.DeserializeObject<Gpt3Response>(dataString);
return response?.Choices[0]?.Text;
}
catch
{
Log.Warning("Unexpected GPT-3 response received: {ResponseString}", dataString);
return null;
}
}
}

View file

@ -0,0 +1,150 @@
#nullable disable
using Ellie.Modules.Games.Common;
using Ellie.Modules.Games.Services;
namespace Ellie.Modules.Games;
/* more games
- Shiritori
- Simple RPG adventure
*/
public partial class Games : EllieModule<GamesService>
{
private readonly IImageCache _images;
private readonly IHttpClientFactory _httpFactory;
private readonly Random _rng = new();
public Games(IImageCache images, IHttpClientFactory factory)
{
_images = images;
_httpFactory = factory;
}
[Cmd]
public async Task Choose([Leftover] string list = null)
{
if (string.IsNullOrWhiteSpace(list))
return;
var listArr = list.Split(';');
if (listArr.Length < 2)
return;
var rng = new EllieRandom();
await SendConfirmAsync("🤔", listArr[rng.Next(0, listArr.Length)]);
}
[Cmd]
public async Task EightBall([Leftover] string question = null)
{
if (string.IsNullOrWhiteSpace(question))
return;
var res = _service.GetEightballResponse(ctx.User.Id, question);
await ctx.Channel.EmbedAsync(_eb.Create()
.WithOkColor()
.WithDescription(ctx.User.ToString())
.AddField("❓ " + GetText(strs.question), question)
.AddField("🎱 " + GetText(strs._8ball), res));
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task RateGirl([Leftover] IGuildUser usr)
{
var gr = _service.GirlRatings.GetOrAdd(usr.Id, GetGirl);
var originalStream = await gr.Stream;
if (originalStream is null)
{
await ReplyErrorLocalizedAsync(strs.something_went_wrong);
return;
}
await using var imgStream = new MemoryStream();
lock (gr)
{
originalStream.Position = 0;
originalStream.CopyTo(imgStream);
}
imgStream.Position = 0;
await ctx.Channel.SendFileAsync(imgStream,
$"rating.png",
Format.Bold($"{ctx.User.Mention} Girl Rating For {usr}"),
embed: _eb.Create()
.WithOkColor()
.AddField("Hot", gr.Hot.ToString("F2"), true)
.AddField("Crazy", gr.Crazy.ToString("F2"), true)
.AddField("Advice", gr.Advice)
.WithImageUrl($"attachment://rating.png")
.Build());
}
private double NextDouble(double x, double y)
=> (_rng.NextDouble() * (y - x)) + x;
private GirlRating GetGirl(ulong uid)
{
var rng = new EllieRandom();
var roll = rng.Next(1, 1001);
var ratings = _service.Ratings.GetAwaiter().GetResult();
double hot;
double crazy;
string advice;
if (roll < 500)
{
hot = NextDouble(0, 5);
crazy = NextDouble(4, 10);
advice = ratings.Nog;
}
else if (roll < 750)
{
hot = NextDouble(5, 8);
crazy = NextDouble(4, (.6 * hot) + 4);
advice = ratings.Fun;
}
else if (roll < 900)
{
hot = NextDouble(5, 10);
crazy = NextDouble((.61 * hot) + 4, 10);
advice = ratings.Dan;
}
else if (roll < 951)
{
hot = NextDouble(8, 10);
crazy = NextDouble(7, (.6 * hot) + 4);
advice = ratings.Dat;
}
else if (roll < 990)
{
hot = NextDouble(8, 10);
crazy = NextDouble(5, 7);
advice = ratings.Wif;
}
else if (roll < 999)
{
hot = NextDouble(8, 10);
crazy = NextDouble(2, 3.99d);
advice = ratings.Tra;
}
else
{
hot = NextDouble(8, 10);
crazy = NextDouble(4, 5);
advice = ratings.Uni;
}
return new(_images, crazy, hot, roll, advice);
}
[Cmd]
public async Task Linux(string guhnoo, string loonix)
=> await SendConfirmAsync(
$@"I'd just like to interject for moment. What you're refering to as {loonix}, is in fact, {guhnoo}/{loonix}, or as I've recently taken to calling it, {guhnoo} plus {loonix}. {loonix} is not an operating system unto itself, but rather another free component of a fully functioning {guhnoo} system made useful by the {guhnoo} corelibs, shell utilities and vital system components comprising a full OS as defined by POSIX.
Many computer users run a modified version of the {guhnoo} system every day, without realizing it. Through a peculiar turn of events, the version of {guhnoo} which is widely used today is often called {loonix}, and many of its users are not aware that it is basically the {guhnoo} system, developed by the {guhnoo} Project.
There really is a {loonix}, and these people are using it, but it is just a part of the system they use. {loonix} is the kernel: the program in the system that allocates the machine's resources to the other programs that you run. The kernel is an essential part of an operating system, but useless by itself; it can only function in the context of a complete operating system. {loonix} is normally used in combination with the {guhnoo} operating system: the whole system is basically {guhnoo} with {loonix} added, or {guhnoo}/{loonix}. All the so-called {loonix} distributions are really distributions of {guhnoo}/{loonix}.");
}

View file

@ -0,0 +1,160 @@
#nullable disable
using Cloneable;
using Ellie.Common.Yml;
namespace Ellie.Modules.Games.Common;
[Cloneable]
public sealed partial class GamesConfig : ICloneable<GamesConfig>
{
[Comment("DO NOT CHANGE")]
public int Version { get; set; } = 2;
[Comment("Hangman related settings (.hangman command)")]
public HangmanConfig Hangman { get; set; } = new()
{
CurrencyReward = 0
};
[Comment("Trivia related settings (.t command)")]
public TriviaConfig Trivia { get; set; } = new()
{
CurrencyReward = 0,
MinimumWinReq = 1
};
[Comment("List of responses for the .8ball command. A random one will be selected every time")]
public List<string> EightBallResponses { get; set; } = new()
{
"Most definitely yes.",
"For sure.",
"Totally!",
"Of course!",
"As I see it, yes.",
"My sources say yes.",
"Yes.",
"Most likely.",
"Perhaps...",
"Maybe...",
"Hm, not sure.",
"It is uncertain.",
"Ask me again later.",
"Don't count on it.",
"Probably not.",
"Very doubtful.",
"Most likely no.",
"Nope.",
"No.",
"My sources say no.",
"Don't even think about it.",
"Definitely no.",
"NO - It may cause disease contraction!"
};
[Comment("List of animals which will be used for the animal race game (.race)")]
public List<RaceAnimal> RaceAnimals { get; set; } = new()
{
new()
{
Icon = "🐼",
Name = "Panda"
},
new()
{
Icon = "🐻",
Name = "Bear"
},
new()
{
Icon = "🐧",
Name = "Pengu"
},
new()
{
Icon = "🐨",
Name = "Koala"
},
new()
{
Icon = "🐬",
Name = "Dolphin"
},
new()
{
Icon = "🐞",
Name = "Ladybird"
},
new()
{
Icon = "🦀",
Name = "Crab"
},
new()
{
Icon = "🦄",
Name = "Unicorn"
}
};
[Comment(@"Which chatbot API should bot use.
'cleverbot' - bot will use Cleverbot API.
'gpt3' - bot will use GPT-3 API")]
public ChatBotImplementation ChatBot { get; set; } = ChatBotImplementation.Gpt3;
public ChatGptConfig ChatGpt { get; set; } = new();
}
[Cloneable]
public sealed partial class ChatGptConfig
{
[Comment(@"Which GPT-3 Model should bot use.
'ada001' - cheapest and fastest
'babbage001' - 2nd option
'curie001' - 3rd option
'davinci003' - Most expensive, slowest")]
public Gpt3Model Model { get; set; } = Gpt3Model.Ada001;
[Comment(@"The maximum number of tokens to use per GPT-3 API call")]
public int MaxTokens { get; set; } = 100;
}
[Cloneable]
public sealed partial class HangmanConfig
{
[Comment("The amount of currency awarded to the winner of a hangman game")]
public long CurrencyReward { get; set; }
}
[Cloneable]
public sealed partial class TriviaConfig
{
[Comment("The amount of currency awarded to the winner of the trivia game.")]
public long CurrencyReward { get; set; }
[Comment("""
Users won't be able to start trivia games which have
a smaller win requirement than the one specified by this setting.
""")]
public int MinimumWinReq { get; set; } = 1;
}
[Cloneable]
public sealed partial class RaceAnimal
{
public string Icon { get; set; }
public string Name { get; set; }
}
public enum ChatBotImplementation
{
Cleverbot,
Gpt3
}
public enum Gpt3Model
{
Ada001,
Babbage001,
Curie001,
Davinci003
}

View file

@ -0,0 +1,72 @@
#nullable disable
using Ellie.Common.Configs;
using Ellie.Modules.Games.Common;
namespace Ellie.Modules.Games.Services;
public sealed class GamesConfigService : ConfigServiceBase<GamesConfig>
{
private const string FILE_PATH = "data/games.yml";
private static readonly TypedKey<GamesConfig> _changeKey = new("config.games.updated");
public override string Name { get; } = "games";
public GamesConfigService(IConfigSeria serializer, IPubSub pubSub)
: base(FILE_PATH, serializer, pubSub, _changeKey)
{
AddParsedProp("trivia.min_win_req",
gs => gs.Trivia.MinimumWinReq,
int.TryParse,
ConfigPrinters.ToString,
val => val > 0);
AddParsedProp("trivia.currency_reward",
gs => gs.Trivia.CurrencyReward,
long.TryParse,
ConfigPrinters.ToString,
val => val >= 0);
AddParsedProp("hangman.currency_reward",
gs => gs.Hangman.CurrencyReward,
long.TryParse,
ConfigPrinters.ToString,
val => val >= 0);
AddParsedProp("chatbot",
gs => gs.ChatBot,
ConfigParsers.InsensitiveEnum,
ConfigPrinters.ToString);
AddParsedProp("gpt.model",
gs => gs.ChatGpt.Model,
ConfigParsers.InsensitiveEnum,
ConfigPrinters.ToString);
AddParsedProp("gpt.max_tokens",
gs => gs.ChatGpt.MaxTokens,
int.TryParse,
ConfigPrinters.ToString,
val => val > 0);
Migrate();
}
private void Migrate()
{
if (data.Version < 1)
{
ModifyConfig(c =>
{
c.Version = 1;
c.Hangman = new()
{
CurrencyReward = 0
};
});
}
if (data.Version < 2)
{
ModifyConfig(c =>
{
c.Version = 2;
c.ChatBot = ChatBotImplementation.Cleverbot;
});
}
}
}

View file

@ -0,0 +1,118 @@
#nullable disable
using Microsoft.Extensions.Caching.Memory;
using Ellie.Common.ModuleBehaviors;
using Ellie.Modules.Games.Common;
using Ellie.Modules.Games.Common.Acrophobia;
using Ellie.Modules.Games.Common.Nunchi;
using Newtonsoft.Json;
namespace Ellie.Modules.Games.Services;
public class GamesService : IEService, IReadyExecutor
{
private const string TYPING_ARTICLES_PATH = "data/typing_articles3.json";
public ConcurrentDictionary<ulong, GirlRating> GirlRatings { get; } = new();
public IReadOnlyList<string> EightBallResponses
=> _gamesConfig.Data.EightBallResponses;
public List<TypingArticle> TypingArticles { get; } = new();
//channelId, game
public ConcurrentDictionary<ulong, AcrophobiaGame> AcrophobiaGames { get; } = new();
public Dictionary<ulong, TicTacToe> TicTacToeGames { get; } = new();
public ConcurrentDictionary<ulong, TypingGame> RunningContests { get; } = new();
public ConcurrentDictionary<ulong, NunchiGame> NunchiGames { get; } = new();
public AsyncLazy<RatingTexts> Ratings { get; }
private readonly GamesConfigService _gamesConfig;
private readonly IHttpClientFactory _httpFactory;
private readonly IMemoryCache _8BallCache;
private readonly Random _rng;
public GamesService(GamesConfigService gamesConfig, IHttpClientFactory httpFactory)
{
_gamesConfig = gamesConfig;
_httpFactory = httpFactory;
_8BallCache = new MemoryCache(new MemoryCacheOptions
{
SizeLimit = 500_000
});
Ratings = new(GetRatingTexts);
_rng = new EllieRandom();
try
{
TypingArticles = JsonConvert.DeserializeObject<List<TypingArticle>>(File.ReadAllText(TYPING_ARTICLES_PATH));
}
catch (Exception ex)
{
Log.Warning(ex, "Error while loading typing articles: {ErrorMessage}", ex.Message);
TypingArticles = new();
}
}
public async Task OnReadyAsync()
{
// reset rating once a day
using var timer = new PeriodicTimer(TimeSpan.FromDays(1));
while (await timer.WaitForNextTickAsync())
GirlRatings.Clear();
}
private async Task<RatingTexts> GetRatingTexts()
{
using var http = _httpFactory.CreateClient();
var text = await http.GetStringAsync(
"https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/rategirl/rates.json");
return JsonConvert.DeserializeObject<RatingTexts>(text);
}
public void AddTypingArticle(IUser user, string text)
{
TypingArticles.Add(new()
{
Source = user.ToString(),
Extra = $"Text added on {DateTime.UtcNow} by {user}.",
Text = text.SanitizeMentions(true)
});
File.WriteAllText(TYPING_ARTICLES_PATH, JsonConvert.SerializeObject(TypingArticles));
}
public string GetEightballResponse(ulong userId, string question)
=> _8BallCache.GetOrCreate($"8ball:{userId}:{question}",
e =>
{
e.Size = question.Length;
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12);
return EightBallResponses[_rng.Next(0, EightBallResponses.Count)];
});
public TypingArticle RemoveTypingArticle(int index)
{
var articles = TypingArticles;
if (index < 0 || index >= articles.Count)
return null;
var removed = articles[index];
TypingArticles.RemoveAt(index);
File.WriteAllText(TYPING_ARTICLES_PATH, JsonConvert.SerializeObject(articles));
return removed;
}
public class RatingTexts
{
public string Nog { get; set; }
public string Tra { get; set; }
public string Fun { get; set; }
public string Uni { get; set; }
public string Wif { get; set; }
public string Dat { get; set; }
public string Dan { get; set; }
}
}

View file

@ -0,0 +1,61 @@
#nullable disable
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using Image = SixLabors.ImageSharp.Image;
namespace Ellie.Modules.Games.Common;
public class GirlRating
{
public double Crazy { get; }
public double Hot { get; }
public int Roll { get; }
public string Advice { get; }
public AsyncLazy<Stream> Stream { get; }
private readonly IImageCache _images;
public GirlRating(
IImageCache images,
double crazy,
double hot,
int roll,
string advice)
{
_images = images;
Crazy = crazy;
Hot = hot;
Roll = roll;
Advice = advice; // convenient to have it here, even though atm there are only few different ones.
Stream = new(async () =>
{
try
{
var bgBytes = await _images.GetRategirlBgAsync();
using var img = Image.Load(bgBytes);
const int minx = 35;
const int miny = 385;
const int length = 345;
var pointx = (int)(minx + (length * (Hot / 10)));
var pointy = (int)(miny - (length * ((Crazy - 4) / 6)));
var dotBytes = await _images.GetRategirlDotAsync();
using (var pointImg = Image.Load(dotBytes))
{
img.Mutate(x => x.DrawImage(pointImg, new(pointx - 10, pointy - 10), new GraphicsOptions()));
}
var imgStream = new MemoryStream();
img.SaveAsPng(imgStream);
return imgStream;
}
catch (Exception ex)
{
Log.Warning(ex, "Error getting RateGirl image");
return null;
}
});
}
}

View file

@ -0,0 +1,64 @@
using Ellie.Common.Yml;
using System.Diagnostics.CodeAnalysis;
namespace Ellie.Modules.Games.Hangman;
public sealed class DefaultHangmanSource : IHangmanSource
{
private IReadOnlyDictionary<string, HangmanTerm[]> termsDict = new Dictionary<string, HangmanTerm[]>();
private readonly Random _rng;
public DefaultHangmanSource()
{
_rng = new EllieRandom();
Reload();
}
public void Reload()
{
if (!Directory.Exists("data/hangman"))
{
Log.Error("Hangman game won't work. Folder 'data/hangman' is missing");
return;
}
var qs = new Dictionary<string, HangmanTerm[]>();
foreach (var file in Directory.EnumerateFiles("data/hangman/", "*.yml"))
{
try
{
var data = Yaml.Deserializer.Deserialize<HangmanTerm[]>(File.ReadAllText(file));
qs[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = data;
}
catch (Exception ex)
{
Log.Error(ex, "Loading {HangmanFile} failed", file);
}
}
termsDict = qs;
Log.Information("Loaded {HangmanCategoryCount} hangman categories", qs.Count);
}
public IReadOnlyCollection<string> GetCategories()
=> termsDict.Keys.ToList();
public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term)
{
if (category is null)
{
var cats = GetCategories();
category = cats.ElementAt(_rng.Next(0, cats.Count));
}
if (termsDict.TryGetValue(category, out var terms))
{
term = terms[_rng.Next(0, terms.Length)];
return true;
}
term = null;
return false;
}
}

View file

@ -0,0 +1,76 @@
using Ellie.Modules.Games.Hangman;
namespace Ellie.Modules.Games;
public partial class Games
{
[Group]
public partial class HangmanCommands : EllieModule<IHangmanService>
{
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Hangmanlist()
=> await SendConfirmAsync(GetText(strs.hangman_types(prefix)), _service.GetHangmanTypes().Join('\n'));
private static string Draw(HangmanGame.State state)
=> $"""
.
................
................
.{(state.Errors > 0 ? ".............😲" : "")}
.{(state.Errors > 1 ? "............./" : "")} {(state.Errors > 2 ? "|" : "")} {(state.Errors > 3 ? "\\" : "")}
.{(state.Errors > 4 ? "............../" : "")} {(state.Errors > 5 ? "\\" : "")}
/-\
""";
public static IEmbedBuilder GetEmbed(IEmbedBuilderService eb, HangmanGame.State state)
{
if (state.Phase == HangmanGame.Phase.Running)
{
return eb.Create()
.WithOkColor()
.AddField("Hangman", Draw(state))
.AddField("Guess", Format.Code(state.Word))
.WithFooter(state.MissedLetters.Join(' '));
}
if (state.Phase == HangmanGame.Phase.Ended && state.Failed)
{
return eb.Create()
.WithErrorColor()
.AddField("Hangman", Draw(state))
.AddField("Guess", Format.Code(state.Word))
.WithFooter(state.MissedLetters.Join(' '));
}
return eb.Create()
.WithOkColor()
.AddField("Hangman", Draw(state))
.AddField("Guess", Format.Code(state.Word))
.WithFooter(state.MissedLetters.Join(' '));
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Hangman([Leftover] string? type = null)
{
if (!_service.StartHangman(ctx.Channel.Id, type, out var hangman))
{
await ReplyErrorLocalizedAsync(strs.hangman_running);
return;
}
var eb = GetEmbed(_eb, hangman);
eb.WithDescription(GetText(strs.hangman_game_started));
await ctx.Channel.EmbedAsync(eb);
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task HangmanStop()
{
if (await _service.StopHangman(ctx.Channel.Id))
await ReplyConfirmLocalizedAsync(strs.hangman_stopped);
}
}
}

View file

@ -0,0 +1,112 @@
#nullable disable
namespace Ellie.Modules.Games.Hangman;
public sealed class HangmanGame
{
public enum GuessResult { NoAction, AlreadyTried, Incorrect, Guess, Win }
public enum Phase { Running, Ended }
private Phase CurrentPhase { get; set; }
private readonly HashSet<char> _incorrect = new();
private readonly HashSet<char> _correct = new();
private readonly HashSet<char> _remaining = new();
private readonly string _word;
private readonly string _imageUrl;
public HangmanGame(HangmanTerm term)
{
_word = term.Word;
_imageUrl = term.ImageUrl;
_remaining = _word.ToLowerInvariant().Where(x => char.IsLetter(x)).Select(char.ToLowerInvariant).ToHashSet();
}
public State GetState(GuessResult guessResult = GuessResult.NoAction)
=> new(_incorrect.Count,
CurrentPhase,
CurrentPhase == Phase.Ended ? _word : GetScrambledWord(),
guessResult,
_incorrect.ToList(),
CurrentPhase == Phase.Ended ? _imageUrl : string.Empty);
private string GetScrambledWord()
{
Span<char> output = stackalloc char[_word.Length * 2];
for (var i = 0; i < _word.Length; i++)
{
var ch = _word[i];
if (ch == ' ')
output[i * 2] = '';
if (!char.IsLetter(ch) || !_remaining.Contains(char.ToLowerInvariant(ch)))
output[i * 2] = ch;
else
output[i * 2] = '_';
output[(i * 2) + 1] = ' ';
}
return new(output);
}
public State Guess(string guess)
{
if (CurrentPhase != Phase.Running)
return GetState();
guess = guess.Trim();
if (guess.Length > 1)
{
if (guess.Equals(_word, StringComparison.InvariantCultureIgnoreCase))
{
CurrentPhase = Phase.Ended;
return GetState(GuessResult.Win);
}
return GetState();
}
var charGuess = guess[0];
if (!char.IsLetter(charGuess))
return GetState();
if (_incorrect.Contains(charGuess) || _correct.Contains(charGuess))
return GetState(GuessResult.AlreadyTried);
if (_remaining.Remove(charGuess))
{
if (_remaining.Count == 0)
{
CurrentPhase = Phase.Ended;
return GetState(GuessResult.Win);
}
_correct.Add(charGuess);
return GetState(GuessResult.Guess);
}
_incorrect.Add(charGuess);
if (_incorrect.Count > 5)
{
CurrentPhase = Phase.Ended;
return GetState(GuessResult.Incorrect);
}
return GetState(GuessResult.Incorrect);
}
public record State(
int Errors,
Phase Phase,
string Word,
GuessResult GuessResult,
List<char> MissedLetters,
string ImageUrl)
{
public bool Failed
=> Errors > 5;
}
}

View file

@ -0,0 +1,136 @@
using Microsoft.Extensions.Caching.Memory;
using Ellie.Common.ModuleBehaviors;
using Ellie.Modules.Games.Services;
using System.Diagnostics.CodeAnalysis;
namespace Ellie.Modules.Games.Hangman;
public sealed class HangmanService : IHangmanService, IExecNoCommand
{
private readonly ConcurrentDictionary<ulong, HangmanGame> _hangmanGames = new();
private readonly IHangmanSource _source;
private readonly IEmbedBuilderService _eb;
private readonly GamesConfigService _gcs;
private readonly ICurrencyService _cs;
private readonly IMemoryCache _cdCache;
private readonly object _locker = new();
public HangmanService(
IHangmanSource source,
IEmbedBuilderService eb,
GamesConfigService gcs,
ICurrencyService cs,
IMemoryCache cdCache)
{
_source = source;
_eb = eb;
_gcs = gcs;
_cs = cs;
_cdCache = cdCache;
}
public bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? state)
{
state = null;
if (!_source.GetTerm(category, out var term))
return false;
var game = new HangmanGame(term);
lock (_locker)
{
var hc = _hangmanGames.GetOrAdd(channelId, game);
if (hc == game)
{
state = hc.GetState();
return true;
}
return false;
}
}
public ValueTask<bool> StopHangman(ulong channelId)
{
lock (_locker)
{
if (_hangmanGames.TryRemove(channelId, out _))
return new(true);
}
return new(false);
}
public IReadOnlyCollection<string> GetHangmanTypes()
=> _source.GetCategories();
public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
{
if (_hangmanGames.ContainsKey(msg.Channel.Id))
{
if (string.IsNullOrWhiteSpace(msg.Content))
return;
if (_cdCache.TryGetValue(msg.Author.Id, out _))
return;
HangmanGame.State state;
long rew = 0;
lock (_locker)
{
if (!_hangmanGames.TryGetValue(msg.Channel.Id, out var game))
return;
state = game.Guess(msg.Content.ToLowerInvariant());
if (state.GuessResult == HangmanGame.GuessResult.NoAction)
return;
if (state.GuessResult is HangmanGame.GuessResult.Incorrect or HangmanGame.GuessResult.AlreadyTried)
{
_cdCache.Set(msg.Author.Id,
string.Empty,
new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3)
});
}
if (state.Phase == HangmanGame.Phase.Ended)
{
if (_hangmanGames.TryRemove(msg.Channel.Id, out _))
rew = _gcs.Data.Hangman.CurrencyReward;
}
}
if (rew > 0)
await _cs.AddAsync(msg.Author, rew, new("hangman", "win"));
await SendState((ITextChannel)msg.Channel, msg.Author, msg.Content, state);
}
}
private Task<IUserMessage> SendState(
ITextChannel channel,
IUser user,
string content,
HangmanGame.State state)
{
var embed = Games.HangmanCommands.GetEmbed(_eb, state);
if (state.GuessResult == HangmanGame.GuessResult.Guess)
embed.WithDescription($"{user} guessed the letter {content}!").WithOkColor();
else if (state.GuessResult == HangmanGame.GuessResult.Incorrect && state.Failed)
embed.WithDescription($"{user} Letter {content} doesn't exist! Game over!").WithErrorColor();
else if (state.GuessResult == HangmanGame.GuessResult.Incorrect)
embed.WithDescription($"{user} Letter {content} doesn't exist!").WithErrorColor();
else if (state.GuessResult == HangmanGame.GuessResult.AlreadyTried)
embed.WithDescription($"{user} Letter {content} has already been used.").WithPendingColor();
else if (state.GuessResult == HangmanGame.GuessResult.Win)
embed.WithDescription($"{user} won!").WithOkColor();
if (!string.IsNullOrWhiteSpace(state.ImageUrl) && Uri.IsWellFormedUriString(state.ImageUrl, UriKind.Absolute))
embed.WithImageUrl(state.ImageUrl);
return channel.EmbedAsync(embed);
}
}

View file

@ -0,0 +1,8 @@
#nullable disable
namespace Ellie.Modules.Games.Hangman;
public sealed class HangmanTerm
{
public string Word { get; set; }
public string ImageUrl { get; set; }
}

View file

@ -0,0 +1,10 @@
using System.Diagnostics.CodeAnalysis;
namespace Ellie.Modules.Games.Hangman;
public interface IHangmanService
{
bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? hangmanController);
ValueTask<bool> StopHangman(ulong channelId);
IReadOnlyCollection<string> GetHangmanTypes();
}

View file

@ -0,0 +1,10 @@
using System.Diagnostics.CodeAnalysis;
namespace Ellie.Modules.Games.Hangman;
public interface IHangmanSource : IEService
{
public IReadOnlyCollection<string> GetCategories();
public void Reload();
public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term);
}

View file

@ -0,0 +1,183 @@
#nullable disable
using System.Collections.Immutable;
namespace Ellie.Modules.Games.Common.Nunchi;
public sealed class NunchiGame : IDisposable
{
public enum Phase
{
Joining,
Playing,
WaitingForNextRound,
Ended
}
private const int KILL_TIMEOUT = 20 * 1000;
private const int NEXT_ROUND_TIMEOUT = 5 * 1000;
public event Func<NunchiGame, Task> OnGameStarted;
public event Func<NunchiGame, int, Task> OnRoundStarted;
public event Func<NunchiGame, Task> OnUserGuessed;
public event Func<NunchiGame, (ulong Id, string Name)?, Task> OnRoundEnded; // tuple of the user who failed
public event Func<NunchiGame, string, Task> OnGameEnded; // name of the user who won
public int CurrentNumber { get; private set; } = new EllieRandom().Next(0, 100);
public Phase CurrentPhase { get; private set; } = Phase.Joining;
public ImmutableArray<(ulong Id, string Name)> Participants
=> participants.ToImmutableArray();
public int ParticipantCount
=> participants.Count;
private readonly SemaphoreSlim _locker = new(1, 1);
private HashSet<(ulong Id, string Name)> participants = new();
private readonly HashSet<(ulong Id, string Name)> _passed = new();
private Timer killTimer;
public NunchiGame(ulong creatorId, string creatorName)
=> participants.Add((creatorId, creatorName));
public async Task<bool> Join(ulong userId, string userName)
{
await _locker.WaitAsync();
try
{
if (CurrentPhase != Phase.Joining)
return false;
return participants.Add((userId, userName));
}
finally { _locker.Release(); }
}
public async Task<bool> Initialize()
{
CurrentPhase = Phase.Joining;
await Task.Delay(30000);
await _locker.WaitAsync();
try
{
if (participants.Count < 3)
{
CurrentPhase = Phase.Ended;
return false;
}
killTimer = new(async _ =>
{
await _locker.WaitAsync();
try
{
if (CurrentPhase != Phase.Playing)
return;
//if some players took too long to type a number, boot them all out and start a new round
participants = new HashSet<(ulong, string)>(_passed);
EndRound();
}
finally { _locker.Release(); }
},
null,
KILL_TIMEOUT,
KILL_TIMEOUT);
CurrentPhase = Phase.Playing;
_ = OnGameStarted?.Invoke(this);
_ = OnRoundStarted?.Invoke(this, CurrentNumber);
return true;
}
finally { _locker.Release(); }
}
public async Task Input(ulong userId, string userName, int input)
{
await _locker.WaitAsync();
try
{
if (CurrentPhase != Phase.Playing)
return;
var userTuple = (Id: userId, Name: userName);
// if the user is not a member of the race,
// or he already successfully typed the number
// ignore the input
if (!participants.Contains(userTuple) || !_passed.Add(userTuple))
return;
//if the number is correct
if (CurrentNumber == input - 1)
{
//increment current number
++CurrentNumber;
if (_passed.Count == participants.Count - 1)
{
// if only n players are left, and n - 1 type the correct number, round is over
// if only 2 players are left, game is over
if (participants.Count == 2)
{
killTimer.Change(Timeout.Infinite, Timeout.Infinite);
CurrentPhase = Phase.Ended;
_ = OnGameEnded?.Invoke(this, userTuple.Name);
}
else // else just start the new round without the user who was the last
{
var failure = participants.Except(_passed).First();
OnUserGuessed?.Invoke(this);
EndRound(failure);
return;
}
}
OnUserGuessed?.Invoke(this);
}
else
{
//if the user failed
EndRound(userTuple);
}
}
finally { _locker.Release(); }
}
private void EndRound((ulong, string)? failure = null)
{
killTimer.Change(KILL_TIMEOUT, KILL_TIMEOUT);
CurrentNumber = new EllieRandom().Next(0, 100); // reset the counter
_passed.Clear(); // reset all users who passed (new round starts)
if (failure is not null)
participants.Remove(failure.Value); // remove the dude who failed from the list of players
_ = OnRoundEnded?.Invoke(this, failure);
if (participants.Count <= 1) // means we have a winner or everyone was booted out
{
killTimer.Change(Timeout.Infinite, Timeout.Infinite);
CurrentPhase = Phase.Ended;
_ = OnGameEnded?.Invoke(this, participants.Count > 0 ? participants.First().Name : null);
return;
}
CurrentPhase = Phase.WaitingForNextRound;
Task.Run(async () =>
{
await Task.Delay(NEXT_ROUND_TIMEOUT);
CurrentPhase = Phase.Playing;
_ = OnRoundStarted?.Invoke(this, CurrentNumber);
});
}
public void Dispose()
{
OnGameEnded = null;
OnGameStarted = null;
OnRoundEnded = null;
OnRoundStarted = null;
OnUserGuessed = null;
}
}

View file

@ -0,0 +1,111 @@
#nullable disable
using Ellie.Modules.Games.Common.Nunchi;
using Ellie.Modules.Games.Services;
namespace Ellie.Modules.Games;
public partial class Games
{
[Group]
public partial class NunchiCommands : EllieModule<GamesService>
{
private readonly DiscordSocketClient _client;
public NunchiCommands(DiscordSocketClient client)
=> _client = client;
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Nunchi()
{
var newNunchi = new NunchiGame(ctx.User.Id, ctx.User.ToString());
NunchiGame nunchi;
//if a game was already active
if ((nunchi = _service.NunchiGames.GetOrAdd(ctx.Guild.Id, newNunchi)) != newNunchi)
{
// join it
if (!await nunchi.Join(ctx.User.Id, ctx.User.ToString()))
// if you failed joining, that means game is running or just ended
// await ReplyErrorLocalized("nunchi_already_started");
return;
await ReplyErrorLocalizedAsync(strs.nunchi_joined(nunchi.ParticipantCount));
return;
}
try { await ConfirmLocalizedAsync(strs.nunchi_created); }
catch { }
nunchi.OnGameEnded += NunchiOnGameEnded;
//nunchi.OnGameStarted += Nunchi_OnGameStarted;
nunchi.OnRoundEnded += Nunchi_OnRoundEnded;
nunchi.OnUserGuessed += Nunchi_OnUserGuessed;
nunchi.OnRoundStarted += Nunchi_OnRoundStarted;
_client.MessageReceived += ClientMessageReceived;
var success = await nunchi.Initialize();
if (!success)
{
if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game))
game.Dispose();
await ConfirmLocalizedAsync(strs.nunchi_failed_to_start);
}
Task ClientMessageReceived(SocketMessage arg)
{
_ = Task.Run(async () =>
{
if (arg.Channel.Id != ctx.Channel.Id)
return;
if (!int.TryParse(arg.Content, out var number))
return;
try
{
await nunchi.Input(arg.Author.Id, arg.Author.ToString(), number);
}
catch
{
}
});
return Task.CompletedTask;
}
Task NunchiOnGameEnded(NunchiGame arg1, string arg2)
{
if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game))
{
_client.MessageReceived -= ClientMessageReceived;
game.Dispose();
}
if (arg2 is null)
return ConfirmLocalizedAsync(strs.nunchi_ended_no_winner);
return ConfirmLocalizedAsync(strs.nunchi_ended(Format.Bold(arg2)));
}
}
private Task Nunchi_OnRoundStarted(NunchiGame arg, int cur)
=> ConfirmLocalizedAsync(strs.nunchi_round_started(Format.Bold(arg.ParticipantCount.ToString()),
Format.Bold(cur.ToString())));
private Task Nunchi_OnUserGuessed(NunchiGame arg)
=> ConfirmLocalizedAsync(strs.nunchi_next_number(Format.Bold(arg.CurrentNumber.ToString())));
private Task Nunchi_OnRoundEnded(NunchiGame arg1, (ulong Id, string Name)? arg2)
{
if (arg2.HasValue)
return ConfirmLocalizedAsync(strs.nunchi_round_ended(Format.Bold(arg2.Value.Name)));
return ConfirmLocalizedAsync(strs.nunchi_round_ended_boot(
Format.Bold("\n"
+ string.Join("\n, ",
arg1.Participants.Select(x
=> x.Name))))); // this won't work if there are too many users
}
private Task Nunchi_OnGameStarted(NunchiGame arg)
=> ConfirmLocalizedAsync(strs.nunchi_started(Format.Bold(arg.ParticipantCount.ToString())));
}
}

View file

@ -0,0 +1,102 @@
#nullable disable
using Ellie.Modules.Games.Services;
using Ellie.Services.Database.Models;
using System.Text;
namespace Ellie.Modules.Games;
public partial class Games
{
[Group]
public partial class PollCommands : EllieModule<PollService>
{
private readonly DiscordSocketClient _client;
public PollCommands(DiscordSocketClient client)
=> _client = client;
[Cmd]
[UserPerm(GuildPerm.ManageMessages)]
[RequireContext(ContextType.Guild)]
public async Task Poll([Leftover] string arg)
{
if (string.IsNullOrWhiteSpace(arg))
return;
var poll = _service.CreatePoll(ctx.Guild.Id, ctx.Channel.Id, arg);
if (poll is null)
{
await ReplyErrorLocalizedAsync(strs.poll_invalid_input);
return;
}
if (_service.StartPoll(poll))
{
await ctx.Channel.EmbedAsync(_eb.Create()
.WithOkColor()
.WithTitle(GetText(strs.poll_created(ctx.User.ToString())))
.WithDescription(Format.Bold(poll.Question)
+ "\n\n"
+ string.Join("\n",
poll.Answers.Select(x
=> $"`{x.Index + 1}.` {Format.Bold(x.Text)}"))));
}
else
await ReplyErrorLocalizedAsync(strs.poll_already_running);
}
[Cmd]
[UserPerm(GuildPerm.ManageMessages)]
[RequireContext(ContextType.Guild)]
public async Task PollStats()
{
if (!_service.ActivePolls.TryGetValue(ctx.Guild.Id, out var pr))
return;
await ctx.Channel.EmbedAsync(GetStats(pr.Poll, GetText(strs.current_poll_results)));
}
[Cmd]
[UserPerm(GuildPerm.ManageMessages)]
[RequireContext(ContextType.Guild)]
public async Task Pollend()
{
Poll p;
if ((p = _service.StopPoll(ctx.Guild.Id)) is null)
return;
var embed = GetStats(p, GetText(strs.poll_closed));
await ctx.Channel.EmbedAsync(embed);
}
public IEmbedBuilder GetStats(Poll poll, string title)
{
var results = poll.Votes.GroupBy(kvp => kvp.VoteIndex).ToDictionary(x => x.Key, x => x.Sum(_ => 1));
var totalVotesCast = results.Sum(x => x.Value);
var eb = _eb.Create().WithTitle(title);
var sb = new StringBuilder().AppendLine(Format.Bold(poll.Question)).AppendLine();
var stats = poll.Answers.Select(x =>
{
results.TryGetValue(x.Index, out var votes);
return (x.Index, votes, x.Text);
})
.OrderByDescending(x => x.votes)
.ToArray();
for (var i = 0; i < stats.Length; i++)
{
var (index, votes, text) = stats[i];
sb.AppendLine(GetText(strs.poll_result(index + 1, Format.Bold(text), Format.Bold(votes.ToString()))));
}
return eb.WithDescription(sb.ToString())
.WithFooter(GetText(strs.x_votes_cast(totalVotesCast)))
.WithOkColor();
}
}
}

View file

@ -0,0 +1,36 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using Ellie.Services.Database;
using Ellie.Services.Database.Models;
namespace Ellie.Db;
public static class PollExtensions
{
public static IEnumerable<Poll> GetAllPolls(this DbSet<Poll> polls)
=> polls.Include(x => x.Answers)
.Include(x => x.Votes)
.ToArray();
public static void RemovePoll(this DbContext ctx, int id)
{
var p = ctx.Set<Poll>().Include(x => x.Answers).Include(x => x.Votes).FirstOrDefault(x => x.Id == id);
if (p is null)
return;
if (p.Votes is not null)
{
ctx.RemoveRange(p.Votes);
p.Votes.Clear();
}
if (p.Answers is not null)
{
ctx.RemoveRange(p.Answers);
p.Answers.Clear();
}
ctx.Set<Poll>().Remove(p);
}
}

View file

@ -0,0 +1,63 @@
#nullable disable
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Games.Common;
public class PollRunner
{
public event Func<IUserMessage, IGuildUser, Task> OnVoted;
public Poll Poll { get; }
private readonly DbService _db;
private readonly SemaphoreSlim _locker = new(1, 1);
public PollRunner(DbService db, Poll poll)
{
_db = db;
Poll = poll;
}
public async Task<bool> TryVote(IUserMessage msg)
{
PollVote voteObj;
await _locker.WaitAsync();
try
{
// has to be a user message
// channel must be the same the poll started in
if (msg is null || msg.Author.IsBot || msg.Channel.Id != Poll.ChannelId)
return false;
// has to be an integer
if (!int.TryParse(msg.Content, out var vote))
return false;
--vote;
if (vote < 0 || vote >= Poll.Answers.Count)
return false;
var usr = msg.Author as IGuildUser;
if (usr is null)
return false;
voteObj = new()
{
UserId = msg.Author.Id,
VoteIndex = vote
};
if (!Poll.Votes.Add(voteObj))
return false;
_ = OnVoted?.Invoke(msg, usr);
}
finally { _locker.Release(); }
await using var uow = _db.GetDbContext();
var trackedPoll = uow.Set<Poll>().FirstOrDefault(x => x.Id == Poll.Id);
trackedPoll.Votes.Add(voteObj);
uow.SaveChanges();
return true;
}
public void End()
=> OnVoted = null;
}

View file

@ -0,0 +1,140 @@
#nullable disable
using Ellie.Common.ModuleBehaviors;
using Ellie.Db;
using Ellie.Modules.Games.Common;
using Ellie.Services.Database.Models;
namespace Ellie.Modules.Games.Services;
public class PollService : IExecOnMessage
{
public ConcurrentDictionary<ulong, PollRunner> ActivePolls { get; } = new();
public int Priority
=> 5;
private readonly DbService _db;
private readonly IBotStrings _strs;
private readonly IEmbedBuilderService _eb;
public PollService(DbService db, IBotStrings strs, IEmbedBuilderService eb)
{
_db = db;
_strs = strs;
_eb = eb;
using var uow = db.GetDbContext();
ActivePolls = uow.Set<Poll>().GetAllPolls()
.ToDictionary(x => x.GuildId,
x =>
{
var pr = new PollRunner(db, x);
pr.OnVoted += Pr_OnVoted;
return pr;
})
.ToConcurrent();
}
public Poll CreatePoll(ulong guildId, ulong channelId, string input)
{
if (string.IsNullOrWhiteSpace(input) || !input.Contains(";"))
return null;
var data = input.Split(';');
if (data.Length < 3)
return null;
var col = new IndexedCollection<PollAnswer>(data.Skip(1)
.Select(x => new PollAnswer
{
Text = x
}));
return new()
{
Answers = col,
Question = data[0],
ChannelId = channelId,
GuildId = guildId,
Votes = new()
};
}
public bool StartPoll(Poll p)
{
var pr = new PollRunner(_db, p);
if (ActivePolls.TryAdd(p.GuildId, pr))
{
using (var uow = _db.GetDbContext())
{
uow.Set<Poll>().Add(p);
uow.SaveChanges();
}
pr.OnVoted += Pr_OnVoted;
return true;
}
return false;
}
public Poll StopPoll(ulong guildId)
{
if (ActivePolls.TryRemove(guildId, out var pr))
{
pr.OnVoted -= Pr_OnVoted;
using var uow = _db.GetDbContext();
uow.RemovePoll(pr.Poll.Id);
uow.SaveChanges();
return pr.Poll;
}
return null;
}
private async Task Pr_OnVoted(IUserMessage msg, IGuildUser usr)
{
var toDelete = await msg.Channel.SendConfirmAsync(_eb,
_strs.GetText(strs.poll_voted(Format.Bold(usr.ToString())), usr.GuildId));
toDelete.DeleteAfter(5);
try
{
await msg.DeleteAsync();
}
catch
{
}
}
public async Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage msg)
{
if (guild is null)
return false;
if (!ActivePolls.TryGetValue(guild.Id, out var poll))
return false;
try
{
var voted = await poll.TryVote(msg);
if (voted)
{
Log.Information("User {UserName} [{UserId}] voted in a poll on {GuildName} [{GuildId}] server",
msg.Author.ToString(),
msg.Author.Id,
guild.Name,
guild.Id);
}
return voted;
}
catch (Exception ex)
{
Log.Warning(ex, "Error voting");
}
return false;
}
}

View file

@ -0,0 +1,103 @@
#nullable disable
using Ellie.Modules.Games.Common;
using Ellie.Modules.Games.Services;
namespace Ellie.Modules.Games;
public partial class Games
{
[Group]
public partial class SpeedTypingCommands : EllieModule<GamesService>
{
private readonly GamesService _games;
private readonly DiscordSocketClient _client;
public SpeedTypingCommands(DiscordSocketClient client, GamesService games)
{
_games = games;
_client = client;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[EllieOptions<TypingGame.Options>]
public async Task TypeStart(params string[] args)
{
var (options, _) = OptionsParser.ParseFrom(new TypingGame.Options(), args);
var channel = (ITextChannel)ctx.Channel;
var game = _service.RunningContests.GetOrAdd(ctx.Guild.Id,
_ => new(_games, _client, channel, prefix, options, _eb));
if (game.IsActive)
await SendErrorAsync($"Contest already running in {game.Channel.Mention} channel.");
else
await game.Start();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task TypeStop()
{
if (_service.RunningContests.TryRemove(ctx.Guild.Id, out var game))
{
await game.Stop();
return;
}
await SendErrorAsync("No contest to stop on this channel.");
}
[Cmd]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
public async Task Typeadd([Leftover] string text)
{
if (string.IsNullOrWhiteSpace(text))
return;
_games.AddTypingArticle(ctx.User, text);
await SendConfirmAsync("Added new article for typing game.");
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Typelist(int page = 1)
{
if (page < 1)
return;
var articles = _games.TypingArticles.Skip((page - 1) * 15).Take(15).ToArray();
if (!articles.Any())
{
await SendErrorAsync($"{ctx.User.Mention} `No articles found on that page.`");
return;
}
var i = (page - 1) * 15;
await SendConfirmAsync("List of articles for Type Race",
string.Join("\n", articles.Select(a => $"`#{++i}` - {a.Text.TrimTo(50)}")));
}
[Cmd]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
public async Task Typedel(int index)
{
var removed = _service.RemoveTypingArticle(--index);
if (removed is null)
return;
var embed = _eb.Create()
.WithTitle($"Removed typing article #{index + 1}")
.WithDescription(removed.Text.TrimTo(50))
.WithOkColor();
await ctx.Channel.EmbedAsync(embed);
}
}
}

View file

@ -0,0 +1,9 @@
#nullable disable
namespace Ellie.Modules.Games.Common;
public class TypingArticle
{
public string Source { get; set; }
public string Extra { get; set; }
public string Text { get; set; }
}

View file

@ -0,0 +1,183 @@
#nullable disable
using CommandLine;
using Ellie.Modules.Games.Services;
using System.Diagnostics;
namespace Ellie.Modules.Games.Common;
public class TypingGame
{
public const float WORD_VALUE = 4.5f;
public ITextChannel Channel { get; }
public string CurrentSentence { get; private set; }
public bool IsActive { get; private set; }
private readonly Stopwatch _sw;
private readonly List<ulong> _finishedUserIds;
private readonly DiscordSocketClient _client;
private readonly GamesService _games;
private readonly string _prefix;
private readonly Options _options;
private readonly IEmbedBuilderService _eb;
public TypingGame(
GamesService games,
DiscordSocketClient client,
ITextChannel channel,
string prefix,
Options options,
IEmbedBuilderService eb)
{
_games = games;
_client = client;
_prefix = prefix;
_options = options;
_eb = eb;
Channel = channel;
IsActive = false;
_sw = new();
_finishedUserIds = new();
}
public async Task<bool> Stop()
{
if (!IsActive)
return false;
_client.MessageReceived -= AnswerReceived;
_finishedUserIds.Clear();
IsActive = false;
_sw.Stop();
_sw.Reset();
try
{
await Channel.SendConfirmAsync(_eb, "Typing contest stopped.");
}
catch
{
}
return true;
}
public async Task Start()
{
if (IsActive)
return; // can't start running game
IsActive = true;
CurrentSentence = GetRandomSentence();
var i = (int)(CurrentSentence.Length / WORD_VALUE * 1.7f);
try
{
await Channel.SendConfirmAsync(_eb,
$":clock2: Next contest will last for {i} seconds. Type the bolded text as fast as you can.");
var time = _options.StartTime;
var msg = await Channel.SendMessageAsync($"Starting new typing contest in **{time}**...");
do
{
await Task.Delay(2000);
time -= 2;
try { await msg.ModifyAsync(m => m.Content = $"Starting new typing contest in **{time}**.."); }
catch { }
} while (time > 2);
await msg.ModifyAsync(m =>
{
m.Content = CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture);
});
_sw.Start();
HandleAnswers();
while (i > 0)
{
await Task.Delay(1000);
i--;
if (!IsActive)
return;
}
}
catch { }
finally
{
await Stop();
}
}
public string GetRandomSentence()
{
if (_games.TypingArticles.Any())
return _games.TypingArticles[new EllieRandom().Next(0, _games.TypingArticles.Count)].Text;
return $"No typing articles found. Use {_prefix}typeadd command to add a new article for typing.";
}
private void HandleAnswers()
=> _client.MessageReceived += AnswerReceived;
private Task AnswerReceived(SocketMessage imsg)
{
_ = Task.Run(async () =>
{
try
{
if (imsg.Author.IsBot)
return;
if (imsg is not SocketUserMessage msg)
return;
if (Channel is null || Channel.Id != msg.Channel.Id)
return;
var guess = msg.Content;
var distance = CurrentSentence.LevenshteinDistance(guess);
var decision = Judge(distance, guess.Length);
if (decision && !_finishedUserIds.Contains(msg.Author.Id))
{
var elapsed = _sw.Elapsed;
var wpm = CurrentSentence.Length / WORD_VALUE / elapsed.TotalSeconds * 60;
_finishedUserIds.Add(msg.Author.Id);
await Channel.EmbedAsync(_eb.Create()
.WithOkColor()
.WithTitle($"{msg.Author} finished the race!")
.AddField("Place", $"#{_finishedUserIds.Count}", true)
.AddField("WPM", $"{wpm:F1} *[{elapsed.TotalSeconds:F2}sec]*", true)
.AddField("Errors", distance.ToString(), true));
if (_finishedUserIds.Count % 4 == 0)
{
await Channel.SendConfirmAsync(_eb,
":exclamation: A lot of people finished, here is the text for those still typing:"
+ $"\n\n**{Format.Sanitize(CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture)).SanitizeMentions(true)}**");
}
}
}
catch (Exception ex)
{
Log.Warning(ex, "Error receiving typing game answer: {ErrorMessage}", ex.Message);
}
});
return Task.CompletedTask;
}
private static bool Judge(int errors, int textLength)
=> errors <= textLength / 25;
public class Options : IEllieCommandOptions
{
[Option('s',
"start-time",
Default = 5,
Required = false,
HelpText = "How long does it take for the race to start. Default 5.")]
public int StartTime { get; set; } = 5;
public void NormalizeOptions()
{
if (StartTime is < 3 or > 30)
StartTime = 5;
}
}
}

Some files were not shown because too many files have changed in this diff Show more