Added Games module

This commit is contained in:
Toastie 2024-09-21 14:41:22 +12:00
parent 3c1b994ab5
commit c4ba5e5593
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
43 changed files with 3537 additions and 0 deletions

View file

@ -0,0 +1,200 @@
#nullable disable
using CommandLine;
using System.Collections.Immutable;
namespace EllieBot.Modules.Games.Common.Acrophobia;
public sealed class AcrophobiaGame : IDisposable
{
public enum Phase
{
Submission,
Voting,
Ended
}
public enum UserInputResult
{
Submitted,
SubmissionFailed,
Voted,
VotingFailed,
Failed
}
public event Func<AcrophobiaGame, Task> OnStarted = delegate { return Task.CompletedTask; };
public event Func<AcrophobiaGame, ImmutableArray<KeyValuePair<AcrophobiaUser, int>>, Task> OnVotingStarted =
delegate { return Task.CompletedTask; };
public event Func<string, Task> OnUserVoted = delegate { return Task.CompletedTask; };
public event Func<AcrophobiaGame, ImmutableArray<KeyValuePair<AcrophobiaUser, int>>, Task> OnEnded = delegate
{
return Task.CompletedTask;
};
public Phase CurrentPhase { get; private set; } = Phase.Submission;
public ImmutableArray<char> StartingLetters { get; private set; }
public Options Opts { get; }
private readonly Dictionary<AcrophobiaUser, int> _submissions = new();
private readonly SemaphoreSlim _locker = new(1, 1);
private readonly EllieRandom _rng;
private readonly HashSet<ulong> _usersWhoVoted = [];
public AcrophobiaGame(Options options)
{
Opts = options;
_rng = new();
InitializeStartingLetters();
}
public async Task Run()
{
await OnStarted(this);
await Task.Delay(Opts.SubmissionTime * 1000);
await _locker.WaitAsync();
try
{
if (_submissions.Count == 0)
{
CurrentPhase = Phase.Ended;
await OnVotingStarted(this, ImmutableArray.Create<KeyValuePair<AcrophobiaUser, int>>());
return;
}
if (_submissions.Count == 1)
{
CurrentPhase = Phase.Ended;
await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray());
return;
}
CurrentPhase = Phase.Voting;
await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray());
}
finally { _locker.Release(); }
await Task.Delay(Opts.VoteTime * 1000);
await _locker.WaitAsync();
try
{
CurrentPhase = Phase.Ended;
await OnEnded(this, _submissions.ToArray().ToImmutableArray());
}
finally { _locker.Release(); }
}
private void InitializeStartingLetters()
{
var wordCount = _rng.Next(3, 6);
var lettersArr = new char[wordCount];
for (var i = 0; i < wordCount; i++)
{
var randChar = (char)_rng.Next(65, 91);
lettersArr[i] = randChar == 'X' ? (char)_rng.Next(65, 88) : randChar;
}
StartingLetters = lettersArr.ToImmutableArray();
}
public async Task<bool> UserInput(ulong userId, string userName, string input)
{
var user = new AcrophobiaUser(userId, userName, input.ToLowerInvariant().ToTitleCase());
await _locker.WaitAsync();
try
{
switch (CurrentPhase)
{
case Phase.Submission:
if (_submissions.ContainsKey(user) || !IsValidAnswer(input))
break;
_submissions.Add(user, 0);
return true;
case Phase.Voting:
AcrophobiaUser toVoteFor;
if (!int.TryParse(input, out var index)
|| --index < 0
|| index >= _submissions.Count
|| (toVoteFor = _submissions.ToArray()[index].Key).UserId == user.UserId
|| !_usersWhoVoted.Add(userId))
break;
++_submissions[toVoteFor];
_ = Task.Run(() => OnUserVoted(userName));
return true;
}
return false;
}
finally
{
_locker.Release();
}
}
private bool IsValidAnswer(string input)
{
input = input.ToUpperInvariant();
var inputWords = input.Split(' ');
if (inputWords.Length
!= StartingLetters.Length) // number of words must be the same as the number of the starting letters
return false;
for (var i = 0; i < StartingLetters.Length; i++)
{
var letter = StartingLetters[i];
if (!inputWords[i]
.StartsWith(letter.ToString(), StringComparison.InvariantCulture)) // all first letters must match
return false;
}
return true;
}
public void Dispose()
{
CurrentPhase = Phase.Ended;
OnStarted = null;
OnEnded = null;
OnUserVoted = null;
OnVotingStarted = null;
_usersWhoVoted.Clear();
_submissions.Clear();
_locker.Dispose();
}
public class Options : IEllieCommandOptions
{
[Option('s',
"submission-time",
Required = false,
Default = 60,
HelpText = "Time after which the submissions are closed and voting starts.")]
public int SubmissionTime { get; set; } = 60;
[Option('v',
"vote-time",
Required = false,
Default = 60,
HelpText = "Time after which the voting is closed and the winner is declared.")]
public int VoteTime { get; set; } = 30;
public void NormalizeOptions()
{
if (SubmissionTime is < 15 or > 300)
SubmissionTime = 60;
if (VoteTime is < 15 or > 120)
VoteTime = 30;
}
}
}

View file

@ -0,0 +1,22 @@
#nullable disable
namespace EllieBot.Modules.Games.Common.Acrophobia;
public class AcrophobiaUser
{
public string UserName { get; }
public ulong UserId { get; }
public string Input { get; }
public AcrophobiaUser(ulong userId, string userName, string input)
{
UserName = userName;
UserId = userId;
Input = input;
}
public override int GetHashCode()
=> UserId.GetHashCode();
public override bool Equals(object obj)
=> obj is AcrophobiaUser x ? x.UserId == UserId : false;
}

View file

