From ea8e444b100f3d0016a2a25a0a0d352f5b8eb2f5 Mon Sep 17 00:00:00 2001 From: Kwoth Date: Mon, 11 Jul 2022 23:11:45 +0000 Subject: [PATCH] Trivia game cleanup --- .../Games/Trivia/DefaultQuestionPool.cs | 33 -- src/NadekoBot/Modules/Games/Trivia/Games.cs | 265 ++++++++++++ .../QuestionPool/DefaultQuestionPool.cs | 22 + .../{ => QuestionPool}/IQuestionPool.cs | 2 +- .../{ => QuestionPool}/PokemonQuestionPool.cs | 2 +- .../Modules/Games/Trivia/TriviaCommands.cs | 102 ----- .../Modules/Games/Trivia/TriviaGame.cs | 395 +++++++----------- .../Games/Trivia/TriviaGamesService.cs | 37 ++ .../Modules/Games/Trivia/TriviaQuestion.cs | 9 - .../Games/Trivia/TriviaQuestionModel.cs | 11 + .../Games/Trivia/TriviaQuestionPool.cs | 54 --- .../Modules/Games/Trivia/TriviaUser.cs | 3 + src/NadekoBot/_Extensions/Extensions.cs | 2 - .../strings/responses/responses.en-US.json | 1 + 14 files changed, 494 insertions(+), 444 deletions(-) delete mode 100644 src/NadekoBot/Modules/Games/Trivia/DefaultQuestionPool.cs create mode 100644 src/NadekoBot/Modules/Games/Trivia/Games.cs create mode 100644 src/NadekoBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs rename src/NadekoBot/Modules/Games/Trivia/{ => QuestionPool}/IQuestionPool.cs (51%) rename src/NadekoBot/Modules/Games/Trivia/{ => QuestionPool}/PokemonQuestionPool.cs (90%) delete mode 100644 src/NadekoBot/Modules/Games/Trivia/TriviaCommands.cs create mode 100644 src/NadekoBot/Modules/Games/Trivia/TriviaGamesService.cs create mode 100644 src/NadekoBot/Modules/Games/Trivia/TriviaQuestionModel.cs delete mode 100644 src/NadekoBot/Modules/Games/Trivia/TriviaQuestionPool.cs create mode 100644 src/NadekoBot/Modules/Games/Trivia/TriviaUser.cs diff --git a/src/NadekoBot/Modules/Games/Trivia/DefaultQuestionPool.cs b/src/NadekoBot/Modules/Games/Trivia/DefaultQuestionPool.cs deleted file mode 100644 index 945d832f1..000000000 --- a/src/NadekoBot/Modules/Games/Trivia/DefaultQuestionPool.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace NadekoBot.Modules.Games.Common.Trivia; - -public sealed class DefaultQuestionPool : IQuestionPool -{ - private readonly ILocalDataCache _cache; - private readonly NadekoRandom _rng; - - public DefaultQuestionPool(ILocalDataCache cache) - { - _cache = cache; - _rng = new NadekoRandom(); - } - public async Task GetRandomQuestionAsync(ISet exclude) - { - TriviaQuestion randomQuestion; - var pool = await _cache.GetTriviaQuestionsAsync(); - - if(pool is null) - return default; - - while (exclude.Contains(randomQuestion = new(pool[_rng.Next(0, pool.Length)]))) - { - // if too many questions are excluded, clear the exclusion list and start over - if (exclude.Count > pool.Length / 10 * 9) - { - exclude.Clear(); - break; - } - } - - return randomQuestion; - } -} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Games/Trivia/Games.cs b/src/NadekoBot/Modules/Games/Trivia/Games.cs new file mode 100644 index 000000000..306967284 --- /dev/null +++ b/src/NadekoBot/Modules/Games/Trivia/Games.cs @@ -0,0 +1,265 @@ +using System.Net; +using System.Text; +using NadekoBot.Modules.Games.Common.Trivia; +using NadekoBot.Modules.Games.Services; + +namespace NadekoBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class TriviaCommands : NadekoModule + { + private readonly ILocalDataCache _cache; + private readonly ICurrencyService _cs; + private readonly GamesConfigService _gamesConfig; + private readonly DiscordSocketClient _client; + + public TriviaCommands( + DiscordSocketClient client, + ILocalDataCache cache, + ICurrencyService cs, + GamesConfigService gamesConfig) + { + _cache = cache; + _cs = cs; + _gamesConfig = gamesConfig; + _client = client; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + [NadekoOptions(typeof(TriviaOptions))] + public async partial Task Trivia(params string[] args) + { + var (opts, _) = OptionsParser.ParseFrom(new TriviaOptions(), args); + + var config = _gamesConfig.Data; + if (config.Trivia.MinimumWinReq > 0 && config.Trivia.MinimumWinReq > opts.WinRequirement) + return; + + var trivia = new TriviaGame(opts, _cache); + if (_service.RunningTrivias.TryAdd(ctx.Guild.Id, trivia)) + { + RegisterEvents(trivia); + await trivia.RunAsync(); + return; + } + + if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var tg)) + { + await SendErrorAsync(GetText(strs.trivia_already_running)); + await tg.TriggerQuestionAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async partial Task Tl() + { + if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var trivia)) + { + await trivia.TriggerStatsAsync(); + return; + } + + await ReplyErrorLocalizedAsync(strs.trivia_none); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async partial Task Tq() + { + var channel = (ITextChannel)ctx.Channel; + + if (_service.RunningTrivias.TryGetValue(channel.Guild.Id, out var trivia)) + { + if (trivia.Stop()) + { + try + { + await ctx.Channel.SendConfirmAsync(_eb, + GetText(strs.trivia_game), + GetText(strs.trivia_stopping)); + } + catch (Exception ex) + { + Log.Warning(ex, "Error sending trivia stopping message"); + } + } + + return; + } + + await ReplyErrorLocalizedAsync(strs.trivia_none); + } + + + private void RegisterEvents(TriviaGame trivia) + { + IEmbedBuilder? questionEmbed = null; + IUserMessage? questionMessage = null; + var showHowToQuit = false; + + trivia.OnQuestion += async (_, question) => + { + try + { + questionEmbed = _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.trivia_game)) + .AddField(GetText(strs.category), question.Category) + .AddField(GetText(strs.question), question.Question); + + showHowToQuit = !showHowToQuit; + if (showHowToQuit) + questionEmbed.WithFooter(GetText(strs.trivia_quit($"{prefix}tq"))); + + if (Uri.IsWellFormedUriString(question.ImageUrl, UriKind.Absolute)) + questionEmbed.WithImageUrl(question.ImageUrl); + + questionMessage = await ctx.Channel.EmbedAsync(questionEmbed); + } + catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound + or HttpStatusCode.Forbidden + or HttpStatusCode.BadRequest) + { + Log.Warning("Unable to send trivia questions. Stopping immediately"); + trivia.Stop(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error sending trivia embed"); + await Task.Delay(2000); + } + }; + + trivia.OnHint += async (_, question) => + { + try + { + if (questionMessage is null) + { + trivia.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"); + trivia.Stop(); + } + catch (Exception ex) { Log.Warning(ex, "Error editing triva message"); } + + }; + + trivia.OnGuess += async (_, user, question, isWin) => + { + try + { + var embed = _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.trivia_game)) + .WithDescription(GetText(strs.trivia_win(user.Name, + Format.Bold(question.Answer)))); + + if (Uri.IsWellFormedUriString(question.AnswerImageUrl, UriKind.Absolute)) + embed.WithImageUrl(question.AnswerImageUrl); + + + if (isWin) + { + await ctx.Channel.EmbedAsync(embed); + + var reward = _gamesConfig.Data.Trivia.CurrencyReward; + if (reward > 0) + await _cs.AddAsync(user.Id, reward, new("trivia", "win")); + + return; + } + + embed.WithDescription(GetText(strs.trivia_guess(user.Name, + Format.Bold(question.Answer)))); + + await ctx.Channel.EmbedAsync(embed); + + } + catch + { + // ignored + } + }; + + trivia.OnEnded += async (game) => + { + try + { + await ctx.Channel.EmbedAsync(_eb.Create(ctx) + .WithOkColor() + .WithAuthor(GetText(strs.trivia_ended)) + .WithTitle(GetText(strs.leaderboard)) + .WithDescription(GetLeaderboardString(game))); + } + catch + { + // ignored + } + finally + { + _service.RunningTrivias.TryRemove(ctx.Guild.Id, out _); + } + + }; + + trivia.OnStats += async (game) => + { + try + { + await SendConfirmAsync(GetText(strs.leaderboard), GetLeaderboardString(game)); + } + catch + { + // ignored + } + }; + + trivia.OnTimeout += async (_, question) => + { + try + { + var embed = _eb.Create() + .WithErrorColor() + .WithTitle(GetText(strs.trivia_game)) + .WithDescription(GetText(strs.trivia_times_up(Format.Bold(question.Answer)))); + + if (Uri.IsWellFormedUriString(question.AnswerImageUrl, UriKind.Absolute)) + embed.WithImageUrl(question.AnswerImageUrl); + + await ctx.Channel.EmbedAsync(embed); + + } + catch + { + // ignored + } + }; + } + + private 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(); + + } + } +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs b/src/NadekoBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs new file mode 100644 index 000000000..8357c7e24 --- /dev/null +++ b/src/NadekoBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs @@ -0,0 +1,22 @@ +namespace NadekoBot.Modules.Games.Common.Trivia; + +public sealed class DefaultQuestionPool : IQuestionPool +{ + private readonly ILocalDataCache _cache; + private readonly NadekoRandom _rng; + + public DefaultQuestionPool(ILocalDataCache cache) + { + _cache = cache; + _rng = new NadekoRandom(); + } + public async Task 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/NadekoBot/Modules/Games/Trivia/IQuestionPool.cs b/src/NadekoBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs similarity index 51% rename from src/NadekoBot/Modules/Games/Trivia/IQuestionPool.cs rename to src/NadekoBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs index dcffa1df8..4a3087c9a 100644 --- a/src/NadekoBot/Modules/Games/Trivia/IQuestionPool.cs +++ b/src/NadekoBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs @@ -2,5 +2,5 @@ namespace NadekoBot.Modules.Games.Common.Trivia; public interface IQuestionPool { - Task GetRandomQuestionAsync(ISet exclude); + Task GetQuestionAsync(); } \ No newline at end of file diff --git a/src/NadekoBot/Modules/Games/Trivia/PokemonQuestionPool.cs b/src/NadekoBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs similarity index 90% rename from src/NadekoBot/Modules/Games/Trivia/PokemonQuestionPool.cs rename to src/NadekoBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs index 3fb843c16..f1cbb8e55 100644 --- a/src/NadekoBot/Modules/Games/Trivia/PokemonQuestionPool.cs +++ b/src/NadekoBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs @@ -12,7 +12,7 @@ public sealed class PokemonQuestionPool : IQuestionPool _rng = new NadekoRandom(); } - public async Task GetRandomQuestionAsync(ISet exclude) + public async Task GetQuestionAsync() { var pokes = await _cache.GetPokemonMapAsync(); diff --git a/src/NadekoBot/Modules/Games/Trivia/TriviaCommands.cs b/src/NadekoBot/Modules/Games/Trivia/TriviaCommands.cs deleted file mode 100644 index 16c2d1fdf..000000000 --- a/src/NadekoBot/Modules/Games/Trivia/TriviaCommands.cs +++ /dev/null @@ -1,102 +0,0 @@ -#nullable disable -using NadekoBot.Modules.Games.Common.Trivia; -using NadekoBot.Modules.Games.Services; - -namespace NadekoBot.Modules.Games; - -public partial class Games -{ - [Group] - public partial class TriviaCommands : NadekoModule - { - 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)] - [NadekoOptionsAttribute(typeof(TriviaOptions))] - public partial Task Trivia(params string[] args) - => InternalTrivia(args); - - private async Task InternalTrivia(params string[] args) - { - var channel = (ITextChannel)ctx.Channel; - - var (opts, _) = OptionsParser.ParseFrom(new TriviaOptions(), args); - - var config = _gamesConfig.Data; - if (config.Trivia.MinimumWinReq > 0 && config.Trivia.MinimumWinReq > opts.WinRequirement) - return; - - var trivia = new TriviaGame(Strings, - _client, - config, - _cache, - _cs, - channel.Guild, - channel, - opts, - prefix + "tq", - _eb); - if (_service.RunningTrivias.TryAdd(channel.Guild.Id, trivia)) - { - try - { - await trivia.StartGame(); - } - finally - { - _service.RunningTrivias.TryRemove(channel.Guild.Id, out trivia); - await trivia.EnsureStopped(); - } - - return; - } - - await SendErrorAsync(GetText(strs.trivia_already_running) + "\n" + trivia.CurrentQuestion); - } - - [Cmd] - [RequireContext(ContextType.Guild)] - public async partial Task Tl() - { - if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var trivia)) - { - await SendConfirmAsync(GetText(strs.leaderboard), trivia.GetLeaderboard()); - return; - } - - await ReplyErrorLocalizedAsync(strs.trivia_none); - } - - [Cmd] - [RequireContext(ContextType.Guild)] - public async partial Task Tq() - { - var channel = (ITextChannel)ctx.Channel; - - if (_service.RunningTrivias.TryGetValue(channel.Guild.Id, out var trivia)) - { - await trivia.StopGame(); - return; - } - - await ReplyErrorLocalizedAsync(strs.trivia_none); - } - } -} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Games/Trivia/TriviaGame.cs b/src/NadekoBot/Modules/Games/Trivia/TriviaGame.cs index ddfd7c754..297748bac 100644 --- a/src/NadekoBot/Modules/Games/Trivia/TriviaGame.cs +++ b/src/NadekoBot/Modules/Games/Trivia/TriviaGame.cs @@ -1,291 +1,202 @@ -#nullable disable -using System.Net; -using System.Text; +using System.Threading.Channels; namespace NadekoBot.Modules.Games.Common.Trivia; -public class TriviaGame +public sealed class TriviaGame { - public IGuild Guild { get; } - public ITextChannel Channel { get; } + private readonly TriviaOptions _opts; - public TriviaQuestion CurrentQuestion { get; private set; } - public HashSet OldQuestions { get; } = new(); - public ConcurrentDictionary Users { get; } = new(); + private readonly IQuestionPool _questionPool; - public bool GameActive { get; private set; } - public bool ShouldStopGame { get; private set; } - private readonly SemaphoreSlim _guessLock = new(1, 1); - private readonly ILocalDataCache _cache; - private readonly IBotStrings _strings; - private readonly DiscordSocketClient _client; - private readonly GamesConfig _config; - private readonly ICurrencyService _cs; - private readonly TriviaOptions _options; + #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 CancellationTokenSource triviaCancelSource; + private bool _isStopped; - private readonly TriviaQuestionPool _questionPool; - private int timeoutCount; - private readonly string _quitCommand; - private readonly IEmbedBuilderService _eb; + public TriviaQuestion? CurrentQuestion { get; set; } - public TriviaGame( - IBotStrings strings, - DiscordSocketClient client, - GamesConfig config, - ILocalDataCache cache, - ICurrencyService cs, - IGuild guild, - ITextChannel channel, - TriviaOptions options, - string quitCommand, - IEmbedBuilderService eb) + + 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) { - _cache = cache; - _questionPool = new(_cache); - _strings = strings; - _client = client; - _config = config; - _cs = cs; - _options = options; - _quitCommand = quitCommand; - _eb = eb; + _opts = options; - Guild = guild; - Channel = channel; + _questionPool = _opts.IsPokemon + ? new PokemonQuestionPool(cache) + : new DefaultQuestionPool(cache); + + } + public async Task RunAsync() + { + await GameLoop(); } - private string GetText(in LocStr key) - => _strings.GetText(key, Channel.GuildId); - - public async Task StartGame() + private async Task GameLoop() { - var showHowToQuit = false; - while (!ShouldStopGame) - { - // reset the cancellation source - triviaCancelSource = new(); - showHowToQuit = !showHowToQuit; + Task TimeOutFactory() => Task.Delay(_opts.QuestionTimer * 1000 / 2); - // load question - CurrentQuestion = await _questionPool.GetRandomQuestionAsync(OldQuestions, _options.IsPokemon); - if (string.IsNullOrWhiteSpace(CurrentQuestion?.Answer) - || string.IsNullOrWhiteSpace(CurrentQuestion.Question)) + var errorCount = 0; + var inactivity = 0; + + // loop until game is stopped + // each iteration is one round + var firstRun = true; + while (!_isStopped) + { + if (errorCount >= 5) { - await Channel.SendErrorAsync(_eb, GetText(strs.trivia_game), GetText(strs.failed_loading_question)); - return; + Log.Warning("Trivia errored 5 times and will quit"); + break; } - OldQuestions.Add(CurrentQuestion); //add it to exclusion list so it doesn't show up again + // wait for 3 seconds before posting the next question + if (firstRun) + { + firstRun = false; + } + else + { + await Task.Delay(3000); + } - IEmbedBuilder questionEmbed; - IUserMessage questionMessage; + var maybeQuestion = await _questionPool.GetQuestionAsync(); + + if(!(maybeQuestion is TriviaQuestion question)) + { + // if question is null (ran out of question, or other bugg ) - stop + break; + } + + CurrentQuestion = question; try { - questionEmbed = _eb.Create() - .WithOkColor() - .WithTitle(GetText(strs.trivia_game)) - .AddField(GetText(strs.category), CurrentQuestion.Category) - .AddField(GetText(strs.question), CurrentQuestion.Question); + // clear out all of the past guesses + while (_inputs.Reader.TryRead(out _)) ; - if (showHowToQuit) - questionEmbed.WithFooter(GetText(strs.trivia_quit(_quitCommand))); - - if (Uri.IsWellFormedUriString(CurrentQuestion.ImageUrl, UriKind.Absolute)) - questionEmbed.WithImageUrl(CurrentQuestion.ImageUrl); - - questionMessage = await Channel.EmbedAsync(questionEmbed); - } - catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound - or HttpStatusCode.Forbidden - or HttpStatusCode.BadRequest) - { - return; + await OnQuestion(this, question); } catch (Exception ex) { - Log.Warning(ex, "Error sending trivia embed"); - await Task.Delay(2000); + Log.Warning(ex, "Error executing OnQuestion: {Message}", ex.Message); + errorCount++; continue; } - //receive messages - try - { - _client.MessageReceived += PotentialGuess; - //allow people to guess - GameActive = true; - try + // 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) + { + var readTask = _inputs.Reader.ReadAsync().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) { - //hint - await Task.Delay(_options.QuestionTimer * 1000 / 2, triviaCancelSource.Token); - if (!_options.NoHint) + // 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(); + // 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; + + var isWin = false; + // if user won the game, tell the game to stop + if (val >= _opts.WinRequirement) { - try - { - await questionMessage.ModifyAsync(m - => m.Embed = questionEmbed.WithFooter(CurrentQuestion.GetHint()).Build()); - } - catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound - or HttpStatusCode.Forbidden) - { - break; - } - catch (Exception ex) { Log.Warning(ex, "Error editing triva message"); } + _isStopped = true; + isWin = true; } - //timeout - await Task.Delay(_options.QuestionTimer * 1000 / 2, triviaCancelSource.Token); - } - catch (TaskCanceledException) { timeoutCount = 0; } //means someone guessed the answer - } - finally - { - GameActive = false; - _client.MessageReceived -= PotentialGuess; - } - - if (!triviaCancelSource.IsCancellationRequested) - { - try - { - var embed = _eb.Create() - .WithErrorColor() - .WithTitle(GetText(strs.trivia_game)) - .WithDescription(GetText(strs.trivia_times_up(Format.Bold(CurrentQuestion.Answer)))); - if (Uri.IsWellFormedUriString(CurrentQuestion.AnswerImageUrl, UriKind.Absolute)) - embed.WithImageUrl(CurrentQuestion.AnswerImageUrl); - - await Channel.EmbedAsync(embed); - - if (_options.Timeout != 0 && ++timeoutCount >= _options.Timeout) - await StopGame(); - } - catch (Exception ex) - { - Log.Warning(ex, "Error sending trivia time's up message"); + // call onguess + await OnGuess(this, user, question, isWin); + break; } } - await Task.Delay(5000); - } - } - - public async Task EnsureStopped() - { - ShouldStopGame = true; - - await Channel.EmbedAsync(_eb.Create() - .WithOkColor() - .WithAuthor("Trivia Game Ended") - .WithTitle("Final Results") - .WithDescription(GetLeaderboard())); - } - - public async Task StopGame() - { - var old = ShouldStopGame; - ShouldStopGame = true; - if (!old) - { - try + if (!guessed) { - await Channel.SendConfirmAsync(_eb, GetText(strs.trivia_game), GetText(strs.trivia_stopping)); - } - catch (Exception ex) - { - Log.Warning(ex, "Error sending trivia stopping message"); - } - } - } - - private Task PotentialGuess(SocketMessage imsg) - { - _ = Task.Run(async () => - { - try - { - if (imsg.Author.IsBot) - return; - - var umsg = imsg as SocketUserMessage; - - if (umsg?.Channel is not ITextChannel textChannel || textChannel.Guild != Guild) - return; - - var guildUser = (IGuildUser)umsg.Author; - - var guess = false; - await _guessLock.WaitAsync(); - try - { - if (GameActive - && CurrentQuestion.IsAnswerCorrect(umsg.Content) - && !triviaCancelSource.IsCancellationRequested) - { - Users.AddOrUpdate(guildUser, 1, (_, old) => ++old); - guess = true; - } - } - finally { _guessLock.Release(); } - - if (!guess) - return; + await OnTimeout(this, question); - triviaCancelSource.Cancel(); - - if (_options.WinRequirement != 0 && Users[guildUser] == _options.WinRequirement) + if (_opts.Timeout != 0 && ++inactivity >= _opts.Timeout) { - ShouldStopGame = true; - try - { - var embedS = _eb.Create() - .WithOkColor() - .WithTitle(GetText(strs.trivia_game)) - .WithDescription(GetText(strs.trivia_win(guildUser.Mention, - Format.Bold(CurrentQuestion.Answer)))); - if (Uri.IsWellFormedUriString(CurrentQuestion.AnswerImageUrl, UriKind.Absolute)) - embedS.WithImageUrl(CurrentQuestion.AnswerImageUrl); - await Channel.EmbedAsync(embedS); - } - catch - { - // ignored - } - - var reward = _config.Trivia.CurrencyReward; - if (reward > 0) - await _cs.AddAsync(guildUser, reward, new("trivia", "win")); - return; + Log.Information("Trivia game is stopping due to inactivity"); + break; } - - var embed = _eb.Create() - .WithOkColor() - .WithTitle(GetText(strs.trivia_game)) - .WithDescription(GetText(strs.trivia_guess(guildUser.Mention, - Format.Bold(CurrentQuestion.Answer)))); - if (Uri.IsWellFormedUriString(CurrentQuestion.AnswerImageUrl, UriKind.Absolute)) - embed.WithImageUrl(CurrentQuestion.AnswerImageUrl); - await Channel.EmbedAsync(embed); } - catch (Exception ex) { Log.Warning(ex, "Exception in a potential guess"); } - }); - return Task.CompletedTask; + } + + // make sure game is set as ended + _isStopped = true; + + await OnEnded(this); } - public string GetLeaderboard() + 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() { - if (Users.Count == 0) - return GetText(strs.no_results); + var isStopped = _isStopped; + _isStopped = true; + return !isStopped; + } - var sb = new StringBuilder(); + public async ValueTask TriggerStatsAsync() + { + await OnStats(this); + } - foreach (var kvp in Users.OrderByDescending(kvp => kvp.Value)) - sb.AppendLine(GetText(strs.trivia_points(Format.Bold(kvp.Key.ToString()), kvp.Value))); - - return sb.ToString(); + public async Task TriggerQuestionAsync() + { + if(CurrentQuestion is TriviaQuestion q) + await OnQuestion(this, q); } } \ No newline at end of file diff --git a/src/NadekoBot/Modules/Games/Trivia/TriviaGamesService.cs b/src/NadekoBot/Modules/Games/Trivia/TriviaGamesService.cs new file mode 100644 index 000000000..fbd94da74 --- /dev/null +++ b/src/NadekoBot/Modules/Games/Trivia/TriviaGamesService.cs @@ -0,0 +1,37 @@ +#nullable disable +using NadekoBot.Common.ModuleBehaviors; +using NadekoBot.Modules.Games.Common.Trivia; + +namespace NadekoBot.Modules.Games; + +public sealed class TriviaGamesService : IReadyExecutor, INService +{ + private readonly DiscordSocketClient _client; + public ConcurrentDictionary 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/NadekoBot/Modules/Games/Trivia/TriviaQuestion.cs b/src/NadekoBot/Modules/Games/Trivia/TriviaQuestion.cs index 159a28113..f16456a7a 100644 --- a/src/NadekoBot/Modules/Games/Trivia/TriviaQuestion.cs +++ b/src/NadekoBot/Modules/Games/Trivia/TriviaQuestion.cs @@ -4,15 +4,6 @@ using System.Text.RegularExpressions; // THANKS @ShoMinamimoto for suggestions and coding help namespace NadekoBot.Modules.Games.Common.Trivia; -public sealed class TriviaQuestionModel -{ - public string Category { get; init; } - public string Question { get; init; } - public string ImageUrl { get; init; } - public string AnswerImageUrl { get; init; } - public string Answer { get; init; } -} - public class TriviaQuestion { public const int MAX_STRING_LENGTH = 22; diff --git a/src/NadekoBot/Modules/Games/Trivia/TriviaQuestionModel.cs b/src/NadekoBot/Modules/Games/Trivia/TriviaQuestionModel.cs new file mode 100644 index 000000000..8cc463360 --- /dev/null +++ b/src/NadekoBot/Modules/Games/Trivia/TriviaQuestionModel.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace NadekoBot.Modules.Games.Common.Trivia; + +public sealed class TriviaQuestionModel +{ + public string Category { get; init; } + public string Question { get; init; } + public string ImageUrl { get; init; } + public string AnswerImageUrl { get; init; } + public string Answer { get; init; } +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Games/Trivia/TriviaQuestionPool.cs b/src/NadekoBot/Modules/Games/Trivia/TriviaQuestionPool.cs deleted file mode 100644 index 79d9ec763..000000000 --- a/src/NadekoBot/Modules/Games/Trivia/TriviaQuestionPool.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace NadekoBot.Modules.Games.Common.Trivia; - -public class TriviaQuestionPool -{ - private readonly ILocalDataCache _cache; - private readonly int _maxPokemonId; - - private readonly NadekoRandom _rng = new(); - - public TriviaQuestionPool(ILocalDataCache cache) - { - _cache = cache; - _maxPokemonId = 721; //xd - } - - public async Task GetRandomQuestionAsync(HashSet exclude, bool isPokemon) - { - if (isPokemon) - { - var pokes = await _cache.GetPokemonMapAsync(); - - if (pokes is null or { Count: 0 }) - return default; - - var num = _rng.Next(1, _maxPokemonId + 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" - }); - } - - TriviaQuestion randomQuestion; - var pool = await _cache.GetTriviaQuestionsAsync(); - - if(pool is null) - return default; - - while (exclude.Contains(randomQuestion = new(pool[_rng.Next(0, pool.Length)]))) - { - // if too many questions are excluded, clear the exclusion list and start over - if (exclude.Count > pool.Length / 10 * 9) - { - exclude.Clear(); - break; - } - } - - return randomQuestion; - } -} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Games/Trivia/TriviaUser.cs b/src/NadekoBot/Modules/Games/Trivia/TriviaUser.cs new file mode 100644 index 000000000..6eb38d189 --- /dev/null +++ b/src/NadekoBot/Modules/Games/Trivia/TriviaUser.cs @@ -0,0 +1,3 @@ +namespace NadekoBot.Modules.Games.Common.Trivia; + +public record class TriviaUser(string Name, ulong Id); \ No newline at end of file diff --git a/src/NadekoBot/_Extensions/Extensions.cs b/src/NadekoBot/_Extensions/Extensions.cs index aad7045d1..113bee4ff 100644 --- a/src/NadekoBot/_Extensions/Extensions.cs +++ b/src/NadekoBot/_Extensions/Extensions.cs @@ -2,10 +2,8 @@ using Humanizer.Localisation; using Nadeko.Medusa; using System.Diagnostics; using System.Globalization; -using System.Net.Http.Headers; using System.Text.Json; using System.Text.RegularExpressions; -using Nadeko.Common; namespace NadekoBot.Extensions; diff --git a/src/NadekoBot/data/strings/responses/responses.en-US.json b/src/NadekoBot/data/strings/responses/responses.en-US.json index 9716d9f3a..967dcaea9 100644 --- a/src/NadekoBot/data/strings/responses/responses.en-US.json +++ b/src/NadekoBot/data/strings/responses/responses.en-US.json @@ -343,6 +343,7 @@ "trivia_times_up": "Time's up! The correct answer was {0}", "trivia_win": "{0} guessed it and WON the game! The answer was: {1}", "trivia_quit": "You can stop trivia by typing {0}", + "trivia_ended": "Trivia game ended", "ttt_against_yourself": "You can't play against yourself.", "ttt_already_running": "TicTacToe Game is already running in this channel.", "ttt_a_draw": "A draw!",