Gambling moved to a separate project. Project builds

This commit is contained in:
Kwoth
2023-03-18 18:36:04 +01:00
parent db2328cdaf
commit 09171fb10a
361 changed files with 532 additions and 476 deletions

View File

@@ -6,7 +6,7 @@ using NadekoBot.Services.Database.Models;
namespace NadekoBot.Modules.Administration.Services;
public class DiscordPermOverrideService : INService, IExecPreCommand
public class DiscordPermOverrideService : INService, IExecPreCommand, IDiscordPermOverrideService
{
public int Priority { get; } = int.MaxValue;
private readonly DbService _db;

View File

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

View File

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

View File

@@ -1,184 +0,0 @@
#nullable disable
using Nadeko.Common;
using NadekoBot.Common.TypeReaders;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Common.AnimalRacing;
using NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
using NadekoBot.Modules.Gambling.Services;
using NadekoBot.Modules.Games.Services;
namespace NadekoBot.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)]
[NadekoOptions<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

@@ -1,26 +0,0 @@
#nullable disable
using NadekoBot.Modules.Games.Common;
namespace NadekoBot.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

@@ -1,19 +0,0 @@
#nullable disable
namespace NadekoBot.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

@@ -1,19 +0,0 @@
#nullable disable
namespace NadekoBot.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

@@ -1,19 +0,0 @@
#nullable disable
namespace NadekoBot.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

@@ -1,19 +0,0 @@
#nullable disable
namespace NadekoBot.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

@@ -1,16 +0,0 @@
#nullable disable
using CommandLine;
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing;
public class RaceOptions : INadekoCommandOptions
{
[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

@@ -1,122 +0,0 @@
using NadekoBot.Common.TypeReaders;
using NadekoBot.Modules.Gambling.Bank;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Services;
namespace NadekoBot.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 ReplyErrorLocalizedAsync(strs.take_fail(N(amount),
_client.GetUser(userId)?.ToString()
?? userId.ToString(),
CurrencySign));
return;
}
await ctx.OkAsync();
}
private async Task BankAwardInternalAsync(long amount, ulong userId)
{
if (await _bank.AwardAsync(userId, amount))
{
await ReplyErrorLocalizedAsync(strs.take_fail(N(amount),
_client.GetUser(userId)?.ToString()
?? userId.ToString(),
CurrencySign));
return;
}
await ctx.OkAsync();
}
[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

@@ -1,120 +0,0 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Db.Models;
namespace NadekoBot.Modules.Gambling.Bank;
public sealed class BankService : IBankService, INService
{
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.BankUsers
.ToLinqToDBTable()
.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.BankUsers
.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.BankUsers
.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.BankUsers
.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.BankUsers
.ToLinqToDBTable()
.FirstOrDefaultAsync(x => x.UserId == userId))
?.Balance
?? 0;
}
}

View File

@@ -1,10 +0,0 @@
namespace NadekoBot.Modules.Gambling.Bank;
public interface IBankService
{
Task<bool> DepositAsync(ulong userId, long amount);
Task<bool> WithdrawAsync(ulong userId, long amount);
Task<long> GetBalanceAsync(ulong userId);
Task<bool> AwardAsync(ulong userId, long amount);
Task<bool> TakeAsync(ulong userId, long amount);
}

View File

@@ -1,184 +0,0 @@
#nullable disable
using Nadeko.Common;
using NadekoBot.Common.TypeReaders;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Common.Blackjack;
using NadekoBot.Modules.Gambling.Services;
namespace NadekoBot.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

@@ -1,9 +0,0 @@
#nullable disable
using NadekoBot.Modules.Gambling.Common.Blackjack;
namespace NadekoBot.Modules.Gambling.Services;
public class BlackJackService : INService
{
public ConcurrentDictionary<ulong, Blackjack> Games { get; } = new();
}

View File

@@ -1,329 +0,0 @@
#nullable disable
using Nadeko.Econ;
namespace NadekoBot.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

@@ -1,58 +0,0 @@
#nullable disable
using Nadeko.Econ;
namespace NadekoBot.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