@ -0,0 +1,140 @@
#nullable disable
using EllieBot.Modules.Games.Common.Acrophobia;
using EllieBot.Modules.Games.Services;
using System.Collections.Immutable;
namespace EllieBot.Modules.Games;
public partial class Games
{
[Group]
public partial class AcropobiaCommands : EllieModule<GamesService>
{
private readonly DiscordSocketClient _client;
public AcropobiaCommands(DiscordSocketClient client)
=> _client = client;
[Cmd]
[RequireContext(ContextType.Guild)]
[EllieOptions<AcrophobiaGame.Options>]
public async Task Acrophobia(params string[] args)
{
var (options, _) = OptionsParser.ParseFrom(new AcrophobiaGame.Options(), args);
var channel = (ITextChannel)ctx.Channel;
var game = new AcrophobiaGame(options);
if (_service.AcrophobiaGames.TryAdd(channel.Id, game))
{
try
{
game.OnStarted += Game_OnStarted;
game.OnEnded += Game_OnEnded;
game.OnVotingStarted += Game_OnVotingStarted;
game.OnUserVoted += Game_OnUserVoted;
_client.MessageReceived += ClientMessageReceived;
await game.Run();
}
finally
{
_client.MessageReceived -= ClientMessageReceived;
_service.AcrophobiaGames.TryRemove(channel.Id, out game);
game?.Dispose();
}
}
else
await Response().Error(strs.acro_running).SendAsync();
Task ClientMessageReceived(SocketMessage msg)
{
if (msg.Channel.Id != ctx.Channel.Id)
return Task.CompletedTask;
_ = Task.Run(async () =>
{
try
{
var success = await game.UserInput(msg.Author.Id, msg.Author.ToString(), msg.Content);
if (success)
await msg.DeleteAsync();
}
catch { }
});
return Task.CompletedTask;
}
}
private Task Game_OnStarted(AcrophobiaGame game)
{
var embed = _sender.CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.acrophobia))
.WithDescription(
GetText(strs.acro_started(Format.Bold(string.Join(".", game.StartingLetters)))))
.WithFooter(GetText(strs.acro_started_footer(game.Opts.SubmissionTime)));
return Response().Embed(embed).SendAsync();
}
private Task Game_OnUserVoted(string user)
=> Response().Confirm(GetText(strs.acrophobia), GetText(strs.acro_vote_cast(Format.Bold(user)))).SendAsync();
private async Task Game_OnVotingStarted(
AcrophobiaGame game,
ImmutableArray<KeyValuePair<AcrophobiaUser, int>> submissions)
{
if (submissions.Length == 0)
{
await Response().Error(GetText(strs.acrophobia), GetText(strs.acro_ended_no_sub)).SendAsync();
return;
}
if (submissions.Length == 1)
{
await Response().Embed(_sender.CreateEmbed()
.WithOkColor()
.WithDescription(GetText(
strs.acro_winner_only(
Format.Bold(submissions.First().Key.UserName))))
.WithFooter(submissions.First().Key.Input)).SendAsync();
return;
}
var i = 0;
var embed = _sender.CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.acrophobia) + " - " + GetText(strs.submissions_closed))
.WithDescription(GetText(strs.acro_nym_was(
Format.Bold(string.Join(".", game.StartingLetters))
+ "\n"
+ $@"--
{submissions.Aggregate("", (agg, cur) => agg + $"`{++i}.` **{cur.Key.Input}**\n")}
--")))
.WithFooter(GetText(strs.acro_vote));
await Response().Embed(embed).SendAsync();
}
private async Task Game_OnEnded(AcrophobiaGame game, ImmutableArray<KeyValuePair<AcrophobiaUser, int>> votes)
{
if (!votes.Any() || votes.All(x => x.Value == 0))
{
await Response().Error(GetText(strs.acrophobia), GetText(strs.acro_no_votes_cast)).SendAsync();
return;
}
var table = votes.OrderByDescending(v => v.Value);
var winner = table.First();
var embed = _sender.CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.acrophobia))
.WithDescription(GetText(strs.acro_winner(Format.Bold(winner.Key.UserName),
Format.Bold(winner.Value.ToString()))))
.WithFooter(winner.Key.Input);
await Response().Embed(embed).SendAsync();
}
}
}

View file

@ -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<ChatterBotService>
{
private readonly DbService _db;
public ChatterBotCommands(DbService db)
=> _db = db;
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageMessages)]
public async Task CleverBot()
{
var channel = (ITextChannel)ctx.Channel;
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();
}
}
}

View file

@ -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<ulong, Lazy<IChatterBotSession>> 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<IChatterBotSession>(() => CreateSession(), true)));
}
public IChatterBotSession CreateSession()
{
switch (_gcs.Data.ChatBot)
{
case ChatBotImplementation.Cleverbot:
if (!string.IsNullOrWhiteSpace(_creds.CleverbotApiKey))
return new OfficialCleverbotSession(_creds.CleverbotApiKey, _httpFactory);
Log.Information("Cleverbot will not work as the api key is missing");
return null;
case ChatBotImplementation.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<bool> 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<bool> 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<bool> ToggleChatterBotAsync(ulong guildId)
{
if (ChatterBotGuilds.TryRemove(guildId, out _))
{
await using var uow = _db.GetDbContext();
await uow.Set<GuildConfig>()
.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<GuildConfig>()
.ToLinqToDBTable()
.Where(x => x.GuildId == guildId)
.UpdateAsync((gc) => new GuildConfig()
{
CleverbotEnabled = true
});
await uow.SaveChangesAsync();
}
return true;
}
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -0,0 +1,10 @@
#nullable disable
using OneOf;
using OneOf.Types;
namespace EllieBot.Modules.Games.Common.ChatterBot;
public interface IChatterBotSession
{
Task<OneOf<ThinkResult, Error<string>>> Think(string input, string username);
}

View file

@ -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; }
}

