mirror of
https://gitlab.com/Kwoth/nadekobot.git
synced 2025-09-10 17:28:27 -04:00
Merge branch '4.3-trivia' into '4.3'
Trivia game cleanup See merge request Kwoth/nadekobot!260
This commit is contained in:
@@ -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<TriviaQuestion?> GetRandomQuestionAsync(ISet<TriviaQuestion> 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;
|
|
||||||
}
|
|
||||||
}
|
|
265
src/NadekoBot/Modules/Games/Trivia/Games.cs
Normal file
265
src/NadekoBot/Modules/Games/Trivia/Games.cs
Normal file
@@ -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<TriviaGamesService>
|
||||||
|
{
|
||||||
|
private readonly ILocalDataCache _cache;
|
||||||
|
private readonly ICurrencyService _cs;
|
||||||
|
private readonly GamesConfigService _gamesConfig;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
|
||||||
|
public TriviaCommands(
|
||||||
|
DiscordSocketClient client,
|
||||||
|
ILocalDataCache cache,
|
||||||
|
ICurrencyService cs,
|
||||||
|
GamesConfigService gamesConfig)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_cs = cs;
|
||||||
|
_gamesConfig = gamesConfig;
|
||||||
|
_client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(0)]
|
||||||
|
[NadekoOptions(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();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -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<TriviaQuestion?> GetQuestionAsync()
|
||||||
|
{
|
||||||
|
var pool = await _cache.GetTriviaQuestionsAsync();
|
||||||
|
|
||||||
|
if(pool is null or {Length: 0})
|
||||||
|
return default;
|
||||||
|
|
||||||
|
return new(pool[_rng.Next(0, pool.Length)]);
|
||||||
|
}
|
||||||
|
}
|
@@ -2,5 +2,5 @@ namespace NadekoBot.Modules.Games.Common.Trivia;
|
|||||||
|
|
||||||
public interface IQuestionPool
|
public interface IQuestionPool
|
||||||
{
|
{
|
||||||
Task<TriviaQuestion?> GetRandomQuestionAsync(ISet<TriviaQuestion> exclude);
|
Task<TriviaQuestion?> GetQuestionAsync();
|
||||||
}
|
}
|
@@ -12,7 +12,7 @@ public sealed class PokemonQuestionPool : IQuestionPool
|
|||||||
_rng = new NadekoRandom();
|
_rng = new NadekoRandom();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TriviaQuestion?> GetRandomQuestionAsync(ISet<TriviaQuestion> exclude)
|
public async Task<TriviaQuestion?> GetQuestionAsync()
|
||||||
{
|
{
|
||||||
var pokes = await _cache.GetPokemonMapAsync();
|
var pokes = await _cache.GetPokemonMapAsync();
|
||||||
|
|
@@ -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<GamesService>
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,291 +1,202 @@
|
|||||||
#nullable disable
|
using System.Threading.Channels;
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace NadekoBot.Modules.Games.Common.Trivia;
|
namespace NadekoBot.Modules.Games.Common.Trivia;
|
||||||
|
|
||||||
public class TriviaGame
|
public sealed class TriviaGame
|
||||||
{
|
{
|
||||||
public IGuild Guild { get; }
|
private readonly TriviaOptions _opts;
|
||||||
public ITextChannel Channel { get; }
|
|
||||||
|
|
||||||
public TriviaQuestion CurrentQuestion { get; private set; }
|
|
||||||
public HashSet<TriviaQuestion> OldQuestions { get; } = new();
|
|
||||||
|
|
||||||
public ConcurrentDictionary<IGuildUser, int> Users { get; } = new();
|
private readonly IQuestionPool _questionPool;
|
||||||
|
|
||||||
public bool GameActive { get; private set; }
|
#region Events
|
||||||
public bool ShouldStopGame { get; private set; }
|
public event Func<TriviaGame, TriviaQuestion, Task> OnQuestion = static delegate { return Task.CompletedTask; };
|
||||||
private readonly SemaphoreSlim _guessLock = new(1, 1);
|
public event Func<TriviaGame, TriviaQuestion, Task> OnHint = static delegate { return Task.CompletedTask; };
|
||||||
private readonly ILocalDataCache _cache;
|
public event Func<TriviaGame, Task> OnStats = static delegate { return Task.CompletedTask; };
|
||||||
private readonly IBotStrings _strings;
|
public event Func<TriviaGame, TriviaUser, TriviaQuestion, bool, Task> OnGuess = static delegate { return Task.CompletedTask; };
|
||||||
private readonly DiscordSocketClient _client;
|
public event Func<TriviaGame, TriviaQuestion, Task> OnTimeout = static delegate { return Task.CompletedTask; };
|
||||||
private readonly GamesConfig _config;
|
public event Func<TriviaGame, Task> OnEnded = static delegate { return Task.CompletedTask; };
|
||||||
private readonly ICurrencyService _cs;
|
#endregion
|
||||||
private readonly TriviaOptions _options;
|
|
||||||
|
|
||||||
private CancellationTokenSource triviaCancelSource;
|
private bool _isStopped;
|
||||||
|
|
||||||
private readonly TriviaQuestionPool _questionPool;
|
public TriviaQuestion? CurrentQuestion { get; set; }
|
||||||
private int timeoutCount;
|
|
||||||
private readonly string _quitCommand;
|
|
||||||
private readonly IEmbedBuilderService _eb;
|
|
||||||
|
|
||||||
public TriviaGame(
|
|
||||||
IBotStrings strings,
|
private readonly ConcurrentDictionary<ulong, int> _users = new ();
|
||||||
DiscordSocketClient client,
|
|
||||||
GamesConfig config,
|
private readonly Channel<(TriviaUser User, string Input)> _inputs
|
||||||
ILocalDataCache cache,
|
= Channel.CreateUnbounded<(TriviaUser, string)>(new UnboundedChannelOptions
|
||||||
ICurrencyService cs,
|
{
|
||||||
IGuild guild,
|
AllowSynchronousContinuations = true,
|
||||||
ITextChannel channel,
|
SingleReader = true,
|
||||||
TriviaOptions options,
|
SingleWriter = false,
|
||||||
string quitCommand,
|
});
|
||||||
IEmbedBuilderService eb)
|
|
||||||
|
public TriviaGame(TriviaOptions options, ILocalDataCache cache)
|
||||||
{
|
{
|
||||||
_cache = cache;
|
_opts = options;
|
||||||
_questionPool = new(_cache);
|
|
||||||
_strings = strings;
|
|
||||||
_client = client;
|
|
||||||
_config = config;
|
|
||||||
_cs = cs;
|
|
||||||
_options = options;
|
|
||||||
_quitCommand = quitCommand;
|
|
||||||
_eb = eb;
|
|
||||||
|
|
||||||
Guild = guild;
|
_questionPool = _opts.IsPokemon
|
||||||
Channel = channel;
|
? new PokemonQuestionPool(cache)
|
||||||
|
: new DefaultQuestionPool(cache);
|
||||||
|
|
||||||
|
}
|
||||||
|
public async Task RunAsync()
|
||||||
|
{
|
||||||
|
await GameLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetText(in LocStr key)
|
private async Task GameLoop()
|
||||||
=> _strings.GetText(key, Channel.GuildId);
|
|
||||||
|
|
||||||
public async Task StartGame()
|
|
||||||
{
|
{
|
||||||
var showHowToQuit = false;
|
Task TimeOutFactory() => Task.Delay(_opts.QuestionTimer * 1000 / 2);
|
||||||
while (!ShouldStopGame)
|
|
||||||
{
|
|
||||||
// reset the cancellation source
|
|
||||||
triviaCancelSource = new();
|
|
||||||
showHowToQuit = !showHowToQuit;
|
|
||||||
|
|
||||||
// load question
|
var errorCount = 0;
|
||||||
CurrentQuestion = await _questionPool.GetRandomQuestionAsync(OldQuestions, _options.IsPokemon);
|
var inactivity = 0;
|
||||||
if (string.IsNullOrWhiteSpace(CurrentQuestion?.Answer)
|
|
||||||
|| string.IsNullOrWhiteSpace(CurrentQuestion.Question))
|
// 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));
|
Log.Warning("Trivia errored 5 times and will quit");
|
||||||
return;
|
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;
|
var maybeQuestion = await _questionPool.GetQuestionAsync();
|
||||||
IUserMessage questionMessage;
|
|
||||||
|
if(!(maybeQuestion is TriviaQuestion question))
|
||||||
|
{
|
||||||
|
// if question is null (ran out of question, or other bugg ) - stop
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
CurrentQuestion = question;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
questionEmbed = _eb.Create()
|
// clear out all of the past guesses
|
||||||
.WithOkColor()
|
while (_inputs.Reader.TryRead(out _)) ;
|
||||||
.WithTitle(GetText(strs.trivia_game))
|
|
||||||
.AddField(GetText(strs.category), CurrentQuestion.Category)
|
|
||||||
.AddField(GetText(strs.question), CurrentQuestion.Question);
|
|
||||||
|
|
||||||
if (showHowToQuit)
|
await OnQuestion(this, question);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning(ex, "Error sending trivia embed");
|
Log.Warning(ex, "Error executing OnQuestion: {Message}", ex.Message);
|
||||||
await Task.Delay(2000);
|
errorCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
//receive messages
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_client.MessageReceived += PotentialGuess;
|
|
||||||
|
|
||||||
//allow people to guess
|
// just keep looping through user inputs until someone guesses the answer
|
||||||
GameActive = true;
|
// or the timer expires
|
||||||
try
|
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
|
// if hint is already sent, means time expired
|
||||||
await Task.Delay(_options.QuestionTimer * 1000 / 2, triviaCancelSource.Token);
|
// break (end the round)
|
||||||
if (!_options.NoHint)
|
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
|
_isStopped = true;
|
||||||
{
|
isWin = true;
|
||||||
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"); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//timeout
|
// call onguess
|
||||||
await Task.Delay(_options.QuestionTimer * 1000 / 2, triviaCancelSource.Token);
|
await OnGuess(this, user, question, isWin);
|
||||||
}
|
break;
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(5000);
|
if (!guessed)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
{
|
||||||
await Channel.SendConfirmAsync(_eb, GetText(strs.trivia_game), GetText(strs.trivia_stopping));
|
await OnTimeout(this, question);
|
||||||
}
|
|
||||||
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;
|
|
||||||
|
|
||||||
triviaCancelSource.Cancel();
|
if (_opts.Timeout != 0 && ++inactivity >= _opts.Timeout)
|
||||||
|
|
||||||
if (_options.WinRequirement != 0 && Users[guildUser] == _options.WinRequirement)
|
|
||||||
{
|
{
|
||||||
ShouldStopGame = true;
|
Log.Information("Trivia game is stopping due to inactivity");
|
||||||
try
|
break;
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
var isStopped = _isStopped;
|
||||||
return GetText(strs.no_results);
|
_isStopped = true;
|
||||||
|
return !isStopped;
|
||||||
|
}
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
public async ValueTask TriggerStatsAsync()
|
||||||
|
{
|
||||||
|
await OnStats(this);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var kvp in Users.OrderByDescending(kvp => kvp.Value))
|
public async Task TriggerQuestionAsync()
|
||||||
sb.AppendLine(GetText(strs.trivia_points(Format.Bold(kvp.Key.ToString()), kvp.Value)));
|
{
|
||||||
|
if(CurrentQuestion is TriviaQuestion q)
|
||||||
return sb.ToString();
|
await OnQuestion(this, q);
|
||||||
}
|
}
|
||||||
}
|
}
|
37
src/NadekoBot/Modules/Games/Trivia/TriviaGamesService.cs
Normal file
37
src/NadekoBot/Modules/Games/Trivia/TriviaGamesService.cs
Normal file
@@ -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<ulong, TriviaGame> RunningTrivias { get; } = new();
|
||||||
|
|
||||||
|
public TriviaGamesService(DiscordSocketClient client)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task OnReadyAsync()
|
||||||
|
{
|
||||||
|
_client.MessageReceived += OnMessageReceived;
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnMessageReceived(SocketMessage msg)
|
||||||
|
{
|
||||||
|
if (msg.Author.IsBot)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var umsg = msg as SocketUserMessage;
|
||||||
|
|
||||||
|
if (umsg?.Channel is not IGuildChannel gc)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (RunningTrivias.TryGetValue(gc.GuildId, out var tg))
|
||||||
|
await tg.InputAsync(new(umsg.Author.Mention, umsg.Author.Id), umsg.Content);
|
||||||
|
}
|
||||||
|
}
|
@@ -4,15 +4,6 @@ using System.Text.RegularExpressions;
|
|||||||
// THANKS @ShoMinamimoto for suggestions and coding help
|
// THANKS @ShoMinamimoto for suggestions and coding help
|
||||||
namespace NadekoBot.Modules.Games.Common.Trivia;
|
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 class TriviaQuestion
|
||||||
{
|
{
|
||||||
public const int MAX_STRING_LENGTH = 22;
|
public const int MAX_STRING_LENGTH = 22;
|
||||||
|
11
src/NadekoBot/Modules/Games/Trivia/TriviaQuestionModel.cs
Normal file
11
src/NadekoBot/Modules/Games/Trivia/TriviaQuestionModel.cs
Normal file
@@ -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; }
|
||||||
|
}
|
@@ -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<TriviaQuestion?> GetRandomQuestionAsync(HashSet<TriviaQuestion> 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;
|
|
||||||
}
|
|
||||||
}
|
|
3
src/NadekoBot/Modules/Games/Trivia/TriviaUser.cs
Normal file
3
src/NadekoBot/Modules/Games/Trivia/TriviaUser.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace NadekoBot.Modules.Games.Common.Trivia;
|
||||||
|
|
||||||
|
public record class TriviaUser(string Name, ulong Id);
|
@@ -2,10 +2,8 @@ using Humanizer.Localisation;
|
|||||||
using Nadeko.Medusa;
|
using Nadeko.Medusa;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Nadeko.Common;
|
|
||||||
|
|
||||||
namespace NadekoBot.Extensions;
|
namespace NadekoBot.Extensions;
|
||||||
|
|
||||||
|
@@ -343,6 +343,7 @@
|
|||||||
"trivia_times_up": "Time's up! The correct answer was {0}",
|
"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_win": "{0} guessed it and WON the game! The answer was: {1}",
|
||||||
"trivia_quit": "You can stop trivia by typing {0}",
|
"trivia_quit": "You can stop trivia by typing {0}",
|
||||||
|
"trivia_ended": "Trivia game ended",
|
||||||
"ttt_against_yourself": "You can't play against yourself.",
|
"ttt_against_yourself": "You can't play against yourself.",
|
||||||
"ttt_already_running": "TicTacToe Game is already running in this channel.",
|
"ttt_already_running": "TicTacToe Game is already running in this channel.",
|
||||||
"ttt_a_draw": "A draw!",
|
"ttt_a_draw": "A draw!",
|
||||||
|
Reference in New Issue
Block a user