Added Games module
This commit is contained in:
parent
922f726a6e
commit
4261abbf72
38 changed files with 3467 additions and 0 deletions
200
src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs
Normal file
200
src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
#nullable disable
|
||||||
|
using CommandLine;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games.Common.Acrophobia;
|
||||||
|
|
||||||
|
public sealed class AcrophobiaGame : IDisposable
|
||||||
|
{
|
||||||
|
public enum Phase
|
||||||
|
{
|
||||||
|
Submission,
|
||||||
|
Voting,
|
||||||
|
Ended
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UserInputResult
|
||||||
|
{
|
||||||
|
Submitted,
|
||||||
|
SubmissionFailed,
|
||||||
|
Voted,
|
||||||
|
VotingFailed,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Func<AcrophobiaGame, Task> OnStarted = delegate { return Task.CompletedTask; };
|
||||||
|
|
||||||
|
public event Func<AcrophobiaGame, ImmutableArray<KeyValuePair<AcrophobiaUser, int>>, Task> OnVotingStarted =
|
||||||
|
delegate { return Task.CompletedTask; };
|
||||||
|
|
||||||
|
public event Func<string, Task> OnUserVoted = delegate { return Task.CompletedTask; };
|
||||||
|
|
||||||
|
public event Func<AcrophobiaGame, ImmutableArray<KeyValuePair<AcrophobiaUser, int>>, Task> OnEnded = delegate
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
public Phase CurrentPhase { get; private set; } = Phase.Submission;
|
||||||
|
public ImmutableArray<char> StartingLetters { get; private set; }
|
||||||
|
public Options Opts { get; }
|
||||||
|
|
||||||
|
private readonly Dictionary<AcrophobiaUser, int> _submissions = new();
|
||||||
|
private readonly SemaphoreSlim _locker = new(1, 1);
|
||||||
|
private readonly EllieRandom _rng;
|
||||||
|
|
||||||
|
private readonly HashSet<ulong> _usersWhoVoted = [];
|
||||||
|
|
||||||
|
public AcrophobiaGame(Options options)
|
||||||
|
{
|
||||||
|
Opts = options;
|
||||||
|
_rng = new();
|
||||||
|
InitializeStartingLetters();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Run()
|
||||||
|
{
|
||||||
|
await OnStarted(this);
|
||||||
|
await Task.Delay(Opts.SubmissionTime * 1000);
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_submissions.Count == 0)
|
||||||
|
{
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
await OnVotingStarted(this, ImmutableArray.Create<KeyValuePair<AcrophobiaUser, int>>());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_submissions.Count == 1)
|
||||||
|
{
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CurrentPhase = Phase.Voting;
|
||||||
|
|
||||||
|
await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray());
|
||||||
|
}
|
||||||
|
finally { _locker.Release(); }
|
||||||
|
|
||||||
|
await Task.Delay(Opts.VoteTime * 1000);
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
await OnEnded(this, _submissions.ToArray().ToImmutableArray());
|
||||||
|
}
|
||||||
|
finally { _locker.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeStartingLetters()
|
||||||
|
{
|
||||||
|
var wordCount = _rng.Next(3, 6);
|
||||||
|
|
||||||
|
var lettersArr = new char[wordCount];
|
||||||
|
|
||||||
|
for (var i = 0; i < wordCount; i++)
|
||||||
|
{
|
||||||
|
var randChar = (char)_rng.Next(65, 91);
|
||||||
|
lettersArr[i] = randChar == 'X' ? (char)_rng.Next(65, 88) : randChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
StartingLetters = lettersArr.ToImmutableArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UserInput(ulong userId, string userName, string input)
|
||||||
|
{
|
||||||
|
var user = new AcrophobiaUser(userId, userName, input.ToLowerInvariant().ToTitleCase());
|
||||||
|
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (CurrentPhase)
|
||||||
|
{
|
||||||
|
case Phase.Submission:
|
||||||
|
if (_submissions.ContainsKey(user) || !IsValidAnswer(input))
|
||||||
|
break;
|
||||||
|
|
||||||
|
_submissions.Add(user, 0);
|
||||||
|
return true;
|
||||||
|
case Phase.Voting:
|
||||||
|
AcrophobiaUser toVoteFor;
|
||||||
|
if (!int.TryParse(input, out var index)
|
||||||
|
|| --index < 0
|
||||||
|
|| index >= _submissions.Count
|
||||||
|
|| (toVoteFor = _submissions.ToArray()[index].Key).UserId == user.UserId
|
||||||
|
|| !_usersWhoVoted.Add(userId))
|
||||||
|
break;
|
||||||
|
++_submissions[toVoteFor];
|
||||||
|
_ = Task.Run(() => OnUserVoted(userName));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_locker.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsValidAnswer(string input)
|
||||||
|
{
|
||||||
|
input = input.ToUpperInvariant();
|
||||||
|
|
||||||
|
var inputWords = input.Split(' ');
|
||||||
|
|
||||||
|
if (inputWords.Length
|
||||||
|
!= StartingLetters.Length) // number of words must be the same as the number of the starting letters
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (var i = 0; i < StartingLetters.Length; i++)
|
||||||
|
{
|
||||||
|
var letter = StartingLetters[i];
|
||||||
|
|
||||||
|
if (!inputWords[i]
|
||||||
|
.StartsWith(letter.ToString(), StringComparison.InvariantCulture)) // all first letters must match
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
OnStarted = null;
|
||||||
|
OnEnded = null;
|
||||||
|
OnUserVoted = null;
|
||||||
|
OnVotingStarted = null;
|
||||||
|
_usersWhoVoted.Clear();
|
||||||
|
_submissions.Clear();
|
||||||
|
_locker.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Options : IEllieCommandOptions
|
||||||
|
{
|
||||||
|
[Option('s',
|
||||||
|
"submission-time",
|
||||||
|
Required = false,
|
||||||
|
Default = 60,
|
||||||
|
HelpText = "Time after which the submissions are closed and voting starts.")]
|
||||||
|
public int SubmissionTime { get; set; } = 60;
|
||||||
|
|
||||||
|
[Option('v',
|
||||||
|
"vote-time",
|
||||||
|
Required = false,
|
||||||
|
Default = 60,
|
||||||
|
HelpText = "Time after which the voting is closed and the winner is declared.")]
|
||||||
|
public int VoteTime { get; set; } = 30;
|
||||||
|
|
||||||
|
public void NormalizeOptions()
|
||||||
|
{
|
||||||
|
if (SubmissionTime is < 15 or > 300)
|
||||||
|
SubmissionTime = 60;
|
||||||
|
if (VoteTime is < 15 or > 120)
|
||||||
|
VoteTime = 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs
Normal file
22
src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.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;
|
||||||
|
}
|
140
src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs
Normal file
140
src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Games.Common.Acrophobia;
|
||||||
|
using EllieBot.Modules.Games.Services;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games;
|
||||||
|
|
||||||
|
public partial class Games
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class AcropobiaCommands : EllieModule<GamesService>
|
||||||
|
{
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
|
||||||
|
public AcropobiaCommands(DiscordSocketClient client)
|
||||||
|
=> _client = client;
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[EllieOptions<AcrophobiaGame.Options>]
|
||||||
|
public async Task Acrophobia(params string[] args)
|
||||||
|
{
|
||||||
|
var (options, _) = OptionsParser.ParseFrom(new AcrophobiaGame.Options(), args);
|
||||||
|
var channel = (ITextChannel)ctx.Channel;
|
||||||
|
|
||||||
|
var game = new AcrophobiaGame(options);
|
||||||
|
if (_service.AcrophobiaGames.TryAdd(channel.Id, game))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
game.OnStarted += Game_OnStarted;
|
||||||
|
game.OnEnded += Game_OnEnded;
|
||||||
|
game.OnVotingStarted += Game_OnVotingStarted;
|
||||||
|
game.OnUserVoted += Game_OnUserVoted;
|
||||||
|
_client.MessageReceived += ClientMessageReceived;
|
||||||
|
await game.Run();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_client.MessageReceived -= ClientMessageReceived;
|
||||||
|
_service.AcrophobiaGames.TryRemove(channel.Id, out game);
|
||||||
|
game?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await Response().Error(strs.acro_running).SendAsync();
|
||||||
|
|
||||||
|
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 = _sender.CreateEmbed()
|
||||||
|
.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 Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task Game_OnUserVoted(string user)
|
||||||
|
=> Response().Confirm(GetText(strs.acrophobia), GetText(strs.acro_vote_cast(Format.Bold(user)))).SendAsync();
|
||||||
|
|
||||||
|
private async Task Game_OnVotingStarted(
|
||||||
|
AcrophobiaGame game,
|
||||||
|
ImmutableArray<KeyValuePair<AcrophobiaUser, int>> submissions)
|
||||||
|
{
|
||||||
|
if (submissions.Length == 0)
|
||||||
|
{
|
||||||
|
await Response().Error(GetText(strs.acrophobia), GetText(strs.acro_ended_no_sub)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submissions.Length == 1)
|
||||||
|
{
|
||||||
|
await Response().Embed(_sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithDescription(GetText(
|
||||||
|
strs.acro_winner_only(
|
||||||
|
Format.Bold(submissions.First().Key.UserName))))
|
||||||
|
.WithFooter(submissions.First().Key.Input)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.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 Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Game_OnEnded(AcrophobiaGame game, ImmutableArray<KeyValuePair<AcrophobiaUser, int>> votes)
|
||||||
|
{
|
||||||
|
if (!votes.Any() || votes.All(x => x.Value == 0))
|
||||||
|
{
|
||||||
|
await Response().Error(GetText(strs.acrophobia), GetText(strs.acro_no_votes_cast)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var table = votes.OrderByDescending(v => v.Value);
|
||||||
|
var winner = table.First();
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.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 Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
215
src/EllieBot/Modules/Games/ChatterBot/ChatterbotService.cs
Normal file
215
src/EllieBot/Modules/Games/ChatterBot/ChatterbotService.cs
Normal file
|
@ -0,0 +1,215 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Common.ModuleBehaviors;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using EllieBot.Modules.Games.Common;
|
||||||
|
using EllieBot.Modules.Games.Common.ChatterBot;
|
||||||
|
using EllieBot.Modules.Patronage;
|
||||||
|
using EllieBot.Modules.Permissions;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games.Services;
|
||||||
|
|
||||||
|
public class ChatterBotService : IExecOnMessage
|
||||||
|
{
|
||||||
|
public ConcurrentDictionary<ulong, Lazy<IChatterBotSession>> ChatterBotGuilds { get; }
|
||||||
|
|
||||||
|
public int Priority
|
||||||
|
=> 1;
|
||||||
|
|
||||||
|
private readonly FeatureLimitKey _flKey;
|
||||||
|
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly IPermissionChecker _perms;
|
||||||
|
private readonly CommandHandler _cmd;
|
||||||
|
private readonly IBotCredentials _creds;
|
||||||
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
|
private readonly IPatronageService _ps;
|
||||||
|
private readonly GamesConfigService _gcs;
|
||||||
|
private readonly IMessageSenderService _sender;
|
||||||
|
|
||||||
|
public ChatterBotService(
|
||||||
|
DiscordSocketClient client,
|
||||||
|
IPermissionChecker perms,
|
||||||
|
IBot bot,
|
||||||
|
CommandHandler cmd,
|
||||||
|
IHttpClientFactory factory,
|
||||||
|
IBotCredentials creds,
|
||||||
|
IPatronageService ps,
|
||||||
|
GamesConfigService gcs,
|
||||||
|
IMessageSenderService sender)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_perms = perms;
|
||||||
|
_cmd = cmd;
|
||||||
|
_creds = creds;
|
||||||
|
_sender = sender;
|
||||||
|
_httpFactory = factory;
|
||||||
|
_ps = ps;
|
||||||
|
_perms = perms;
|
||||||
|
_gcs = gcs;
|
||||||
|
|
||||||
|
_flKey = new FeatureLimitKey()
|
||||||
|
{
|
||||||
|
Key = CleverBotResponseStr.CLEVERBOT_RESPONSE,
|
||||||
|
PrettyName = "Cleverbot Replies"
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatterBotGuilds = new(bot.AllGuildConfigs
|
||||||
|
.Where(gc => gc.CleverbotEnabled)
|
||||||
|
.ToDictionary(gc => gc.GuildId,
|
||||||
|
_ => new Lazy<IChatterBotSession>(() => CreateSession(), true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IChatterBotSession CreateSession()
|
||||||
|
{
|
||||||
|
switch (_gcs.Data.ChatBot)
|
||||||
|
{
|
||||||
|
case ChatBotImplementation.Cleverbot:
|
||||||
|
if (!string.IsNullOrWhiteSpace(_creds.CleverbotApiKey))
|
||||||
|
return new OfficialCleverbotSession(_creds.CleverbotApiKey, _httpFactory);
|
||||||
|
|
||||||
|
Log.Information("Cleverbot will not work as the api key is missing");
|
||||||
|
return null;
|
||||||
|
case ChatBotImplementation.Gpt3:
|
||||||
|
if (!string.IsNullOrWhiteSpace(_creds.Gpt3ApiKey))
|
||||||
|
return new OfficialGpt3Session(_creds.Gpt3ApiKey,
|
||||||
|
_gcs.Data.ChatGpt.ModelName,
|
||||||
|
_gcs.Data.ChatGpt.ChatHistory,
|
||||||
|
_gcs.Data.ChatGpt.MaxTokens,
|
||||||
|
_gcs.Data.ChatGpt.MinTokens,
|
||||||
|
_gcs.Data.ChatGpt.PersonalityPrompt,
|
||||||
|
_client.CurrentUser.Username,
|
||||||
|
_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 ellieId = _client.CurrentUser.Id;
|
||||||
|
var normalMention = $"<@{ellieId}> ";
|
||||||
|
var nickMention = $"<@!{ellieId}> ";
|
||||||
|
string message;
|
||||||
|
if (msg.Content.StartsWith(normalMention, StringComparison.InvariantCulture))
|
||||||
|
message = msg.Content[normalMention.Length..].Trim();
|
||||||
|
else if (msg.Content.StartsWith(nickMention, StringComparison.InvariantCulture))
|
||||||
|
message = msg.Content[nickMention.Length..].Trim();
|
||||||
|
else
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg)
|
||||||
|
{
|
||||||
|
if (guild is not SocketGuild sg)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var message = PrepareMessage(usrMsg, out var cbs);
|
||||||
|
if (message is null || cbs is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var res = await _perms.CheckPermsAsync(sg,
|
||||||
|
usrMsg.Channel,
|
||||||
|
usrMsg.Author,
|
||||||
|
CleverBotResponseStr.CLEVERBOT_RESPONSE,
|
||||||
|
CleverBotResponseStr.CLEVERBOT_RESPONSE);
|
||||||
|
|
||||||
|
if (!res.IsAllowed)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
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 _sender.Response(channel)
|
||||||
|
.Error(null,
|
||||||
|
text:
|
||||||
|
"In order to use the cleverbot feature, the owner of this server should be [Patron Tier X](https://patreon.com/join/elliebot) on patreon.",
|
||||||
|
footer:
|
||||||
|
"You may disable the cleverbot feature, and this message via '.cleverbot' command")
|
||||||
|
.SendAsync();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _sender.Response(channel)
|
||||||
|
.Error(
|
||||||
|
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 .")
|
||||||
|
.SendAsync();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = channel.TriggerTypingAsync();
|
||||||
|
var response = await cbs.Think(message, usrMsg.Author.ToString());
|
||||||
|
await _sender.Response(channel)
|
||||||
|
.Confirm(response)
|
||||||
|
.SendAsync();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
48
src/EllieBot/Modules/Games/ChatterBot/CleverBotCommands.cs
Normal file
48
src/EllieBot/Modules/Games/ChatterBot/CleverBotCommands.cs
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Db;
|
||||||
|
using EllieBot.Modules.Games.Services;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games;
|
||||||
|
|
||||||
|
public partial class Games
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class ChatterBotCommands : EllieModule<ChatterBotService>
|
||||||
|
{
|
||||||
|
private readonly DbService _db;
|
||||||
|
|
||||||
|
public ChatterBotCommands(DbService db)
|
||||||
|
=> _db = db;
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.ManageMessages)]
|
||||||
|
public async Task Cleverbot()
|
||||||
|
{
|
||||||
|
var channel = (ITextChannel)ctx.Channel;
|
||||||
|
|
||||||
|
if (_service.ChatterBotGuilds.TryRemove(channel.Guild.Id, out _))
|
||||||
|
{
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
uow.Set<GuildConfig>().SetCleverbotEnabled(ctx.Guild.Id, false);
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Confirm(strs.cleverbot_disabled).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_service.ChatterBotGuilds.TryAdd(channel.Guild.Id, new(() => _service.CreateSession(), true));
|
||||||
|
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
uow.Set<GuildConfig>().SetCleverbotEnabled(ctx.Guild.Id, true);
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Confirm(strs.cleverbot_enabled).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Games.Common.ChatterBot;
|
||||||
|
|
||||||
|
public class CleverbotResponse
|
||||||
|
{
|
||||||
|
public string Cs { get; set; }
|
||||||
|
public string Output { get; set; }
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
#nullable disable
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games.Common.ChatterBot;
|
||||||
|
|
||||||
|
public class Gpt3Response
|
||||||
|
{
|
||||||
|
[JsonPropertyName("choices")]
|
||||||
|
public Choice[] Choices { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Choice
|
||||||
|
{
|
||||||
|
[JsonPropertyName("message")]
|
||||||
|
public Message Message { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Message {
|
||||||
|
[JsonPropertyName("content")]
|
||||||
|
public string Content { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Gpt3ApiRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("model")]
|
||||||
|
public string Model { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("messages")]
|
||||||
|
public List<GPTMessage> Messages { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("temperature")]
|
||||||
|
public int Temperature { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("max_tokens")]
|
||||||
|
public int MaxTokens { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GPTMessage
|
||||||
|
{
|
||||||
|
[JsonPropertyName("role")]
|
||||||
|
public string Role {get; init;}
|
||||||
|
[JsonPropertyName("content")]
|
||||||
|
public string Content {get; init;}
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name {get; init;}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Games.Common.ChatterBot;
|
||||||
|
|
||||||
|
public interface IChatterBotSession
|
||||||
|
{
|
||||||
|
Task<string> Think(string input, string username);
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
#nullable disable
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games.Common.ChatterBot;
|
||||||
|
|
||||||
|
public class OfficialCleverbotSession : IChatterBotSession
|
||||||
|
{
|
||||||
|
private string QueryString
|
||||||
|
=> $"https://www.cleverbot.com/getreply?key={_apiKey}" + "&wrapper=elliebot" + "&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, string username)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,105 @@
|
||||||
|
#nullable disable
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using SharpToken;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games.Common.ChatterBot;
|
||||||
|
|
||||||
|
public class OfficialGpt3Session : IChatterBotSession
|
||||||
|
{
|
||||||
|
private string Uri
|
||||||
|
=> $"https://api.openai.com/v1/chat/completions";
|
||||||
|
|
||||||
|
private readonly string _apiKey;
|
||||||
|
private readonly string _model;
|
||||||
|
private readonly int _maxHistory;
|
||||||
|
private readonly int _maxTokens;
|
||||||
|
private readonly int _minTokens;
|
||||||
|
private readonly string _ellieUsername;
|
||||||
|
private readonly GptEncoding _encoding;
|
||||||
|
private List<GPTMessage> messages = new();
|
||||||
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public OfficialGpt3Session(
|
||||||
|
string apiKey,
|
||||||
|
ChatGptModel model,
|
||||||
|
int chatHistory,
|
||||||
|
int maxTokens,
|
||||||
|
int minTokens,
|
||||||
|
string personality,
|
||||||
|
string ellieUsername,
|
||||||
|
IHttpClientFactory factory)
|
||||||
|
{
|
||||||
|
_apiKey = apiKey;
|
||||||
|
_httpFactory = factory;
|
||||||
|
switch (model)
|
||||||
|
{
|
||||||
|
case ChatGptModel.Gpt35Turbo:
|
||||||
|
_model = "gpt-3.5-turbo";
|
||||||
|
break;
|
||||||
|
case ChatGptModel.Gpt4:
|
||||||
|
_model = "gpt-4";
|
||||||
|
break;
|
||||||
|
case ChatGptModel.Gpt432k:
|
||||||
|
_model = "gpt-4-32k";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_maxHistory = chatHistory;
|
||||||
|
_maxTokens = maxTokens;
|
||||||
|
_minTokens = minTokens;
|
||||||
|
_ellieUsername = ellieUsername;
|
||||||
|
_encoding = GptEncoding.GetEncodingForModel(_model);
|
||||||
|
messages.Add(new GPTMessage(){Role = "user", Content = personality, Name = _ellieUsername});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> Think(string input, string username)
|
||||||
|
{
|
||||||
|
messages.Add(new GPTMessage(){Role = "user", Content = input, Name = username});
|
||||||
|
while(messages.Count > _maxHistory + 2){
|
||||||
|
messages.RemoveAt(1);
|
||||||
|
}
|
||||||
|
int tokensUsed = 0;
|
||||||
|
foreach(GPTMessage message in messages){
|
||||||
|
tokensUsed += _encoding.Encode(message.Content).Count;
|
||||||
|
}
|
||||||
|
tokensUsed *= 2; //Unsure why this is the case, but the token count chatgpt reports back is double what I calculate.
|
||||||
|
//check if we have the minimum number of tokens available to use. Remove messages until we have enough, otherwise exit out and inform the user why.
|
||||||
|
while(_maxTokens - tokensUsed <= _minTokens){
|
||||||
|
if(messages.Count > 2){
|
||||||
|
int tokens = _encoding.Encode(messages[1].Content).Count * 2;
|
||||||
|
tokensUsed -= tokens;
|
||||||
|
messages.RemoveAt(1);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
return "Token count exceeded, please increase the number of tokens in the bot config and restart.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
http.DefaultRequestHeaders.Authorization = new("Bearer", _apiKey);
|
||||||
|
var data = await http.PostAsJsonAsync(Uri, new Gpt3ApiRequest()
|
||||||
|
{
|
||||||
|
Model = _model,
|
||||||
|
Messages = messages,
|
||||||
|
MaxTokens = _maxTokens - tokensUsed,
|
||||||
|
Temperature = 1,
|
||||||
|
});
|
||||||
|
var dataString = await data.Content.ReadAsStringAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = JsonConvert.DeserializeObject<Gpt3Response>(dataString);
|
||||||
|
string message = response?.Choices[0]?.Message?.Content;
|
||||||
|
//Can't rely on the return to except, now that we need to add it to the messages list.
|
||||||
|
_ = message ?? throw new ArgumentNullException(nameof(message));
|
||||||
|
messages.Add(new GPTMessage(){Role = "assistant", Content = message, Name = _ellieUsername});
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Log.Warning("Unexpected GPT-3 response received: {ResponseString}", dataString);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
47
src/EllieBot/Modules/Games/Games.cs
Normal file
47
src/EllieBot/Modules/Games/Games.cs
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Games.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games;
|
||||||
|
|
||||||
|
/* more games
|
||||||
|
- Shiritori
|
||||||
|
- Simple RPG adventure
|
||||||
|
*/
|
||||||
|
public partial class Games : EllieModule<GamesService>
|
||||||
|
{
|
||||||
|
private readonly IImageCache _images;
|
||||||
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
|
private readonly Random _rng = new();
|
||||||
|
|
||||||
|
public Games(IImageCache images, IHttpClientFactory factory)
|
||||||
|
{
|
||||||
|
_images = images;
|
||||||
|
_httpFactory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task Choose([Leftover] string list = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(list))
|
||||||
|
return;
|
||||||
|
var listArr = list.Split(';');
|
||||||
|
if (listArr.Length < 2)
|
||||||
|
return;
|
||||||
|
var rng = new EllieRandom();
|
||||||
|
await Response().Confirm("🤔", listArr[rng.Next(0, listArr.Length)]).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
public async Task EightBall([Leftover] string question = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(question))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var res = _service.GetEightballResponse(ctx.User.Id, question);
|
||||||
|
await Response().Embed(_sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithDescription(ctx.User.ToString())
|
||||||
|
.AddField("❓ " + GetText(strs.question), question)
|
||||||
|
.AddField("🎱 " + GetText(strs._8ball), res)).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
174
src/EllieBot/Modules/Games/GamesConfig.cs
Normal file
174
src/EllieBot/Modules/Games/GamesConfig.cs
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
#nullable disable
|
||||||
|
using Cloneable;
|
||||||
|
using EllieBot.Common.Yml;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games.Common;
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public sealed partial class GamesConfig : ICloneable<GamesConfig>
|
||||||
|
{
|
||||||
|
[Comment("DO NOT CHANGE")]
|
||||||
|
public int Version { get; set; } = 3;
|
||||||
|
|
||||||
|
[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; } =
|
||||||
|
[
|
||||||
|
"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()
|
||||||
|
{
|
||||||
|
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.
|
||||||
|
gpt35turbo - cheapest
|
||||||
|
gpt4 - 30x more expensive, higher quality
|
||||||
|
gp432k - same model as above, but with a 32k token limit")]
|
||||||
|
public ChatGptModel ModelName { get; set; } = ChatGptModel.Gpt35Turbo;
|
||||||
|
|
||||||
|
[Comment(@"How should the chat bot behave, what's its personality? (Usage of this counts towards the max tokens)")]
|
||||||
|
public string PersonalityPrompt { get; set; } = "You are a chat bot willing to have a conversation with anyone about anything.";
|
||||||
|
|
||||||
|
[Comment(@"The maximum number of messages in a conversation that can be remembered. (This will increase the number of tokens used)")]
|
||||||
|
public int ChatHistory { get; set; } = 5;
|
||||||
|
|
||||||
|
[Comment(@"The maximum number of tokens to use per GPT-3 API call")]
|
||||||
|
public int MaxTokens { get; set; } = 100;
|
||||||
|
|
||||||
|
[Comment(@"The minimum number of tokens to use per GPT-3 API call, such that chat history is removed to make room.")]
|
||||||
|
public int MinTokens { get; set; } = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 ChatGptModel
|
||||||
|
{
|
||||||
|
Gpt35Turbo,
|
||||||
|
Gpt4,
|
||||||
|
Gpt432k
|
||||||
|
}
|
94
src/EllieBot/Modules/Games/GamesConfigService.cs
Normal file
94
src/EllieBot/Modules/Games/GamesConfigService.cs
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Common.Configs;
|
||||||
|
using EllieBot.Modules.Games.Common;
|
||||||
|
|
||||||
|
namespace EllieBot.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.modelName",
|
||||||
|
gs => gs.ChatGpt.ModelName,
|
||||||
|
ConfigParsers.InsensitiveEnum,
|
||||||
|
ConfigPrinters.ToString);
|
||||||
|
AddParsedProp("gpt.personality",
|
||||||
|
gs => gs.ChatGpt.PersonalityPrompt,
|
||||||
|
ConfigParsers.String,
|
||||||
|
ConfigPrinters.ToString);
|
||||||
|
AddParsedProp("gpt.chathistory",
|
||||||
|
gs => gs.ChatGpt.ChatHistory,
|
||||||
|
int.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val > 0);
|
||||||
|
AddParsedProp("gpt.max_tokens",
|
||||||
|
gs => gs.ChatGpt.MaxTokens,
|
||||||
|
int.TryParse,
|
||||||
|
ConfigPrinters.ToString,
|
||||||
|
val => val > 0);
|
||||||
|
AddParsedProp("gpt.min_tokens",
|
||||||
|
gs => gs.ChatGpt.MinTokens,
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.Version < 3)
|
||||||
|
{
|
||||||
|
ModifyConfig(c =>
|
||||||
|
{
|
||||||
|
c.Version = 3;
|
||||||
|
c.ChatGpt.ModelName = ChatGptModel.Gpt35Turbo;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
118
src/EllieBot/Modules/Games/GamesService.cs
Normal file
118
src/EllieBot/Modules/Games/GamesService.cs
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
#nullable disable
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using EllieBot.Common.ModuleBehaviors;
|
||||||
|
using EllieBot.Modules.Games.Common;
|
||||||
|
using EllieBot.Modules.Games.Common.Acrophobia;
|
||||||
|
using EllieBot.Modules.Games.Common.Nunchi;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games.Services;
|
||||||
|
|
||||||
|
public class GamesService : IEService, IReadyExecutor
|
||||||
|
{
|
||||||
|
private const string TYPING_ARTICLES_PATH = "data/typing_articles3.json";
|
||||||
|
|
||||||
|
public ConcurrentDictionary<ulong, GirlRating> GirlRatings { get; } = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> EightBallResponses
|
||||||
|
=> _gamesConfig.Data.EightBallResponses;
|
||||||
|
|
||||||
|
public List<TypingArticle> TypingArticles { get; } = new();
|
||||||
|
|
||||||
|
//channelId, game
|
||||||
|
public ConcurrentDictionary<ulong, AcrophobiaGame> AcrophobiaGames { get; } = new();
|
||||||
|
public Dictionary<ulong, TicTacToe> TicTacToeGames { get; } = new();
|
||||||
|
public ConcurrentDictionary<ulong, TypingGame> RunningContests { get; } = new();
|
||||||
|
public ConcurrentDictionary<ulong, NunchiGame> NunchiGames { get; } = new();
|
||||||
|
|
||||||
|
public AsyncLazy<RatingTexts> Ratings { get; }
|
||||||
|
private readonly GamesConfigService _gamesConfig;
|
||||||
|
|
||||||
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
|
private readonly IMemoryCache _8BallCache;
|
||||||
|
private readonly Random _rng;
|
||||||
|
|
||||||
|
public GamesService(GamesConfigService gamesConfig, IHttpClientFactory httpFactory)
|
||||||
|
{
|
||||||
|
_gamesConfig = gamesConfig;
|
||||||
|
_httpFactory = httpFactory;
|
||||||
|
_8BallCache = new MemoryCache(new MemoryCacheOptions
|
||||||
|
{
|
||||||
|
SizeLimit = 500_000
|
||||||
|
});
|
||||||
|
|
||||||
|
Ratings = new(GetRatingTexts);
|
||||||
|
_rng = new EllieRandom();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TypingArticles = JsonConvert.DeserializeObject<List<TypingArticle>>(File.ReadAllText(TYPING_ARTICLES_PATH));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error while loading typing articles: {ErrorMessage}", ex.Message);
|
||||||
|
TypingArticles = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnReadyAsync()
|
||||||
|
{
|
||||||
|
// reset rating once a day
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromDays(1));
|
||||||
|
while (await timer.WaitForNextTickAsync())
|
||||||
|
GirlRatings.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RatingTexts> GetRatingTexts()
|
||||||
|
{
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
var text = await http.GetStringAsync(
|
||||||
|
"https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/rategirl/rates.json");
|
||||||
|
return JsonConvert.DeserializeObject<RatingTexts>(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddTypingArticle(IUser user, string text)
|
||||||
|
{
|
||||||
|
TypingArticles.Add(new()
|
||||||
|
{
|
||||||
|
Source = user.ToString(),
|
||||||
|
Extra = $"Text added on {DateTime.UtcNow} by {user}.",
|
||||||
|
Text = text.SanitizeMentions(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
File.WriteAllText(TYPING_ARTICLES_PATH, JsonConvert.SerializeObject(TypingArticles));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetEightballResponse(ulong userId, string question)
|
||||||
|
=> _8BallCache.GetOrCreate($"8ball:{userId}:{question}",
|
||||||
|
e =>
|
||||||
|
{
|
||||||
|
e.Size = question.Length;
|
||||||
|
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12);
|
||||||
|
return EightBallResponses[_rng.Next(0, EightBallResponses.Count)];
|
||||||
|
});
|
||||||
|
|
||||||
|
public TypingArticle RemoveTypingArticle(int index)
|
||||||
|
{
|
||||||
|
var articles = TypingArticles;
|
||||||
|
if (index < 0 || index >= articles.Count)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var removed = articles[index];
|
||||||
|
TypingArticles.RemoveAt(index);
|
||||||
|
|
||||||
|
File.WriteAllText(TYPING_ARTICLES_PATH, JsonConvert.SerializeObject(articles));
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RatingTexts
|
||||||
|
{
|
||||||
|
public string Nog { get; set; }
|
||||||
|
public string Tra { get; set; }
|
||||||
|
public string Fun { get; set; }
|
||||||
|
public string Uni { get; set; }
|
||||||
|
public string Wif { get; set; }
|
||||||
|
public string Dat { get; set; }
|
||||||
|
public string Dan { get; set; }
|
||||||
|
}
|
||||||
|
}
|
61
src/EllieBot/Modules/Games/GirlRating.cs
Normal file
61
src/EllieBot/Modules/Games/GirlRating.cs
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
#nullable disable
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
using Image = SixLabors.ImageSharp.Image;
|
||||||
|
|
||||||
|
namespace EllieBot.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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
64
src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs
Normal file
64
src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
using EllieBot.Common.Yml;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games.Hangman;
|
||||||
|
|
||||||
|
public sealed class DefaultHangmanSource : IHangmanSource
|
||||||
|
{
|
||||||
|
private IReadOnlyDictionary<string, HangmanTerm[]> termsDict = new Dictionary<string, HangmanTerm[]>();
|
||||||
|
private readonly Random _rng;
|
||||||
|
|
||||||
|
public DefaultHangmanSource()
|
||||||
|
{
|
||||||
|
_rng = new EllieRandom();
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reload()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists("data/hangman"))
|
||||||
|
{
|
||||||
|
Log.Error("Hangman game won't work. Folder 'data/hangman' is missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var qs = new Dictionary<string, HangmanTerm[]>();
|
||||||
|
foreach (var file in Directory.EnumerateFiles("data/hangman/", "*.yml"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = Yaml.Deserializer.Deserialize<HangmanTerm[]>(File.ReadAllText(file));
|
||||||
|
qs[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = data;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Loading {HangmanFile} failed", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
termsDict = qs;
|
||||||
|
|
||||||
|
Log.Information("Loaded {HangmanCategoryCount} hangman categories", qs.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> GetCategories()
|
||||||
|
=> termsDict.Keys.ToList();
|
||||||
|
|
||||||
|
public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term)
|
||||||
|
{
|
||||||
|
if (category is null)
|
||||||
|
{
|
||||||
|
var cats = GetCategories();
|
||||||
|
category = cats.ElementAt(_rng.Next(0, cats.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (termsDict.TryGetValue(category, out var terms))
|
||||||
|
{
|
||||||
|
term = terms[_rng.Next(0, terms.Length)];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
term = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
76
src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs
Normal file
76
src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
using EllieBot.Modules.Games.Hangman;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games;
|
||||||
|
|
||||||
|
public partial class Games
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class HangmanCommands : EllieModule<IHangmanService>
|
||||||
|
{
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Hangmanlist()
|
||||||
|
=> await Response().Confirm(GetText(strs.hangman_types(prefix)), _service.GetHangmanTypes().Join('\n')).SendAsync();
|
||||||
|
|
||||||
|
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 EmbedBuilder GetEmbed(IMessageSenderService sender, HangmanGame.State state)
|
||||||
|
{
|
||||||
|
if (state.Phase == HangmanGame.Phase.Running)
|
||||||
|
{
|
||||||
|
return sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.AddField("Hangman", Draw(state))
|
||||||
|
.AddField("Guess", Format.Code(state.Word))
|
||||||
|
.WithFooter(state.MissedLetters.Join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.Phase == HangmanGame.Phase.Ended && state.Failed)
|
||||||
|
{
|
||||||
|
return sender.CreateEmbed()
|
||||||
|
.WithErrorColor()
|
||||||
|
.AddField("Hangman", Draw(state))
|
||||||
|
.AddField("Guess", Format.Code(state.Word))
|
||||||
|
.WithFooter(state.MissedLetters.Join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sender.CreateEmbed()
|
||||||
|
.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 Response().Error(strs.hangman_running).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var eb = GetEmbed(_sender, hangman);
|
||||||
|
eb.WithDescription(GetText(strs.hangman_game_started));
|
||||||
|
await Response().Embed(eb).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task HangmanStop()
|
||||||
|
{
|
||||||
|
if (await _service.StopHangman(ctx.Channel.Id))
|
||||||
|
await Response().Confirm(strs.hangman_stopped).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
111
src/EllieBot/Modules/Games/Hangman/HangmanGame.cs
Normal file
111
src/EllieBot/Modules/Games/Hangman/HangmanGame.cs
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Games.Hangman;
|
||||||
|
|
||||||
|
public sealed class HangmanGame
|
||||||
|
{
|
||||||
|
public enum GuessResult { NoAction, AlreadyTried, Incorrect, Guess, Win }
|
||||||
|
|
||||||
|
public enum Phase { Running, Ended }
|
||||||
|
|
||||||
|
private Phase CurrentPhase { get; set; }
|
||||||
|
|
||||||
|
private readonly HashSet<char> _incorrect = new();
|
||||||
|
private readonly HashSet<char> _correct = new();
|
||||||
|
private readonly HashSet<char> _remaining = new();
|
||||||
|
|
||||||
|
private readonly string _word;
|
||||||
|
private readonly string _imageUrl;
|
||||||
|
|
||||||
|
public HangmanGame(HangmanTerm term)
|
||||||
|
{
|
||||||
|
_word = term.Word;
|
||||||
|
_imageUrl = term.ImageUrl;
|
||||||
|
|
||||||
|
_remaining = _word.ToLowerInvariant().Where(x => char.IsLetter(x)).Select(char.ToLowerInvariant).ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public State GetState(GuessResult guessResult = GuessResult.NoAction)
|
||||||
|
=> new(_incorrect.Count,
|
||||||
|
CurrentPhase,
|
||||||
|
CurrentPhase == Phase.Ended ? _word : GetScrambledWord(),
|
||||||
|
guessResult,
|
||||||
|
_incorrect.ToList(),
|
||||||
|
CurrentPhase == Phase.Ended ? _imageUrl : string.Empty);
|
||||||
|
|
||||||
|
private string GetScrambledWord()
|
||||||
|
{
|
||||||
|
Span<char> output = stackalloc char[_word.Length * 2];
|
||||||
|
for (var i = 0; i < _word.Length; i++)
|
||||||
|
{
|
||||||
|
var ch = _word[i];
|
||||||
|
if (ch == ' ')
|
||||||
|
output[i * 2] = ' ';
|
||||||
|
if (!char.IsLetter(ch) || !_remaining.Contains(char.ToLowerInvariant(ch)))
|
||||||
|
output[i * 2] = ch;
|
||||||
|
else
|
||||||
|
output[i * 2] = '_';
|
||||||
|
|
||||||
|
output[(i * 2) + 1] = ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public State Guess(string guess)
|
||||||
|
{
|
||||||
|
if (CurrentPhase != Phase.Running)
|
||||||
|
return GetState();
|
||||||
|
|
||||||
|
guess = guess.Trim();
|
||||||
|
if (guess.Length > 1)
|
||||||
|
{
|
||||||
|
if (guess.Equals(_word, StringComparison.InvariantCultureIgnoreCase))
|
||||||
|
{
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
return GetState(GuessResult.Win);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
var charGuess = guess[0];
|
||||||
|
if (!char.IsLetter(charGuess))
|
||||||
|
return GetState();
|
||||||
|
|
||||||
|
if (_incorrect.Contains(charGuess) || _correct.Contains(charGuess))
|
||||||
|
return GetState(GuessResult.AlreadyTried);
|
||||||
|
|
||||||
|
if (_remaining.Remove(charGuess))
|
||||||
|
{
|
||||||
|
if (_remaining.Count == 0)
|
||||||
|
{
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
return GetState(GuessResult.Win);
|
||||||
|
}
|
||||||
|
|
||||||
|
_correct.Add(charGuess);
|
||||||
|
return GetState(GuessResult.Guess);
|
||||||
|
}
|
||||||
|
|
||||||
|
_incorrect.Add(charGuess);
|
||||||
|
if (_incorrect.Count > 5)
|
||||||
|
{
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
return GetState(GuessResult.Incorrect);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetState(GuessResult.Incorrect);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record State(
|
||||||
|
int Errors,
|
||||||
|
Phase Phase,
|
||||||
|
string Word,
|
||||||
|
GuessResult GuessResult,
|
||||||
|
List<char> MissedLetters,
|
||||||
|
string ImageUrl)
|
||||||
|
{
|
||||||
|
public bool Failed
|
||||||
|
=> Errors > 5;
|
||||||
|
}
|
||||||
|
}
|
136
src/EllieBot/Modules/Games/Hangman/HangmanService.cs
Normal file
136
src/EllieBot/Modules/Games/Hangman/HangmanService.cs
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using EllieBot.Common.ModuleBehaviors;
|
||||||
|
using EllieBot.Modules.Games.Services;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games.Hangman;
|
||||||
|
|
||||||
|
public sealed class HangmanService : IHangmanService, IExecNoCommand
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<ulong, HangmanGame> _hangmanGames = new();
|
||||||
|
private readonly IHangmanSource _source;
|
||||||
|
private readonly IMessageSenderService _sender;
|
||||||
|
private readonly GamesConfigService _gcs;
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
private readonly IMemoryCache _cdCache;
|
||||||
|
private readonly object _locker = new();
|
||||||
|
|
||||||
|
public HangmanService(
|
||||||
|
IHangmanSource source,
|
||||||
|
IMessageSenderService sender,
|
||||||
|
GamesConfigService gcs,
|
||||||
|
ICurrencyService cs,
|
||||||
|
IMemoryCache cdCache)
|
||||||
|
{
|
||||||
|
_source = source;
|
||||||
|
_sender = sender;
|
||||||
|
_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(_sender, 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 _sender.Response(channel).Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
8
src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs
Normal file
8
src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Games.Hangman;
|
||||||
|
|
||||||
|
public sealed class HangmanTerm
|
||||||
|
{
|
||||||
|
public string Word { get; set; }
|
||||||
|
public string ImageUrl { get; set; }
|
||||||
|
}
|
10
src/EllieBot/Modules/Games/Hangman/IHangmanService.cs
Normal file
10
src/EllieBot/Modules/Games/Hangman/IHangmanService.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace EllieBot.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();
|
||||||
|
}
|
10
src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs
Normal file
10
src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games.Hangman;
|
||||||
|
|
||||||
|
public interface IHangmanSource : IEService
|
||||||
|
{
|
||||||
|
public IReadOnlyCollection<string> GetCategories();
|
||||||
|
public void Reload();
|
||||||
|
public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term);
|
||||||
|
}
|
183
src/EllieBot/Modules/Games/Nunchi/Nunchi.cs
Normal file
183
src/EllieBot/Modules/Games/Nunchi/Nunchi.cs
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
#nullable disable
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games.Common.Nunchi;
|
||||||
|
|
||||||
|
public sealed class NunchiGame : IDisposable
|
||||||
|
{
|
||||||
|
public enum Phase
|
||||||
|
{
|
||||||
|
Joining,
|
||||||
|
Playing,
|
||||||
|
WaitingForNextRound,
|
||||||
|
Ended
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int KILL_TIMEOUT = 20 * 1000;
|
||||||
|
private const int NEXT_ROUND_TIMEOUT = 5 * 1000;
|
||||||
|
|
||||||
|
public event Func<NunchiGame, Task> OnGameStarted;
|
||||||
|
public event Func<NunchiGame, int, Task> OnRoundStarted;
|
||||||
|
public event Func<NunchiGame, Task> OnUserGuessed;
|
||||||
|
public event Func<NunchiGame, (ulong Id, string Name)?, Task> OnRoundEnded; // tuple of the user who failed
|
||||||
|
public event Func<NunchiGame, string, Task> OnGameEnded; // name of the user who won
|
||||||
|
|
||||||
|
public int CurrentNumber { get; private set; } = new EllieRandom().Next(0, 100);
|
||||||
|
public Phase CurrentPhase { get; private set; } = Phase.Joining;
|
||||||
|
|
||||||
|
public ImmutableArray<(ulong Id, string Name)> Participants
|
||||||
|
=> participants.ToImmutableArray();
|
||||||
|
|
||||||
|
public int ParticipantCount
|
||||||
|
=> participants.Count;
|
||||||
|
|
||||||
|
private readonly SemaphoreSlim _locker = new(1, 1);
|
||||||
|
|
||||||
|
private HashSet<(ulong Id, string Name)> participants = [];
|
||||||
|
private readonly HashSet<(ulong Id, string Name)> _passed = [];
|
||||||
|
private Timer killTimer;
|
||||||
|
|
||||||
|
public NunchiGame(ulong creatorId, string creatorName)
|
||||||
|
=> participants.Add((creatorId, creatorName));
|
||||||
|
|
||||||
|
public async Task<bool> Join(ulong userId, string userName)
|
||||||
|
{
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (CurrentPhase != Phase.Joining)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return participants.Add((userId, userName));
|
||||||
|
}
|
||||||
|
finally { _locker.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Initialize()
|
||||||
|
{
|
||||||
|
CurrentPhase = Phase.Joining;
|
||||||
|
await Task.Delay(30000);
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (participants.Count < 3)
|
||||||
|
{
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
killTimer = new(async _ =>
|
||||||
|
{
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (CurrentPhase != Phase.Playing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
//if some players took too long to type a number, boot them all out and start a new round
|
||||||
|
participants = new HashSet<(ulong, string)>(_passed);
|
||||||
|
EndRound();
|
||||||
|
}
|
||||||
|
finally { _locker.Release(); }
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
KILL_TIMEOUT,
|
||||||
|
KILL_TIMEOUT);
|
||||||
|
|
||||||
|
CurrentPhase = Phase.Playing;
|
||||||
|
_ = OnGameStarted?.Invoke(this);
|
||||||
|
_ = OnRoundStarted?.Invoke(this, CurrentNumber);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally { _locker.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Input(ulong userId, string userName, int input)
|
||||||
|
{
|
||||||
|
await _locker.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (CurrentPhase != Phase.Playing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var userTuple = (Id: userId, Name: userName);
|
||||||
|
|
||||||
|
// if the user is not a member of the race,
|
||||||
|
// or he already successfully typed the number
|
||||||
|
// ignore the input
|
||||||
|
if (!participants.Contains(userTuple) || !_passed.Add(userTuple))
|
||||||
|
return;
|
||||||
|
|
||||||
|
//if the number is correct
|
||||||
|
if (CurrentNumber == input - 1)
|
||||||
|
{
|
||||||
|
//increment current number
|
||||||
|
++CurrentNumber;
|
||||||
|
if (_passed.Count == participants.Count - 1)
|
||||||
|
{
|
||||||
|
// if only n players are left, and n - 1 type the correct number, round is over
|
||||||
|
|
||||||
|
// if only 2 players are left, game is over
|
||||||
|
if (participants.Count == 2)
|
||||||
|
{
|
||||||
|
killTimer.Change(Timeout.Infinite, Timeout.Infinite);
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
_ = OnGameEnded?.Invoke(this, userTuple.Name);
|
||||||
|
}
|
||||||
|
else // else just start the new round without the user who was the last
|
||||||
|
{
|
||||||
|
var failure = participants.Except(_passed).First();
|
||||||
|
|
||||||
|
OnUserGuessed?.Invoke(this);
|
||||||
|
EndRound(failure);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OnUserGuessed?.Invoke(this);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
//if the user failed
|
||||||
|
|
||||||
|
EndRound(userTuple);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { _locker.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EndRound((ulong, string)? failure = null)
|
||||||
|
{
|
||||||
|
killTimer.Change(KILL_TIMEOUT, KILL_TIMEOUT);
|
||||||
|
CurrentNumber = new EllieRandom().Next(0, 100); // reset the counter
|
||||||
|
_passed.Clear(); // reset all users who passed (new round starts)
|
||||||
|
if (failure is not null)
|
||||||
|
participants.Remove(failure.Value); // remove the dude who failed from the list of players
|
||||||
|
|
||||||
|
_ = OnRoundEnded?.Invoke(this, failure);
|
||||||
|
if (participants.Count <= 1) // means we have a winner or everyone was booted out
|
||||||
|
{
|
||||||
|
killTimer.Change(Timeout.Infinite, Timeout.Infinite);
|
||||||
|
CurrentPhase = Phase.Ended;
|
||||||
|
_ = OnGameEnded?.Invoke(this, participants.Count > 0 ? participants.First().Name : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CurrentPhase = Phase.WaitingForNextRound;
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(NEXT_ROUND_TIMEOUT);
|
||||||
|
CurrentPhase = Phase.Playing;
|
||||||
|
_ = OnRoundStarted?.Invoke(this, CurrentNumber);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
OnGameEnded = null;
|
||||||
|
OnGameStarted = null;
|
||||||
|
OnRoundEnded = null;
|
||||||
|
OnRoundStarted = null;
|
||||||
|
OnUserGuessed = null;
|
||||||
|
}
|
||||||
|
}
|
114
src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs
Normal file
114
src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Games.Common.Nunchi;
|
||||||
|
using EllieBot.Modules.Games.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games;
|
||||||
|
|
||||||
|
public partial class Games
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class NunchiCommands : EllieModule<GamesService>
|
||||||
|
{
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
|
||||||
|
public NunchiCommands(DiscordSocketClient client)
|
||||||
|
=> _client = client;
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Nunchi()
|
||||||
|
{
|
||||||
|
var newNunchi = new NunchiGame(ctx.User.Id, ctx.User.ToString());
|
||||||
|
NunchiGame nunchi;
|
||||||
|
|
||||||
|
//if a game was already active
|
||||||
|
if ((nunchi = _service.NunchiGames.GetOrAdd(ctx.Guild.Id, newNunchi)) != newNunchi)
|
||||||
|
{
|
||||||
|
// join it
|
||||||
|
// if you failed joining, that means game is running or just ended
|
||||||
|
if (!await nunchi.Join(ctx.User.Id, ctx.User.ToString()))
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Response().Error(strs.nunchi_joined(nunchi.ParticipantCount)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
try { await Response().Confirm(strs.nunchi_created).SendAsync(); }
|
||||||
|
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 Response().Confirm(strs.nunchi_failed_to_start).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Response().Confirm(strs.nunchi_ended_no_winner).SendAsync();
|
||||||
|
return Response().Confirm(strs.nunchi_ended(Format.Bold(arg2))).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task Nunchi_OnRoundStarted(NunchiGame arg, int cur)
|
||||||
|
=> Response()
|
||||||
|
.Confirm(strs.nunchi_round_started(Format.Bold(arg.ParticipantCount.ToString()),
|
||||||
|
Format.Bold(cur.ToString())))
|
||||||
|
.SendAsync();
|
||||||
|
|
||||||
|
private Task Nunchi_OnUserGuessed(NunchiGame arg)
|
||||||
|
=> Response().Confirm(strs.nunchi_next_number(Format.Bold(arg.CurrentNumber.ToString()))).SendAsync();
|
||||||
|
|
||||||
|
private Task Nunchi_OnRoundEnded(NunchiGame arg1, (ulong Id, string Name)? arg2)
|
||||||
|
{
|
||||||
|
if (arg2.HasValue)
|
||||||
|
return Response().Confirm(strs.nunchi_round_ended(Format.Bold(arg2.Value.Name))).SendAsync();
|
||||||
|
return Response()
|
||||||
|
.Confirm(strs.nunchi_round_ended_boot(
|
||||||
|
Format.Bold("\n"
|
||||||
|
+ string.Join("\n, ",
|
||||||
|
arg1.Participants.Select(x
|
||||||
|
=> x.Name)))))
|
||||||
|
.SendAsync(); // this won't work if there are too many users
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task Nunchi_OnGameStarted(NunchiGame arg)
|
||||||
|
=> Response().Confirm(strs.nunchi_started(Format.Bold(arg.ParticipantCount.ToString()))).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
105
src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs
Normal file
105
src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Games.Common;
|
||||||
|
using EllieBot.Modules.Games.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games;
|
||||||
|
|
||||||
|
public partial class Games
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class SpeedTypingCommands : EllieModule<GamesService>
|
||||||
|
{
|
||||||
|
private readonly GamesService _games;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
|
||||||
|
public SpeedTypingCommands(DiscordSocketClient client, GamesService games)
|
||||||
|
{
|
||||||
|
_games = games;
|
||||||
|
_client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[EllieOptions<TypingGame.Options>]
|
||||||
|
public async Task TypeStart(params string[] args)
|
||||||
|
{
|
||||||
|
var (options, _) = OptionsParser.ParseFrom(new TypingGame.Options(), args);
|
||||||
|
var channel = (ITextChannel)ctx.Channel;
|
||||||
|
|
||||||
|
var game = _service.RunningContests.GetOrAdd(ctx.Guild.Id,
|
||||||
|
_ => new(_games, _client, channel, prefix, options, _sender));
|
||||||
|
|
||||||
|
if (game.IsActive)
|
||||||
|
await Response().Error($"Contest already running in {game.Channel.Mention} channel.").SendAsync();
|
||||||
|
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 Response().Error("No contest to stop on this channel.").SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[OwnerOnly]
|
||||||
|
public async Task Typeadd([Leftover] string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_games.AddTypingArticle(ctx.User, text);
|
||||||
|
|
||||||
|
await Response().Confirm("Added new article for typing game.").SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 Response().Error($"{ctx.User.Mention} `No articles found on that page.`").SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var i = (page - 1) * 15;
|
||||||
|
await Response()
|
||||||
|
.Confirm("List of articles for Type Race",
|
||||||
|
string.Join("\n", articles.Select(a => $"`#{++i}` - {a.Text.TrimTo(50)}")))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[OwnerOnly]
|
||||||
|
public async Task Typedel(int index)
|
||||||
|
{
|
||||||
|
var removed = _service.RemoveTypingArticle(--index);
|
||||||
|
|
||||||
|
if (removed is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithTitle($"Removed typing article #{index + 1}")
|
||||||
|
.WithDescription(removed.Text.TrimTo(50))
|
||||||
|
.WithOkColor();
|
||||||
|
|
||||||
|
await Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs
Normal file
9
src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Games.Common;
|
||||||
|
|
||||||
|
public class TypingArticle
|
||||||
|
{
|
||||||
|
public string Source { get; set; }
|
||||||
|
public string Extra { get; set; }
|
||||||
|
public string Text { get; set; }
|
||||||
|
}
|
197
src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs
Normal file
197
src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
#nullable disable
|
||||||
|
using CommandLine;
|
||||||
|
using EllieBot.Modules.Games.Services;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace EllieBot.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 IMessageSenderService _sender;
|
||||||
|
|
||||||
|
public TypingGame(
|
||||||
|
GamesService games,
|
||||||
|
DiscordSocketClient client,
|
||||||
|
ITextChannel channel,
|
||||||
|
string prefix,
|
||||||
|
Options options,
|
||||||
|
IMessageSenderService sender)
|
||||||
|
{
|
||||||
|
_games = games;
|
||||||
|
_client = client;
|
||||||
|
_prefix = prefix;
|
||||||
|
_options = options;
|
||||||
|
_sender = sender;
|
||||||
|
|
||||||
|
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 _sender.Response(Channel)
|
||||||
|
.Confirm("Typing contest stopped.")
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
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 _sender.Response(Channel)
|
||||||
|
.Confirm(
|
||||||
|
$":clock2: Next contest will last for {i} seconds. Type the bolded text as fast as you can.")
|
||||||
|
.SendAsync();
|
||||||
|
|
||||||
|
|
||||||
|
var time = _options.StartTime;
|
||||||
|
|
||||||
|
var msg = await _sender.Response(Channel).Confirm($"Starting new typing contest in **{time}**...").SendAsync();
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
await Task.Delay(2000);
|
||||||
|
time -= 2;
|
||||||
|
try { await msg.ModifyAsync(m => m.Content = $"Starting new typing contest in **{time}**.."); }
|
||||||
|
catch { }
|
||||||
|
} while (time > 2);
|
||||||
|
|
||||||
|
await msg.ModifyAsync(m =>
|
||||||
|
{
|
||||||
|
m.Content = CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture);
|
||||||
|
});
|
||||||
|
_sw.Start();
|
||||||
|
HandleAnswers();
|
||||||
|
|
||||||
|
while (i > 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(1000);
|
||||||
|
i--;
|
||||||
|
if (!IsActive)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetRandomSentence()
|
||||||
|
{
|
||||||
|
if (_games.TypingArticles.Any())
|
||||||
|
return _games.TypingArticles[new EllieRandom().Next(0, _games.TypingArticles.Count)].Text;
|
||||||
|
return $"No typing articles found. Use {_prefix}typeadd command to add a new article for typing.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleAnswers()
|
||||||
|
=> _client.MessageReceived += AnswerReceived;
|
||||||
|
|
||||||
|
private Task AnswerReceived(SocketMessage imsg)
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (imsg.Author.IsBot)
|
||||||
|
return;
|
||||||
|
if (imsg is not SocketUserMessage msg)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (Channel is null || Channel.Id != msg.Channel.Id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var guess = msg.Content;
|
||||||
|
|
||||||
|
var distance = CurrentSentence.LevenshteinDistance(guess);
|
||||||
|
var decision = Judge(distance, guess.Length);
|
||||||
|
if (decision && !_finishedUserIds.Contains(msg.Author.Id))
|
||||||
|
{
|
||||||
|
var elapsed = _sw.Elapsed;
|
||||||
|
var wpm = CurrentSentence.Length / WORD_VALUE / elapsed.TotalSeconds * 60;
|
||||||
|
_finishedUserIds.Add(msg.Author.Id);
|
||||||
|
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.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);
|
||||||
|
|
||||||
|
await _sender.Response(Channel)
|
||||||
|
.Embed(embed)
|
||||||
|
.SendAsync();
|
||||||
|
|
||||||
|
if (_finishedUserIds.Count % 4 == 0)
|
||||||
|
{
|
||||||
|
await _sender.Response(Channel)
|
||||||
|
.Confirm(
|
||||||
|
$"""
|
||||||
|
:exclamation: A lot of people finished, here is the text for those still typing:
|
||||||
|
|
||||||
|
**{Format.Sanitize(CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture)).SanitizeMentions(true)}**
|
||||||
|
""")
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error receiving typing game answer: {ErrorMessage}", ex.Message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Judge(int errors, int textLength)
|
||||||
|
=> errors <= textLength / 25;
|
||||||
|
|
||||||
|
public class Options : IEllieCommandOptions
|
||||||
|
{
|
||||||
|
[Option('s',
|
||||||
|
"start-time",
|
||||||
|
Default = 5,
|
||||||
|
Required = false,
|
||||||
|
HelpText = "How long does it take for the race to start. Default 5.")]
|
||||||
|
public int StartTime { get; set; } = 5;
|
||||||
|
|
||||||
|
public void NormalizeOptions()
|
||||||
|
{
|
||||||
|
if (StartTime is < 3 or > 30)
|
||||||
|
StartTime = 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
307
src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs
Normal file
307
src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs
Normal file
|
@ -0,0 +1,307 @@
|
||||||
|
#nullable disable
|
||||||
|
using CommandLine;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EllieBot.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 IMessageSenderService _sender;
|
||||||
|
|
||||||
|
public TicTacToe(
|
||||||
|
IBotStrings strings,
|
||||||
|
DiscordSocketClient client,
|
||||||
|
ITextChannel channel,
|
||||||
|
IGuildUser firstUser,
|
||||||
|
Options options,
|
||||||
|
IMessageSenderService sender)
|
||||||
|
{
|
||||||
|
_channel = channel;
|
||||||
|
_strings = strings;
|
||||||
|
_client = client;
|
||||||
|
_options = options;
|
||||||
|
_sender = sender;
|
||||||
|
|
||||||
|
_users = [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 EmbedBuilder GetEmbed(string title = null)
|
||||||
|
{
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.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 _sender.Response(_channel).Error(user.Mention + GetText(strs.ttt_already_running)).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_users[0] == user)
|
||||||
|
{
|
||||||
|
await _sender.Response(_channel).Error(user.Mention + GetText(strs.ttt_against_yourself)).SendAsync();
|
||||||
|
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 _sender.Response(_channel).Embed(GetEmbed(GetText(strs.ttt_time_expired))).SendAsync();
|
||||||
|
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 _sender.Response(_channel).Embed(GetEmbed(GetText(strs.game_started))).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _sender.Response(_channel).Embed(GetEmbed(reason)).SendAsync(); }
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
try { await del1; }
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (del2 is not null)
|
||||||
|
await del2;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
});
|
||||||
|
curUserIndex ^= 1;
|
||||||
|
|
||||||
|
timeoutTimer.Change(_options.TurnTimer * 1000, Timeout.Infinite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_moveLock.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Options : IEllieCommandOptions
|
||||||
|
{
|
||||||
|
[Option('t', "turn-timer", Required = false, Default = 15, HelpText = "Turn time in seconds. Default 15.")]
|
||||||
|
public int TurnTimer { get; set; } = 15;
|
||||||
|
|
||||||
|
public void NormalizeOptions()
|
||||||
|
{
|
||||||
|
if (TurnTimer is < 5 or > 60)
|
||||||
|
TurnTimer = 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Phase
|
||||||
|
{
|
||||||
|
Starting,
|
||||||
|
Started,
|
||||||
|
Ended
|
||||||
|
}
|
||||||
|
}
|
54
src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs
Normal file
54
src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Games.Common;
|
||||||
|
using EllieBot.Modules.Games.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games;
|
||||||
|
|
||||||
|
public partial class Games
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class TicTacToeCommands : EllieModule<GamesService>
|
||||||
|
{
|
||||||
|
private readonly SemaphoreSlim _sem = new(1, 1);
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
|
||||||
|
public TicTacToeCommands(DiscordSocketClient client)
|
||||||
|
=> _client = client;
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[EllieOptions<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, _sender);
|
||||||
|
_service.TicTacToeGames.Add(channel.Id, game);
|
||||||
|
await Response().Confirm(strs.ttt_created(ctx.User)).SendAsync();
|
||||||
|
|
||||||
|
game.OnEnded += _ =>
|
||||||
|
{
|
||||||
|
_service.TicTacToeGames.Remove(channel.Id);
|
||||||
|
_sem.Dispose();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_sem.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
282
src/EllieBot/Modules/Games/Trivia/Games.cs
Normal file
282
src/EllieBot/Modules/Games/Trivia/Games.cs
Normal file
|
@ -0,0 +1,282 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using EllieBot.Modules.Games.Common.Trivia;
|
||||||
|
using EllieBot.Modules.Games.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games;
|
||||||
|
|
||||||
|
public partial class Games
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class TriviaCommands : EllieModule<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)]
|
||||||
|
[EllieOptions<TriviaOptions>]
|
||||||
|
public async Task Trivia(params string[] args)
|
||||||
|
{
|
||||||
|
var (opts, _) = OptionsParser.ParseFrom(new TriviaOptions(), args);
|
||||||
|
|
||||||
|
var config = _gamesConfig.Data;
|
||||||
|
if (opts.WinRequirement != 0
|
||||||
|
&& config.Trivia.MinimumWinReq > 0
|
||||||
|
&& config.Trivia.MinimumWinReq > opts.WinRequirement)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var trivia = new TriviaGame(opts, _cache);
|
||||||
|
if (_service.RunningTrivias.TryAdd(ctx.Guild.Id, trivia))
|
||||||
|
{
|
||||||
|
RegisterEvents(trivia);
|
||||||
|
await trivia.RunAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var tg))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.trivia_already_running).SendAsync();
|
||||||
|
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 Response().Error(strs.trivia_none).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 Response()
|
||||||
|
.Confirm(GetText(strs.trivia_game), GetText(strs.trivia_stopping))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error sending trivia stopping message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response().Error(strs.trivia_none).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 EmbedBuilder? 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 = _sender.CreateEmbed()
|
||||||
|
.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 Response().Embed(questionEmbed).SendAsync();
|
||||||
|
}
|
||||||
|
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 = _sender.CreateEmbed()
|
||||||
|
.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 Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnTriviaStats(TriviaGame game)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Response().Confirm(GetText(strs.leaderboard), GetLeaderboardString(game)).SendAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnTriviaEnded(TriviaGame game)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Response().Embed(_sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithAuthor(GetText(strs.trivia_ended))
|
||||||
|
.WithTitle(GetText(strs.leaderboard))
|
||||||
|
.WithDescription(GetLeaderboardString(game))).SendAsync();
|
||||||
|
}
|
||||||
|
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 = _sender.CreateEmbed()
|
||||||
|
.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 Response().Embed(embed).SendAsync();
|
||||||
|
|
||||||
|
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 Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace EllieBot.Modules.Games.Common.Trivia;
|
||||||
|
|
||||||
|
public sealed class DefaultQuestionPool : IQuestionPool
|
||||||
|
{
|
||||||
|
private readonly ILocalDataCache _cache;
|
||||||
|
private readonly EllieRandom _rng;
|
||||||
|
|
||||||
|
public DefaultQuestionPool(ILocalDataCache cache)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_rng = new EllieRandom();
|
||||||
|
}
|
||||||
|
public async Task<TriviaQuestion?> GetQuestionAsync()
|
||||||
|
{
|
||||||
|
var pool = await _cache.GetTriviaQuestionsAsync();
|
||||||
|
|
||||||
|
if(pool is null or {Length: 0})
|
||||||
|
return default;
|
||||||
|
|
||||||
|
return new(pool[_rng.Next(0, pool.Length)]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace EllieBot.Modules.Games.Common.Trivia;
|
||||||
|
|
||||||
|
public interface IQuestionPool
|
||||||
|
{
|
||||||
|
Task<TriviaQuestion?> GetQuestionAsync();
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
namespace EllieBot.Modules.Games.Common.Trivia;
|
||||||
|
|
||||||
|
public sealed class PokemonQuestionPool : IQuestionPool
|
||||||
|
{
|
||||||
|
public int QuestionsCount => 905; // xd
|
||||||
|
private readonly EllieRandom _rng;
|
||||||
|
private readonly ILocalDataCache _cache;
|
||||||
|
|
||||||
|
public PokemonQuestionPool(ILocalDataCache cache)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_rng = new EllieRandom();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<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"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
219
src/EllieBot/Modules/Games/Trivia/TriviaGame.cs
Normal file
219
src/EllieBot/Modules/Games/Trivia/TriviaGame.cs
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using Exception = System.Exception;
|
||||||
|
|
||||||
|
namespace EllieBot.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 (_opts.WinRequirement != 0 && val >= _opts.WinRequirement)
|
||||||
|
{
|
||||||
|
_isStopped = true;
|
||||||
|
isWin = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// call onguess
|
||||||
|
await OnGuess(this, user, question, isWin);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!guessed)
|
||||||
|
{
|
||||||
|
await OnTimeout(this, question);
|
||||||
|
|
||||||
|
if (_opts.Timeout != 0 && ++inactivity >= _opts.Timeout)
|
||||||
|
{
|
||||||
|
Log.Information("Trivia game is stopping due to inactivity");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Fatal error in trivia game: {ErrorMessage}", ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// make sure game is set as ended
|
||||||
|
_isStopped = true;
|
||||||
|
_ = OnEnded(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<(ulong User, int points)> GetLeaderboard()
|
||||||
|
=> _users.Select(x => (x.Key, x.Value)).ToArray();
|
||||||
|
|
||||||
|
public ValueTask InputAsync(TriviaUser user, string input)
|
||||||
|
=> _inputs.Writer.WriteAsync((user, input));
|
||||||
|
|
||||||
|
public bool Stop()
|
||||||
|
{
|
||||||
|
var isStopped = _isStopped;
|
||||||
|
_isStopped = true;
|
||||||
|
return !isStopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask TriggerStatsAsync()
|
||||||
|
{
|
||||||
|
await OnStats(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TriggerQuestionAsync()
|
||||||
|
{
|
||||||
|
if(CurrentQuestion is TriviaQuestion q)
|
||||||
|
await OnQuestion(this, q);
|
||||||
|
}
|
||||||
|
}
|
37
src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs
Normal file
37
src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Common.ModuleBehaviors;
|
||||||
|
using EllieBot.Modules.Games.Common.Trivia;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games;
|
||||||
|
|
||||||
|
public sealed class TriviaGamesService : IReadyExecutor, IEService
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
44
src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs
Normal file
44
src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
#nullable disable
|
||||||
|
using CommandLine;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Games.Common.Trivia;
|
||||||
|
|
||||||
|
public class TriviaOptions : IEllieCommandOptions
|
||||||
|
{
|
||||||
|
[Option('p', "pokemon", Required = false, Default = false, HelpText = "Whether it's 'Who's that pokemon?' trivia.")]
|
||||||
|
public bool IsPokemon { get; set; } = false;
|
||||||
|
|
||||||
|
[Option("nohint", Required = false, Default = false, HelpText = "Don't show any hints.")]
|
||||||
|
public bool NoHint { get; set; } = false;
|
||||||
|
|
||||||
|
[Option('w',
|
||||||
|
"win-req",
|
||||||
|
Required = false,
|
||||||
|
Default = 10,
|
||||||
|
HelpText = "Winning requirement. Set 0 for an infinite game. Default 10.")]
|
||||||
|
public int WinRequirement { get; set; } = 10;
|
||||||
|
|
||||||
|
[Option('q',
|
||||||
|
"question-timer",
|
||||||
|
Required = false,
|
||||||
|
Default = 30,
|
||||||
|
HelpText = "How long until the question ends. Default 30.")]
|
||||||
|
public int QuestionTimer { get; set; } = 30;
|
||||||
|
|
||||||
|
[Option('t',
|
||||||
|
"timeout",
|
||||||
|
Required = false,
|
||||||
|
Default = 10,
|
||||||
|
HelpText = "Number of questions of inactivity in order stop. Set 0 for never. Default 10.")]
|
||||||
|
public int Timeout { get; set; } = 10;
|
||||||
|
|
||||||
|
public void NormalizeOptions()
|
||||||
|
{
|
||||||
|
if (WinRequirement < 0)
|
||||||
|
WinRequirement = 10;
|
||||||
|
if (QuestionTimer is < 10 or > 300)
|
||||||
|
QuestionTimer = 30;
|
||||||
|
if (Timeout is < 0 or > 20)
|
||||||
|
Timeout = 10;
|
||||||
|
}
|
||||||
|
}
|
115
src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs
Normal file
115
src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
#nullable disable
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace EllieBot.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(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());
|
||||||
|
}
|
||||||
|
}
|
3
src/EllieBot/Modules/Games/Trivia/TriviaUser.cs
Normal file
3
src/EllieBot/Modules/Games/Trivia/TriviaUser.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace EllieBot.Modules.Games.Common.Trivia;
|
||||||
|
|
||||||
|
public record class TriviaUser(string Name, ulong Id);
|
Loading…
Reference in a new issue