View file

@ -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<OneOf<ThinkResult, Error<string>>> Think(string input, string username)
{
using var http = _httpFactory.CreateClient();
var dataString = await http.GetStringAsync(string.Format(QueryString, input, cs ?? ""));
try
{
var data = JsonConvert.DeserializeObject<CleverbotResponse>(dataString);
cs = data?.Cs;
return new ThinkResult
{
Text = data?.Output,
TokensIn = 2,
TokensOut = 1
};
}
catch
{
Log.Warning("Unexpected response from CleverBot: {ResponseString}", dataString);
return new Error<string>("Unexpected CleverBot response received");
}
}
}

View file

@ -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; }
}

View file

@ -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<OpenAiApiMessage> Messages { get; init; }
[JsonPropertyName("temperature")]
public required int Temperature { get; init; }
[JsonPropertyName("max_tokens")]
public required int MaxTokens { get; init; }
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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<OpenAiApiMessage> 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<OneOf.OneOf<ThinkResult, Error<string>>> 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<string>(
"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<OpenAiCompletionResponse>(dataString);
// Log.Information("Received response: {Response} ", dataString);
var res = response?.Choices?[0];
var message = res?.Message?.Content;
if (message is null)
{
return new Error<string>("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<string>("Unexpected response received");
}
}
}

View file

@ -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; }
}

View file

@ -0,0 +1,47 @@
#nullable disable
using EllieBot.Modules.Games.Services;
namespace EllieBot.Modules.Games;
/* more games
- Shiritori
- Simple RPG adventure
*/
public partial class Games : EllieModule<GamesService>
{
private readonly IImageCache _images;
private readonly IHttpClientFactory _httpFactory;
private readonly Random _rng = new();
public Games(IImageCache images, IHttpClientFactory factory)
{
_images = images;
_httpFactory = factory;
}
[Cmd]
public async Task Choose([Leftover] string list = null)
{
if (string.IsNullOrWhiteSpace(list))
return;
var listArr = list.Split(';');
if (listArr.Length < 2)
return;
var rng = new EllieRandom();
await Response().Confirm("🤔", listArr[rng.Next(0, listArr.Length)]).SendAsync();
}
[Cmd]
public async Task EightBall([Leftover] string question = null)
{
if (string.IsNullOrWhiteSpace(question))
return;
var res = _service.GetEightballResponse(ctx.User.Id, question);
await Response().Embed(_sender.CreateEmbed()
.WithOkColor()
.WithDescription(ctx.User.ToString())
.AddField("❓ " + GetText(strs.question), question)
.AddField("🎱 " + GetText(strs._8ball), res)).SendAsync();
}
}

View file