@@ -1,409 +0,0 @@
#nullable disable
using CommandLine;
using System.Collections.Immutable;
namespace NadekoBot.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 NadekoRandom _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 : INadekoCommandOptions
{
[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

@@ -1,204 +0,0 @@
#nullable disable
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Common.Connect4;
using NadekoBot.Modules.Gambling.Services;
using System.Text;
namespace NadekoBot.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)]
[NadekoOptions<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,18 @@
using Nadeko.Bot.Common;
using NadekoBot.Modules.Gambling.Services;
namespace NadekoBot.Modules.Gambling;
// todo do we need both currencyprovider and currencyservice
public sealed class CurrencyProvider : ICurrencyProvider
{
private readonly GamblingConfigService _cs;
public CurrencyProvider(GamblingConfigService cs)
{
_cs = cs;
}
public string GetCurrencySign()
=> _cs.Data.Currency.Sign;
}

View File

@@ -1,224 +0,0 @@
#nullable disable
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System.Text.RegularExpressions;
using Image = SixLabors.ImageSharp.Image;
namespace NadekoBot.Modules.Gambling;
public partial class Gambling
{
[Group]
public partial class DiceRollCommands : NadekoModule
{
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(ImageCache images)
=> _images = images;
[Cmd]
public async Task Roll()
{
var rng = new NadekoRandom();
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 NadekoRandom();
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 NadekoRandom();
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 NadekoRandom();
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 NadekoRandom().Next(arr[0], arr[1] + 1);
}
else
rolled = new NadekoRandom().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

@@ -1,234 +0,0 @@
#nullable disable
using Nadeko.Econ;
using NadekoBot.Common.TypeReaders;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Services;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Image = SixLabors.ImageSharp.Image;
namespace NadekoBot.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

@@ -1,12 +0,0 @@
#nullable disable
namespace NadekoBot.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

@@ -1,60 +0,0 @@
#nullable disable
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Common.Events;
using NadekoBot.Modules.Gambling.Services;
using NadekoBot.Services.Database.Models;
namespace NadekoBot.Modules.Gambling;
public partial class Gambling
{
[Group]
public partial class CurrencyEventsCommands : GamblingSubmodule<CurrencyEventsService>
{
public CurrencyEventsCommands(GamblingConfigService gamblingConf)
: base(gamblingConf)
{
}
[Cmd]
[RequireContext(ContextType.Guild)]
[NadekoOptions<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

@@ -1,67 +0,0 @@
#nullable disable
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Common.Events;
using NadekoBot.Services.Database.Models;
namespace NadekoBot.Modules.Gambling.Services;
public class CurrencyEventsService : INService
{
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

@@ -1,39 +0,0 @@
#nullable disable
using CommandLine;
namespace NadekoBot.Modules.Gambling.Common.Events;
public class EventOptions : INadekoCommandOptions
{
[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

@@ -1,196 +0,0 @@
#nullable disable
using NadekoBot.Services.Database.Models;
using System.Collections.Concurrent;
using Nadeko.Common;
namespace NadekoBot.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

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

View File

@@ -1,194 +0,0 @@
#nullable disable
using NadekoBot.Services.Database.Models;
namespace NadekoBot.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

@@ -1,141 +0,0 @@
#nullable disable
using Nadeko.Common;
using NadekoBot.Common.TypeReaders;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Services;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Image = SixLabors.ImageSharp.Image;
namespace NadekoBot.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 NadekoRandom _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

@@ -1,7 +0,0 @@
namespace Nadeko.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

@@ -1,387 +0,0 @@
#nullable disable
using Cloneable;
using NadekoBot.Common.Yml;
using SixLabors.ImageSharp.PixelFormats;
using YamlDotNet.Serialization;
using Color = SixLabors.ImageSharp.Color;
namespace NadekoBot.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

@@ -1,186 +0,0 @@
#nullable disable
using NadekoBot.Common.Configs;
using NadekoBot.Modules.Gambling.Common;
namespace NadekoBot.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

@@ -1,218 +0,0 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db;
using NadekoBot.Db.Models;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Common.Connect4;
namespace NadekoBot.Modules.Gambling.Services;
public class GamblingService : INService, IReadyExecutor
{
public ConcurrentDictionary<(ulong, ulong), RollDuelGame> Duels { get; } = new();
public ConcurrentDictionary<ulong, Connect4Game> Connect4Games { get; } = new();
private readonly DbService _db;
private readonly ICurrencyService _cs;
private readonly Bot _bot;
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,
Bot bot,
ICurrencyService cs,
DiscordSocketClient client,
IBotCache cache,
GamblingConfigService gss)
{
_db = db;
_cs = cs;
_bot = bot;
_client = client;
_cache = cache;
_gss = gss;
}
public Task OnReadyAsync()
=> Task.WhenAll(CurrencyDecayLoopAsync(), TransactionClearLoopAsync());
private async Task TransactionClearLoopAsync()
{
if (_bot.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.CurrencyTransactions
.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 (_bot.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.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.DiscordUser.GetTotalCurrency();
var onePercent = uow.DiscordUser.GetTopOnePercentCurrency(_client.CurrentUser.Id);
decimal planted = uow.PlantedCurrency.AsQueryable().Sum(x => x.Amount);
var waifus = uow.WaifuInfo.GetTotalValue();
var bot = await uow.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

@@ -1,80 +0,0 @@
#nullable disable
using NadekoBot.Modules.Gambling.Services;
using System.Globalization;
using System.Numerics;
namespace NadekoBot.Modules.Gambling.Common;
public abstract class GamblingModule<TService> : NadekoModule<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;
}
public static string N<T>(T cur, IFormatProvider format)
where T : INumber<T>
=> cur.ToString("C0", format);
protected string N<T>(T cur)
where T : INumber<T>
=> N(cur, GetFlowersCiInternal());
protected IFormatProvider GetFlowersCiInternal()
{
var flowersCi = (CultureInfo)Culture.Clone();
flowersCi.NumberFormat.CurrencySymbol = CurrencySign;
flowersCi.NumberFormat.CurrencyNegativePattern = 5;
return flowersCi;
}
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

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

View File

@@ -1,113 +0,0 @@
#nullable disable
using Nadeko.Common;
using NadekoBot.Common.TypeReaders;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Services;
namespace NadekoBot.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

@@ -1,385 +0,0 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db;
using NadekoBot.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 NadekoBot.Modules.Gambling.Services;
public class PlantPickService : INService, 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 NadekoRandom _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.GuildConfigs.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 NadekoRandom();
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 NadekoRandom().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.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.PlantedCurrency.Add(new()
{
Amount = amount,
GuildId = gid,
ChannelId = cid,
Password = pass,
UserId = uid,
MessageId = mid
});
await uow.SaveChangesAsync();
}
}

View File

@@ -1,58 +0,0 @@
#nullable disable
using Nadeko.Common;
using NadekoBot.Common.TypeReaders;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Services;
namespace NadekoBot.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

@@ -1,69 +0,0 @@
#nullable disable
namespace NadekoBot.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 NadekoRandom();
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

@@ -1,81 +0,0 @@
#nullable disable
using NadekoBot.Modules.Gambling.Common;
namespace NadekoBot.Modules.Gambling.Services;
public class CurrencyRaffleService : INService
{
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

@@ -1,43 +0,0 @@
#nullable disable
namespace NadekoBot.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

@@ -1,497 +0,0 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using Nadeko.Common;
using NadekoBot.Db;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Services;
using NadekoBot.Services.Database.Models;
namespace NadekoBot.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 NadekoRandom().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

@@ -1,113 +0,0 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using Nadeko.Common;
using NadekoBot.Db;
using NadekoBot.Services.Database;
using NadekoBot.Services.Database.Models;
namespace NadekoBot.Modules.Gambling.Services;
public class ShopService : IShopService, INService
{
private readonly DbService _db;
public ShopService(DbService db)
=> _db = db;
private IndexedCollection<ShopEntry> GetEntriesInternal(NadekoContext 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

@@ -1,228 +0,0 @@
#nullable disable warnings
using NadekoBot.Db.Models;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Services;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using System.Text;
using Nadeko.Econ.Gambling;
using NadekoBot.Common.TypeReaders;
using Color = SixLabors.ImageSharp.Color;
using Image = SixLabors.ImageSharp.Image;
namespace NadekoBot.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(
ImageCache 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

@@ -1,106 +0,0 @@
#nullable disable
using NadekoBot.Common.ModuleBehaviors;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Gambling.Services;
public class VoteModel
{
[JsonPropertyName("userId")]
public ulong UserId { get; set; }
}
public class VoteRewardService : INService, 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

@@ -1,16 +0,0 @@
#nullable disable
namespace NadekoBot.Modules.Gambling.Common.Waifu;
public enum AffinityTitle
{
Pure,
Faithful,
Playful,
Cheater,
Tainted,
Corrupted,
Lewd,
Sloot,
Depraved,
Harlot
}

View File

@@ -1,18 +0,0 @@
#nullable disable
namespace NadekoBot.Modules.Gambling.Common.Waifu;
public enum ClaimTitle
{
Lonely,
Devoted,
Rookie,
Schemer,
Dilettante,
Intermediate,
Seducer,
Expert,
Veteran,
Incubis,
Harem_King,
Harem_God
}

View File

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

View File

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

View File

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

View File

@@ -1,374 +0,0 @@
#nullable disable
using Nadeko.Common;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Common.Waifu;
using NadekoBot.Modules.Gambling.Services;
using NadekoBot.Services.Database.Models;
namespace NadekoBot.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

@@ -1,583 +0,0 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db;
using NadekoBot.Db.Models;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Common.Waifu;
using NadekoBot.Services.Database.Models;
namespace NadekoBot.Modules.Gambling.Services;
public class WaifuService : INService, 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.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.WaifuInfo.ByWaifuUserId(user.Id);
if (waifu is null)
return settings.Waifu.MinPrice;
var divorces = uow.WaifuUpdates.Count(x
=> x.Old != null && x.Old.UserId == user.Id && x.UpdateType == WaifuUpdateType.Claimed && x.New == null);
var affs = uow.WaifuUpdates.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.WaifuUpdates.AsQueryable()
.Where(w => w.User.UserId == user.Id
&& w.UpdateType == WaifuUpdateType.AffinityChanged
&& w.New != null);
var divorces = uow.WaifuUpdates.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.WaifuUpdates.RemoveRange(affs);
//reset divorces to 0
uow.WaifuUpdates.RemoveRange(divorces);
var waifu = uow.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.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.WaifuInfo.Add(w = new()
{
Waifu = waifu,
Claimer = claimer,
Affinity = null,
Price = amount
});
uow.WaifuUpdates.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.WaifuUpdates.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.WaifuUpdates.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.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.WaifuInfo.Add(new()
{
Affinity = newAff,
Waifu = thisUser,
Price = 1,
Claimer = null
});
success = true;
uow.WaifuUpdates.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.WaifuUpdates.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.WaifuInfo.GetTop(9, page * 9);
}
public ulong GetWaifuUserId(ulong ownerId, string name)
{
using var uow = _db.GetDbContext();
return uow.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.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.WaifuUpdates.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.WaifuInfo.ByWaifuUserId(giftedWaifu.Id, set => set.Include(x => x.Items).Include(x => x.Claimer));
if (w is null)
{
uow.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

@@ -1,19 +0,0 @@
using Nadeko.Econ;
namespace NadekoBot.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

@@ -1,18 +0,0 @@
#nullable disable
using Nadeko.Econ.Gambling;
using Nadeko.Econ.Gambling.Betdraw;
using Nadeko.Econ.Gambling.Rps;
using OneOf;
namespace NadekoBot.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

@@ -1,279 +0,0 @@
#nullable disable
using Nadeko.Econ.Gambling;
using Nadeko.Econ.Gambling.Betdraw;
using Nadeko.Econ.Gambling.Rps;
using NadekoBot.Modules.Gambling.Services;
using OneOf;
namespace NadekoBot.Modules.Gambling;
public sealed class NewGamblingService : IGamblingService, INService
{
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

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

View File

@@ -1,200 +0,0 @@
#nullable disable
using CommandLine;
using System.Collections.Immutable;
namespace NadekoBot.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 NadekoRandom _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 : INadekoCommandOptions
{
[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

@@ -1,22 +0,0 @@
#nullable disable
namespace NadekoBot.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

@@ -1,140 +0,0 @@
#nullable disable
using NadekoBot.Modules.Games.Common.Acrophobia;
using NadekoBot.Modules.Games.Services;
using System.Collections.Immutable;
namespace NadekoBot.Modules.Games;
public partial class Games
{
[Group]
public partial class AcropobiaCommands : NadekoModule<GamesService>
{
private readonly DiscordSocketClient _client;
public AcropobiaCommands(DiscordSocketClient client)
=> _client = client;
[Cmd]
[RequireContext(ContextType.Guild)]
[NadekoOptions<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

@@ -1,233 +0,0 @@
#nullable disable
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db.Models;
using NadekoBot.Modules.Games.Common;
using NadekoBot.Modules.Games.Common.ChatterBot;
using NadekoBot.Modules.Permissions;
using NadekoBot.Modules.Permissions.Common;
using NadekoBot.Modules.Permissions.Services;
using NadekoBot.Modules.Utility.Patronage;
namespace NadekoBot.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 PermissionService _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 CmdCdService _ccs;
private readonly GamesConfigService _gcs;
public ChatterBotService(
DiscordSocketClient client,
PermissionService perms,
Bot bot,
CommandHandler cmd,
IBotStrings strings,
IHttpClientFactory factory,
IBotCredentials creds,
IEmbedBuilderService eb,
IPatronageService ps,
CmdCdService cmdCdService,
GamesConfigService gcs)
{
_client = client;
_perms = perms;
_cmd = cmd;
_strings = strings;
_creds = creds;
_eb = eb;
_httpFactory = factory;
_ps = ps;
_ccs = cmdCdService;
_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 pc = _perms.GetCacheFor(guild.Id);
if (!pc.Permissions.CheckPermissions(usrMsg,
"cleverbot",
"games",
out var index))
{
if (pc.Verbose)
{
var returnMsg = _strings.GetText(strs.perm_prevent(index + 1,
Format.Bold(pc.Permissions[index].GetCommand(_cmd.GetPrefix(sg), sg))));
try { await usrMsg.Channel.SendErrorAsync(_eb, returnMsg); }
catch { }
Log.Information("{PermissionMessage}", returnMsg);
}
return false;
}
if (await _ccs.TryBlock(sg, usrMsg.Author, CleverBotResponseStr.CLEVERBOT_RESPONSE))
{
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/nadekobot) 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

@@ -1,47 +0,0 @@
#nullable disable
using NadekoBot.Db;
using NadekoBot.Modules.Games.Services;
namespace NadekoBot.Modules.Games;
public partial class Games
{
[Group]
public partial class ChatterBotCommands : NadekoModule<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.GuildConfigs.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.GuildConfigs.SetCleverbotEnabled(ctx.Guild.Id, true);
await uow.SaveChangesAsync();
}
await ReplyConfirmLocalizedAsync(strs.cleverbot_enabled);
}
}
}

View File

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

View File

@@ -1,30 +0,0 @@
#nullable disable
using System.Text.Json.Serialization;
namespace NadekoBot.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

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

View File

@@ -1,38 +0,0 @@
#nullable disable
using Newtonsoft.Json;
namespace NadekoBot.Modules.Games.Common.ChatterBot;
public class OfficialCleverbotSession : IChatterBotSession
{
private string QueryString
=> $"https://www.cleverbot.com/getreply?key={_apiKey}" + "&wrapper=nadekobot" + "&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

@@ -1,69 +0,0 @@
#nullable disable
using Newtonsoft.Json;
using System.Net.Http.Json;
namespace NadekoBot.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

@@ -1,150 +0,0 @@
#nullable disable
using NadekoBot.Modules.Games.Common;
using NadekoBot.Modules.Games.Services;
namespace NadekoBot.Modules.Games;
/* more games
- Shiritori
- Simple RPG adventure
*/
public partial class Games : NadekoModule<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 NadekoRandom();
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 NadekoRandom();
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

@@ -1,160 +0,0 @@
#nullable disable
using Cloneable;
using NadekoBot.Common.Yml;
namespace NadekoBot.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

@@ -1,72 +0,0 @@
#nullable disable
using NadekoBot.Common.Configs;
using NadekoBot.Modules.Games.Common;
namespace NadekoBot.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

@@ -1,120 +0,0 @@
#nullable disable
using Microsoft.Extensions.Caching.Memory;
using Nadeko.Common;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Modules.Games.Common;
using NadekoBot.Modules.Games.Common.Acrophobia;
using NadekoBot.Modules.Games.Common.Nunchi;
using NadekoBot.Modules.Games.Common.Trivia;
using Newtonsoft.Json;
namespace NadekoBot.Modules.Games.Services;
public class GamesService : INService, 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 NadekoRandom();
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

@@ -1,62 +0,0 @@
#nullable disable
using Nadeko.Common;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using Image = SixLabors.ImageSharp.Image;
namespace NadekoBot.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

@@ -1,64 +0,0 @@
using NadekoBot.Common.Yml;
using System.Diagnostics.CodeAnalysis;
namespace NadekoBot.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 NadekoRandom();
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

@@ -1,77 +0,0 @@
using Nadeko.Common;
using NadekoBot.Modules.Games.Hangman;
namespace NadekoBot.Modules.Games;
public partial class Games
{
[Group]
public partial class HangmanCommands : NadekoModule<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

@@ -1,113 +0,0 @@
#nullable disable
using AngleSharp.Text;
namespace NadekoBot.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 => x.IsLetter()).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 (!ch.IsLetter() || !_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

@@ -1,136 +0,0 @@
using Microsoft.Extensions.Caching.Memory;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Modules.Games.Services;
using System.Diagnostics.CodeAnalysis;
namespace NadekoBot.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

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

View File

@@ -1,10 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace NadekoBot.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

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

View File

@@ -1,183 +0,0 @@
#nullable disable
using System.Collections.Immutable;
namespace NadekoBot.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 NadekoRandom().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 NadekoRandom().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

@@ -1,111 +0,0 @@
#nullable disable
using NadekoBot.Modules.Games.Common.Nunchi;
using NadekoBot.Modules.Games.Services;
namespace NadekoBot.Modules.Games;
public partial class Games
{
[Group]
public partial class NunchiCommands : NadekoModule<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

@@ -1,102 +0,0 @@
#nullable disable
using NadekoBot.Modules.Games.Services;
using NadekoBot.Services.Database.Models;
using System.Text;
namespace NadekoBot.Modules.Games;
public partial class Games
{
[Group]
public partial class PollCommands : NadekoModule<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

@@ -1,63 +0,0 @@
#nullable disable
using NadekoBot.Services.Database.Models;
namespace NadekoBot.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.Poll.FirstOrDefault(x => x.Id == Poll.Id);
trackedPoll.Votes.Add(voteObj);
uow.SaveChanges();
return true;
}
public void End()
=> OnVoted = null;
}

View File

@@ -1,135 +0,0 @@
#nullable disable
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db;
using NadekoBot.Modules.Games.Common;
using NadekoBot.Services.Database.Models;
namespace NadekoBot.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.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.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

@@ -1,103 +0,0 @@
#nullable disable
using NadekoBot.Modules.Games.Common;
using NadekoBot.Modules.Games.Services;
namespace NadekoBot.Modules.Games;
public partial class Games
{
[Group]
public partial class SpeedTypingCommands : NadekoModule<GamesService>
{
private readonly GamesService _games;
private readonly DiscordSocketClient _client;
public SpeedTypingCommands(DiscordSocketClient client, GamesService games)
{
_games = games;
_client = client;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[NadekoOptions<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

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

View File

@@ -1,183 +0,0 @@
#nullable disable
using CommandLine;
using NadekoBot.Modules.Games.Services;
using System.Diagnostics;
namespace NadekoBot.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 NadekoRandom().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 : INadekoCommandOptions
{
[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;
}
}
}

View File

@@ -1,307 +0,0 @@
#nullable disable
using CommandLine;
using System.Text;
namespace NadekoBot.Modules.Games.Common;
public class TicTacToe
{
public event Action<TicTacToe> OnEnded;
private readonly ITextChannel _channel;
private readonly IGuildUser[] _users;
private readonly int?[,] _state;
private Phase phase;
private int curUserIndex;
private readonly SemaphoreSlim _moveLock;
private IGuildUser winner;
private readonly string[] _numbers =
{
":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:", ":nine:"
};
private IUserMessage previousMessage;
private Timer timeoutTimer;
private readonly IBotStrings _strings;
private readonly DiscordSocketClient _client;
private readonly Options _options;
private readonly IEmbedBuilderService _eb;
public TicTacToe(
IBotStrings strings,
DiscordSocketClient client,
ITextChannel channel,
IGuildUser firstUser,
Options options,
IEmbedBuilderService eb)
{
_channel = channel;
_strings = strings;
_client = client;
_options = options;
_eb = eb;
_users = new[] { firstUser, null };
_state = new int?[,] { { null, null, null }, { null, null, null }, { null, null, null } };
phase = Phase.Starting;
_moveLock = new(1, 1);
}
private string GetText(LocStr key)
=> _strings.GetText(key, _channel.GuildId);
public string GetState()
{
var sb = new StringBuilder();
for (var i = 0; i < _state.GetLength(0); i++)
{
for (var j = 0; j < _state.GetLength(1); j++)
{
sb.Append(_state[i, j] is null ? _numbers[(i * 3) + j] : GetIcon(_state[i, j]));
if (j < _state.GetLength(1) - 1)
sb.Append("┃");
}
if (i < _state.GetLength(0) - 1)
sb.AppendLine("\n──────────");
}
return sb.ToString();
}
public IEmbedBuilder GetEmbed(string title = null)
{
var embed = _eb.Create()
.WithOkColor()
.WithDescription(Environment.NewLine + GetState())
.WithAuthor(GetText(strs.vs(_users[0], _users[1])));
if (!string.IsNullOrWhiteSpace(title))
embed.WithTitle(title);
if (winner is null)
{
if (phase == Phase.Ended)
embed.WithFooter(GetText(strs.ttt_no_moves));
else
embed.WithFooter(GetText(strs.ttt_users_move(_users[curUserIndex])));
}
else
embed.WithFooter(GetText(strs.ttt_has_won(winner)));
return embed;
}
private static string GetIcon(int? val)
{
switch (val)
{
case 0:
return "❌";
case 1:
return "⭕";
case 2:
return "❎";
case 3:
return "🅾";
default:
return "⬛";
}
}
public async Task Start(IGuildUser user)
{
if (phase is Phase.Started or Phase.Ended)
{
await _channel.SendErrorAsync(_eb, user.Mention + GetText(strs.ttt_already_running));
return;
}
if (_users[0] == user)
{
await _channel.SendErrorAsync(_eb, user.Mention + GetText(strs.ttt_against_yourself));
return;
}
_users[1] = user;
phase = Phase.Started;
timeoutTimer = new(async _ =>
{
await _moveLock.WaitAsync();
try
{
if (phase == Phase.Ended)
return;
phase = Phase.Ended;
if (_users[1] is not null)
{
winner = _users[curUserIndex ^= 1];
var del = previousMessage?.DeleteAsync();
try
{
await _channel.EmbedAsync(GetEmbed(GetText(strs.ttt_time_expired)));
if (del is not null)
await del;
}
catch { }
}
OnEnded?.Invoke(this);
}
catch { }
finally
{
_moveLock.Release();
}
},
null,
_options.TurnTimer * 1000,
Timeout.Infinite);
_client.MessageReceived += Client_MessageReceived;
previousMessage = await _channel.EmbedAsync(GetEmbed(GetText(strs.game_started)));
}
private bool IsDraw()
{
for (var i = 0; i < 3; i++)
for (var j = 0; j < 3; j++)
{
if (_state[i, j] is null)
return false;
}
return true;
}
private Task Client_MessageReceived(SocketMessage msg)
{
_ = Task.Run(async () =>
{
await _moveLock.WaitAsync();
try
{
var curUser = _users[curUserIndex];
if (phase == Phase.Ended || msg.Author?.Id != curUser.Id)
return;
if (int.TryParse(msg.Content, out var index)
&& --index >= 0
&& index <= 9
&& _state[index / 3, index % 3] is null)
{
_state[index / 3, index % 3] = curUserIndex;
// i'm lazy
if (_state[index / 3, 0] == _state[index / 3, 1] && _state[index / 3, 1] == _state[index / 3, 2])
{
_state[index / 3, 0] = curUserIndex + 2;
_state[index / 3, 1] = curUserIndex + 2;
_state[index / 3, 2] = curUserIndex + 2;
phase = Phase.Ended;
}
else if (_state[0, index % 3] == _state[1, index % 3]
&& _state[1, index % 3] == _state[2, index % 3])
{
_state[0, index % 3] = curUserIndex + 2;
_state[1, index % 3] = curUserIndex + 2;
_state[2, index % 3] = curUserIndex + 2;
phase = Phase.Ended;
}
else if (curUserIndex == _state[0, 0]
&& _state[0, 0] == _state[1, 1]
&& _state[1, 1] == _state[2, 2])
{
_state[0, 0] = curUserIndex + 2;
_state[1, 1] = curUserIndex + 2;
_state[2, 2] = curUserIndex + 2;
phase = Phase.Ended;
}
else if (curUserIndex == _state[0, 2]
&& _state[0, 2] == _state[1, 1]
&& _state[1, 1] == _state[2, 0])
{
_state[0, 2] = curUserIndex + 2;
_state[1, 1] = curUserIndex + 2;
_state[2, 0] = curUserIndex + 2;
phase = Phase.Ended;
}
var reason = string.Empty;
if (phase == Phase.Ended) // if user won, stop receiving moves
{
reason = GetText(strs.ttt_matched_three);
winner = _users[curUserIndex];
_client.MessageReceived -= Client_MessageReceived;
OnEnded?.Invoke(this);
}
else if (IsDraw())
{
reason = GetText(strs.ttt_a_draw);
phase = Phase.Ended;
_client.MessageReceived -= Client_MessageReceived;
OnEnded?.Invoke(this);
}
_ = Task.Run(async () =>
{
var del1 = msg.DeleteAsync();
var del2 = previousMessage?.DeleteAsync();
try { previousMessage = await _channel.EmbedAsync(GetEmbed(reason)); }
catch { }
try { await del1; }
catch { }
try
{
if (del2 is not null)
await del2;
}
catch { }
});
curUserIndex ^= 1;
timeoutTimer.Change(_options.TurnTimer * 1000, Timeout.Infinite);
}
}
finally
{
_moveLock.Release();
}
});
return Task.CompletedTask;
}
public class Options : INadekoCommandOptions
{
[Option('t', "turn-timer", Required = false, Default = 15, HelpText = "Turn time in seconds. Default 15.")]
public int TurnTimer { get; set; } = 15;
public void NormalizeOptions()
{
if (TurnTimer is < 5 or > 60)
TurnTimer = 15;
}
}
private enum Phase
{
Starting,
Started,
Ended
}
}

View File

@@ -1,54 +0,0 @@
#nullable disable
using NadekoBot.Modules.Games.Common;
using NadekoBot.Modules.Games.Services;
namespace NadekoBot.Modules.Games;
public partial class Games
{
[Group]
public partial class TicTacToeCommands : NadekoModule<GamesService>
{
private readonly SemaphoreSlim _sem = new(1, 1);
private readonly DiscordSocketClient _client;
public TicTacToeCommands(DiscordSocketClient client)
=> _client = client;
[Cmd]
[RequireContext(ContextType.Guild)]
[NadekoOptions<TicTacToe.Options>]
public async Task TicTacToe(params string[] args)
{
var (options, _) = OptionsParser.ParseFrom(new TicTacToe.Options(), args);
var channel = (ITextChannel)ctx.Channel;
await _sem.WaitAsync(1000);
try
{
if (_service.TicTacToeGames.TryGetValue(channel.Id, out var game))
{
_ = Task.Run(async () =>
{
await game.Start((IGuildUser)ctx.User);
});
return;
}
game = new(Strings, _client, channel, (IGuildUser)ctx.User, options, _eb);
_service.TicTacToeGames.Add(channel.Id, game);
await ReplyConfirmLocalizedAsync(strs.ttt_created);
game.OnEnded += _ =>
{
_service.TicTacToeGames.Remove(channel.Id);
_sem.Dispose();
};
}
finally
{
_sem.Release();
}
}
}
}

View File

@@ -1,275 +0,0 @@
using System.Net;
using System.Text;
using NadekoBot.Modules.Games.Common.Trivia;
using NadekoBot.Modules.Games.Services;
namespace NadekoBot.Modules.Games;
public partial class Games
{
[Group]
public partial class TriviaCommands : NadekoModule<TriviaGamesService>
{
private readonly ILocalDataCache _cache;
private readonly ICurrencyService _cs;
private readonly GamesConfigService _gamesConfig;
private readonly DiscordSocketClient _client;
public TriviaCommands(
DiscordSocketClient client,
ILocalDataCache cache,
ICurrencyService cs,
GamesConfigService gamesConfig)
{
_cache = cache;
_cs = cs;
_gamesConfig = gamesConfig;
_client = client;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(0)]
[NadekoOptions<TriviaOptions>]
public async Task Trivia(params string[] args)
{
var (opts, _) = OptionsParser.ParseFrom(new TriviaOptions(), args);
var config = _gamesConfig.Data;
if (config.Trivia.MinimumWinReq > 0 && config.Trivia.MinimumWinReq > opts.WinRequirement)
return;
var trivia = new TriviaGame(opts, _cache);
if (_service.RunningTrivias.TryAdd(ctx.Guild.Id, trivia))
{
RegisterEvents(trivia);
await trivia.RunAsync();
return;
}
if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var tg))
{
await SendErrorAsync(GetText(strs.trivia_already_running));
await tg.TriggerQuestionAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Tl()
{
if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var trivia))
{
await trivia.TriggerStatsAsync();
return;
}
await ReplyErrorLocalizedAsync(strs.trivia_none);
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Tq()
{
var channel = (ITextChannel)ctx.Channel;
if (_service.RunningTrivias.TryGetValue(channel.Guild.Id, out var trivia))
{
if (trivia.Stop())
{
try
{
await ctx.Channel.SendConfirmAsync(_eb,
GetText(strs.trivia_game),
GetText(strs.trivia_stopping));
}
catch (Exception ex)
{
Log.Warning(ex, "Error sending trivia stopping message");
}
}
return;
}
await ReplyErrorLocalizedAsync(strs.trivia_none);
}
private string GetLeaderboardString(TriviaGame tg)
{
var sb = new StringBuilder();
foreach (var (id, pts) in tg.GetLeaderboard())
sb.AppendLine(GetText(strs.trivia_points(Format.Bold($"<@{id}>"), pts)));
return sb.ToString();
}
private IEmbedBuilder? questionEmbed = null;
private IUserMessage? questionMessage = null;
private bool showHowToQuit = false;
private void RegisterEvents(TriviaGame trivia)
{
trivia.OnQuestion += OnTriviaQuestion;
trivia.OnHint += OnTriviaHint;
trivia.OnGuess += OnTriviaGuess;
trivia.OnEnded += OnTriviaEnded;
trivia.OnStats += OnTriviaStats;
trivia.OnTimeout += OnTriviaTimeout;
}
private void UnregisterEvents(TriviaGame trivia)
{
trivia.OnQuestion -= OnTriviaQuestion;
trivia.OnHint -= OnTriviaHint;
trivia.OnGuess -= OnTriviaGuess;
trivia.OnEnded -= OnTriviaEnded;
trivia.OnStats -= OnTriviaStats;
trivia.OnTimeout -= OnTriviaTimeout;
}
private async Task OnTriviaHint(TriviaGame game, TriviaQuestion question)
{
try
{
if (questionMessage is null)
{
game.Stop();
return;
}
if (questionEmbed is not null)
await questionMessage.ModifyAsync(m => m.Embed = questionEmbed.WithFooter(question.GetHint()).Build());
}
catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound or HttpStatusCode.Forbidden)
{
Log.Warning("Unable to edit message to show hint. Stopping trivia");
game.Stop();
}
catch (Exception ex)
{
Log.Warning(ex, "Error editing trivia message");
}
}
private async Task OnTriviaQuestion(TriviaGame game, TriviaQuestion question)
{
try
{
questionEmbed = _eb.Create()
.WithOkColor()
.WithTitle(GetText(strs.trivia_game))
.AddField(GetText(strs.category), question.Category)
.AddField(GetText(strs.question), question.Question);
showHowToQuit = !showHowToQuit;
if (showHowToQuit)
questionEmbed.WithFooter(GetText(strs.trivia_quit($"{prefix}tq")));
if (Uri.IsWellFormedUriString(question.ImageUrl, UriKind.Absolute))
questionEmbed.WithImageUrl(question.ImageUrl);
questionMessage = await ctx.Channel.EmbedAsync(questionEmbed);
}
catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound or HttpStatusCode.Forbidden
or HttpStatusCode.BadRequest)
{
Log.Warning("Unable to send trivia questions. Stopping immediately");
game.Stop();
throw;
}
}
private async Task OnTriviaTimeout(TriviaGame _, TriviaQuestion question)
{
try
{
var embed = _eb.Create()
.WithErrorColor()
.WithTitle(GetText(strs.trivia_game))
.WithDescription(GetText(strs.trivia_times_up(Format.Bold(question.Answer))));
if (Uri.IsWellFormedUriString(question.AnswerImageUrl, UriKind.Absolute))
embed.WithImageUrl(question.AnswerImageUrl);
await ctx.Channel.EmbedAsync(embed);
}
catch
{
// ignored
}
}
private async Task OnTriviaStats(TriviaGame game)
{
try
{
await SendConfirmAsync(GetText(strs.leaderboard), GetLeaderboardString(game));
}
catch
{
// ignored
}
}
private async Task OnTriviaEnded(TriviaGame game)
{
try
{
await ctx.Channel.EmbedAsync(_eb.Create(ctx)
.WithOkColor()
.WithAuthor(GetText(strs.trivia_ended))
.WithTitle(GetText(strs.leaderboard))
.WithDescription(GetLeaderboardString(game)));
}
catch
{
// ignored
}
finally
{
_service.RunningTrivias.TryRemove(ctx.Guild.Id, out _);
}
UnregisterEvents(game);
}
private async Task OnTriviaGuess(TriviaGame _, TriviaUser user, TriviaQuestion question, bool isWin)
{
try
{
var embed = _eb.Create()
.WithOkColor()
.WithTitle(GetText(strs.trivia_game))
.WithDescription(GetText(strs.trivia_win(user.Name,
Format.Bold(question.Answer))));
if (Uri.IsWellFormedUriString(question.AnswerImageUrl, UriKind.Absolute))
embed.WithImageUrl(question.AnswerImageUrl);
if (isWin)
{
await ctx.Channel.EmbedAsync(embed);
var reward = _gamesConfig.Data.Trivia.CurrencyReward;
if (reward > 0)
await _cs.AddAsync(user.Id, reward, new("trivia", "win"));
return;
}
embed.WithDescription(GetText(strs.trivia_guess(user.Name,
Format.Bold(question.Answer))));
await ctx.Channel.EmbedAsync(embed);
}
catch
{
// ignored
}
}
}
}

View File

@@ -1,22 +0,0 @@
namespace NadekoBot.Modules.Games.Common.Trivia;
public sealed class DefaultQuestionPool : IQuestionPool
{
private readonly ILocalDataCache _cache;
private readonly NadekoRandom _rng;
public DefaultQuestionPool(ILocalDataCache cache)
{
_cache = cache;
_rng = new NadekoRandom();
}
public async Task<TriviaQuestion?> GetQuestionAsync()
{
var pool = await _cache.GetTriviaQuestionsAsync();
if(pool is null or {Length: 0})
return default;
return new(pool[_rng.Next(0, pool.Length)]);
}
}

View File

@@ -1,6 +0,0 @@
namespace NadekoBot.Modules.Games.Common.Trivia;
public interface IQuestionPool
{
Task<TriviaQuestion?> GetQuestionAsync();
}

View File

@@ -1,32 +0,0 @@
namespace NadekoBot.Modules.Games.Common.Trivia;
public sealed class PokemonQuestionPool : IQuestionPool
{
public int QuestionsCount => 905; // xd
private readonly NadekoRandom _rng;
private readonly ILocalDataCache _cache;
public PokemonQuestionPool(ILocalDataCache cache)
{
_cache = cache;
_rng = new NadekoRandom();
}
public async Task<TriviaQuestion?> GetQuestionAsync()
{
var pokes = await _cache.GetPokemonMapAsync();
if (pokes is null or { Count: 0 })
return default;
var num = _rng.Next(1, QuestionsCount + 1);
return new(new()
{
Question = "Who's That Pokémon?",
Answer = pokes[num].ToTitleCase(),
Category = "Pokemon",
ImageUrl = $@"https://nadeko.bot/images/pokemon/shadows/{num}.png",
AnswerImageUrl = $@"https://nadeko.bot/images/pokemon/real/{num}.png"
});
}
}

View File

@@ -1,219 +0,0 @@
using System.Threading.Channels;
using Exception = System.Exception;
namespace NadekoBot.Modules.Games.Common.Trivia;
public sealed class TriviaGame
{
private readonly TriviaOptions _opts;
private readonly IQuestionPool _questionPool;
#region Events
public event Func<TriviaGame, TriviaQuestion, Task> OnQuestion = static delegate { return Task.CompletedTask; };
public event Func<TriviaGame, TriviaQuestion, Task> OnHint = static delegate { return Task.CompletedTask; };
public event Func<TriviaGame, Task> OnStats = static delegate { return Task.CompletedTask; };
public event Func<TriviaGame, TriviaUser, TriviaQuestion, bool, Task> OnGuess = static delegate { return Task.CompletedTask; };
public event Func<TriviaGame, TriviaQuestion, Task> OnTimeout = static delegate { return Task.CompletedTask; };
public event Func<TriviaGame, Task> OnEnded = static delegate { return Task.CompletedTask; };
#endregion
private bool _isStopped;
public TriviaQuestion? CurrentQuestion { get; set; }
private readonly ConcurrentDictionary<ulong, int> _users = new ();
private readonly Channel<(TriviaUser User, string Input)> _inputs
= Channel.CreateUnbounded<(TriviaUser, string)>(new UnboundedChannelOptions
{
AllowSynchronousContinuations = true,
SingleReader = true,
SingleWriter = false,
});
public TriviaGame(TriviaOptions options, ILocalDataCache cache)
{
_opts = options;
_questionPool = _opts.IsPokemon
? new PokemonQuestionPool(cache)
: new DefaultQuestionPool(cache);
}
public async Task RunAsync()
{
await GameLoop();
}
private async Task GameLoop()
{
Task TimeOutFactory() => Task.Delay(_opts.QuestionTimer * 1000 / 2);
var errorCount = 0;
var inactivity = 0;
// loop until game is stopped
// each iteration is one round
var firstRun = true;
try
{
while (!_isStopped)
{
if (errorCount >= 5)
{
Log.Warning("Trivia errored 5 times and will quit");
break;
}
// wait for 3 seconds before posting the next question
if (firstRun)
{
firstRun = false;
}
else
{
await Task.Delay(3000);
}
var maybeQuestion = await _questionPool.GetQuestionAsync();
if (maybeQuestion is not { } question)
{
// if question is null (ran out of question, or other bugg ) - stop
break;
}
CurrentQuestion = question;
try
{
// clear out all of the past guesses
while (_inputs.Reader.TryRead(out _))
;
await OnQuestion(this, question);
}
catch (Exception ex)
{
Log.Warning(ex, "Error executing OnQuestion: {Message}", ex.Message);
errorCount++;
continue;
}
// just keep looping through user inputs until someone guesses the answer
// or the timer expires
var halfGuessTimerTask = TimeOutFactory();
var hintSent = false;
var guessed = false;
while (true)
{
using var readCancel = new CancellationTokenSource();
var readTask = _inputs.Reader.ReadAsync(readCancel.Token).AsTask();
// wait for either someone to attempt to guess
// or for timeout
var task = await Task.WhenAny(readTask, halfGuessTimerTask);
// if the task which completed is the timeout task
if (task == halfGuessTimerTask)
{
readCancel.Cancel();
// if hint is already sent, means time expired
// break (end the round)
if (hintSent)
break;
// else, means half time passed, send a hint
hintSent = true;
// start a new countdown of the same length
halfGuessTimerTask = TimeOutFactory();
if (!_opts.NoHint)
{
// send a hint out
await OnHint(this, question);
}
continue;
}
// otherwise, read task is successful, and we're gonna
// get the user input data
var (user, input) = await readTask;
// check the guess
if (question.IsAnswerCorrect(input))
{
// add 1 point to the user
var val = _users.AddOrUpdate(user.Id, 1, (_, points) => ++points);
guessed = true;
// reset inactivity counter
inactivity = 0;
errorCount = 0;
var isWin = false;
// if user won the game, tell the game to stop
if (val >= _opts.WinRequirement)
{
_isStopped = true;
isWin = true;
}
// call onguess
await OnGuess(this, user, question, isWin);
break;
}
}
if (!guessed)
{
await OnTimeout(this, question);
if (_opts.Timeout != 0 && ++inactivity >= _opts.Timeout)
{
Log.Information("Trivia game is stopping due to inactivity");
break;
}
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Fatal error in trivia game: {ErrorMessage}", ex.Message);
}
finally
{
// make sure game is set as ended
_isStopped = true;
_ = OnEnded(this);
}
}
public IReadOnlyList<(ulong User, int points)> GetLeaderboard()
=> _users.Select(x => (x.Key, x.Value)).ToArray();
public ValueTask InputAsync(TriviaUser user, string input)
=> _inputs.Writer.WriteAsync((user, input));
public bool Stop()
{
var isStopped = _isStopped;
_isStopped = true;
return !isStopped;
}
public async ValueTask TriggerStatsAsync()
{
await OnStats(this);
}
public async Task TriggerQuestionAsync()
{
if(CurrentQuestion is TriviaQuestion q)
await OnQuestion(this, q);
}
}

View File

@@ -1,37 +0,0 @@
#nullable disable
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Modules.Games.Common.Trivia;
namespace NadekoBot.Modules.Games;
public sealed class TriviaGamesService : IReadyExecutor, INService
{
private readonly DiscordSocketClient _client;
public ConcurrentDictionary<ulong, TriviaGame> RunningTrivias { get; } = new();
public TriviaGamesService(DiscordSocketClient client)
{
_client = client;
}
public Task OnReadyAsync()
{
_client.MessageReceived += OnMessageReceived;
return Task.CompletedTask;
}
private async Task OnMessageReceived(SocketMessage msg)
{
if (msg.Author.IsBot)
return;
var umsg = msg as SocketUserMessage;
if (umsg?.Channel is not IGuildChannel gc)
return;
if (RunningTrivias.TryGetValue(gc.GuildId, out var tg))
await tg.InputAsync(new(umsg.Author.Mention, umsg.Author.Id), umsg.Content);
}
}

View File

@@ -1,44 +0,0 @@
#nullable disable
using CommandLine;
namespace NadekoBot.Modules.Games.Common.Trivia;
public class TriviaOptions : INadekoCommandOptions
{
[Option('p', "pokemon", Required = false, Default = false, HelpText = "Whether it's 'Who's that pokemon?' trivia.")]
public bool IsPokemon { get; set; } = false;
[Option("nohint", Required = false, Default = false, HelpText = "Don't show any hints.")]
public bool NoHint { get; set; } = false;
[Option('w',
"win-req",
Required = false,
Default = 10,
HelpText = "Winning requirement. Set 0 for an infinite game. Default 10.")]
public int WinRequirement { get; set; } = 10;
[Option('q',
"question-timer",
Required = false,
Default = 30,
HelpText = "How long until the question ends. Default 30.")]
public int QuestionTimer { get; set; } = 30;
[Option('t',
"timeout",
Required = false,
Default = 10,
HelpText = "Number of questions of inactivity in order stop. Set 0 for never. Default 10.")]
public int Timeout { get; set; } = 10;
public void NormalizeOptions()
{
if (WinRequirement < 0)
WinRequirement = 10;
if (QuestionTimer is < 10 or > 300)
QuestionTimer = 30;
if (Timeout is < 0 or > 20)
Timeout = 10;
}
}

View File

@@ -1,116 +0,0 @@
#nullable disable
using System.Text.RegularExpressions;
// THANKS @ShoMinamimoto for suggestions and coding help
namespace NadekoBot.Modules.Games.Common.Trivia;
public class TriviaQuestion
{
public const int MAX_STRING_LENGTH = 22;
//represents the min size to judge levDistance with
private static readonly HashSet<Tuple<int, int>> _strictness = new()
{
new(9, 0),
new(14, 1),
new(19, 2),
new(22, 3)
};
public string Category
=> _qModel.Category;
public string Question
=> _qModel.Question;
public string ImageUrl
=> _qModel.ImageUrl;
public string AnswerImageUrl
=> _qModel.AnswerImageUrl ?? ImageUrl;
public string Answer
=> _qModel.Answer;
public string CleanAnswer
=> cleanAnswer ?? (cleanAnswer = Clean(Answer));
private string cleanAnswer;
private readonly TriviaQuestionModel _qModel;
public TriviaQuestion(TriviaQuestionModel qModel)
{
_qModel = qModel;
}
public string GetHint()
=> Scramble(Answer);
public bool IsAnswerCorrect(string guess)
{
if (Answer.Equals(guess, StringComparison.InvariantCulture))
return true;
var cleanGuess = Clean(guess);
if (CleanAnswer.Equals(cleanGuess, StringComparison.InvariantCulture))
return true;
var levDistanceClean = CleanAnswer.LevenshteinDistance(cleanGuess);
var levDistanceNormal = Answer.LevenshteinDistance(guess);
return JudgeGuess(CleanAnswer.Length, cleanGuess.Length, levDistanceClean)
|| JudgeGuess(Answer.Length, guess.Length, levDistanceNormal);
}
private static bool JudgeGuess(int guessLength, int answerLength, int levDistance)
{
foreach (var level in _strictness)
{
if (guessLength <= level.Item1 || answerLength <= level.Item1)
{
if (levDistance <= level.Item2)
return true;
return false;
}
}
return false;
}
private static string Clean(string str)
{
str = " " + str.ToLowerInvariant() + " ";
str = Regex.Replace(str, @"\s+", " ");
str = Regex.Replace(str, @"[^\w\d\s]", "");
//Here's where custom modification can be done
str = Regex.Replace(str, @"\s(a|an|the|of|in|for|to|as|at|be)\s", " ");
//End custom mod and cleanup whitespace
str = Regex.Replace(str, @"^\s+", "");
str = Regex.Replace(str, @"\s+$", "");
//Trim the really long answers
str = str.Length <= MAX_STRING_LENGTH ? str : str[..MAX_STRING_LENGTH];
return str;
}
private static string Scramble(string word)
{
var letters = word.ToCharArray();
var count = 0;
for (var i = 0; i < letters.Length; i++)
{
if (letters[i] == ' ')
continue;
count++;
if (count <= letters.Length / 5)
continue;
if (count % 3 == 0)
continue;
if (letters[i] != ' ')
letters[i] = '_';
}
return string.Join(" ",
new string(letters).Replace(" ", " \u2000", StringComparison.InvariantCulture).AsEnumerable());
}
}

View File

@@ -1,11 +0,0 @@
#nullable disable
namespace NadekoBot.Modules.Games.Common.Trivia;
public sealed class TriviaQuestionModel
{
public string Category { get; init; }
public string Question { get; init; }
public string ImageUrl { get; init; }
public string AnswerImageUrl { get; init; }
public string Answer { get; init; }
}

View File

@@ -1,3 +0,0 @@
namespace NadekoBot.Modules.Games.Common.Trivia;
public record class TriviaUser(string Name, ulong Id);

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