diff --git a/src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs b/src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs new file mode 100644 index 0000000..ae8ba56 --- /dev/null +++ b/src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs @@ -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 OnStarted = delegate { return Task.CompletedTask; }; + + public event Func>, Task> OnVotingStarted = + delegate { return Task.CompletedTask; }; + + public event Func OnUserVoted = delegate { return Task.CompletedTask; }; + + public event Func>, Task> OnEnded = delegate + { + return Task.CompletedTask; + }; + + public Phase CurrentPhase { get; private set; } = Phase.Submission; + public ImmutableArray StartingLetters { get; private set; } + public Options Opts { get; } + + private readonly Dictionary _submissions = new(); + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly EllieRandom _rng; + + private readonly HashSet _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>()); + 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 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; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs b/src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs new file mode 100644 index 0000000..2de2917 --- /dev/null +++ b/src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs @@ -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; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs b/src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs new file mode 100644 index 0000000..30defba --- /dev/null +++ b/src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs @@ -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 + { + private readonly DiscordSocketClient _client; + + public AcropobiaCommands(DiscordSocketClient client) + => _client = client; + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + 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> 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> 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(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/ChatterBotCommands.cs b/src/EllieBot/Modules/Games/ChatterBot/ChatterBotCommands.cs new file mode 100644 index 0000000..194cf79 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/ChatterBotCommands.cs @@ -0,0 +1,35 @@ +#nullable disable +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class ChatterBotCommands : EllieModule + { + 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; + + var newState = await _service.ToggleChatterBotAsync(ctx.Guild.Id); + + if (!newState) + { + await Response().Confirm(strs.chatbot_disabled).SendAsync(); + return; + } + + await Response().Confirm(strs.chatbot_enabled).SendAsync(); + + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/ChatterBotService.cs b/src/EllieBot/Modules/Games/ChatterBot/ChatterBotService.cs new file mode 100644 index 0000000..b1ca50c --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/ChatterBotService.cs @@ -0,0 +1,239 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +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 +{ + private ConcurrentDictionary> ChatterBotGuilds { get; } + + public int Priority + => 1; + + private readonly DiscordSocketClient _client; + private readonly IPermissionChecker _perms; + private readonly IBotCredentials _creds; + private readonly IHttpClientFactory _httpFactory; + private readonly GamesConfigService _gcs; + private readonly IMessageSenderService _sender; + private readonly DbService _db; + public readonly IPatronageService _ps; + + public ChatterBotService( + DiscordSocketClient client, + IPermissionChecker perms, + IBot bot, + IPatronageService ps, + IHttpClientFactory factory, + IBotCredentials creds, + GamesConfigService gcs, + IMessageSenderService sender, + DbService db) + { + _client = client; + _perms = perms; + _creds = creds; + _sender = sender; + _db = db; + _httpFactory = factory; + _perms = perms; + _gcs = gcs; + _ps = ps; + + ChatterBotGuilds = new(bot.AllGuildConfigs + .Where(gc => gc.CleverbotEnabled) + .ToDictionary(gc => gc.GuildId, + _ => new Lazy(() => 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.OpenAi: + var data = _gcs.Data; + if (!string.IsNullOrWhiteSpace(_creds.Gpt3ApiKey)) + return new OpenAiApiSession( + data.ChatGpt.ApiUrl, + _creds.Gpt3ApiKey, + data.ChatGpt.ModelName, + data.ChatGpt.ChatHistory, + data.ChatGpt.MaxTokens, + data.ChatGpt.MinTokens, + data.ChatGpt.PersonalityPrompt, + _client.CurrentUser.Username, + _httpFactory); + + Log.Information("Openai Api will likely not work as the api key is missing"); + return null; + default: + return null; + } + } + + public IChatterBotSession GetOrCreateSession(ulong guildId) + { + if (ChatterBotGuilds.TryGetValue(guildId, out var lazyChatBot)) + return lazyChatBot.Value; + + lazyChatBot = new(() => CreateSession(), true); + ChatterBotGuilds.TryAdd(guildId, lazyChatBot); + return lazyChatBot.Value; + } + + public string PrepareMessage(IUserMessage msg) + { + var nadekoId = _client.CurrentUser.Id; + var normalMention = $"<@{nadekoId}> "; + var nickMention = $"<@!{nadekoId}> "; + string message; + if (msg.Content.StartsWith(normalMention, StringComparison.InvariantCulture)) + message = msg.Content[normalMention.Length..].Trim(); + else if (msg.Content.StartsWith(nickMention, StringComparison.InvariantCulture)) + message = msg.Content[nickMention.Length..].Trim(); + else if (msg.ReferencedMessage?.Author.Id == nadekoId) + message = msg.Content; + else + return null; + + return message; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg) + { + if (guild is not SocketGuild sg) + return false; + + var channel = usrMsg.Channel as ITextChannel; + if (channel is null) + return false; + + if (!ChatterBotGuilds.TryGetValue(channel.Guild.Id, out var lazyChatBot)) + return false; + + var chatBot = lazyChatBot.Value; + var message = PrepareMessage(usrMsg); + if (message is null) + return false; + + return await RunChatterBot(sg, usrMsg, channel, chatBot, message); + } + + public async Task RunChatterBot( + SocketGuild guild, + IUserMessage usrMsg, + ITextChannel channel, + IChatterBotSession chatBot, + string message) + { + try + { + var res = await _perms.CheckPermsAsync(guild, + usrMsg.Channel, + usrMsg.Author, + CleverBotResponseStr.CLEVERBOT_RESPONSE, + CleverBotResponseStr.CLEVERBOT_RESPONSE); + + if (!res.IsAllowed) + return false; + + if (!await _ps.LimitHitAsync(LimitedFeatureName.ChatBot, usrMsg.Author.Id, 2048 / 2)) + { + // limit exceeded + return false; + } + + _ = channel.TriggerTypingAsync(); + var response = await chatBot.Think(message, usrMsg.Author.ToString()); + + if (response.TryPickT0(out var result, out var error)) + { + // calculate the diff in case we overestimated user's usage + var inTokens = (result.TokensIn - 2048) / 2; + + // add the output tokens to the limit + await _ps.LimitForceHit(LimitedFeatureName.ChatBot, + usrMsg.Author.Id, + (inTokens) + (result.TokensOut / 2 * 3)); + + await _sender.Response(channel) + .Confirm(result.Text) + .SendAsync(); + } + else + { + Log.Warning("Error in chatterbot: {Error}", error.Value); + } + + 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; + } + + public async Task ToggleChatterBotAsync(ulong guildId) + { + if (ChatterBotGuilds.TryRemove(guildId, out _)) + { + await using var uow = _db.GetDbContext(); + await uow.Set() + .ToLinqToDBTable() + .Where(x => x.GuildId == guildId) + .UpdateAsync((gc) => new GuildConfig() + { + CleverbotEnabled = false + }); + await uow.SaveChangesAsync(); + return false; + } + + ChatterBotGuilds.TryAdd(guildId, new(() => CreateSession(), true)); + + await using (var uow = _db.GetDbContext()) + { + await uow.Set() + .ToLinqToDBTable() + .Where(x => x.GuildId == guildId) + .UpdateAsync((gc) => new GuildConfig() + { + CleverbotEnabled = true + }); + + await uow.SaveChangesAsync(); + } + + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/Choice.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/Choice.cs new file mode 100644 index 0000000..c1290dd --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/Choice.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class Choice +{ + [JsonPropertyName("message")] + public required Message Message { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs new file mode 100644 index 0000000..2f83164 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs @@ -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; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs new file mode 100644 index 0000000..0372c87 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs @@ -0,0 +1,10 @@ +#nullable disable +using OneOf; +using OneOf.Types; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public interface IChatterBotSession +{ + Task>> Think(string input, string username); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/Message.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/Message.cs new file mode 100644 index 0000000..df26315 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/Message.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class Message +{ + [JsonPropertyName("content")] + public required string Content { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs new file mode 100644 index 0000000..d435947 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs @@ -0,0 +1,45 @@ +#nullable disable +using Newtonsoft.Json; +using OneOf; +using OneOf.Types; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class OfficialCleverbotSession : IChatterBotSession +{ + private string QueryString + => $"https://www.cleverbot.com/getreply?key={_apiKey}" + "&wrapper=nadekobot" + "&input={0}" + "&cs={1}"; + + private readonly string _apiKey; + private readonly IHttpClientFactory _httpFactory; + private string cs; + + public OfficialCleverbotSession(string apiKey, IHttpClientFactory factory) + { + _apiKey = apiKey; + _httpFactory = factory; + } + + public async Task>> 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(dataString); + + cs = data?.Cs; + return new ThinkResult + { + Text = data?.Output, + TokensIn = 2, + TokensOut = 1 + }; + } + catch + { + Log.Warning("Unexpected response from CleverBot: {ResponseString}", dataString); + return new Error("Unexpected CleverBot response received"); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiMessage.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiMessage.cs new file mode 100644 index 0000000..9f0a028 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiMessage.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class OpenAiApiMessage +{ + [JsonPropertyName("role")] + public required string Role { get; init; } + + [JsonPropertyName("content")] + public required string Content { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiRequest.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiRequest.cs new file mode 100644 index 0000000..3ab7d68 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class OpenAiApiRequest +{ + [JsonPropertyName("model")] + public required string Model { get; init; } + + [JsonPropertyName("messages")] + public required List Messages { get; init; } + + [JsonPropertyName("temperature")] + public required int Temperature { get; init; } + + [JsonPropertyName("max_tokens")] + public required int MaxTokens { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiUsageData.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiUsageData.cs new file mode 100644 index 0000000..174af3e --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiUsageData.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class OpenAiApiUsageData +{ + [JsonPropertyName("prompt_tokens")] + public int PromptTokens { get; set; } + + [JsonPropertyName("completion_tokens")] + public int CompletionTokens { get; set; } + + [JsonPropertyName("total_tokens")] + public int TotalTokens { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiCompletionResponse.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiCompletionResponse.cs new file mode 100644 index 0000000..db0b92f --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiCompletionResponse.cs @@ -0,0 +1,13 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class OpenAiCompletionResponse +{ + [JsonPropertyName("choices")] + public Choice[] Choices { get; set; } + + [JsonPropertyName("usage")] + public OpenAiApiUsageData Usage { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApiSession.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApiSession.cs new file mode 100644 index 0000000..42afd22 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApiSession.cs @@ -0,0 +1,147 @@ +#nullable disable +using Newtonsoft.Json; +using OneOf.Types; +using SharpToken; +using System.Net.Http.Json; +using System.Text.RegularExpressions; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public partial class OpenAiApiSession : IChatterBotSession +{ + private readonly string _baseUrl; + 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 messages = new(); + private readonly IHttpClientFactory _httpFactory; + + + public OpenAiApiSession( + string url, + string apiKey, + string model, + int chatHistory, + int maxTokens, + int minTokens, + string personality, + string ellieUsername, + IHttpClientFactory factory) + { + if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out _)) + { + throw new ArgumentException("Invalid OpenAi api url provided", nameof(url)); + } + + _baseUrl = url.TrimEnd('/'); + + _apiKey = apiKey; + _model = model; + _httpFactory = factory; + _maxHistory = chatHistory; + _maxTokens = maxTokens; + _minTokens = minTokens; + _ellieUsername = UsernameCleaner().Replace(ellieUsername, ""); + _encoding = GptEncoding.GetEncodingForModel("gpt-4o"); + if (!string.IsNullOrWhiteSpace(personality)) + { + messages.Add(new() + { + Role = "system", + Content = personality, + Name = _ellieUsername + }); + } + } + + + [GeneratedRegex("[^a-zA-Z0-9_-]")] + private static partial Regex UsernameCleaner(); + + public async Task>> Think(string input, string username) + { + username = UsernameCleaner().Replace(username, ""); + + messages.Add(new() + { + Role = "user", + Content = input, + Name = username + }); + + while (messages.Count > _maxHistory + 2) + { + messages.RemoveAt(1); + } + + var tokensUsed = messages.Sum(message => _encoding.Encode(message.Content).Count); + + tokensUsed *= 2; + + //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) + { + var tokens = _encoding.Encode(messages[1].Content).Count * 2; + tokensUsed -= tokens; + messages.RemoveAt(1); + } + else + { + return new Error( + "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($"{_baseUrl}/v1/chat/completions", + new OpenAiApiRequest() + { + Model = _model, + Messages = messages, + MaxTokens = _maxTokens - tokensUsed, + Temperature = 1, + }); + + var dataString = await data.Content.ReadAsStringAsync(); + try + { + var response = JsonConvert.DeserializeObject(dataString); + + // Log.Information("Received response: {Response} ", dataString); + var res = response?.Choices?[0]; + var message = res?.Message?.Content; + + if (message is null) + { + return new Error("ChatGpt: Received no response."); + } + + messages.Add(new() + { + Role = "assistant", + Content = message, + Name = _ellieUsername + }); + + return new ThinkResult() + { + Text = message, + TokensIn = response.Usage.PromptTokens, + TokensOut = response.Usage.CompletionTokens + }; + } + catch + { + Log.Warning("Unexpected response received from OpenAI: {ResponseString}", dataString); + return new Error("Unexpected response received"); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/ThinkResult.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/ThinkResult.cs new file mode 100644 index 0000000..3d881e2 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/ThinkResult.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public sealed class ThinkResult +{ + public string Text { get; set; } + public int TokensIn { get; set; } + public int TokensOut { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Games.cs b/src/EllieBot/Modules/Games/Games.cs new file mode 100644 index 0000000..c14d6ee --- /dev/null +++ b/src/EllieBot/Modules/Games/Games.cs @@ -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 +{ + 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(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/GamesConfig.cs b/src/EllieBot/Modules/Games/GamesConfig.cs new file mode 100644 index 0000000..9308329 --- /dev/null +++ b/src/EllieBot/Modules/Games/GamesConfig.cs @@ -0,0 +1,195 @@ +#nullable disable +using Cloneable; +using EllieBot.Common.Yml; + +namespace EllieBot.Modules.Games.Common; + +[Cloneable] +public sealed partial class GamesConfig : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 5; + + [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 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 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. + 'openai' - bot will use OpenAi API + """)] + public ChatBotImplementation ChatBot { get; set; } = ChatBotImplementation.OpenAi; + + public ChatGptConfig ChatGpt { get; set; } = new(); +} + +[Cloneable] +public sealed partial class ChatGptConfig +{ + [Comment(""" + Url to any openai api compatible url. + Make sure to modify the modelName appropriately + DO NOT add /v1/chat/completions suffix to the url + """)] + public string ApiUrl { get; set; } = "https://api.openai.com"; + + [Comment(""" + Which GPT Model should bot use. + gpt-3.5-turbo - cheapest + gpt-4o - more expensive, higher quality + + If you are using another openai compatible api, you may use any of the models supported by that api + """)] + public string ModelName { get; set; } = "gpt-3.5-turbo"; + + [Comment(""" + How should the chatbot behave, what's its personality? + This will be sent as a system message. + 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 OpenAi API call")] + public int MaxTokens { get; set; } = 100; + + [Comment(@"The minimum number of tokens to use per GPT 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, + OpenAi = 1, + + [Obsolete] + Gpt = 1, + + [Obsolete] + Gpt3 = 1, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/GamesConfigService.cs b/src/EllieBot/Modules/Games/GamesConfigService.cs new file mode 100644 index 0000000..f6ff635 --- /dev/null +++ b/src/EllieBot/Modules/Games/GamesConfigService.cs @@ -0,0 +1,120 @@ +#nullable disable +using EllieBot.Common.Configs; +using EllieBot.Modules.Games.Common; + +namespace EllieBot.Modules.Games.Services; + +public sealed class GamesConfigService : ConfigServiceBase +{ + private const string FILE_PATH = "data/games.yml"; + private static readonly TypedKey _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.apiUrl", + gs => gs.ChatGpt.ApiUrl, + ConfigParsers.String, + ConfigPrinters.ToString); + + AddParsedProp("gpt.modelName", + gs => gs.ChatGpt.ModelName, + ConfigParsers.String, + ConfigPrinters.ToString); + + AddParsedProp("gpt.personality", + gs => gs.ChatGpt.PersonalityPrompt, + ConfigParsers.String, + ConfigPrinters.ToString); + + Migrate(); + } + + private void Migrate() + { + if (data.Version < 1) + { + ModifyConfig(c => + { + c.Version = 1; + c.Hangman = new() + { + CurrencyReward = 0 + }; + }); + } + + if (data.Version < 3) + { + ModifyConfig(c => + { + c.Version = 3; + c.ChatGpt.ModelName = "gpt35turbo"; + }); + } + + if (data.Version < 4) + { + ModifyConfig(c => + { + c.Version = 4; +#pragma warning disable CS0612 // Type or member is obsolete + c.ChatGpt.ModelName = + c.ChatGpt.ModelName.Equals("gpt4", StringComparison.OrdinalIgnoreCase) + || c.ChatGpt.ModelName.Equals("gpt432k", StringComparison.OrdinalIgnoreCase) + ? "gpt-4o" + : "gpt-3.5-turbo"; +#pragma warning restore CS0612 // Type or member is obsolete + }); + } + + if (data.Version < 5) + { + ModifyConfig(c => + { + c.Version = 5; + c.ChatBot = c.ChatBot == ChatBotImplementation.OpenAi + ? ChatBotImplementation.OpenAi + : c.ChatBot; + + if (c.ChatGpt.ModelName.Equals("gpt4o", StringComparison.OrdinalIgnoreCase)) + { + c.ChatGpt.ModelName = "gpt-4o"; + } + else if (c.ChatGpt.ModelName.Equals("gpt35turbo", StringComparison.OrdinalIgnoreCase)) + { + c.ChatGpt.ModelName = "gpt-3.5-turbo"; + } + else + { + Log.Warning( + "Unknown OpenAI api model name: {ModelName}. " + + "It will be reset to 'gpt-3.5-turbo' only this time", + c.ChatGpt.ModelName); + c.ChatGpt.ModelName = "gpt-3.5-turbo"; + } + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/GamesService.cs b/src/EllieBot/Modules/Games/GamesService.cs new file mode 100644 index 0000000..1436285 --- /dev/null +++ b/src/EllieBot/Modules/Games/GamesService.cs @@ -0,0 +1,87 @@ +#nullable disable +using Microsoft.Extensions.Caching.Memory; +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 +{ + private const string TYPING_ARTICLES_PATH = "data/typing_articles3.json"; + + + public IReadOnlyList EightBallResponses + => _gamesConfig.Data.EightBallResponses; + + public List TypingArticles { get; } = new(); + + //channelId, game + public ConcurrentDictionary AcrophobiaGames { get; } = new(); + public Dictionary TicTacToeGames { get; } = new(); + public ConcurrentDictionary RunningContests { get; } = new(); + public ConcurrentDictionary NunchiGames { get; } = new(); + + 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 + }); + + _rng = new EllieRandom(); + + try + { + TypingArticles = JsonConvert.DeserializeObject>(File.ReadAllText(TYPING_ARTICLES_PATH)); + } + catch (Exception ex) + { + Log.Warning(ex, "Error while loading typing articles: {ErrorMessage}", ex.Message); + TypingArticles = new(); + } + } + + 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; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs b/src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs new file mode 100644 index 0000000..333e8f0 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs @@ -0,0 +1,64 @@ +using EllieBot.Common.Yml; +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Games.Hangman; + +public sealed class DefaultHangmanSource : IHangmanSource +{ + private IReadOnlyDictionary termsDict = new Dictionary(); + 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(); + foreach (var file in Directory.EnumerateFiles("data/hangman/", "*.yml")) + { + try + { + var data = Yaml.Deserializer.Deserialize(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 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; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs b/src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs new file mode 100644 index 0000000..acc0323 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs @@ -0,0 +1,76 @@ +using EllieBot.Modules.Games.Hangman; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class HangmanCommands : EllieModule + { + [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(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanGame.cs b/src/EllieBot/Modules/Games/Hangman/HangmanGame.cs new file mode 100644 index 0000000..58e779b --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/HangmanGame.cs @@ -0,0 +1,112 @@ +#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 _incorrect = new(); + private readonly HashSet _correct = new(); + private readonly HashSet _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 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 MissedLetters, + string ImageUrl) + { + public bool Failed + => Errors > 5; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanService.cs b/src/EllieBot/Modules/Games/Hangman/HangmanService.cs new file mode 100644 index 0000000..0b5c9c0 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/HangmanService.cs @@ -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 _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 StopHangman(ulong channelId) + { + lock (_locker) + { + if (_hangmanGames.TryRemove(channelId, out _)) + return new(true); + } + + return new(false); + } + + public IReadOnlyCollection 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 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(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs b/src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs new file mode 100644 index 0000000..22e5144 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs @@ -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; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/IHangmanService.cs b/src/EllieBot/Modules/Games/Hangman/IHangmanService.cs new file mode 100644 index 0000000..da8d027 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/IHangmanService.cs @@ -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 StopHangman(ulong channelId); + IReadOnlyCollection GetHangmanTypes(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs b/src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs new file mode 100644 index 0000000..d28199b --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Games.Hangman; + +public interface IHangmanSource : IEService +{ + public IReadOnlyCollection GetCategories(); + public void Reload(); + public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Nunchi/Nunchi.cs b/src/EllieBot/Modules/Games/Nunchi/Nunchi.cs new file mode 100644 index 0000000..6fa579c --- /dev/null +++ b/src/EllieBot/Modules/Games/Nunchi/Nunchi.cs @@ -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 OnGameStarted; + public event Func OnRoundStarted; + public event Func OnUserGuessed; + public event Func OnRoundEnded; // tuple of the user who failed + public event Func 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 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 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; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs b/src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs new file mode 100644 index 0000000..80e6c42 --- /dev/null +++ b/src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs @@ -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 + { + 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(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs b/src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs new file mode 100644 index 0000000..65b8bbd --- /dev/null +++ b/src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs @@ -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 + { + private readonly GamesService _games; + private readonly DiscordSocketClient _client; + + public SpeedTypingCommands(DiscordSocketClient client, GamesService games) + { + _games = games; + _client = client; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + 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(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs b/src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs new file mode 100644 index 0000000..cb55893 --- /dev/null +++ b/src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs @@ -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; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs b/src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs new file mode 100644 index 0000000..d712525 --- /dev/null +++ b/src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs @@ -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 _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 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; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs b/src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs new file mode 100644 index 0000000..fa8070d --- /dev/null +++ b/src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs @@ -0,0 +1,307 @@ +#nullable disable +using CommandLine; +using System.Text; + +namespace EllieBot.Modules.Games.Common; + +public class TicTacToe +{ + public event Action 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 + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs b/src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs new file mode 100644 index 0000000..904f8db --- /dev/null +++ b/src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs @@ -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 + { + private readonly SemaphoreSlim _sem = new(1, 1); + private readonly DiscordSocketClient _client; + + public TicTacToeCommands(DiscordSocketClient client) + => _client = client; + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + 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(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs b/src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs new file mode 100644 index 0000000..b82dd62 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs @@ -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 GetQuestionAsync() + { + var pool = await _cache.GetTriviaQuestionsAsync(); + + if(pool is null or {Length: 0}) + return default; + + return new(pool[_rng.Next(0, pool.Length)]); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs b/src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs new file mode 100644 index 0000000..636ae16 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Games.Common.Trivia; + +public interface IQuestionPool +{ + Task GetQuestionAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs b/src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs new file mode 100644 index 0000000..53f56cf --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs @@ -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 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" + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaCommands.cs b/src/EllieBot/Modules/Games/Trivia/TriviaCommands.cs new file mode 100644 index 0000000..72b4eb4 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaCommands.cs @@ -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 + { + 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] + 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; + private IUserMessage? questionMessage; + private bool showHowToQuit; + + 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 + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaGame.cs b/src/EllieBot/Modules/Games/Trivia/TriviaGame.cs new file mode 100644 index 0000000..07c4ab4 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaGame.cs @@ -0,0 +1,228 @@ +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 OnQuestion = static delegate { return Task.CompletedTask; }; + public event Func OnHint = static delegate { return Task.CompletedTask; }; + public event Func OnStats = static delegate { return Task.CompletedTask; }; + + public event Func OnGuess = static delegate + { + return Task.CompletedTask; + }; + + public event Func OnTimeout = static delegate { return Task.CompletedTask; }; + public event Func OnEnded = static delegate { return Task.CompletedTask; }; + + #endregion + + private bool _isStopped; + + public TriviaQuestion? CurrentQuestion { get; set; } + + + private readonly ConcurrentDictionary _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); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs b/src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs new file mode 100644 index 0000000..6fc4ab6 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs @@ -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 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); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs b/src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs new file mode 100644 index 0000000..47bdc0b --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs @@ -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; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs b/src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs new file mode 100644 index 0000000..299c762 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs @@ -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> _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()); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaUser.cs b/src/EllieBot/Modules/Games/Trivia/TriviaUser.cs new file mode 100644 index 0000000..b61e827 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaUser.cs @@ -0,0 +1,3 @@ +namespace EllieBot.Modules.Games.Common.Trivia; + +public record class TriviaUser(string Name, ulong Id); \ No newline at end of file