@ -0,0 +1,195 @@
#nullable disable
using Cloneable;
using EllieBot.Common.Yml;
namespace EllieBot.Modules.Games.Common;
[Cloneable]
public sealed partial class GamesConfig : ICloneable<GamesConfig>
{
[Comment("DO NOT CHANGE")]
public int Version { get; set; } = 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<string> EightBallResponses { get; set; } =
[
"Most definitely yes.",
"For sure.",
"Totally!",
"Of course!",
"As I see it, yes.",
"My sources say yes.",
"Yes.",
"Most likely.",
"Perhaps...",
"Maybe...",
"Hm, not sure.",
"It is uncertain.",
"Ask me again later.",
"Don't count on it.",
"Probably not.",
"Very doubtful.",
"Most likely no.",
"Nope.",
"No.",
"My sources say no.",
"Don't even think about it.",
"Definitely no.",
"NO - It may cause disease contraction!"
];
[Comment("List of animals which will be used for the animal race game (.race)")]
public List<RaceAnimal> RaceAnimals { get; set; } =
[
new()
{
Icon = "🐼",
Name = "Panda"
},
new()
{
Icon = "🐻",
Name = "Bear"
},
new()
{
Icon = "🐧",
Name = "Pengu"
},
new()
{
Icon = "🐨",
Name = "Koala"
},
new()
{
Icon = "🐬",
Name = "Dolphin"
},
new()
{
Icon = "🐞",
Name = "Ladybird"
},
new()
{
Icon = "🦀",
Name = "Crab"
},
new()
{
Icon = "🦄",
Name = "Unicorn"
}
];
[Comment(
"""
Which chatbot API should bot use.
'cleverbot' - bot will use Cleverbot API.
'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,
}

View file

@ -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<GamesConfig>
{
private const string FILE_PATH = "data/games.yml";
private static readonly TypedKey<GamesConfig> _changeKey = new("config.games.updated");
public override string Name { get; } = "games";
public GamesConfigService(IConfigSeria serializer, IPubSub pubSub)
: base(FILE_PATH, serializer, pubSub, _changeKey)
{
AddParsedProp("trivia.min_win_req",
gs => gs.Trivia.MinimumWinReq,
int.TryParse,
ConfigPrinters.ToString,
val => val > 0);
AddParsedProp("trivia.currency_reward",
gs => gs.Trivia.CurrencyReward,
long.TryParse,
ConfigPrinters.ToString,
val => val >= 0);
AddParsedProp("hangman.currency_reward",
gs => gs.Hangman.CurrencyReward,
long.TryParse,
ConfigPrinters.ToString,
val => val >= 0);
AddParsedProp("chatbot",
gs => gs.ChatBot,
ConfigParsers.InsensitiveEnum,
ConfigPrinters.ToString);
AddParsedProp("gpt.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";
}
});
}
}
}

View file

@ -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<string> EightBallResponses
=> _gamesConfig.Data.EightBallResponses;
public List<TypingArticle> TypingArticles { get; } = new();
//channelId, game
public ConcurrentDictionary<ulong, AcrophobiaGame> AcrophobiaGames { get; } = new();
public Dictionary<ulong, TicTacToe> TicTacToeGames { get; } = new();
public ConcurrentDictionary<ulong, TypingGame> RunningContests { get; } = new();
public ConcurrentDictionary<ulong, NunchiGame> NunchiGames { get; } = new();
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<List<TypingArticle>>(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;
}
}

View file

@ -0,0 +1,64 @@
using EllieBot.Common.Yml;
using System.Diagnostics.CodeAnalysis;
namespace EllieBot.Modules.Games.Hangman;
public sealed class DefaultHangmanSource : IHangmanSource
{
private IReadOnlyDictionary<string, HangmanTerm[]> termsDict = new Dictionary<string, HangmanTerm[]>();
private readonly Random _rng;
public DefaultHangmanSource()
{
_rng = new EllieRandom();
Reload();
}
public void Reload()
{
if (!Directory.Exists("data/hangman"))
{
Log.Error("Hangman game won't work. Folder 'data/hangman' is missing");
return;
}
var qs = new Dictionary<string, HangmanTerm[]>();
foreach (var file in Directory.EnumerateFiles("data/hangman/", "*.yml"))
{
try
{
var data = Yaml.Deserializer.Deserialize<HangmanTerm[]>(File.ReadAllText(file));
qs[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = data;
}
catch (Exception ex)
{
Log.Error(ex, "Loading {HangmanFile} failed", file);
}
}
termsDict = qs;
Log.Information("Loaded {HangmanCategoryCount} hangman categories", qs.Count);
}
public IReadOnlyCollection<string> GetCategories()
=> termsDict.Keys.ToList();
public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term)
{
if (category is null)
{
var cats = GetCategories();
category = cats.ElementAt(_rng.Next(0, cats.Count));
}
if (termsDict.TryGetValue(category, out var terms))
{
term = terms[_rng.Next(0, terms.Length)];
return true;
}
term = null;
return false;
}
}

View file

@ -0,0 +1,76 @@
using EllieBot.Modules.Games.Hangman;
namespace EllieBot.Modules.Games;
public partial class Games
{
[Group]
public partial class HangmanCommands : EllieModule<IHangmanService>
{
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Hangmanlist()
=> await Response().Confirm(GetText(strs.hangman_types(prefix)), _service.GetHangmanTypes().Join('\n')).SendAsync();
private static string Draw(HangmanGame.State state)
=> $"""
.
................
................
.{(state.Errors > 0 ? ".............😲" : "")}
.{(state.Errors > 1 ? "............./" : "")} {(state.Errors > 2 ? "|" : "")} {(state.Errors > 3 ? "\\" : "")}
.{(state.Errors > 4 ? "............../" : "")} {(state.Errors > 5 ? "\\" : "")}
/-\
""";
public static EmbedBuilder GetEmbed(IMessageSenderService sender, HangmanGame.State state)
{
if (state.Phase == HangmanGame.Phase.Running)
{
return sender.CreateEmbed()
.WithOkColor()
.AddField("Hangman", Draw(state))
.AddField("Guess", Format.Code(state.Word))
.WithFooter(state.MissedLetters.Join(' '));
}
if (state.Phase == HangmanGame.Phase.Ended && state.Failed)
{
return sender.CreateEmbed()
.WithErrorColor()
.AddField("Hangman", Draw(state))
.AddField("Guess", Format.Code(state.Word))
.WithFooter(state.MissedLetters.Join(' '));
}
return sender.CreateEmbed()
.WithOkColor()
.AddField("Hangman", Draw(state))
.AddField("Guess", Format.Code(state.Word))
.WithFooter(state.MissedLetters.Join(' '));
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Hangman([Leftover] string? type = null)
{
if (!_service.StartHangman(ctx.Channel.Id, type, out var hangman))
{
await Response().Error(strs.hangman_running).SendAsync();
return;
}
var eb = GetEmbed(_sender, hangman);
eb.WithDescription(GetText(strs.hangman_game_started));
await Response().Embed(eb).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task HangmanStop()
{
if (await _service.StopHangman(ctx.Channel.Id))
await Response().Confirm(strs.hangman_stopped).SendAsync();
}
}
}

View file

@ -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<char> _incorrect = new();
private readonly HashSet<char> _correct = new();
private readonly HashSet<char> _remaining = new();
private readonly string _word;
private readonly string _imageUrl;
public HangmanGame(HangmanTerm term)
{
_word = term.Word;
_imageUrl = term.ImageUrl;
_remaining = _word.ToLowerInvariant().Where(x => char.IsLetter(x)).Select(char.ToLowerInvariant).ToHashSet();
}
public State GetState(GuessResult guessResult = GuessResult.NoAction)
=> new(_incorrect.Count,
CurrentPhase,
CurrentPhase == Phase.Ended ? _word : GetScrambledWord(),
guessResult,
_incorrect.ToList(),
CurrentPhase == Phase.Ended ? _imageUrl : string.Empty);
private string GetScrambledWord()
{
Span<char> output = stackalloc char[_word.Length * 2];
for (var i = 0; i < _word.Length; i++)
{
var ch = _word[i];
if (ch == ' ')
output[i * 2] = '';
if (!char.IsLetter(ch) || !_remaining.Contains(char.ToLowerInvariant(ch)))
output[i * 2] = ch;
else
output[i * 2] = '_';
output[(i * 2) + 1] = ' ';
}
return new(output);
}
public State Guess(string guess)
{
if (CurrentPhase != Phase.Running)
return GetState();
guess = guess.Trim();
if (guess.Length > 1)
{
if (guess.Equals(_word, StringComparison.InvariantCultureIgnoreCase))
{
CurrentPhase = Phase.Ended;
return GetState(GuessResult.Win);
}
return GetState();
}
var charGuess = guess[0];
if (!char.IsLetter(charGuess))
return GetState();
if (_incorrect.Contains(charGuess) || _correct.Contains(charGuess))
return GetState(GuessResult.AlreadyTried);
if (_remaining.Remove(charGuess))
{
if (_remaining.Count == 0)
{
CurrentPhase = Phase.Ended;
return GetState(GuessResult.Win);
}
_correct.Add(charGuess);
return GetState(GuessResult.Guess);
}
_incorrect.Add(charGuess);
if (_incorrect.Count > 5)
{
CurrentPhase = Phase.Ended;
return GetState(GuessResult.Incorrect);
}
return GetState(GuessResult.Incorrect);
}
public record State(
int Errors,
Phase Phase,
string Word,
GuessResult GuessResult,
List<char> MissedLetters,
string ImageUrl)
{
public bool Failed
=> Errors > 5;
}
}

View file

@ -0,0 +1,136 @@
using Microsoft.Extensions.Caching.Memory;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Modules.Games.Services;
using System.Diagnostics.CodeAnalysis;
namespace EllieBot.Modules.Games.Hangman;
public sealed class HangmanService : IHangmanService, IExecNoCommand
{
private readonly ConcurrentDictionary<ulong, HangmanGame> _hangmanGames = new();
private readonly IHangmanSource _source;
private readonly IMessageSenderService _sender;
private readonly GamesConfigService _gcs;
private readonly ICurrencyService _cs;
private readonly IMemoryCache _cdCache;
private readonly object _locker = new();
public HangmanService(
IHangmanSource source,
IMessageSenderService sender,
GamesConfigService gcs,
ICurrencyService cs,
IMemoryCache cdCache)
{
_source = source;
_sender = sender;
_gcs = gcs;
_cs = cs;
_cdCache = cdCache;
}
public bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? state)
{
state = null;
if (!_source.GetTerm(category, out var term))
return false;
var game = new HangmanGame(term);
lock (_locker)
{
var hc = _hangmanGames.GetOrAdd(channelId, game);
if (hc == game)
{
state = hc.GetState();
return true;
}
return false;
}
}
public ValueTask<bool> StopHangman(ulong channelId)
{
lock (_locker)
{
if (_hangmanGames.TryRemove(channelId, out _))
return new(true);
}
return new(false);
}
public IReadOnlyCollection<string> GetHangmanTypes()
=> _source.GetCategories();
public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
{
if (_hangmanGames.ContainsKey(msg.Channel.Id))
{
if (string.IsNullOrWhiteSpace(msg.Content))
return;
if (_cdCache.TryGetValue(msg.Author.Id, out _))
return;
HangmanGame.State state;
long rew = 0;
lock (_locker)
{
if (!_hangmanGames.TryGetValue(msg.Channel.Id, out var game))
return;
state = game.Guess(msg.Content.ToLowerInvariant());
if (state.GuessResult == HangmanGame.GuessResult.NoAction)
return;
if (state.GuessResult is HangmanGame.GuessResult.Incorrect or HangmanGame.GuessResult.AlreadyTried)
{
_cdCache.Set(msg.Author.Id,
string.Empty,
new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3)
});
}
if (state.Phase == HangmanGame.Phase.Ended)
{
if (_hangmanGames.TryRemove(msg.Channel.Id, out _))
rew = _gcs.Data.Hangman.CurrencyReward;
}
}
if (rew > 0)
await _cs.AddAsync(msg.Author, rew, new("hangman", "win"));
await SendState((ITextChannel)msg.Channel, msg.Author, msg.Content, state);
}
}
private Task<IUserMessage> SendState(
ITextChannel channel,
IUser user,
string content,
HangmanGame.State state)
{
var embed = Games.HangmanCommands.GetEmbed(_sender, state);
if (state.GuessResult == HangmanGame.GuessResult.Guess)
embed.WithDescription($"{user} guessed the letter {content}!").WithOkColor();
else if (state.GuessResult == HangmanGame.GuessResult.Incorrect && state.Failed)
embed.WithDescription($"{user} Letter {content} doesn't exist! Game over!").WithErrorColor();
else if (state.GuessResult == HangmanGame.GuessResult.Incorrect)
embed.WithDescription($"{user} Letter {content} doesn't exist!").WithErrorColor();
else if (state.GuessResult == HangmanGame.GuessResult.AlreadyTried)
embed.WithDescription($"{user} Letter {content} has already been used.").WithPendingColor();
else if (state.GuessResult == HangmanGame.GuessResult.Win)
embed.WithDescription($"{user} won!").WithOkColor();
if (!string.IsNullOrWhiteSpace(state.ImageUrl) && Uri.IsWellFormedUriString(state.ImageUrl, UriKind.Absolute))
embed.WithImageUrl(state.ImageUrl);
return _sender.Response(channel).Embed(embed).SendAsync();
}
}

View file

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

View file

@ -0,0 +1,10 @@
using System.Diagnostics.CodeAnalysis;
namespace EllieBot.Modules.Games.Hangman;
public interface IHangmanService
{
bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? hangmanController);
ValueTask<bool> StopHangman(ulong channelId);
IReadOnlyCollection<string> GetHangmanTypes();
}

View file

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

View file

@ -0,0 +1,183 @@
#nullable disable
using System.Collections.Immutable;
namespace EllieBot.Modules.Games.Common.Nunchi;
public sealed class NunchiGame : IDisposable
{
public enum Phase
{
Joining,
Playing,
WaitingForNextRound,
Ended
}
private const int KILL_TIMEOUT = 20 * 1000;
private const int NEXT_ROUND_TIMEOUT = 5 * 1000;
public event Func<NunchiGame, Task> OnGameStarted;
public event Func<NunchiGame, int, Task> OnRoundStarted;
public event Func<NunchiGame, Task> OnUserGuessed;
public event Func<NunchiGame, (ulong Id, string Name)?, Task> OnRoundEnded; // tuple of the user who failed
public event Func<NunchiGame, string, Task> OnGameEnded; // name of the user who won
public int CurrentNumber { get; private set; } = new EllieRandom().Next(0, 100);
public Phase CurrentPhase { get; private set; } = Phase.Joining;
public ImmutableArray<(ulong Id, string Name)> Participants
=> participants.ToImmutableArray();
public int ParticipantCount
=> participants.Count;
private readonly SemaphoreSlim _locker = new(1, 1);
private HashSet<(ulong Id, string Name)> participants = [];
private readonly HashSet<(ulong Id, string Name)> _passed = [];
private Timer killTimer;
public NunchiGame(ulong creatorId, string creatorName)
=> participants.Add((creatorId, creatorName));
public async Task<bool> Join(ulong userId, string userName)
{
await _locker.WaitAsync();
try
{
if (CurrentPhase != Phase.Joining)
return false;
return participants.Add((userId, userName));
}
finally { _locker.Release(); }
}
public async Task<bool> Initialize()
{
CurrentPhase = Phase.Joining;
await Task.Delay(30000);
await _locker.WaitAsync();
try
{
if (participants.Count < 3)
{
CurrentPhase = Phase.Ended;
return false;
}
killTimer = new(async _ =>
{
await _locker.WaitAsync();
try
{
if (CurrentPhase != Phase.Playing)
return;
//if some players took too long to type a number, boot them all out and start a new round
participants = new HashSet<(ulong, string)>(_passed);
EndRound();
}
finally { _locker.Release(); }
},
null,
KILL_TIMEOUT,
KILL_TIMEOUT);
CurrentPhase = Phase.Playing;
_ = OnGameStarted?.Invoke(this);
_ = OnRoundStarted?.Invoke(this, CurrentNumber);
return true;
}
finally { _locker.Release(); }
}
public async Task Input(ulong userId, string userName, int input)
{
await _locker.WaitAsync();
try
{
if (CurrentPhase != Phase.Playing)
return;
var userTuple = (Id: userId, Name: userName);
// if the user is not a member of the race,
// or he already successfully typed the number
// ignore the input
if (!participants.Contains(userTuple) || !_passed.Add(userTuple))
return;
//if the number is correct
if (CurrentNumber == input - 1)
{
//increment current number
++CurrentNumber;
if (_passed.Count == participants.Count - 1)
{
// if only n players are left, and n - 1 type the correct number, round is over
// if only 2 players are left, game is over
if (participants.Count == 2)
{
killTimer.Change(Timeout.Infinite, Timeout.Infinite);
CurrentPhase = Phase.Ended;
_ = OnGameEnded?.Invoke(this, userTuple.Name);
}
else // else just start the new round without the user who was the last
{
var failure = participants.Except(_passed).First();
OnUserGuessed?.Invoke(this);
EndRound(failure);
return;
}
}
OnUserGuessed?.Invoke(this);
}
else
{
//if the user failed
EndRound(userTuple);
}
}
finally { _locker.Release(); }
}
private void EndRound((ulong, string)? failure = null)
{
killTimer.Change(KILL_TIMEOUT, KILL_TIMEOUT);
CurrentNumber = new EllieRandom().Next(0, 100); // reset the counter
_passed.Clear(); // reset all users who passed (new round starts)
if (failure is not null)
participants.Remove(failure.Value); // remove the dude who failed from the list of players
_ = OnRoundEnded?.Invoke(this, failure);
if (participants.Count <= 1) // means we have a winner or everyone was booted out
{
killTimer.Change(Timeout.Infinite, Timeout.Infinite);
CurrentPhase = Phase.Ended;
_ = OnGameEnded?.Invoke(this, participants.Count > 0 ? participants.First().Name : null);
return;
}
CurrentPhase = Phase.WaitingForNextRound;
Task.Run(async () =>
{
await Task.Delay(NEXT_ROUND_TIMEOUT);
CurrentPhase = Phase.Playing;
_ = OnRoundStarted?.Invoke(this, CurrentNumber);
});
}
public void Dispose()
{
OnGameEnded = null;
OnGameStarted = null;
OnRoundEnded = null;
OnRoundStarted = null;
OnUserGuessed = null;
}
}

View file

@ -0,0 +1,114 @@
#nullable disable
using EllieBot.Modules.Games.Common.Nunchi;
using EllieBot.Modules.Games.Services;
namespace EllieBot.Modules.Games;
public partial class Games
{
[Group]
public partial class NunchiCommands : EllieModule<GamesService>
{
private readonly DiscordSocketClient _client;
public NunchiCommands(DiscordSocketClient client)
=> _client = client;
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Nunchi()
{
var newNunchi = new NunchiGame(ctx.User.Id, ctx.User.ToString());
NunchiGame nunchi;
//if a game was already active
if ((nunchi = _service.NunchiGames.GetOrAdd(ctx.Guild.Id, newNunchi)) != newNunchi)
{
// join it
// if you failed joining, that means game is running or just ended
if (!await nunchi.Join(ctx.User.Id, ctx.User.ToString()))
return;
await Response().Error(strs.nunchi_joined(nunchi.ParticipantCount)).SendAsync();
return;
}
try { await Response().Confirm(strs.nunchi_created).SendAsync(); }
catch { }
nunchi.OnGameEnded += NunchiOnGameEnded;
//nunchi.OnGameStarted += Nunchi_OnGameStarted;
nunchi.OnRoundEnded += Nunchi_OnRoundEnded;
nunchi.OnUserGuessed += Nunchi_OnUserGuessed;
nunchi.OnRoundStarted += Nunchi_OnRoundStarted;
_client.MessageReceived += ClientMessageReceived;
var success = await nunchi.Initialize();
if (!success)
{
if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game))
game.Dispose();
await Response().Confirm(strs.nunchi_failed_to_start).SendAsync();
}
Task ClientMessageReceived(SocketMessage arg)
{
_ = Task.Run(async () =>
{
if (arg.Channel.Id != ctx.Channel.Id)
return;
if (!int.TryParse(arg.Content, out var number))
return;
try
{
await nunchi.Input(arg.Author.Id, arg.Author.ToString(), number);
}
catch
{
}
});
return Task.CompletedTask;
}
Task NunchiOnGameEnded(NunchiGame arg1, string arg2)
{
if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game))
{
_client.MessageReceived -= ClientMessageReceived;
game.Dispose();
}
if (arg2 is null)
return Response().Confirm(strs.nunchi_ended_no_winner).SendAsync();
return Response().Confirm(strs.nunchi_ended(Format.Bold(arg2))).SendAsync();
}
}
private Task Nunchi_OnRoundStarted(NunchiGame arg, int cur)
=> Response()
.Confirm(strs.nunchi_round_started(Format.Bold(arg.ParticipantCount.ToString()),
Format.Bold(cur.ToString())))
.SendAsync();
private Task Nunchi_OnUserGuessed(NunchiGame arg)
=> Response().Confirm(strs.nunchi_next_number(Format.Bold(arg.CurrentNumber.ToString()))).SendAsync();
private Task Nunchi_OnRoundEnded(NunchiGame arg1, (ulong Id, string Name)? arg2)
{
if (arg2.HasValue)
return Response().Confirm(strs.nunchi_round_ended(Format.Bold(arg2.Value.Name))).SendAsync();
return Response()
.Confirm(strs.nunchi_round_ended_boot(
Format.Bold("\n"
+ string.Join("\n, ",
arg1.Participants.Select(x
=> x.Name)))))
.SendAsync(); // this won't work if there are too many users
}
private Task Nunchi_OnGameStarted(NunchiGame arg)
=> Response().Confirm(strs.nunchi_started(Format.Bold(arg.ParticipantCount.ToString()))).SendAsync();
}
}

View file

@ -0,0 +1,105 @@
#nullable disable
using EllieBot.Modules.Games.Common;
using EllieBot.Modules.Games.Services;
namespace EllieBot.Modules.Games;
public partial class Games
{
[Group]
public partial class SpeedTypingCommands : EllieModule<GamesService>
{
private readonly GamesService _games;
private readonly DiscordSocketClient _client;
public SpeedTypingCommands(DiscordSocketClient client, GamesService games)
{
_games = games;
_client = client;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[EllieOptions<TypingGame.Options>]
public async Task TypeStart(params string[] args)
{
var (options, _) = OptionsParser.ParseFrom(new TypingGame.Options(), args);
var channel = (ITextChannel)ctx.Channel;
var game = _service.RunningContests.GetOrAdd(ctx.Guild.Id,
_ => new(_games, _client, channel, prefix, options, _sender));
if (game.IsActive)
await Response().Error($"Contest already running in {game.Channel.Mention} channel.").SendAsync();
else
await game.Start();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task TypeStop()
{
if (_service.RunningContests.TryRemove(ctx.Guild.Id, out var game))
{
await game.Stop();
return;
}
await Response().Error("No contest to stop on this channel.").SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
public async Task Typeadd([Leftover] string text)
{
if (string.IsNullOrWhiteSpace(text))
return;
_games.AddTypingArticle(ctx.User, text);
await Response().Confirm("Added new article for typing game.").SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Typelist(int page = 1)
{
if (page < 1)
return;
var articles = _games.TypingArticles.Skip((page - 1) * 15).Take(15).ToArray();
if (!articles.Any())
{
await Response().Error($"{ctx.User.Mention} `No articles found on that page.`").SendAsync();
return;
}
var i = (page - 1) * 15;
await Response()
.Confirm("List of articles for Type Race",
string.Join("\n", articles.Select(a => $"`#{++i}` - {a.Text.TrimTo(50)}")))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
public async Task Typedel(int index)
{
var removed = _service.RemoveTypingArticle(--index);
if (removed is null)
return;
var embed = _sender.CreateEmbed()
.WithTitle($"Removed typing article #{index + 1}")
.WithDescription(removed.Text.TrimTo(50))
.WithOkColor();
await Response().Embed(embed).SendAsync();
}
}
}

View file

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

View file

@ -0,0 +1,197 @@
#nullable disable
using CommandLine;
using EllieBot.Modules.Games.Services;
using System.Diagnostics;
namespace EllieBot.Modules.Games.Common;
public class TypingGame
{
public const float WORD_VALUE = 4.5f;
public ITextChannel Channel { get; }
public string CurrentSentence { get; private set; }
public bool IsActive { get; private set; }
private readonly Stopwatch _sw;
private readonly List<ulong> _finishedUserIds;
private readonly DiscordSocketClient _client;
private readonly GamesService _games;
private readonly string _prefix;
private readonly Options _options;
private readonly IMessageSenderService _sender;
public TypingGame(
GamesService games,
DiscordSocketClient client,
ITextChannel channel,
string prefix,
Options options,
IMessageSenderService sender)
{
_games = games;
_client = client;
_prefix = prefix;
_options = options;
_sender = sender;
Channel = channel;
IsActive = false;
_sw = new();
_finishedUserIds = new();
}
public async Task<bool> Stop()
{
if (!IsActive)
return false;
_client.MessageReceived -= AnswerReceived;
_finishedUserIds.Clear();
IsActive = false;
_sw.Stop();
_sw.Reset();
try
{
await _sender.Response(Channel)
.Confirm("Typing contest stopped.")
.SendAsync();
}
catch
{
}
return true;
}
public async Task Start()
{
if (IsActive)
return; // can't start running game
IsActive = true;
CurrentSentence = GetRandomSentence();
var i = (int)(CurrentSentence.Length / WORD_VALUE * 1.7f);
try
{
await _sender.Response(Channel)
.Confirm(
$":clock2: Next contest will last for {i} seconds. Type the bolded text as fast as you can.")
.SendAsync();
var time = _options.StartTime;
var msg = await _sender.Response(Channel).Confirm($"Starting new typing contest in **{time}**...").SendAsync();
do
{
await Task.Delay(2000);
time -= 2;
try { await msg.ModifyAsync(m => m.Content = $"Starting new typing contest in **{time}**.."); }
catch { }
} while (time > 2);
await msg.ModifyAsync(m =>
{
m.Content = CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture);
});
_sw.Start();
HandleAnswers();
while (i > 0)
{
await Task.Delay(1000);
i--;
if (!IsActive)
return;
}
}
catch { }
finally
{
await Stop();
}
}
public string GetRandomSentence()
{
if (_games.TypingArticles.Any())
return _games.TypingArticles[new EllieRandom().Next(0, _games.TypingArticles.Count)].Text;
return $"No typing articles found. Use {_prefix}typeadd command to add a new article for typing.";
}
private void HandleAnswers()
=> _client.MessageReceived += AnswerReceived;
private Task AnswerReceived(SocketMessage imsg)
{
_ = Task.Run(async () =>
{
try
{
if (imsg.Author.IsBot)
return;
if (imsg is not SocketUserMessage msg)
return;
if (Channel is null || Channel.Id != msg.Channel.Id)
return;
var guess = msg.Content;
var distance = CurrentSentence.LevenshteinDistance(guess);
var decision = Judge(distance, guess.Length);
if (decision && !_finishedUserIds.Contains(msg.Author.Id))
{
var elapsed = _sw.Elapsed;
var wpm = CurrentSentence.Length / WORD_VALUE / elapsed.TotalSeconds * 60;
_finishedUserIds.Add(msg.Author.Id);
var embed = _sender.CreateEmbed()
.WithOkColor()
.WithTitle($"{msg.Author} finished the race!")
.AddField("Place", $"#{_finishedUserIds.Count}", true)
.AddField("WPM", $"{wpm:F1} *[{elapsed.TotalSeconds:F2}sec]*", true)
.AddField("Errors", distance.ToString(), true);
await _sender.Response(Channel)
.Embed(embed)
.SendAsync();
if (_finishedUserIds.Count % 4 == 0)
{
await _sender.Response(Channel)
.Confirm(
$"""
:exclamation: A lot of people finished, here is the text for those still typing:
**{Format.Sanitize(CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture)).SanitizeMentions(true)}**
""")
.SendAsync();
}
}
}
catch (Exception ex)
{
Log.Warning(ex, "Error receiving typing game answer: {ErrorMessage}", ex.Message);
}
});
return Task.CompletedTask;
}
private static bool Judge(int errors, int textLength)
=> errors <= textLength / 25;
public class Options : IEllieCommandOptions
{
[Option('s',
"start-time",
Default = 5,
Required = false,
HelpText = "How long does it take for the race to start. Default 5.")]
public int StartTime { get; set; } = 5;
public void NormalizeOptions()
{
if (StartTime is < 3 or > 30)
StartTime = 5;
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,282 @@
using System.Net;
using System.Text;
using EllieBot.Modules.Games.Common.Trivia;
using EllieBot.Modules.Games.Services;
namespace EllieBot.Modules.Games;
public partial class Games
{
[Group]
public partial class TriviaCommands : EllieModule<TriviaGamesService>
{
private readonly ILocalDataCache _cache;
private readonly ICurrencyService _cs;
private readonly GamesConfigService _gamesConfig;
private readonly DiscordSocketClient _client;
public TriviaCommands(
DiscordSocketClient client,
ILocalDataCache cache,
ICurrencyService cs,
GamesConfigService gamesConfig)
{
_cache = cache;
_cs = cs;
_gamesConfig = gamesConfig;
_client = client;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(0)]
[EllieOptions<TriviaOptions>]
public async Task Trivia(params string[] args)
{
var (opts, _) = OptionsParser.ParseFrom(new TriviaOptions(), args);
var config = _gamesConfig.Data;
if (opts.WinRequirement != 0
&& config.Trivia.MinimumWinReq > 0
&& config.Trivia.MinimumWinReq > opts.WinRequirement)
return;
var trivia = new TriviaGame(opts, _cache);
if (_service.RunningTrivias.TryAdd(ctx.Guild.Id, trivia))
{
RegisterEvents(trivia);
await trivia.RunAsync();
return;
}
if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var tg))
{
await Response().Error(strs.trivia_already_running).SendAsync();
await tg.TriggerQuestionAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Tl()
{
if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var trivia))
{
await trivia.TriggerStatsAsync();
return;
}
await Response().Error(strs.trivia_none).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Tq()
{
var channel = (ITextChannel)ctx.Channel;
if (_service.RunningTrivias.TryGetValue(channel.Guild.Id, out var trivia))
{
if (trivia.Stop())
{
try
{
await Response()
.Confirm(GetText(strs.trivia_game), GetText(strs.trivia_stopping))
.SendAsync();
}
catch (Exception ex)
{
Log.Warning(ex, "Error sending trivia stopping message");
}
}
return;
}
await Response().Error(strs.trivia_none).SendAsync();
}
private string GetLeaderboardString(TriviaGame tg)
{
var sb = new StringBuilder();
foreach (var (id, pts) in tg.GetLeaderboard())
sb.AppendLine(GetText(strs.trivia_points(Format.Bold($"<@{id}>"), pts)));
return sb.ToString();
}
private EmbedBuilder? questionEmbed;
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
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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