Added Games module
This commit is contained in:
parent
3c1b994ab5
commit
c4ba5e5593
43 changed files with 3537 additions and 0 deletions
200
src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs
Normal file
200
src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs
Normal file
|
@ -0,0 +1,200 @@
|
|||
#nullable disable
|
||||
using CommandLine;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace EllieBot.Modules.Games.Common.Acrophobia;
|
||||
|
||||
public sealed class AcrophobiaGame : IDisposable
|
||||
{
|
||||
public enum Phase
|
||||
{
|
||||
Submission,
|
||||
Voting,
|
||||
Ended
|
||||
}
|
||||
|
||||
public enum UserInputResult
|
||||
{
|
||||
Submitted,
|
||||
SubmissionFailed,
|
||||
Voted,
|
||||
VotingFailed,
|
||||
Failed
|
||||
}
|
||||
|
||||
public event Func<AcrophobiaGame, Task> OnStarted = delegate { return Task.CompletedTask; };
|
||||
|
||||
public event Func<AcrophobiaGame, ImmutableArray<KeyValuePair<AcrophobiaUser, int>>, Task> OnVotingStarted =
|
||||
delegate { return Task.CompletedTask; };
|
||||
|
||||
public event Func<string, Task> OnUserVoted = delegate { return Task.CompletedTask; };
|
||||
|
||||
public event Func<AcrophobiaGame, ImmutableArray<KeyValuePair<AcrophobiaUser, int>>, Task> OnEnded = delegate
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
public Phase CurrentPhase { get; private set; } = Phase.Submission;
|
||||
public ImmutableArray<char> StartingLetters { get; private set; }
|
||||
public Options Opts { get; }
|
||||
|
||||
private readonly Dictionary<AcrophobiaUser, int> _submissions = new();
|
||||
private readonly SemaphoreSlim _locker = new(1, 1);
|
||||
private readonly EllieRandom _rng;
|
||||
|
||||
private readonly HashSet<ulong> _usersWhoVoted = [];
|
||||
|
||||
public AcrophobiaGame(Options options)
|
||||
{
|
||||
Opts = options;
|
||||
_rng = new();
|
||||
InitializeStartingLetters();
|
||||
}
|
||||
|
||||
public async Task Run()
|
||||
{
|
||||
await OnStarted(this);
|
||||
await Task.Delay(Opts.SubmissionTime * 1000);
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_submissions.Count == 0)
|
||||
{
|
||||
CurrentPhase = Phase.Ended;
|
||||
await OnVotingStarted(this, ImmutableArray.Create<KeyValuePair<AcrophobiaUser, int>>());
|
||||
return;
|
||||
}
|
||||
|
||||
if (_submissions.Count == 1)
|
||||
{
|
||||
CurrentPhase = Phase.Ended;
|
||||
await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray());
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentPhase = Phase.Voting;
|
||||
|
||||
await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray());
|
||||
}
|
||||
finally { _locker.Release(); }
|
||||
|
||||
await Task.Delay(Opts.VoteTime * 1000);
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
CurrentPhase = Phase.Ended;
|
||||
await OnEnded(this, _submissions.ToArray().ToImmutableArray());
|
||||
}
|
||||
finally { _locker.Release(); }
|
||||
}
|
||||
|
||||
private void InitializeStartingLetters()
|
||||
{
|
||||
var wordCount = _rng.Next(3, 6);
|
||||
|
||||
var lettersArr = new char[wordCount];
|
||||
|
||||
for (var i = 0; i < wordCount; i++)
|
||||
{
|
||||
var randChar = (char)_rng.Next(65, 91);
|
||||
lettersArr[i] = randChar == 'X' ? (char)_rng.Next(65, 88) : randChar;
|
||||
}
|
||||
|
||||
StartingLetters = lettersArr.ToImmutableArray();
|
||||
}
|
||||
|
||||
public async Task<bool> UserInput(ulong userId, string userName, string input)
|
||||
{
|
||||
var user = new AcrophobiaUser(userId, userName, input.ToLowerInvariant().ToTitleCase());
|
||||
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
switch (CurrentPhase)
|
||||
{
|
||||
case Phase.Submission:
|
||||
if (_submissions.ContainsKey(user) || !IsValidAnswer(input))
|
||||
break;
|
||||
|
||||
_submissions.Add(user, 0);
|
||||
return true;
|
||||
case Phase.Voting:
|
||||
AcrophobiaUser toVoteFor;
|
||||
if (!int.TryParse(input, out var index)
|
||||
|| --index < 0
|
||||
|| index >= _submissions.Count
|
||||
|| (toVoteFor = _submissions.ToArray()[index].Key).UserId == user.UserId
|
||||
|| !_usersWhoVoted.Add(userId))
|
||||
break;
|
||||
++_submissions[toVoteFor];
|
||||
_ = Task.Run(() => OnUserVoted(userName));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_locker.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsValidAnswer(string input)
|
||||
{
|
||||
input = input.ToUpperInvariant();
|
||||
|
||||
var inputWords = input.Split(' ');
|
||||
|
||||
if (inputWords.Length
|
||||
!= StartingLetters.Length) // number of words must be the same as the number of the starting letters
|
||||
return false;
|
||||
|
||||
for (var i = 0; i < StartingLetters.Length; i++)
|
||||
{
|
||||
var letter = StartingLetters[i];
|
||||
|
||||
if (!inputWords[i]
|
||||
.StartsWith(letter.ToString(), StringComparison.InvariantCulture)) // all first letters must match
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
CurrentPhase = Phase.Ended;
|
||||
OnStarted = null;
|
||||
OnEnded = null;
|
||||
OnUserVoted = null;
|
||||
OnVotingStarted = null;
|
||||
_usersWhoVoted.Clear();
|
||||
_submissions.Clear();
|
||||
_locker.Dispose();
|
||||
}
|
||||
|
||||
public class Options : IEllieCommandOptions
|
||||
{
|
||||
[Option('s',
|
||||
"submission-time",
|
||||
Required = false,
|
||||
Default = 60,
|
||||
HelpText = "Time after which the submissions are closed and voting starts.")]
|
||||
public int SubmissionTime { get; set; } = 60;
|
||||
|
||||
[Option('v',
|
||||
"vote-time",
|
||||
Required = false,
|
||||
Default = 60,
|
||||
HelpText = "Time after which the voting is closed and the winner is declared.")]
|
||||
public int VoteTime { get; set; } = 30;
|
||||
|
||||
public void NormalizeOptions()
|
||||
{
|
||||
if (SubmissionTime is < 15 or > 300)
|
||||
SubmissionTime = 60;
|
||||
if (VoteTime is < 15 or > 120)
|
||||
VoteTime = 30;
|
||||
}
|
||||
}
|
||||
}
|
22
src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs
Normal file
22
src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Modules.Games.Common.Acrophobia;
|
||||
|
||||
public class AcrophobiaUser
|
||||
{
|
||||
public string UserName { get; }
|
||||
public ulong UserId { get; }
|
||||
public string Input { get; }
|
||||
|
||||
public AcrophobiaUser(ulong userId, string userName, string input)
|
||||
{
|
||||
UserName = userName;
|
||||
UserId = userId;
|
||||
Input = input;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
=> UserId.GetHashCode();
|
||||
|
||||
public override bool Equals(object obj)
|
||||
=> obj is AcrophobiaUser x ? x.UserId == UserId : false;
|
||||
}
|
140
src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs
Normal file
140
src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs
Normal file
|
@ -0,0 +1,140 @@
|
|||
#nullable disable
|
||||
using EllieBot.Modules.Games.Common.Acrophobia;
|
||||
using EllieBot.Modules.Games.Services;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public partial class Games
|
||||
{
|
||||
[Group]
|
||||
public partial class AcropobiaCommands : EllieModule<GamesService>
|
||||
{
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public AcropobiaCommands(DiscordSocketClient client)
|
||||
=> _client = client;
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[EllieOptions<AcrophobiaGame.Options>]
|
||||
public async Task Acrophobia(params string[] args)
|
||||
{
|
||||
var (options, _) = OptionsParser.ParseFrom(new AcrophobiaGame.Options(), args);
|
||||
var channel = (ITextChannel)ctx.Channel;
|
||||
|
||||
var game = new AcrophobiaGame(options);
|
||||
if (_service.AcrophobiaGames.TryAdd(channel.Id, game))
|
||||
{
|
||||
try
|
||||
{
|
||||
game.OnStarted += Game_OnStarted;
|
||||
game.OnEnded += Game_OnEnded;
|
||||
game.OnVotingStarted += Game_OnVotingStarted;
|
||||
game.OnUserVoted += Game_OnUserVoted;
|
||||
_client.MessageReceived += ClientMessageReceived;
|
||||
await game.Run();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_client.MessageReceived -= ClientMessageReceived;
|
||||
_service.AcrophobiaGames.TryRemove(channel.Id, out game);
|
||||
game?.Dispose();
|
||||
}
|
||||
}
|
||||
else
|
||||
await Response().Error(strs.acro_running).SendAsync();
|
||||
|
||||
Task ClientMessageReceived(SocketMessage msg)
|
||||
{
|
||||
if (msg.Channel.Id != ctx.Channel.Id)
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await game.UserInput(msg.Author.Id, msg.Author.ToString(), msg.Content);
|
||||
if (success)
|
||||
await msg.DeleteAsync();
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private Task Game_OnStarted(AcrophobiaGame game)
|
||||
{
|
||||
var embed = _sender.CreateEmbed()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.acrophobia))
|
||||
.WithDescription(
|
||||
GetText(strs.acro_started(Format.Bold(string.Join(".", game.StartingLetters)))))
|
||||
.WithFooter(GetText(strs.acro_started_footer(game.Opts.SubmissionTime)));
|
||||
|
||||
return Response().Embed(embed).SendAsync();
|
||||
}
|
||||
|
||||
private Task Game_OnUserVoted(string user)
|
||||
=> Response().Confirm(GetText(strs.acrophobia), GetText(strs.acro_vote_cast(Format.Bold(user)))).SendAsync();
|
||||
|
||||
private async Task Game_OnVotingStarted(
|
||||
AcrophobiaGame game,
|
||||
ImmutableArray<KeyValuePair<AcrophobiaUser, int>> submissions)
|
||||
{
|
||||
if (submissions.Length == 0)
|
||||
{
|
||||
await Response().Error(GetText(strs.acrophobia), GetText(strs.acro_ended_no_sub)).SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (submissions.Length == 1)
|
||||
{
|
||||
await Response().Embed(_sender.CreateEmbed()
|
||||
.WithOkColor()
|
||||
.WithDescription(GetText(
|
||||
strs.acro_winner_only(
|
||||
Format.Bold(submissions.First().Key.UserName))))
|
||||
.WithFooter(submissions.First().Key.Input)).SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var i = 0;
|
||||
var embed = _sender.CreateEmbed()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.acrophobia) + " - " + GetText(strs.submissions_closed))
|
||||
.WithDescription(GetText(strs.acro_nym_was(
|
||||
Format.Bold(string.Join(".", game.StartingLetters))
|
||||
+ "\n"
|
||||
+ $@"--
|
||||
{submissions.Aggregate("", (agg, cur) => agg + $"`{++i}.` **{cur.Key.Input}**\n")}
|
||||
--")))
|
||||
.WithFooter(GetText(strs.acro_vote));
|
||||
|
||||
await Response().Embed(embed).SendAsync();
|
||||
}
|
||||
|
||||
private async Task Game_OnEnded(AcrophobiaGame game, ImmutableArray<KeyValuePair<AcrophobiaUser, int>> votes)
|
||||
{
|
||||
if (!votes.Any() || votes.All(x => x.Value == 0))
|
||||
{
|
||||
await Response().Error(GetText(strs.acrophobia), GetText(strs.acro_no_votes_cast)).SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var table = votes.OrderByDescending(v => v.Value);
|
||||
var winner = table.First();
|
||||
var embed = _sender.CreateEmbed()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.acrophobia))
|
||||
.WithDescription(GetText(strs.acro_winner(Format.Bold(winner.Key.UserName),
|
||||
Format.Bold(winner.Value.ToString()))))
|
||||
.WithFooter(winner.Key.Input);
|
||||
|
||||
await Response().Embed(embed).SendAsync();
|
||||
}
|
||||
}
|
||||
}
|
35
src/EllieBot/Modules/Games/ChatterBot/ChatterBotCommands.cs
Normal file
35
src/EllieBot/Modules/Games/ChatterBot/ChatterBotCommands.cs
Normal 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();
|
||||
|
||||
}
|
||||
}
|
||||
}
|
239
src/EllieBot/Modules/Games/ChatterBot/ChatterBotService.cs
Normal file
239
src/EllieBot/Modules/Games/ChatterBot/ChatterBotService.cs
Normal 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;
|
||||
}
|
||||
}
|
9
src/EllieBot/Modules/Games/ChatterBot/_common/Choice.cs
Normal file
9
src/EllieBot/Modules/Games/ChatterBot/_common/Choice.cs
Normal 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; }
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Modules.Games.Common.ChatterBot;
|
||||
|
||||
public class CleverbotResponse
|
||||
{
|
||||
public string Cs { get; set; }
|
||||
public string Output { get; set; }
|
||||
}
|
|
@ -0,0 +1,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);
|
||||
}
|
9
src/EllieBot/Modules/Games/ChatterBot/_common/Message.cs
Normal file
9
src/EllieBot/Modules/Games/ChatterBot/_common/Message.cs
Normal 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; }
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
}
|
47
src/EllieBot/Modules/Games/Games.cs
Normal file
47
src/EllieBot/Modules/Games/Games.cs
Normal file
|
@ -0,0 +1,47 @@
|
|||
#nullable disable
|
||||
using EllieBot.Modules.Games.Services;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
/* more games
|
||||
- Shiritori
|
||||
- Simple RPG adventure
|
||||
*/
|
||||
public partial class Games : EllieModule<GamesService>
|
||||
{
|
||||
private readonly IImageCache _images;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly Random _rng = new();
|
||||
|
||||
public Games(IImageCache images, IHttpClientFactory factory)
|
||||
{
|
||||
_images = images;
|
||||
_httpFactory = factory;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Choose([Leftover] string list = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(list))
|
||||
return;
|
||||
var listArr = list.Split(';');
|
||||
if (listArr.Length < 2)
|
||||
return;
|
||||
var rng = new EllieRandom();
|
||||
await Response().Confirm("🤔", listArr[rng.Next(0, listArr.Length)]).SendAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task EightBall([Leftover] string question = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(question))
|
||||
return;
|
||||
|
||||
var res = _service.GetEightballResponse(ctx.User.Id, question);
|
||||
await Response().Embed(_sender.CreateEmbed()
|
||||
.WithOkColor()
|
||||
.WithDescription(ctx.User.ToString())
|
||||
.AddField("❓ " + GetText(strs.question), question)
|
||||
.AddField("🎱 " + GetText(strs._8ball), res)).SendAsync();
|
||||
}
|
||||
}
|
195
src/EllieBot/Modules/Games/GamesConfig.cs
Normal file
195
src/EllieBot/Modules/Games/GamesConfig.cs
Normal 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,
|
||||
}
|
120
src/EllieBot/Modules/Games/GamesConfigService.cs
Normal file
120
src/EllieBot/Modules/Games/GamesConfigService.cs
Normal 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";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
87
src/EllieBot/Modules/Games/GamesService.cs
Normal file
87
src/EllieBot/Modules/Games/GamesService.cs
Normal 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;
|
||||
}
|
||||
}
|
64
src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs
Normal file
64
src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs
Normal file
|
@ -0,0 +1,64 @@
|
|||
using EllieBot.Common.Yml;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace EllieBot.Modules.Games.Hangman;
|
||||
|
||||
public sealed class DefaultHangmanSource : IHangmanSource
|
||||
{
|
||||
private IReadOnlyDictionary<string, HangmanTerm[]> termsDict = new Dictionary<string, HangmanTerm[]>();
|
||||
private readonly Random _rng;
|
||||
|
||||
public DefaultHangmanSource()
|
||||
{
|
||||
_rng = new EllieRandom();
|
||||
Reload();
|
||||
}
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
if (!Directory.Exists("data/hangman"))
|
||||
{
|
||||
Log.Error("Hangman game won't work. Folder 'data/hangman' is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
var qs = new Dictionary<string, HangmanTerm[]>();
|
||||
foreach (var file in Directory.EnumerateFiles("data/hangman/", "*.yml"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = Yaml.Deserializer.Deserialize<HangmanTerm[]>(File.ReadAllText(file));
|
||||
qs[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Loading {HangmanFile} failed", file);
|
||||
}
|
||||
}
|
||||
|
||||
termsDict = qs;
|
||||
|
||||
Log.Information("Loaded {HangmanCategoryCount} hangman categories", qs.Count);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> GetCategories()
|
||||
=> termsDict.Keys.ToList();
|
||||
|
||||
public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term)
|
||||
{
|
||||
if (category is null)
|
||||
{
|
||||
var cats = GetCategories();
|
||||
category = cats.ElementAt(_rng.Next(0, cats.Count));
|
||||
}
|
||||
|
||||
if (termsDict.TryGetValue(category, out var terms))
|
||||
{
|
||||
term = terms[_rng.Next(0, terms.Length)];
|
||||
return true;
|
||||
}
|
||||
|
||||
term = null;
|
||||
return false;
|
||||
}
|
||||
}
|
76
src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs
Normal file
76
src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs
Normal file
|
@ -0,0 +1,76 @@
|
|||
using EllieBot.Modules.Games.Hangman;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public partial class Games
|
||||
{
|
||||
[Group]
|
||||
public partial class HangmanCommands : EllieModule<IHangmanService>
|
||||
{
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Hangmanlist()
|
||||
=> await Response().Confirm(GetText(strs.hangman_types(prefix)), _service.GetHangmanTypes().Join('\n')).SendAsync();
|
||||
|
||||
private static string Draw(HangmanGame.State state)
|
||||
=> $"""
|
||||
. ┌─────┐
|
||||
.┃...............┋
|
||||
.┃...............┋
|
||||
.┃{(state.Errors > 0 ? ".............😲" : "")}
|
||||
.┃{(state.Errors > 1 ? "............./" : "")} {(state.Errors > 2 ? "|" : "")} {(state.Errors > 3 ? "\\" : "")}
|
||||
.┃{(state.Errors > 4 ? "............../" : "")} {(state.Errors > 5 ? "\\" : "")}
|
||||
/-\
|
||||
""";
|
||||
|
||||
public static EmbedBuilder GetEmbed(IMessageSenderService sender, HangmanGame.State state)
|
||||
{
|
||||
if (state.Phase == HangmanGame.Phase.Running)
|
||||
{
|
||||
return sender.CreateEmbed()
|
||||
.WithOkColor()
|
||||
.AddField("Hangman", Draw(state))
|
||||
.AddField("Guess", Format.Code(state.Word))
|
||||
.WithFooter(state.MissedLetters.Join(' '));
|
||||
}
|
||||
|
||||
if (state.Phase == HangmanGame.Phase.Ended && state.Failed)
|
||||
{
|
||||
return sender.CreateEmbed()
|
||||
.WithErrorColor()
|
||||
.AddField("Hangman", Draw(state))
|
||||
.AddField("Guess", Format.Code(state.Word))
|
||||
.WithFooter(state.MissedLetters.Join(' '));
|
||||
}
|
||||
|
||||
return sender.CreateEmbed()
|
||||
.WithOkColor()
|
||||
.AddField("Hangman", Draw(state))
|
||||
.AddField("Guess", Format.Code(state.Word))
|
||||
.WithFooter(state.MissedLetters.Join(' '));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Hangman([Leftover] string? type = null)
|
||||
{
|
||||
if (!_service.StartHangman(ctx.Channel.Id, type, out var hangman))
|
||||
{
|
||||
await Response().Error(strs.hangman_running).SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var eb = GetEmbed(_sender, hangman);
|
||||
eb.WithDescription(GetText(strs.hangman_game_started));
|
||||
await Response().Embed(eb).SendAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task HangmanStop()
|
||||
{
|
||||
if (await _service.StopHangman(ctx.Channel.Id))
|
||||
await Response().Confirm(strs.hangman_stopped).SendAsync();
|
||||
}
|
||||
}
|
||||
}
|
112
src/EllieBot/Modules/Games/Hangman/HangmanGame.cs
Normal file
112
src/EllieBot/Modules/Games/Hangman/HangmanGame.cs
Normal 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;
|
||||
}
|
||||
}
|
136
src/EllieBot/Modules/Games/Hangman/HangmanService.cs
Normal file
136
src/EllieBot/Modules/Games/Hangman/HangmanService.cs
Normal file
|
@ -0,0 +1,136 @@
|
|||
using Microsoft.Extensions.Caching.Memory;
|
||||
using EllieBot.Common.ModuleBehaviors;
|
||||
using EllieBot.Modules.Games.Services;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace EllieBot.Modules.Games.Hangman;
|
||||
|
||||
public sealed class HangmanService : IHangmanService, IExecNoCommand
|
||||
{
|
||||
private readonly ConcurrentDictionary<ulong, HangmanGame> _hangmanGames = new();
|
||||
private readonly IHangmanSource _source;
|
||||
private readonly IMessageSenderService _sender;
|
||||
private readonly GamesConfigService _gcs;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly IMemoryCache _cdCache;
|
||||
private readonly object _locker = new();
|
||||
|
||||
public HangmanService(
|
||||
IHangmanSource source,
|
||||
IMessageSenderService sender,
|
||||
GamesConfigService gcs,
|
||||
ICurrencyService cs,
|
||||
IMemoryCache cdCache)
|
||||
{
|
||||
_source = source;
|
||||
_sender = sender;
|
||||
_gcs = gcs;
|
||||
_cs = cs;
|
||||
_cdCache = cdCache;
|
||||
}
|
||||
|
||||
public bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? state)
|
||||
{
|
||||
state = null;
|
||||
if (!_source.GetTerm(category, out var term))
|
||||
return false;
|
||||
|
||||
|
||||
var game = new HangmanGame(term);
|
||||
lock (_locker)
|
||||
{
|
||||
var hc = _hangmanGames.GetOrAdd(channelId, game);
|
||||
if (hc == game)
|
||||
{
|
||||
state = hc.GetState();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<bool> StopHangman(ulong channelId)
|
||||
{
|
||||
lock (_locker)
|
||||
{
|
||||
if (_hangmanGames.TryRemove(channelId, out _))
|
||||
return new(true);
|
||||
}
|
||||
|
||||
return new(false);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> GetHangmanTypes()
|
||||
=> _source.GetCategories();
|
||||
|
||||
public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
|
||||
{
|
||||
if (_hangmanGames.ContainsKey(msg.Channel.Id))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(msg.Content))
|
||||
return;
|
||||
|
||||
if (_cdCache.TryGetValue(msg.Author.Id, out _))
|
||||
return;
|
||||
|
||||
HangmanGame.State state;
|
||||
long rew = 0;
|
||||
lock (_locker)
|
||||
{
|
||||
if (!_hangmanGames.TryGetValue(msg.Channel.Id, out var game))
|
||||
return;
|
||||
|
||||
state = game.Guess(msg.Content.ToLowerInvariant());
|
||||
|
||||
if (state.GuessResult == HangmanGame.GuessResult.NoAction)
|
||||
return;
|
||||
|
||||
if (state.GuessResult is HangmanGame.GuessResult.Incorrect or HangmanGame.GuessResult.AlreadyTried)
|
||||
{
|
||||
_cdCache.Set(msg.Author.Id,
|
||||
string.Empty,
|
||||
new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3)
|
||||
});
|
||||
}
|
||||
|
||||
if (state.Phase == HangmanGame.Phase.Ended)
|
||||
{
|
||||
if (_hangmanGames.TryRemove(msg.Channel.Id, out _))
|
||||
rew = _gcs.Data.Hangman.CurrencyReward;
|
||||
}
|
||||
}
|
||||
|
||||
if (rew > 0)
|
||||
await _cs.AddAsync(msg.Author, rew, new("hangman", "win"));
|
||||
|
||||
await SendState((ITextChannel)msg.Channel, msg.Author, msg.Content, state);
|
||||
}
|
||||
}
|
||||
|
||||
private Task<IUserMessage> SendState(
|
||||
ITextChannel channel,
|
||||
IUser user,
|
||||
string content,
|
||||
HangmanGame.State state)
|
||||
{
|
||||
var embed = Games.HangmanCommands.GetEmbed(_sender, state);
|
||||
if (state.GuessResult == HangmanGame.GuessResult.Guess)
|
||||
embed.WithDescription($"{user} guessed the letter {content}!").WithOkColor();
|
||||
else if (state.GuessResult == HangmanGame.GuessResult.Incorrect && state.Failed)
|
||||
embed.WithDescription($"{user} Letter {content} doesn't exist! Game over!").WithErrorColor();
|
||||
else if (state.GuessResult == HangmanGame.GuessResult.Incorrect)
|
||||
embed.WithDescription($"{user} Letter {content} doesn't exist!").WithErrorColor();
|
||||
else if (state.GuessResult == HangmanGame.GuessResult.AlreadyTried)
|
||||
embed.WithDescription($"{user} Letter {content} has already been used.").WithPendingColor();
|
||||
else if (state.GuessResult == HangmanGame.GuessResult.Win)
|
||||
embed.WithDescription($"{user} won!").WithOkColor();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(state.ImageUrl) && Uri.IsWellFormedUriString(state.ImageUrl, UriKind.Absolute))
|
||||
embed.WithImageUrl(state.ImageUrl);
|
||||
|
||||
return _sender.Response(channel).Embed(embed).SendAsync();
|
||||
}
|
||||
}
|
8
src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs
Normal file
8
src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Modules.Games.Hangman;
|
||||
|
||||
public sealed class HangmanTerm
|
||||
{
|
||||
public string Word { get; set; }
|
||||
public string ImageUrl { get; set; }
|
||||
}
|
10
src/EllieBot/Modules/Games/Hangman/IHangmanService.cs
Normal file
10
src/EllieBot/Modules/Games/Hangman/IHangmanService.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace EllieBot.Modules.Games.Hangman;
|
||||
|
||||
public interface IHangmanService
|
||||
{
|
||||
bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? hangmanController);
|
||||
ValueTask<bool> StopHangman(ulong channelId);
|
||||
IReadOnlyCollection<string> GetHangmanTypes();
|
||||
}
|
10
src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs
Normal file
10
src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace EllieBot.Modules.Games.Hangman;
|
||||
|
||||
public interface IHangmanSource : IEService
|
||||
{
|
||||
public IReadOnlyCollection<string> GetCategories();
|
||||
public void Reload();
|
||||
public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term);
|
||||
}
|
183
src/EllieBot/Modules/Games/Nunchi/Nunchi.cs
Normal file
183
src/EllieBot/Modules/Games/Nunchi/Nunchi.cs
Normal file
|
@ -0,0 +1,183 @@
|
|||
#nullable disable
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace EllieBot.Modules.Games.Common.Nunchi;
|
||||
|
||||
public sealed class NunchiGame : IDisposable
|
||||
{
|
||||
public enum Phase
|
||||
{
|
||||
Joining,
|
||||
Playing,
|
||||
WaitingForNextRound,
|
||||
Ended
|
||||
}
|
||||
|
||||
private const int KILL_TIMEOUT = 20 * 1000;
|
||||
private const int NEXT_ROUND_TIMEOUT = 5 * 1000;
|
||||
|
||||
public event Func<NunchiGame, Task> OnGameStarted;
|
||||
public event Func<NunchiGame, int, Task> OnRoundStarted;
|
||||
public event Func<NunchiGame, Task> OnUserGuessed;
|
||||
public event Func<NunchiGame, (ulong Id, string Name)?, Task> OnRoundEnded; // tuple of the user who failed
|
||||
public event Func<NunchiGame, string, Task> OnGameEnded; // name of the user who won
|
||||
|
||||
public int CurrentNumber { get; private set; } = new EllieRandom().Next(0, 100);
|
||||
public Phase CurrentPhase { get; private set; } = Phase.Joining;
|
||||
|
||||
public ImmutableArray<(ulong Id, string Name)> Participants
|
||||
=> participants.ToImmutableArray();
|
||||
|
||||
public int ParticipantCount
|
||||
=> participants.Count;
|
||||
|
||||
private readonly SemaphoreSlim _locker = new(1, 1);
|
||||
|
||||
private HashSet<(ulong Id, string Name)> participants = [];
|
||||
private readonly HashSet<(ulong Id, string Name)> _passed = [];
|
||||
private Timer killTimer;
|
||||
|
||||
public NunchiGame(ulong creatorId, string creatorName)
|
||||
=> participants.Add((creatorId, creatorName));
|
||||
|
||||
public async Task<bool> Join(ulong userId, string userName)
|
||||
{
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (CurrentPhase != Phase.Joining)
|
||||
return false;
|
||||
|
||||
return participants.Add((userId, userName));
|
||||
}
|
||||
finally { _locker.Release(); }
|
||||
}
|
||||
|
||||
public async Task<bool> Initialize()
|
||||
{
|
||||
CurrentPhase = Phase.Joining;
|
||||
await Task.Delay(30000);
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (participants.Count < 3)
|
||||
{
|
||||
CurrentPhase = Phase.Ended;
|
||||
return false;
|
||||
}
|
||||
|
||||
killTimer = new(async _ =>
|
||||
{
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (CurrentPhase != Phase.Playing)
|
||||
return;
|
||||
|
||||
//if some players took too long to type a number, boot them all out and start a new round
|
||||
participants = new HashSet<(ulong, string)>(_passed);
|
||||
EndRound();
|
||||
}
|
||||
finally { _locker.Release(); }
|
||||
},
|
||||
null,
|
||||
KILL_TIMEOUT,
|
||||
KILL_TIMEOUT);
|
||||
|
||||
CurrentPhase = Phase.Playing;
|
||||
_ = OnGameStarted?.Invoke(this);
|
||||
_ = OnRoundStarted?.Invoke(this, CurrentNumber);
|
||||
return true;
|
||||
}
|
||||
finally { _locker.Release(); }
|
||||
}
|
||||
|
||||
public async Task Input(ulong userId, string userName, int input)
|
||||
{
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (CurrentPhase != Phase.Playing)
|
||||
return;
|
||||
|
||||
var userTuple = (Id: userId, Name: userName);
|
||||
|
||||
// if the user is not a member of the race,
|
||||
// or he already successfully typed the number
|
||||
// ignore the input
|
||||
if (!participants.Contains(userTuple) || !_passed.Add(userTuple))
|
||||
return;
|
||||
|
||||
//if the number is correct
|
||||
if (CurrentNumber == input - 1)
|
||||
{
|
||||
//increment current number
|
||||
++CurrentNumber;
|
||||
if (_passed.Count == participants.Count - 1)
|
||||
{
|
||||
// if only n players are left, and n - 1 type the correct number, round is over
|
||||
|
||||
// if only 2 players are left, game is over
|
||||
if (participants.Count == 2)
|
||||
{
|
||||
killTimer.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
CurrentPhase = Phase.Ended;
|
||||
_ = OnGameEnded?.Invoke(this, userTuple.Name);
|
||||
}
|
||||
else // else just start the new round without the user who was the last
|
||||
{
|
||||
var failure = participants.Except(_passed).First();
|
||||
|
||||
OnUserGuessed?.Invoke(this);
|
||||
EndRound(failure);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
OnUserGuessed?.Invoke(this);
|
||||
}
|
||||
else
|
||||
{
|
||||
//if the user failed
|
||||
|
||||
EndRound(userTuple);
|
||||
}
|
||||
}
|
||||
finally { _locker.Release(); }
|
||||
}
|
||||
|
||||
private void EndRound((ulong, string)? failure = null)
|
||||
{
|
||||
killTimer.Change(KILL_TIMEOUT, KILL_TIMEOUT);
|
||||
CurrentNumber = new EllieRandom().Next(0, 100); // reset the counter
|
||||
_passed.Clear(); // reset all users who passed (new round starts)
|
||||
if (failure is not null)
|
||||
participants.Remove(failure.Value); // remove the dude who failed from the list of players
|
||||
|
||||
_ = OnRoundEnded?.Invoke(this, failure);
|
||||
if (participants.Count <= 1) // means we have a winner or everyone was booted out
|
||||
{
|
||||
killTimer.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
CurrentPhase = Phase.Ended;
|
||||
_ = OnGameEnded?.Invoke(this, participants.Count > 0 ? participants.First().Name : null);
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentPhase = Phase.WaitingForNextRound;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(NEXT_ROUND_TIMEOUT);
|
||||
CurrentPhase = Phase.Playing;
|
||||
_ = OnRoundStarted?.Invoke(this, CurrentNumber);
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
OnGameEnded = null;
|
||||
OnGameStarted = null;
|
||||
OnRoundEnded = null;
|
||||
OnRoundStarted = null;
|
||||
OnUserGuessed = null;
|
||||
}
|
||||
}
|
114
src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs
Normal file
114
src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs
Normal file
|
@ -0,0 +1,114 @@
|
|||
#nullable disable
|
||||
using EllieBot.Modules.Games.Common.Nunchi;
|
||||
using EllieBot.Modules.Games.Services;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public partial class Games
|
||||
{
|
||||
[Group]
|
||||
public partial class NunchiCommands : EllieModule<GamesService>
|
||||
{
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public NunchiCommands(DiscordSocketClient client)
|
||||
=> _client = client;
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Nunchi()
|
||||
{
|
||||
var newNunchi = new NunchiGame(ctx.User.Id, ctx.User.ToString());
|
||||
NunchiGame nunchi;
|
||||
|
||||
//if a game was already active
|
||||
if ((nunchi = _service.NunchiGames.GetOrAdd(ctx.Guild.Id, newNunchi)) != newNunchi)
|
||||
{
|
||||
// join it
|
||||
// if you failed joining, that means game is running or just ended
|
||||
if (!await nunchi.Join(ctx.User.Id, ctx.User.ToString()))
|
||||
return;
|
||||
|
||||
await Response().Error(strs.nunchi_joined(nunchi.ParticipantCount)).SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try { await Response().Confirm(strs.nunchi_created).SendAsync(); }
|
||||
catch { }
|
||||
|
||||
nunchi.OnGameEnded += NunchiOnGameEnded;
|
||||
//nunchi.OnGameStarted += Nunchi_OnGameStarted;
|
||||
nunchi.OnRoundEnded += Nunchi_OnRoundEnded;
|
||||
nunchi.OnUserGuessed += Nunchi_OnUserGuessed;
|
||||
nunchi.OnRoundStarted += Nunchi_OnRoundStarted;
|
||||
_client.MessageReceived += ClientMessageReceived;
|
||||
|
||||
var success = await nunchi.Initialize();
|
||||
if (!success)
|
||||
{
|
||||
if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game))
|
||||
game.Dispose();
|
||||
await Response().Confirm(strs.nunchi_failed_to_start).SendAsync();
|
||||
}
|
||||
|
||||
Task ClientMessageReceived(SocketMessage arg)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
if (arg.Channel.Id != ctx.Channel.Id)
|
||||
return;
|
||||
|
||||
if (!int.TryParse(arg.Content, out var number))
|
||||
return;
|
||||
try
|
||||
{
|
||||
await nunchi.Input(arg.Author.Id, arg.Author.ToString(), number);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task NunchiOnGameEnded(NunchiGame arg1, string arg2)
|
||||
{
|
||||
if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game))
|
||||
{
|
||||
_client.MessageReceived -= ClientMessageReceived;
|
||||
game.Dispose();
|
||||
}
|
||||
|
||||
if (arg2 is null)
|
||||
return Response().Confirm(strs.nunchi_ended_no_winner).SendAsync();
|
||||
return Response().Confirm(strs.nunchi_ended(Format.Bold(arg2))).SendAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private Task Nunchi_OnRoundStarted(NunchiGame arg, int cur)
|
||||
=> Response()
|
||||
.Confirm(strs.nunchi_round_started(Format.Bold(arg.ParticipantCount.ToString()),
|
||||
Format.Bold(cur.ToString())))
|
||||
.SendAsync();
|
||||
|
||||
private Task Nunchi_OnUserGuessed(NunchiGame arg)
|
||||
=> Response().Confirm(strs.nunchi_next_number(Format.Bold(arg.CurrentNumber.ToString()))).SendAsync();
|
||||
|
||||
private Task Nunchi_OnRoundEnded(NunchiGame arg1, (ulong Id, string Name)? arg2)
|
||||
{
|
||||
if (arg2.HasValue)
|
||||
return Response().Confirm(strs.nunchi_round_ended(Format.Bold(arg2.Value.Name))).SendAsync();
|
||||
return Response()
|
||||
.Confirm(strs.nunchi_round_ended_boot(
|
||||
Format.Bold("\n"
|
||||
+ string.Join("\n, ",
|
||||
arg1.Participants.Select(x
|
||||
=> x.Name)))))
|
||||
.SendAsync(); // this won't work if there are too many users
|
||||
}
|
||||
|
||||
private Task Nunchi_OnGameStarted(NunchiGame arg)
|
||||
=> Response().Confirm(strs.nunchi_started(Format.Bold(arg.ParticipantCount.ToString()))).SendAsync();
|
||||
}
|
||||
}
|
105
src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs
Normal file
105
src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs
Normal file
|
@ -0,0 +1,105 @@
|
|||
#nullable disable
|
||||
using EllieBot.Modules.Games.Common;
|
||||
using EllieBot.Modules.Games.Services;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public partial class Games
|
||||
{
|
||||
[Group]
|
||||
public partial class SpeedTypingCommands : EllieModule<GamesService>
|
||||
{
|
||||
private readonly GamesService _games;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public SpeedTypingCommands(DiscordSocketClient client, GamesService games)
|
||||
{
|
||||
_games = games;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[EllieOptions<TypingGame.Options>]
|
||||
public async Task TypeStart(params string[] args)
|
||||
{
|
||||
var (options, _) = OptionsParser.ParseFrom(new TypingGame.Options(), args);
|
||||
var channel = (ITextChannel)ctx.Channel;
|
||||
|
||||
var game = _service.RunningContests.GetOrAdd(ctx.Guild.Id,
|
||||
_ => new(_games, _client, channel, prefix, options, _sender));
|
||||
|
||||
if (game.IsActive)
|
||||
await Response().Error($"Contest already running in {game.Channel.Mention} channel.").SendAsync();
|
||||
else
|
||||
await game.Start();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task TypeStop()
|
||||
{
|
||||
if (_service.RunningContests.TryRemove(ctx.Guild.Id, out var game))
|
||||
{
|
||||
await game.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
await Response().Error("No contest to stop on this channel.").SendAsync();
|
||||
}
|
||||
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[OwnerOnly]
|
||||
public async Task Typeadd([Leftover] string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return;
|
||||
|
||||
_games.AddTypingArticle(ctx.User, text);
|
||||
|
||||
await Response().Confirm("Added new article for typing game.").SendAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Typelist(int page = 1)
|
||||
{
|
||||
if (page < 1)
|
||||
return;
|
||||
|
||||
var articles = _games.TypingArticles.Skip((page - 1) * 15).Take(15).ToArray();
|
||||
|
||||
if (!articles.Any())
|
||||
{
|
||||
await Response().Error($"{ctx.User.Mention} `No articles found on that page.`").SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var i = (page - 1) * 15;
|
||||
await Response()
|
||||
.Confirm("List of articles for Type Race",
|
||||
string.Join("\n", articles.Select(a => $"`#{++i}` - {a.Text.TrimTo(50)}")))
|
||||
.SendAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[OwnerOnly]
|
||||
public async Task Typedel(int index)
|
||||
{
|
||||
var removed = _service.RemoveTypingArticle(--index);
|
||||
|
||||
if (removed is null)
|
||||
return;
|
||||
|
||||
var embed = _sender.CreateEmbed()
|
||||
.WithTitle($"Removed typing article #{index + 1}")
|
||||
.WithDescription(removed.Text.TrimTo(50))
|
||||
.WithOkColor();
|
||||
|
||||
await Response().Embed(embed).SendAsync();
|
||||
}
|
||||
}
|
||||
}
|
9
src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs
Normal file
9
src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Modules.Games.Common;
|
||||
|
||||
public class TypingArticle
|
||||
{
|
||||
public string Source { get; set; }
|
||||
public string Extra { get; set; }
|
||||
public string Text { get; set; }
|
||||
}
|
197
src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs
Normal file
197
src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs
Normal file
|
@ -0,0 +1,197 @@
|
|||
#nullable disable
|
||||
using CommandLine;
|
||||
using EllieBot.Modules.Games.Services;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace EllieBot.Modules.Games.Common;
|
||||
|
||||
public class TypingGame
|
||||
{
|
||||
public const float WORD_VALUE = 4.5f;
|
||||
public ITextChannel Channel { get; }
|
||||
public string CurrentSentence { get; private set; }
|
||||
public bool IsActive { get; private set; }
|
||||
private readonly Stopwatch _sw;
|
||||
private readonly List<ulong> _finishedUserIds;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly GamesService _games;
|
||||
private readonly string _prefix;
|
||||
private readonly Options _options;
|
||||
private readonly IMessageSenderService _sender;
|
||||
|
||||
public TypingGame(
|
||||
GamesService games,
|
||||
DiscordSocketClient client,
|
||||
ITextChannel channel,
|
||||
string prefix,
|
||||
Options options,
|
||||
IMessageSenderService sender)
|
||||
{
|
||||
_games = games;
|
||||
_client = client;
|
||||
_prefix = prefix;
|
||||
_options = options;
|
||||
_sender = sender;
|
||||
|
||||
Channel = channel;
|
||||
IsActive = false;
|
||||
_sw = new();
|
||||
_finishedUserIds = new();
|
||||
}
|
||||
|
||||
public async Task<bool> Stop()
|
||||
{
|
||||
if (!IsActive)
|
||||
return false;
|
||||
_client.MessageReceived -= AnswerReceived;
|
||||
_finishedUserIds.Clear();
|
||||
IsActive = false;
|
||||
_sw.Stop();
|
||||
_sw.Reset();
|
||||
try
|
||||
{
|
||||
await _sender.Response(Channel)
|
||||
.Confirm("Typing contest stopped.")
|
||||
.SendAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task Start()
|
||||
{
|
||||
if (IsActive)
|
||||
return; // can't start running game
|
||||
IsActive = true;
|
||||
CurrentSentence = GetRandomSentence();
|
||||
var i = (int)(CurrentSentence.Length / WORD_VALUE * 1.7f);
|
||||
try
|
||||
{
|
||||
await _sender.Response(Channel)
|
||||
.Confirm(
|
||||
$":clock2: Next contest will last for {i} seconds. Type the bolded text as fast as you can.")
|
||||
.SendAsync();
|
||||
|
||||
|
||||
var time = _options.StartTime;
|
||||
|
||||
var msg = await _sender.Response(Channel).Confirm($"Starting new typing contest in **{time}**...").SendAsync();
|
||||
|
||||
do
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
time -= 2;
|
||||
try { await msg.ModifyAsync(m => m.Content = $"Starting new typing contest in **{time}**.."); }
|
||||
catch { }
|
||||
} while (time > 2);
|
||||
|
||||
await msg.ModifyAsync(m =>
|
||||
{
|
||||
m.Content = CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture);
|
||||
});
|
||||
_sw.Start();
|
||||
HandleAnswers();
|
||||
|
||||
while (i > 0)
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
i--;
|
||||
if (!IsActive)
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
await Stop();
|
||||
}
|
||||
}
|
||||
|
||||
public string GetRandomSentence()
|
||||
{
|
||||
if (_games.TypingArticles.Any())
|
||||
return _games.TypingArticles[new EllieRandom().Next(0, _games.TypingArticles.Count)].Text;
|
||||
return $"No typing articles found. Use {_prefix}typeadd command to add a new article for typing.";
|
||||
}
|
||||
|
||||
private void HandleAnswers()
|
||||
=> _client.MessageReceived += AnswerReceived;
|
||||
|
||||
private Task AnswerReceived(SocketMessage imsg)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (imsg.Author.IsBot)
|
||||
return;
|
||||
if (imsg is not SocketUserMessage msg)
|
||||
return;
|
||||
|
||||
if (Channel is null || Channel.Id != msg.Channel.Id)
|
||||
return;
|
||||
|
||||
var guess = msg.Content;
|
||||
|
||||
var distance = CurrentSentence.LevenshteinDistance(guess);
|
||||
var decision = Judge(distance, guess.Length);
|
||||
if (decision && !_finishedUserIds.Contains(msg.Author.Id))
|
||||
{
|
||||
var elapsed = _sw.Elapsed;
|
||||
var wpm = CurrentSentence.Length / WORD_VALUE / elapsed.TotalSeconds * 60;
|
||||
_finishedUserIds.Add(msg.Author.Id);
|
||||
|
||||
var embed = _sender.CreateEmbed()
|
||||
.WithOkColor()
|
||||
.WithTitle($"{msg.Author} finished the race!")
|
||||
.AddField("Place", $"#{_finishedUserIds.Count}", true)
|
||||
.AddField("WPM", $"{wpm:F1} *[{elapsed.TotalSeconds:F2}sec]*", true)
|
||||
.AddField("Errors", distance.ToString(), true);
|
||||
|
||||
await _sender.Response(Channel)
|
||||
.Embed(embed)
|
||||
.SendAsync();
|
||||
|
||||
if (_finishedUserIds.Count % 4 == 0)
|
||||
{
|
||||
await _sender.Response(Channel)
|
||||
.Confirm(
|
||||
$"""
|
||||
:exclamation: A lot of people finished, here is the text for those still typing:
|
||||
|
||||
**{Format.Sanitize(CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture)).SanitizeMentions(true)}**
|
||||
""")
|
||||
.SendAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error receiving typing game answer: {ErrorMessage}", ex.Message);
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static bool Judge(int errors, int textLength)
|
||||
=> errors <= textLength / 25;
|
||||
|
||||
public class Options : IEllieCommandOptions
|
||||
{
|
||||
[Option('s',
|
||||
"start-time",
|
||||
Default = 5,
|
||||
Required = false,
|
||||
HelpText = "How long does it take for the race to start. Default 5.")]
|
||||
public int StartTime { get; set; } = 5;
|
||||
|
||||
public void NormalizeOptions()
|
||||
{
|
||||
if (StartTime is < 3 or > 30)
|
||||
StartTime = 5;
|
||||
}
|
||||
}
|
||||
}
|
307
src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs
Normal file
307
src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs
Normal file
|
@ -0,0 +1,307 @@
|
|||
#nullable disable
|
||||
using CommandLine;
|
||||
using System.Text;
|
||||
|
||||
namespace EllieBot.Modules.Games.Common;
|
||||
|
||||
public class TicTacToe
|
||||
{
|
||||
public event Action<TicTacToe> OnEnded;
|
||||
private readonly ITextChannel _channel;
|
||||
private readonly IGuildUser[] _users;
|
||||
private readonly int?[,] _state;
|
||||
private Phase phase;
|
||||
private int curUserIndex;
|
||||
private readonly SemaphoreSlim _moveLock;
|
||||
|
||||
private IGuildUser winner;
|
||||
|
||||
private readonly string[] _numbers =
|
||||
[
|
||||
":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:", ":nine:"
|
||||
];
|
||||
|
||||
private IUserMessage previousMessage;
|
||||
private Timer timeoutTimer;
|
||||
private readonly IBotStrings _strings;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly Options _options;
|
||||
private readonly IMessageSenderService _sender;
|
||||
|
||||
public TicTacToe(
|
||||
IBotStrings strings,
|
||||
DiscordSocketClient client,
|
||||
ITextChannel channel,
|
||||
IGuildUser firstUser,
|
||||
Options options,
|
||||
IMessageSenderService sender)
|
||||
{
|
||||
_channel = channel;
|
||||
_strings = strings;
|
||||
_client = client;
|
||||
_options = options;
|
||||
_sender = sender;
|
||||
|
||||
_users = [firstUser, null];
|
||||
_state = new int?[,] { { null, null, null }, { null, null, null }, { null, null, null } };
|
||||
|
||||
phase = Phase.Starting;
|
||||
_moveLock = new(1, 1);
|
||||
}
|
||||
|
||||
private string GetText(LocStr key)
|
||||
=> _strings.GetText(key, _channel.GuildId);
|
||||
|
||||
public string GetState()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < _state.GetLength(0); i++)
|
||||
{
|
||||
for (var j = 0; j < _state.GetLength(1); j++)
|
||||
{
|
||||
sb.Append(_state[i, j] is null ? _numbers[(i * 3) + j] : GetIcon(_state[i, j]));
|
||||
if (j < _state.GetLength(1) - 1)
|
||||
sb.Append("┃");
|
||||
}
|
||||
|
||||
if (i < _state.GetLength(0) - 1)
|
||||
sb.AppendLine("\n──────────");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public EmbedBuilder GetEmbed(string title = null)
|
||||
{
|
||||
var embed = _sender.CreateEmbed()
|
||||
.WithOkColor()
|
||||
.WithDescription(Environment.NewLine + GetState())
|
||||
.WithAuthor(GetText(strs.vs(_users[0], _users[1])));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
embed.WithTitle(title);
|
||||
|
||||
if (winner is null)
|
||||
{
|
||||
if (phase == Phase.Ended)
|
||||
embed.WithFooter(GetText(strs.ttt_no_moves));
|
||||
else
|
||||
embed.WithFooter(GetText(strs.ttt_users_move(_users[curUserIndex])));
|
||||
}
|
||||
else
|
||||
embed.WithFooter(GetText(strs.ttt_has_won(winner)));
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
private static string GetIcon(int? val)
|
||||
{
|
||||
switch (val)
|
||||
{
|
||||
case 0:
|
||||
return "❌";
|
||||
case 1:
|
||||
return "⭕";
|
||||
case 2:
|
||||
return "❎";
|
||||
case 3:
|
||||
return "🅾";
|
||||
default:
|
||||
return "⬛";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Start(IGuildUser user)
|
||||
{
|
||||
if (phase is Phase.Started or Phase.Ended)
|
||||
{
|
||||
await _sender.Response(_channel).Error(user.Mention + GetText(strs.ttt_already_running)).SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_users[0] == user)
|
||||
{
|
||||
await _sender.Response(_channel).Error(user.Mention + GetText(strs.ttt_against_yourself)).SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
_users[1] = user;
|
||||
|
||||
phase = Phase.Started;
|
||||
|
||||
timeoutTimer = new(async _ =>
|
||||
{
|
||||
await _moveLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (phase == Phase.Ended)
|
||||
return;
|
||||
|
||||
phase = Phase.Ended;
|
||||
if (_users[1] is not null)
|
||||
{
|
||||
winner = _users[curUserIndex ^= 1];
|
||||
var del = previousMessage?.DeleteAsync();
|
||||
try
|
||||
{
|
||||
await _sender.Response(_channel).Embed(GetEmbed(GetText(strs.ttt_time_expired))).SendAsync();
|
||||
if (del is not null)
|
||||
await del;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
OnEnded?.Invoke(this);
|
||||
}
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
_moveLock.Release();
|
||||
}
|
||||
},
|
||||
null,
|
||||
_options.TurnTimer * 1000,
|
||||
Timeout.Infinite);
|
||||
|
||||
_client.MessageReceived += Client_MessageReceived;
|
||||
|
||||
|
||||
previousMessage = await _sender.Response(_channel).Embed(GetEmbed(GetText(strs.game_started))).SendAsync();
|
||||
}
|
||||
|
||||
private bool IsDraw()
|
||||
{
|
||||
for (var i = 0; i < 3; i++)
|
||||
for (var j = 0; j < 3; j++)
|
||||
{
|
||||
if (_state[i, j] is null)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private Task Client_MessageReceived(SocketMessage msg)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await _moveLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var curUser = _users[curUserIndex];
|
||||
if (phase == Phase.Ended || msg.Author?.Id != curUser.Id)
|
||||
return;
|
||||
|
||||
if (int.TryParse(msg.Content, out var index)
|
||||
&& --index >= 0
|
||||
&& index <= 9
|
||||
&& _state[index / 3, index % 3] is null)
|
||||
{
|
||||
_state[index / 3, index % 3] = curUserIndex;
|
||||
|
||||
// i'm lazy
|
||||
if (_state[index / 3, 0] == _state[index / 3, 1] && _state[index / 3, 1] == _state[index / 3, 2])
|
||||
{
|
||||
_state[index / 3, 0] = curUserIndex + 2;
|
||||
_state[index / 3, 1] = curUserIndex + 2;
|
||||
_state[index / 3, 2] = curUserIndex + 2;
|
||||
|
||||
phase = Phase.Ended;
|
||||
}
|
||||
else if (_state[0, index % 3] == _state[1, index % 3]
|
||||
&& _state[1, index % 3] == _state[2, index % 3])
|
||||
{
|
||||
_state[0, index % 3] = curUserIndex + 2;
|
||||
_state[1, index % 3] = curUserIndex + 2;
|
||||
_state[2, index % 3] = curUserIndex + 2;
|
||||
|
||||
phase = Phase.Ended;
|
||||
}
|
||||
else if (curUserIndex == _state[0, 0]
|
||||
&& _state[0, 0] == _state[1, 1]
|
||||
&& _state[1, 1] == _state[2, 2])
|
||||
{
|
||||
_state[0, 0] = curUserIndex + 2;
|
||||
_state[1, 1] = curUserIndex + 2;
|
||||
_state[2, 2] = curUserIndex + 2;
|
||||
|
||||
phase = Phase.Ended;
|
||||
}
|
||||
else if (curUserIndex == _state[0, 2]
|
||||
&& _state[0, 2] == _state[1, 1]
|
||||
&& _state[1, 1] == _state[2, 0])
|
||||
{
|
||||
_state[0, 2] = curUserIndex + 2;
|
||||
_state[1, 1] = curUserIndex + 2;
|
||||
_state[2, 0] = curUserIndex + 2;
|
||||
|
||||
phase = Phase.Ended;
|
||||
}
|
||||
|
||||
var reason = string.Empty;
|
||||
|
||||
if (phase == Phase.Ended) // if user won, stop receiving moves
|
||||
{
|
||||
reason = GetText(strs.ttt_matched_three);
|
||||
winner = _users[curUserIndex];
|
||||
_client.MessageReceived -= Client_MessageReceived;
|
||||
OnEnded?.Invoke(this);
|
||||
}
|
||||
else if (IsDraw())
|
||||
{
|
||||
reason = GetText(strs.ttt_a_draw);
|
||||
phase = Phase.Ended;
|
||||
_client.MessageReceived -= Client_MessageReceived;
|
||||
OnEnded?.Invoke(this);
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var del1 = msg.DeleteAsync();
|
||||
var del2 = previousMessage?.DeleteAsync();
|
||||
try { previousMessage = await _sender.Response(_channel).Embed(GetEmbed(reason)).SendAsync(); }
|
||||
catch { }
|
||||
|
||||
try { await del1; }
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
if (del2 is not null)
|
||||
await del2;
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
curUserIndex ^= 1;
|
||||
|
||||
timeoutTimer.Change(_options.TurnTimer * 1000, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_moveLock.Release();
|
||||
}
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public class Options : IEllieCommandOptions
|
||||
{
|
||||
[Option('t', "turn-timer", Required = false, Default = 15, HelpText = "Turn time in seconds. Default 15.")]
|
||||
public int TurnTimer { get; set; } = 15;
|
||||
|
||||
public void NormalizeOptions()
|
||||
{
|
||||
if (TurnTimer is < 5 or > 60)
|
||||
TurnTimer = 15;
|
||||
}
|
||||
}
|
||||
|
||||
private enum Phase
|
||||
{
|
||||
Starting,
|
||||
Started,
|
||||
Ended
|
||||
}
|
||||
}
|
54
src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs
Normal file
54
src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs
Normal file
|
@ -0,0 +1,54 @@
|
|||
#nullable disable
|
||||
using EllieBot.Modules.Games.Common;
|
||||
using EllieBot.Modules.Games.Services;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public partial class Games
|
||||
{
|
||||
[Group]
|
||||
public partial class TicTacToeCommands : EllieModule<GamesService>
|
||||
{
|
||||
private readonly SemaphoreSlim _sem = new(1, 1);
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public TicTacToeCommands(DiscordSocketClient client)
|
||||
=> _client = client;
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[EllieOptions<TicTacToe.Options>]
|
||||
public async Task TicTacToe(params string[] args)
|
||||
{
|
||||
var (options, _) = OptionsParser.ParseFrom(new TicTacToe.Options(), args);
|
||||
var channel = (ITextChannel)ctx.Channel;
|
||||
|
||||
await _sem.WaitAsync(1000);
|
||||
try
|
||||
{
|
||||
if (_service.TicTacToeGames.TryGetValue(channel.Id, out var game))
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await game.Start((IGuildUser)ctx.User);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
game = new(Strings, _client, channel, (IGuildUser)ctx.User, options, _sender);
|
||||
_service.TicTacToeGames.Add(channel.Id, game);
|
||||
await Response().Confirm(strs.ttt_created(ctx.User)).SendAsync();
|
||||
|
||||
game.OnEnded += _ =>
|
||||
{
|
||||
_service.TicTacToeGames.Remove(channel.Id);
|
||||
_sem.Dispose();
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sem.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
namespace EllieBot.Modules.Games.Common.Trivia;
|
||||
|
||||
public sealed class DefaultQuestionPool : IQuestionPool
|
||||
{
|
||||
private readonly ILocalDataCache _cache;
|
||||
private readonly EllieRandom _rng;
|
||||
|
||||
public DefaultQuestionPool(ILocalDataCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
_rng = new EllieRandom();
|
||||
}
|
||||
public async Task<TriviaQuestion?> GetQuestionAsync()
|
||||
{
|
||||
var pool = await _cache.GetTriviaQuestionsAsync();
|
||||
|
||||
if(pool is null or {Length: 0})
|
||||
return default;
|
||||
|
||||
return new(pool[_rng.Next(0, pool.Length)]);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
namespace EllieBot.Modules.Games.Common.Trivia;
|
||||
|
||||
public interface IQuestionPool
|
||||
{
|
||||
Task<TriviaQuestion?> GetQuestionAsync();
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
namespace EllieBot.Modules.Games.Common.Trivia;
|
||||
|
||||
public sealed class PokemonQuestionPool : IQuestionPool
|
||||
{
|
||||
public int QuestionsCount => 905; // xd
|
||||
private readonly EllieRandom _rng;
|
||||
private readonly ILocalDataCache _cache;
|
||||
|
||||
public PokemonQuestionPool(ILocalDataCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
_rng = new EllieRandom();
|
||||
}
|
||||
|
||||
public async Task<TriviaQuestion?> GetQuestionAsync()
|
||||
{
|
||||
var pokes = await _cache.GetPokemonMapAsync();
|
||||
|
||||
if (pokes is null or { Count: 0 })
|
||||
return default;
|
||||
|
||||
var num = _rng.Next(1, QuestionsCount + 1);
|
||||
return new(new()
|
||||
{
|
||||
Question = "Who's That Pokémon?",
|
||||
Answer = pokes[num].ToTitleCase(),
|
||||
Category = "Pokemon",
|
||||
ImageUrl = $@"https://nadeko.bot/images/pokemon/shadows/{num}.png",
|
||||
AnswerImageUrl = $@"https://nadeko.bot/images/pokemon/real/{num}.png"
|
||||
});
|
||||
}
|
||||
}
|
282
src/EllieBot/Modules/Games/Trivia/TriviaCommands.cs
Normal file
282
src/EllieBot/Modules/Games/Trivia/TriviaCommands.cs
Normal file
|
@ -0,0 +1,282 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using EllieBot.Modules.Games.Common.Trivia;
|
||||
using EllieBot.Modules.Games.Services;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public partial class Games
|
||||
{
|
||||
[Group]
|
||||
public partial class TriviaCommands : EllieModule<TriviaGamesService>
|
||||
{
|
||||
private readonly ILocalDataCache _cache;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly GamesConfigService _gamesConfig;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public TriviaCommands(
|
||||
DiscordSocketClient client,
|
||||
ILocalDataCache cache,
|
||||
ICurrencyService cs,
|
||||
GamesConfigService gamesConfig)
|
||||
{
|
||||
_cache = cache;
|
||||
_cs = cs;
|
||||
_gamesConfig = gamesConfig;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(0)]
|
||||
[EllieOptions<TriviaOptions>]
|
||||
public async Task Trivia(params string[] args)
|
||||
{
|
||||
var (opts, _) = OptionsParser.ParseFrom(new TriviaOptions(), args);
|
||||
|
||||
var config = _gamesConfig.Data;
|
||||
if (opts.WinRequirement != 0
|
||||
&& config.Trivia.MinimumWinReq > 0
|
||||
&& config.Trivia.MinimumWinReq > opts.WinRequirement)
|
||||
return;
|
||||
|
||||
var trivia = new TriviaGame(opts, _cache);
|
||||
if (_service.RunningTrivias.TryAdd(ctx.Guild.Id, trivia))
|
||||
{
|
||||
RegisterEvents(trivia);
|
||||
await trivia.RunAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var tg))
|
||||
{
|
||||
await Response().Error(strs.trivia_already_running).SendAsync();
|
||||
await tg.TriggerQuestionAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Tl()
|
||||
{
|
||||
if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var trivia))
|
||||
{
|
||||
await trivia.TriggerStatsAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await Response().Error(strs.trivia_none).SendAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Tq()
|
||||
{
|
||||
var channel = (ITextChannel)ctx.Channel;
|
||||
|
||||
if (_service.RunningTrivias.TryGetValue(channel.Guild.Id, out var trivia))
|
||||
{
|
||||
if (trivia.Stop())
|
||||
{
|
||||
try
|
||||
{
|
||||
await Response()
|
||||
.Confirm(GetText(strs.trivia_game), GetText(strs.trivia_stopping))
|
||||
.SendAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error sending trivia stopping message");
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await Response().Error(strs.trivia_none).SendAsync();
|
||||
}
|
||||
|
||||
private string GetLeaderboardString(TriviaGame tg)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
foreach (var (id, pts) in tg.GetLeaderboard())
|
||||
sb.AppendLine(GetText(strs.trivia_points(Format.Bold($"<@{id}>"), pts)));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private EmbedBuilder? questionEmbed;
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
228
src/EllieBot/Modules/Games/Trivia/TriviaGame.cs
Normal file
228
src/EllieBot/Modules/Games/Trivia/TriviaGame.cs
Normal 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);
|
||||
}
|
||||
}
|
37
src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs
Normal file
37
src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs
Normal file
|
@ -0,0 +1,37 @@
|
|||
#nullable disable
|
||||
using EllieBot.Common.ModuleBehaviors;
|
||||
using EllieBot.Modules.Games.Common.Trivia;
|
||||
|
||||
namespace EllieBot.Modules.Games;
|
||||
|
||||
public sealed class TriviaGamesService : IReadyExecutor, IEService
|
||||
{
|
||||
private readonly DiscordSocketClient _client;
|
||||
public ConcurrentDictionary<ulong, TriviaGame> RunningTrivias { get; } = new();
|
||||
|
||||
public TriviaGamesService(DiscordSocketClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public Task OnReadyAsync()
|
||||
{
|
||||
_client.MessageReceived += OnMessageReceived;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task OnMessageReceived(SocketMessage msg)
|
||||
{
|
||||
if (msg.Author.IsBot)
|
||||
return;
|
||||
|
||||
var umsg = msg as SocketUserMessage;
|
||||
|
||||
if (umsg?.Channel is not IGuildChannel gc)
|
||||
return;
|
||||
|
||||
if (RunningTrivias.TryGetValue(gc.GuildId, out var tg))
|
||||
await tg.InputAsync(new(umsg.Author.Mention, umsg.Author.Id), umsg.Content);
|
||||
}
|
||||
}
|
44
src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs
Normal file
44
src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs
Normal file
|
@ -0,0 +1,44 @@
|
|||
#nullable disable
|
||||
using CommandLine;
|
||||
|
||||
namespace EllieBot.Modules.Games.Common.Trivia;
|
||||
|
||||
public class TriviaOptions : IEllieCommandOptions
|
||||
{
|
||||
[Option('p', "pokemon", Required = false, Default = false, HelpText = "Whether it's 'Who's that pokemon?' trivia.")]
|
||||
public bool IsPokemon { get; set; } = false;
|
||||
|
||||
[Option("nohint", Required = false, Default = false, HelpText = "Don't show any hints.")]
|
||||
public bool NoHint { get; set; } = false;
|
||||
|
||||
[Option('w',
|
||||
"win-req",
|
||||
Required = false,
|
||||
Default = 10,
|
||||
HelpText = "Winning requirement. Set 0 for an infinite game. Default 10.")]
|
||||
public int WinRequirement { get; set; } = 10;
|
||||
|
||||
[Option('q',
|
||||
"question-timer",
|
||||
Required = false,
|
||||
Default = 30,
|
||||
HelpText = "How long until the question ends. Default 30.")]
|
||||
public int QuestionTimer { get; set; } = 30;
|
||||
|
||||
[Option('t',
|
||||
"timeout",
|
||||
Required = false,
|
||||
Default = 10,
|
||||
HelpText = "Number of questions of inactivity in order stop. Set 0 for never. Default 10.")]
|
||||
public int Timeout { get; set; } = 10;
|
||||
|
||||
public void NormalizeOptions()
|
||||
{
|
||||
if (WinRequirement < 0)
|
||||
WinRequirement = 10;
|
||||
if (QuestionTimer is < 10 or > 300)
|
||||
QuestionTimer = 30;
|
||||
if (Timeout is < 0 or > 20)
|
||||
Timeout = 10;
|
||||
}
|
||||
}
|
115
src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs
Normal file
115
src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs
Normal file
|
@ -0,0 +1,115 @@
|
|||
#nullable disable
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace EllieBot.Modules.Games.Common.Trivia;
|
||||
|
||||
public class TriviaQuestion
|
||||
{
|
||||
public const int MAX_STRING_LENGTH = 22;
|
||||
|
||||
//represents the min size to judge levDistance with
|
||||
private static readonly HashSet<Tuple<int, int>> _strictness =
|
||||
[
|
||||
new(9, 0),
|
||||
new(14, 1),
|
||||
new(19, 2),
|
||||
new(22, 3)
|
||||
];
|
||||
|
||||
public string Category
|
||||
=> _qModel.Category;
|
||||
|
||||
public string Question
|
||||
=> _qModel.Question;
|
||||
|
||||
public string ImageUrl
|
||||
=> _qModel.ImageUrl;
|
||||
|
||||
public string AnswerImageUrl
|
||||
=> _qModel.AnswerImageUrl ?? ImageUrl;
|
||||
|
||||
public string Answer
|
||||
=> _qModel.Answer;
|
||||
|
||||
public string CleanAnswer
|
||||
=> cleanAnswer ?? (cleanAnswer = Clean(Answer));
|
||||
|
||||
private string cleanAnswer;
|
||||
private readonly TriviaQuestionModel _qModel;
|
||||
|
||||
public TriviaQuestion(TriviaQuestionModel qModel)
|
||||
{
|
||||
_qModel = qModel;
|
||||
}
|
||||
|
||||
public string GetHint()
|
||||
=> Scramble(Answer);
|
||||
|
||||
public bool IsAnswerCorrect(string guess)
|
||||
{
|
||||
if (Answer.Equals(guess, StringComparison.InvariantCulture))
|
||||
return true;
|
||||
var cleanGuess = Clean(guess);
|
||||
if (CleanAnswer.Equals(cleanGuess, StringComparison.InvariantCulture))
|
||||
return true;
|
||||
|
||||
var levDistanceClean = CleanAnswer.LevenshteinDistance(cleanGuess);
|
||||
var levDistanceNormal = Answer.LevenshteinDistance(guess);
|
||||
return JudgeGuess(CleanAnswer.Length, cleanGuess.Length, levDistanceClean)
|
||||
|| JudgeGuess(Answer.Length, guess.Length, levDistanceNormal);
|
||||
}
|
||||
|
||||
private static bool JudgeGuess(int guessLength, int answerLength, int levDistance)
|
||||
{
|
||||
foreach (var level in _strictness)
|
||||
{
|
||||
if (guessLength <= level.Item1 || answerLength <= level.Item1)
|
||||
{
|
||||
if (levDistance <= level.Item2)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Clean(string str)
|
||||
{
|
||||
str = " " + str.ToLowerInvariant() + " ";
|
||||
str = Regex.Replace(str, @"\s+", " ");
|
||||
str = Regex.Replace(str, @"[^\w\d\s]", "");
|
||||
//Here's where custom modification can be done
|
||||
str = Regex.Replace(str, @"\s(a|an|the|of|in|for|to|as|at|be)\s", " ");
|
||||
//End custom mod and cleanup whitespace
|
||||
str = Regex.Replace(str, @"^\s+", "");
|
||||
str = Regex.Replace(str, @"\s+$", "");
|
||||
//Trim the really long answers
|
||||
str = str.Length <= MAX_STRING_LENGTH ? str : str[..MAX_STRING_LENGTH];
|
||||
return str;
|
||||
}
|
||||
|
||||
private static string Scramble(string word)
|
||||
{
|
||||
var letters = word.ToCharArray();
|
||||
var count = 0;
|
||||
for (var i = 0; i < letters.Length; i++)
|
||||
{
|
||||
if (letters[i] == ' ')
|
||||
continue;
|
||||
|
||||
count++;
|
||||
if (count <= letters.Length / 5)
|
||||
continue;
|
||||
|
||||
if (count % 3 == 0)
|
||||
continue;
|
||||
|
||||
if (letters[i] != ' ')
|
||||
letters[i] = '_';
|
||||
}
|
||||
|
||||
return string.Join(" ",
|
||||
new string(letters).Replace(" ", " \u2000", StringComparison.InvariantCulture).AsEnumerable());
|
||||
}
|
||||
}
|
3
src/EllieBot/Modules/Games/Trivia/TriviaUser.cs
Normal file
3
src/EllieBot/Modules/Games/Trivia/TriviaUser.cs
Normal file
|
@ -0,0 +1,3 @@
|
|||
namespace EllieBot.Modules.Games.Common.Trivia;
|
||||
|
||||
public record class TriviaUser(string Name, ulong Id);
|
Loading…
Reference in a new issue