From d5978a0d66d0af779b84dac2fc6cbf8263953e60 Mon Sep 17 00:00:00 2001 From: Master Kwoth Date: Fri, 28 Jul 2017 12:28:08 +0200 Subject: [PATCH] .hangman completely rewritten. Should work almost the same, with some minor improvements (such as showing the category, and you can now guess the whole word at once) --- .../Modules/Games/AcropobiaCommands.cs | 6 - .../Exceptions/TermNotFoundException.cs | 11 + .../Modules/Games/Common/Hangman/Hangman.cs | 169 ++++++++++++++ .../Games/Common/Hangman/HangmanGame.cs | 214 ------------------ .../{IHangmanObject.cs => HangmanObject.cs} | 0 .../Modules/Games/Common/Hangman/Phase.cs | 14 ++ .../Modules/Games/Common/Hangman/TermPool.cs | 52 +++++ .../Modules/Games/Common/Hangman/TermType.cs | 18 ++ .../Modules/Games/HangmanCommands.cs | 91 ++++++-- 9 files changed, 339 insertions(+), 236 deletions(-) create mode 100644 src/NadekoBot/Modules/Games/Common/Hangman/Exceptions/TermNotFoundException.cs create mode 100644 src/NadekoBot/Modules/Games/Common/Hangman/Hangman.cs delete mode 100644 src/NadekoBot/Modules/Games/Common/Hangman/HangmanGame.cs rename src/NadekoBot/Modules/Games/Common/Hangman/{IHangmanObject.cs => HangmanObject.cs} (100%) create mode 100644 src/NadekoBot/Modules/Games/Common/Hangman/Phase.cs create mode 100644 src/NadekoBot/Modules/Games/Common/Hangman/TermPool.cs create mode 100644 src/NadekoBot/Modules/Games/Common/Hangman/TermType.cs diff --git a/src/NadekoBot/Modules/Games/AcropobiaCommands.cs b/src/NadekoBot/Modules/Games/AcropobiaCommands.cs index c9deb585..f933d3bf 100644 --- a/src/NadekoBot/Modules/Games/AcropobiaCommands.cs +++ b/src/NadekoBot/Modules/Games/AcropobiaCommands.cs @@ -2,18 +2,12 @@ using Discord.Commands; using Discord.WebSocket; using NadekoBot.Extensions; -using NLog; -using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; -using System.Threading; using System.Threading.Tasks; -using NadekoBot.Common; using NadekoBot.Common.Attributes; -using NadekoBot.Common.Collections; -using NadekoBot.Services.Impl; using NadekoBot.Modules.Games.Common.Acrophobia; namespace NadekoBot.Modules.Games diff --git a/src/NadekoBot/Modules/Games/Common/Hangman/Exceptions/TermNotFoundException.cs b/src/NadekoBot/Modules/Games/Common/Hangman/Exceptions/TermNotFoundException.cs new file mode 100644 index 00000000..01573bf1 --- /dev/null +++ b/src/NadekoBot/Modules/Games/Common/Hangman/Exceptions/TermNotFoundException.cs @@ -0,0 +1,11 @@ +using System; + +namespace NadekoBot.Modules.Games.Common.Hangman.Exceptions +{ + public class TermNotFoundException : Exception + { + public TermNotFoundException() : base("Term of that type couldn't be found") + { + } + } +} diff --git a/src/NadekoBot/Modules/Games/Common/Hangman/Hangman.cs b/src/NadekoBot/Modules/Games/Common/Hangman/Hangman.cs new file mode 100644 index 00000000..94f14835 --- /dev/null +++ b/src/NadekoBot/Modules/Games/Common/Hangman/Hangman.cs @@ -0,0 +1,169 @@ +using NadekoBot.Extensions; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace NadekoBot.Modules.Games.Common.Hangman +{ + public class Hangman : IDisposable + { + public string TermType { get; } + public HangmanObject Term { get; } + + public string ScrambledWord => "`" + String.Concat(Term.Word.Select(c => + { + if (c == ' ') + return " \u2000"; + if (!(char.IsLetter(c) || char.IsDigit(c))) + return $" {c}"; + + c = char.ToLowerInvariant(c); + return _previousGuesses.Contains(c) ? $" {c}" : " ◯"; + })) + "`"; + + private Phase _currentPhase = Phase.Active; + public Phase CurrentPhase + { + get => _currentPhase; + set + { + if (value == Phase.Ended) + _endingCompletionSource.TrySetResult(true); + + _currentPhase = value; + } + } + + private readonly SemaphoreSlim _locker = new SemaphoreSlim(1, 1); + + private readonly HashSet _recentUsers = new HashSet(); + + public uint Errors { get; private set; } = 0; + public uint MaxErrors { get; } = 6; + + public event Func OnGameEnded = delegate { return Task.CompletedTask; }; + public event Func OnLetterAlreadyUsed = delegate { return Task.CompletedTask; }; + public event Func OnGuessFailed = delegate { return Task.CompletedTask; }; + public event Func OnGuessSucceeded = delegate { return Task.CompletedTask; }; + + private readonly HashSet _previousGuesses = new HashSet(); + public ImmutableArray PreviousGuesses => _previousGuesses.ToImmutableArray(); + + private readonly TaskCompletionSource _endingCompletionSource = new TaskCompletionSource(); + + public Task EndedTask => _endingCompletionSource.Task; + + public Hangman(TermType type) + { + this.TermType = type.ToString().Replace('_', ' ').ToTitleCase(); + this.Term = TermPool.GetTerm(type); + } + + private void AddError() + { + Errors++; + if (Errors > MaxErrors) + { + CurrentPhase = Phase.Ended; + var _ = OnGameEnded(this, null); + } + } + + public string GetHangman() => $@". ┌─────┐ +.┃...............┋ +.┃...............┋ +.┃{(Errors > 0 ? ".............😲" : "")} +.┃{(Errors > 1 ? "............./" : "")} {(Errors > 2 ? "|" : "")} {(Errors > 3 ? "\\" : "")} +.┃{(Errors > 4 ? "............../" : "")} {(Errors > 5 ? "\\" : "")} +/-\"; + + public async Task Input(ulong userId, string userName, string input) + { + if (CurrentPhase == Phase.Ended) + return; + + if (string.IsNullOrWhiteSpace(input)) + return; + + input = input.Trim().ToLowerInvariant(); + + await _locker.WaitAsync().ConfigureAwait(false); + try + { + if (CurrentPhase == Phase.Ended) + return; + + if (input.Length > 1) // tried to guess the whole word + { + if (input != Term.Word) // failed + return; + + CurrentPhase = Phase.Ended; + var _ = OnGameEnded?.Invoke(this, userName); + return; + } + + var ch = input[0]; + + if (!(char.IsLetterOrDigit(ch))) + return; + + if (!_recentUsers.Add(userId)) // don't let a single user spam guesses + return; + + if (!_previousGuesses.Add(ch)) // that latter was already guessed + { + var _ = OnLetterAlreadyUsed?.Invoke(this, userName, ch); + AddError(); + } + else if (!Term.Word.Contains(ch)) // guessed letter doesn't exist + { + var _ = OnGuessFailed?.Invoke(this, userName, ch); + AddError(); + } + else if (Term.Word.All(x => _previousGuesses.IsSupersetOf(Term.Word.ToLowerInvariant() + .Where(c => char.IsLetterOrDigit(c))))) + { + var _ = OnGameEnded.Invoke(this, userName); //if all letters are guessed + } + else //guessed but not last letter + { + var _ = OnGuessSucceeded?.Invoke(this, userName, ch); + _recentUsers.Remove(userId); // he can guess again right away + return; + } + + var clearSpam = Task.Run(async () => + { + await Task.Delay(3000).ConfigureAwait(false); // remove the user from the spamlist after 5 seconds + _recentUsers.Remove(userId); + }); + } + finally { _locker.Release(); } + } + + public async Task Stop() + { + await _locker.WaitAsync().ConfigureAwait(false); + try + { + CurrentPhase = Phase.Ended; + } + finally { _locker.Release(); } + } + + public void Dispose() + { + OnGameEnded = null; + OnGuessFailed = null; + OnGuessSucceeded = null; + OnLetterAlreadyUsed = null; + _previousGuesses.Clear(); + _recentUsers.Clear(); + _locker.Dispose(); + } + } +} diff --git a/src/NadekoBot/Modules/Games/Common/Hangman/HangmanGame.cs b/src/NadekoBot/Modules/Games/Common/Hangman/HangmanGame.cs deleted file mode 100644 index f0f5c820..00000000 --- a/src/NadekoBot/Modules/Games/Common/Hangman/HangmanGame.cs +++ /dev/null @@ -1,214 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Discord; -using Discord.WebSocket; -using NadekoBot.Common; -using NadekoBot.Extensions; -using Newtonsoft.Json; -using NLog; - -namespace NadekoBot.Modules.Games.Common.Hangman -{ - public class HangmanTermPool - { - const string termsPath = "data/hangman3.json"; - public static IReadOnlyDictionary data { get; } - static HangmanTermPool() - { - try - { - data = JsonConvert.DeserializeObject>(File.ReadAllText(termsPath)); - } - catch (Exception) - { - //ignored - } - } - - public static HangmanObject GetTerm(string type) - { - if (string.IsNullOrWhiteSpace(type)) - throw new ArgumentNullException(nameof(type)); - - type = type.Trim(); - - var rng = new NadekoRandom(); - - if (type == "All") { - var keys = data.Keys.ToArray(); - type = keys[rng.Next(0, keys.Length)]; - } - - HangmanObject[] termTypes; - data.TryGetValue(type, out termTypes); - - if (termTypes == null || termTypes.Length == 0) - return null; - - return termTypes[rng.Next(0, termTypes.Length)]; - } - } - - public class HangmanGame: IDisposable - { - private readonly Logger _log; - private readonly DiscordSocketClient _client; - - public IMessageChannel GameChannel { get; } - public HashSet Guesses { get; } = new HashSet(); - public HangmanObject Term { get; private set; } - public uint Errors { get; private set; } = 0; - public uint MaxErrors { get; } = 6; - public uint MessagesSinceLastPost { get; private set; } = 0; - public string ScrambledWord => "`" + String.Concat(Term.Word.Select(c => - { - if (c == ' ') - return " \u2000"; - if (!(char.IsLetter(c) || char.IsDigit(c))) - return $" {c}"; - - c = char.ToUpperInvariant(c); - return Guesses.Contains(c) ? $" {c}" : " ◯"; - })) + "`"; - - public bool GuessedAll => Guesses.IsSupersetOf(Term.Word.ToUpperInvariant() - .Where(c => char.IsLetter(c) || char.IsDigit(c))); - - public string TermType { get; } - - public event Action OnEnded; - - public HangmanGame(DiscordSocketClient client, IMessageChannel channel, string type) - { - _log = LogManager.GetCurrentClassLogger(); - _client = client; - - this.GameChannel = channel; - this.TermType = type.ToTitleCase(); - } - - public void Start() - { - this.Term = HangmanTermPool.GetTerm(TermType); - - if (this.Term == null) - throw new KeyNotFoundException("Can't find a term with that type. Use hangmanlist command."); - // start listening for answers when game starts - _client.MessageReceived += PotentialGuess; - } - - public async Task End() - { - _client.MessageReceived -= PotentialGuess; - OnEnded(this); - var toSend = "Game ended. You **" + (Errors >= MaxErrors ? "LOSE" : "WIN") + "**!\n" + GetHangman(); - var embed = new EmbedBuilder().WithTitle("Hangman Game") - .WithDescription(toSend) - .AddField(efb => efb.WithName("It was").WithValue(Term.Word)) - .WithFooter(efb => efb.WithText(string.Join(" ", Guesses))); - if(Uri.IsWellFormedUriString(Term.ImageUrl, UriKind.Absolute)) - embed.WithImageUrl(Term.ImageUrl); - - if (Errors >= MaxErrors) - await GameChannel.EmbedAsync(embed.WithErrorColor()).ConfigureAwait(false); - else - await GameChannel.EmbedAsync(embed.WithOkColor()).ConfigureAwait(false); - } - - private Task PotentialGuess(SocketMessage msg) - { - var _ = Task.Run(async () => - { - try - { - if (!(msg is SocketUserMessage)) - return; - - if (msg.Channel != GameChannel) - return; // message's channel has to be the same as game's - if (msg.Content.Length == 1) // message must be 1 char long - { - if (++MessagesSinceLastPost > 10) - { - MessagesSinceLastPost = 0; - try - { - await GameChannel.SendConfirmAsync("Hangman Game", - ScrambledWord + "\n" + GetHangman(), - footer: string.Join(" ", Guesses)).ConfigureAwait(false); - } - catch { } - } - - if (!(char.IsLetter(msg.Content[0]) || char.IsDigit(msg.Content[0])))// and a letter or a digit - return; - - var guess = char.ToUpperInvariant(msg.Content[0]); - if (Guesses.Contains(guess)) - { - MessagesSinceLastPost = 0; - ++Errors; - if (Errors < MaxErrors) - await GameChannel.SendErrorAsync("Hangman Game", $"{msg.Author} Letter `{guess}` has already been used.\n" + ScrambledWord + "\n" + GetHangman(), - footer: string.Join(" ", Guesses)).ConfigureAwait(false); - else - await End().ConfigureAwait(false); - return; - } - - Guesses.Add(guess); - - if (Term.Word.ToUpperInvariant().Contains(guess)) - { - if (GuessedAll) - { - try { await GameChannel.SendConfirmAsync("Hangman Game", $"{msg.Author} guessed a letter `{guess}`!").ConfigureAwait(false); } catch { } - - await End().ConfigureAwait(false); - return; - } - MessagesSinceLastPost = 0; - try - { - await GameChannel.SendConfirmAsync("Hangman Game", $"{msg.Author} guessed a letter `{guess}`!\n" + ScrambledWord + "\n" + GetHangman(), - footer: string.Join(" ", Guesses)).ConfigureAwait(false); - } - catch { } - - } - else - { - MessagesSinceLastPost = 0; - ++Errors; - if (Errors < MaxErrors) - await GameChannel.SendErrorAsync("Hangman Game", $"{msg.Author} Letter `{guess}` does not exist.\n" + ScrambledWord + "\n" + GetHangman(), - footer: string.Join(" ", Guesses)).ConfigureAwait(false); - else - await End().ConfigureAwait(false); - } - - } - } - catch (Exception ex) { _log.Warn(ex); } - }); - return Task.CompletedTask; - } - - public string GetHangman() => $@". ┌─────┐ -.┃...............┋ -.┃...............┋ -.┃{(Errors > 0 ? ".............😲" : "")} -.┃{(Errors > 1 ? "............./" : "")} {(Errors > 2 ? "|" : "")} {(Errors > 3 ? "\\" : "")} -.┃{(Errors > 4 ? "............../" : "")} {(Errors > 5 ? "\\" : "")} -/-\"; - - public void Dispose() - { - _client.MessageReceived -= PotentialGuess; - OnEnded = null; - } - } -} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Games/Common/Hangman/IHangmanObject.cs b/src/NadekoBot/Modules/Games/Common/Hangman/HangmanObject.cs similarity index 100% rename from src/NadekoBot/Modules/Games/Common/Hangman/IHangmanObject.cs rename to src/NadekoBot/Modules/Games/Common/Hangman/HangmanObject.cs diff --git a/src/NadekoBot/Modules/Games/Common/Hangman/Phase.cs b/src/NadekoBot/Modules/Games/Common/Hangman/Phase.cs new file mode 100644 index 00000000..b23b856b --- /dev/null +++ b/src/NadekoBot/Modules/Games/Common/Hangman/Phase.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace NadekoBot.Modules.Games.Common.Hangman +{ + public enum Phase + { + Active, + Ended, + } +} diff --git a/src/NadekoBot/Modules/Games/Common/Hangman/TermPool.cs b/src/NadekoBot/Modules/Games/Common/Hangman/TermPool.cs new file mode 100644 index 00000000..4647e740 --- /dev/null +++ b/src/NadekoBot/Modules/Games/Common/Hangman/TermPool.cs @@ -0,0 +1,52 @@ +using NadekoBot.Common; +using NadekoBot.Extensions; +using NadekoBot.Modules.Games.Common.Hangman.Exceptions; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; + +namespace NadekoBot.Modules.Games.Common.Hangman +{ + public class TermPool + { + const string termsPath = "data/hangman3.json"; + public static IReadOnlyDictionary data { get; } = new Dictionary(); + static TermPool() + { + try + { + data = JsonConvert.DeserializeObject>(File.ReadAllText(termsPath)); + } + catch (Exception) + { + //ignored + } + } + + private static readonly ImmutableArray _termTypes = Enum.GetValues(typeof(TermType)) + .Cast() + .ToImmutableArray(); + + public static HangmanObject GetTerm(TermType type) + { + var rng = new NadekoRandom(); + + if (type == TermType.Random) + { + var keys = data.Keys.ToArray(); + + type = _termTypes[rng.Next(0, _termTypes.Length - 1)]; // - 1 because last one is 'all' + } + if (!data.TryGetValue(type.ToString(), out var termTypes) || termTypes.Length == 0) + throw new TermNotFoundException(); + + var obj = termTypes[rng.Next(0, termTypes.Length)]; + + obj.Word = obj.Word.Trim().ToLowerInvariant(); + return obj; + } + } +} diff --git a/src/NadekoBot/Modules/Games/Common/Hangman/TermType.cs b/src/NadekoBot/Modules/Games/Common/Hangman/TermType.cs new file mode 100644 index 00000000..f3c09af6 --- /dev/null +++ b/src/NadekoBot/Modules/Games/Common/Hangman/TermType.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace NadekoBot.Modules.Games.Common.Hangman +{ + [Flags] + public enum TermType + { + Countries = 0, + Movies = 1, + Animals = 2, + Things = 4, + Random = 8, + } +} diff --git a/src/NadekoBot/Modules/Games/HangmanCommands.cs b/src/NadekoBot/Modules/Games/HangmanCommands.cs index 5ebd979a..9d28d49a 100644 --- a/src/NadekoBot/Modules/Games/HangmanCommands.cs +++ b/src/NadekoBot/Modules/Games/HangmanCommands.cs @@ -23,43 +23,102 @@ namespace NadekoBot.Modules.Games } //channelId, game - public static ConcurrentDictionary HangmanGames { get; } = new ConcurrentDictionary(); + public static ConcurrentDictionary HangmanGames { get; } = new ConcurrentDictionary(); + [NadekoCommand, Usage, Description, Aliases] [RequireContext(ContextType.Guild)] public async Task Hangmanlist() { - await Context.Channel.SendConfirmAsync(Format.Code(GetText("hangman_types", Prefix)) + "\n" + string.Join(", ", HangmanTermPool.data.Keys)); + await Context.Channel.SendConfirmAsync(Format.Code(GetText("hangman_types", Prefix)) + "\n" + string.Join(", ", TermPool.data.Keys)); } [NadekoCommand, Usage, Description, Aliases] [RequireContext(ContextType.Guild)] - public async Task Hangman([Remainder]string type = "All") + public async Task Hangman([Remainder]TermType type = TermType.Random) { - var hm = new HangmanGame(_client, Context.Channel, type); + var hm = new Hangman(type); if (!HangmanGames.TryAdd(Context.Channel.Id, hm)) { + hm.Dispose(); await ReplyErrorLocalized("hangman_running").ConfigureAwait(false); return; } + hm.OnGameEnded += Hm_OnGameEnded; + hm.OnGuessFailed += Hm_OnGuessFailed; + hm.OnGuessSucceeded += Hm_OnGuessSucceeded; + hm.OnLetterAlreadyUsed += Hm_OnLetterAlreadyUsed; + _client.MessageReceived += _client_MessageReceived; - hm.OnEnded += g => - { - HangmanGames.TryRemove(g.GameChannel.Id, out _); - }; try { - hm.Start(); + await Context.Channel.SendConfirmAsync(GetText("hangman_game_started") + $" ({hm.TermType})", + hm.ScrambledWord + "\n" + hm.GetHangman()) + .ConfigureAwait(false); } - catch (Exception ex) + catch { } + + await hm.EndedTask.ConfigureAwait(false); + + + Task _client_MessageReceived(SocketMessage msg) { - try { await Context.Channel.SendErrorAsync(GetText("hangman_start_errored") + " " + ex.Message).ConfigureAwait(false); } catch { } - if(HangmanGames.TryRemove(Context.Channel.Id, out var removed)) - removed.Dispose(); - return; + var _ = Task.Run(() => + { + if (Context.Channel.Id == msg.Channel.Id) + return hm.Input(msg.Author.Id, msg.Author.ToString(), msg.Content); + else + return Task.CompletedTask; + }); + return Task.CompletedTask; + } + } + + Task Hm_OnGameEnded(Hangman game, string winner) + { + HangmanGames.TryRemove(Context.Channel.Id, out _); + + if (winner == null) + { + var loseEmbed = new EmbedBuilder().WithTitle($"Hangman Game ({game.TermType}) - Ended") + .WithDescription(Format.Bold("You lose.")) + .AddField(efb => efb.WithName("It was").WithValue(game.Term.Word.ToTitleCase())) + .WithFooter(efb => efb.WithText(string.Join(" ", game.PreviousGuesses))) + .WithErrorColor(); + + if (Uri.IsWellFormedUriString(game.Term.ImageUrl, UriKind.Absolute)) + loseEmbed.WithImageUrl(game.Term.ImageUrl); + + return Context.Channel.EmbedAsync(loseEmbed); } - await Context.Channel.SendConfirmAsync(GetText("hangman_game_started"), hm.ScrambledWord + "\n" + hm.GetHangman()); + var winEmbed = new EmbedBuilder().WithTitle($"Hangman Game ({game.TermType}) - Ended") + .WithDescription(Format.Bold($"{winner} Won.")) + .AddField(efb => efb.WithName("It was").WithValue(game.Term.Word.ToTitleCase())) + .WithFooter(efb => efb.WithText(string.Join(" ", game.PreviousGuesses))) + .WithOkColor(); + + if (Uri.IsWellFormedUriString(game.Term.ImageUrl, UriKind.Absolute)) + winEmbed.WithImageUrl(game.Term.ImageUrl); + + return Context.Channel.EmbedAsync(winEmbed); + } + + private Task Hm_OnLetterAlreadyUsed(Hangman game, string user, char guess) + { + return Context.Channel.SendErrorAsync($"Hangman Game ({game.TermType})", $"{user} Letter `{guess}` has already been used. You can guess again in 3 seconds.\n" + game.ScrambledWord + "\n" + game.GetHangman(), + footer: string.Join(" ", game.PreviousGuesses)); + } + + private Task Hm_OnGuessSucceeded(Hangman game, string user, char guess) + { + return Context.Channel.SendConfirmAsync($"Hangman Game ({game.TermType})", $"{user} guessed a letter `{guess}`!\n" + game.ScrambledWord + "\n" + game.GetHangman()); + } + + private Task Hm_OnGuessFailed(Hangman game, string user, char guess) + { + return Context.Channel.SendErrorAsync($"Hangman Game ({game.TermType})", $"{user} Letter `{guess}` does not exist. You can guess again in 3 seconds.\n" + game.ScrambledWord + "\n" + game.GetHangman(), + footer: string.Join(" ", game.PreviousGuesses)); } [NadekoCommand, Usage, Description, Aliases] @@ -74,4 +133,4 @@ namespace NadekoBot.Modules.Games } } } -} +} \ No newline at end of file