diff --git a/src/NadekoBot/Modules/Games/Common/Connect4/Connect4.cs b/src/NadekoBot/Modules/Games/Common/Connect4/Connect4.cs new file mode 100644 index 00000000..0b247301 --- /dev/null +++ b/src/NadekoBot/Modules/Games/Common/Connect4/Connect4.cs @@ -0,0 +1,356 @@ +using NadekoBot.Common; +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace NadekoBot.Modules.Games.Common.Connect4 +{ + //todo: diagonal checking + public class Connect4Game : IDisposable + { + public enum Phase + { + Joining, // waiting for second player to join + P1Move, + P2Move, + Ended, + } + + public enum Field //temporary most likely + { + Empty, + P1, + P2, + } + + public enum Result + { + Draw, + CurrentPlayerWon, + OtherPlayerWon, + } + + public const int NumberOfColumns = 6; + public const int NumberOfRows = 7; + + public Phase CurrentPhase { get; private set; } = Phase.Joining; + + //state is bottom to top, left to right + private readonly Field[] _gameState = new Field[NumberOfRows * NumberOfColumns]; + private readonly (ulong UserId, string Username)?[] _players = new(ulong, string)?[2]; + + public ImmutableArray GameState => _gameState.ToImmutableArray(); + public ImmutableArray<(ulong UserId, string Username)?> Players => _players.ToImmutableArray(); + + public string CurrentPlayer => CurrentPhase == Phase.P1Move + ? _players[0].Value.Username + : _players[1].Value.Username; + + public string OtherPlayer => CurrentPhase == Phase.P2Move + ? _players[0].Value.Username + : _players[1].Value.Username; + + //public event Func OnGameStarted; + public event Func OnGameStateUpdated; + public event Func OnGameFailedToStart; + public event Func OnGameEnded; + + private readonly SemaphoreSlim _locker = new SemaphoreSlim(1, 1); + private readonly NadekoRandom _rng; + + private Timer _playerTimeoutTimer; + + /* rows = 4, columns = 3, total = 12 + * [][][][][][] + * [][][][][][] + * [][][][][][] + * [][][][][][] + * [][][][][][] + * [][][][][][] + * [][][][][][] + * */ + + public Connect4Game(ulong userId, string userName) + { + _players[0] = (userId, userName); + + _rng = new NadekoRandom(); + for (int i = 0; i < NumberOfColumns * NumberOfRows; i++) + { + _gameState[i] = Field.Empty; + } + } + + public void Initialize() + { + var _ = Task.Run(async () => + { + await Task.Delay(15000).ConfigureAwait(false); + await _locker.WaitAsync().ConfigureAwait(false); + try + { + if (_players[1] == null) + { + var __ = OnGameFailedToStart?.Invoke(this); + CurrentPhase = Phase.Ended; + return; + } + } + finally { _locker.Release(); } + }); + } + + public async Task Join(ulong userId, string userName) + { + await _locker.WaitAsync().ConfigureAwait(false); + try + { + if (CurrentPhase != Phase.Joining) //can't join if its not a joining phase + return false; + + if (_players[0].Value.UserId == userId) // same user can't join own game + return false; + + if (_rng.Next(0, 2) == 0) //rolling from 0-1, if number is 0, join as first player + { + _players[1] = _players[0]; + _players[0] = (userId, userName); + } + else //else join as a second player + _players[1] = (userId, userName); + + CurrentPhase = Phase.P1Move; //start the game + _playerTimeoutTimer = new Timer(async state => + { + await _locker.WaitAsync().ConfigureAwait(false); + try + { + EndGame(Result.OtherPlayerWon); + } + finally { _locker.Release(); } + }, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30)); + var __ = OnGameStateUpdated?.Invoke(this); + + return true; + } + finally { _locker.Release(); } + } + + public async Task Input(ulong userId, string userName, int inputCol) + { + await _locker.WaitAsync().ConfigureAwait(false); + try + { + inputCol -= 1; + if (CurrentPhase == Phase.Ended || CurrentPhase == Phase.Joining) + return false; + + if (!((_players[0].Value.UserId == userId && CurrentPhase == Phase.P1Move) + || (_players[1].Value.UserId == userId && CurrentPhase == Phase.P2Move))) + return false; + + if (inputCol < 0 || inputCol > NumberOfColumns) //invalid input + return false; + + if (IsColumnFull(inputCol)) //can't play there event? + return false; + + var start = NumberOfRows * inputCol; + for (int i = start; i < start + NumberOfRows; i++) + { + if (_gameState[i] == Field.Empty) + { + _gameState[i] = GetPlayerPiece(userId); + break; + } + } + + //check winnning condition + // ok, i'll go from [0-2] in rows (and through all columns) and check upward if 4 are connected + + for (int i = 0; i < NumberOfRows - 3; i++) + { + if (CurrentPhase == Phase.Ended) + break; + + for (int j = 0; j < NumberOfColumns; j++) + { + if (CurrentPhase == Phase.Ended) + break; + + var first = _gameState[i + j * NumberOfRows]; + if (first != Field.Empty) + { + //Console.WriteLine(i + j * NumberOfRows); + for (int k = 1; k < 4; k++) + { + var next = _gameState[i + k + j * NumberOfRows]; + if (next == first) + { + //Console.WriteLine(i + k + j * NumberOfRows); + if (k == 3) + EndGame(Result.CurrentPlayerWon); + else + continue; + } + else break; + } + } + } + } + + // i'll go [0-1] in columns (and through all rows) and check to the right if 4 are connected + for (int i = 0; i < NumberOfColumns - 3; i++) + { + if (CurrentPhase == Phase.Ended) + break; + + for (int j = 0; j < NumberOfRows; j++) + { + if (CurrentPhase == Phase.Ended) + break; + + var first = _gameState[j + i * NumberOfRows]; + if (first != Field.Empty) + { + for (int k = 1; k < 4; k++) + { + var next = _gameState[j + (i + k) * NumberOfRows]; + if (next == first) + if (k == 3) + EndGame(Result.CurrentPlayerWon); + else + continue; + else break; + } + } + } + } + + //need to check diagonal now + for (int col = 0; col < NumberOfColumns; col++) + { + if (CurrentPhase == Phase.Ended) + break; + + for (int row = 0; row < NumberOfRows; row++) + { + if (CurrentPhase == Phase.Ended) + break; + + var first = _gameState[row + col * NumberOfRows]; + + if (first != Field.Empty) + { + var same = 1; + + //top left + for (int i = 1; i < 4; i++) + { + //while going top left, rows are increasing, columns are decreasing + var curRow = row + i; + var curCol = col - i; + + //check if current values are in range + if (curRow >= NumberOfRows || curRow < 0) + break; + if (curCol < 0 || curCol >= NumberOfColumns) + break; + + var cur = _gameState[curRow + curCol * NumberOfRows]; + if (cur == first) + same++; + else break; + } + + if (same == 4) + { + EndGame(Result.CurrentPlayerWon); + break; + } + + //top right + for (int i = 1; i < 4; i++) + { + //while going top right, rows are increasing, columns are increasing + var curRow = row + i; + var curCol = col + i; + + //check if current values are in range + if (curRow >= NumberOfRows || curRow < 0) + break; + if (curCol < 0 || curCol >= NumberOfColumns) + break; + + var cur = _gameState[curRow + curCol * NumberOfRows]; + if (cur == first) + same++; + else break; + } + + if (same == 4) + { + EndGame(Result.CurrentPlayerWon); + break; + } + } + } + } + + //check draw? if it's even possible + if (_gameState.All(x => x != Field.Empty)) + { + EndGame(Result.Draw); + } + + if (CurrentPhase != Phase.Ended) + { + if (CurrentPhase == Phase.P1Move) + CurrentPhase = Phase.P2Move; + else + CurrentPhase = Phase.P1Move; + + ResetTimer(); + } + var _ = OnGameStateUpdated?.Invoke(this); + return true; + } + finally { _locker.Release(); } + } + + private void ResetTimer() + { + _playerTimeoutTimer.Change(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30)); + } + + private void EndGame(Result result) + { + var _ = OnGameEnded?.Invoke(this, result); + CurrentPhase = Phase.Ended; + } + + private Field GetPlayerPiece(ulong userId) => _players[0].Value.UserId == userId + ? Field.P1 + : Field.P2; + + //column is full if there are no empty fields + private bool IsColumnFull(int column) + { + var start = NumberOfRows * column; + for (int i = start; i < start + NumberOfRows; i++) + { + if (_gameState[i] == Field.Empty) + return false; + } + return true; + } + + public void Dispose() + { + OnGameFailedToStart = null; + OnGameStateUpdated = null; + } + } +} diff --git a/src/NadekoBot/Modules/Games/Connect4Commands.cs b/src/NadekoBot/Modules/Games/Connect4Commands.cs new file mode 100644 index 00000000..81d03b4f --- /dev/null +++ b/src/NadekoBot/Modules/Games/Connect4Commands.cs @@ -0,0 +1,180 @@ +using Discord; +using Discord.Commands; +using Discord.WebSocket; +using NadekoBot.Common.Attributes; +using NadekoBot.Extensions; +using NadekoBot.Modules.Games.Common.Connect4; +using System.Collections.Concurrent; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace NadekoBot.Modules.Games +{ + public partial class Games + { + public class Connect4Commands : NadekoSubmodule + { + public static ConcurrentDictionary Games = new ConcurrentDictionary(); + private readonly DiscordSocketClient _client; + + //private readonly string[] numbers = new string[] { "⓪", " ①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨" }; + private readonly string[] numbers = new string[] { ":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:"}; + + public Connect4Commands(DiscordSocketClient client) + { + _client = client; + } + + [NadekoCommand, Usage, Description, Aliases] + [RequireContext(ContextType.Guild)] + public async Task Connect4() + { + var newGame = new Connect4Game(Context.User.Id, Context.User.ToString()); + Connect4Game game; + if ((game = Games.GetOrAdd(Context.Channel.Id, newGame)) != newGame) + { + //means game already exists, try to join + var joined = await game.Join(Context.User.Id, Context.User.ToString()).ConfigureAwait(false); + return; + } + + game.OnGameStateUpdated += Game_OnGameStateUpdated; + game.OnGameFailedToStart += Game_OnGameFailedToStart; + game.OnGameEnded += Game_OnGameEnded; + _client.MessageReceived += _client_MessageReceived; + + game.Initialize(); + + await ReplyConfirmLocalized("connect4_created").ConfigureAwait(false); + + Task _client_MessageReceived(SocketMessage arg) + { + if (Context.Channel.Id != arg.Channel.Id) + return Task.CompletedTask; + + var _ = Task.Run(async () => + { + bool success = false; + if (int.TryParse(arg.Content, out var col)) + { + success = await game.Input(arg.Author.Id, arg.Author.ToString(), col).ConfigureAwait(false); + } + + if (success) + try { await arg.DeleteAsync().ConfigureAwait(false); } catch { } + else + { + if (game.CurrentPhase == Connect4Game.Phase.Joining + || game.CurrentPhase == Connect4Game.Phase.Ended) + { + return; + } + RepostCounter++; + if (RepostCounter == 0) + try { msg = await Context.Channel.SendMessageAsync("", embed: (Embed)msg.Embeds.First()); } catch { } + } + }); + return Task.CompletedTask; + } + + Task Game_OnGameFailedToStart(Connect4Game arg) + { + if (Games.TryRemove(Context.Channel.Id, out var toDispose)) + { + _client.MessageReceived -= _client_MessageReceived; + toDispose.Dispose(); + } + return ErrorLocalized("connect4_failed_to_start"); + } + + Task Game_OnGameEnded(Connect4Game arg, Connect4Game.Result result) + { + if (Games.TryRemove(Context.Channel.Id, out var toDispose)) + { + _client.MessageReceived -= _client_MessageReceived; + toDispose.Dispose(); + } + + string title; + if (result == Connect4Game.Result.CurrentPlayerWon) + { + title = GetText("connect4_won", Format.Bold(arg.CurrentPlayer), Format.Bold(arg.OtherPlayer)); + } + else if (result == Connect4Game.Result.OtherPlayerWon) + { + title = GetText("connect4_won", Format.Bold(arg.OtherPlayer), Format.Bold(arg.CurrentPlayer)); + } + else + title = GetText("connect4_draw"); + + return msg.ModifyAsync(x => x.Embed = new EmbedBuilder() + .WithTitle(title) + .WithDescription(GetGameStateText(game)) + .WithOkColor() + .Build()); + } + } + + private IUserMessage msg; + + private int _repostCounter = 0; + private int RepostCounter + { + get => _repostCounter; + set + { + if (value < 0 || value > 7) + _repostCounter = 0; + else _repostCounter = value; + } + } + + private async Task Game_OnGameStateUpdated(Connect4Game game) + { + var embed = new EmbedBuilder() + .WithTitle($"{game.CurrentPlayer} vs {game.OtherPlayer}") + .WithDescription(GetGameStateText(game)) + .WithOkColor(); + + + if (msg == null) + msg = await Context.Channel.EmbedAsync(embed).ConfigureAwait(false); + else + await msg.ModifyAsync(x => x.Embed = embed.Build()).ConfigureAwait(false); + } + + private string GetGameStateText(Connect4Game game) + { + var sb = new StringBuilder(); + + if (game.CurrentPhase == Connect4Game.Phase.P1Move || + game.CurrentPhase == Connect4Game.Phase.P2Move) + sb.AppendLine(GetText("connect4_player_to_move", Format.Bold(game.CurrentPlayer))); + + for (int i = Connect4Game.NumberOfRows; i > 0; i--) + { + for (int j = 0; j < Connect4Game.NumberOfColumns; j++) + { + //Console.WriteLine(i + (j * Connect4Game.NumberOfRows) - 1); + var cur = game.GameState[i + (j * Connect4Game.NumberOfRows) - 1]; + + if (cur == Connect4Game.Field.Empty) + sb.Append("⚫"); //black circle + else if (cur == Connect4Game.Field.P1) + sb.Append("🔴"); //red circle + else + sb.Append("🔵"); //blue circle + } + sb.AppendLine(); + } + + for (int i = 0; i < Connect4Game.NumberOfColumns; i++) + { + sb.Append(/*new string(' ', 1 + ((i + 1) / 2)) + */numbers[i]); + } + return sb.ToString(); + } + } + } +} diff --git a/src/NadekoBot/Modules/Games/NunchiCommands.cs b/src/NadekoBot/Modules/Games/NunchiCommands.cs index 3f4fdc7d..e5b3af96 100644 --- a/src/NadekoBot/Modules/Games/NunchiCommands.cs +++ b/src/NadekoBot/Modules/Games/NunchiCommands.cs @@ -64,21 +64,39 @@ namespace NadekoBot.Modules.Games await ConfirmLocalized("nunchi_failed_to_start").ConfigureAwait(false); } - async Task _client_MessageReceived(SocketMessage arg) + Task _client_MessageReceived(SocketMessage arg) { - if (arg.Channel.Id != Context.Channel.Id) - return; + var _ = Task.Run(async () => + { + if (arg.Channel.Id != Context.Channel.Id) + return; - if (!int.TryParse(arg.Content, out var number)) - return; - try + if (!int.TryParse(arg.Content, out var number)) + return; + try + { + await nunchi.Input(arg.Author.Id, arg.Author.ToString(), number).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.WriteLine(ex); + } + }); + return Task.CompletedTask; + } + + Task Nunchi_OnGameEnded(Nunchi arg1, string arg2) + { + if (Games.TryRemove(Context.Guild.Id, out var game)) { - await nunchi.Input(arg.Author.Id, arg.Author.ToString(), number).ConfigureAwait(false); - } - catch (Exception ex) - { - Console.WriteLine(ex); + _client.MessageReceived -= _client_MessageReceived; + game.Dispose(); } + + if (arg2 == null) + return ConfirmLocalized("nunchi_ended_no_winner", Format.Bold(arg2)); + else + return ConfirmLocalized("nunchi_ended", Format.Bold(arg2)); } } @@ -105,17 +123,6 @@ namespace NadekoBot.Modules.Games { return ConfirmLocalized("nunchi_started", Format.Bold(arg.ParticipantCount.ToString())); } - - private Task Nunchi_OnGameEnded(Nunchi arg1, string arg2) - { - if (Games.TryRemove(Context.Guild.Id, out var game)) - game.Dispose(); - - if(arg2 == null) - return ConfirmLocalized("nunchi_ended_no_winner", Format.Bold(arg2)); - else - return ConfirmLocalized("nunchi_ended", Format.Bold(arg2)); - } } } } \ No newline at end of file diff --git a/src/NadekoBot/Modules/Searches/Common/SearchImageCacher.cs b/src/NadekoBot/Modules/Searches/Common/SearchImageCacher.cs index cc446af0..18bc2064 100644 --- a/src/NadekoBot/Modules/Searches/Common/SearchImageCacher.cs +++ b/src/NadekoBot/Modules/Searches/Common/SearchImageCacher.cs @@ -101,19 +101,19 @@ namespace NadekoBot.Modules.Searches.Common website = $"https://e621.net/post/index.json?limit=1000&tags={tag}"; break; case DapiSearchType.Danbooru: - website = $"http://danbooru.donmai.us/posts.json?limit=200&tags={tag}"; + website = $"http://danbooru.donmai.us/posts.json?limit=100&tags={tag}"; break; case DapiSearchType.Gelbooru: - website = $"http://gelbooru.com/index.php?page=dapi&s=post&q=index&limit=1000&tags={tag}"; + website = $"http://gelbooru.com/index.php?page=dapi&s=post&q=index&limit=100&tags={tag}"; break; case DapiSearchType.Rule34: website = $"https://rule34.xxx/index.php?page=dapi&s=post&q=index&limit=100&tags={tag}"; break; case DapiSearchType.Konachan: - website = $"https://konachan.com/post.json?s=post&q=index&limit=1000&tags={tag}"; + website = $"https://konachan.com/post.json?s=post&q=index&limit=100&tags={tag}"; break; case DapiSearchType.Yandere: - website = $"https://yande.re/post.json?limit=1000&tags={tag}"; + website = $"https://yande.re/post.json?limit=100&tags={tag}"; break; } @@ -137,7 +137,7 @@ namespace NadekoBot.Modules.Searches.Common private async Task LoadXmlAsync(string website, DapiSearchType type) { - var list = new List(1000); + var list = new List(); using (var http = new HttpClient()) { using (var reader = XmlReader.Create(await http.GetStreamAsync(website), new XmlReaderSettings() diff --git a/src/NadekoBot/Modules/Searches/Services/SearchesService.cs b/src/NadekoBot/Modules/Searches/Services/SearchesService.cs index c8819b11..396937a9 100644 --- a/src/NadekoBot/Modules/Searches/Services/SearchesService.cs +++ b/src/NadekoBot/Modules/Searches/Services/SearchesService.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using NadekoBot.Modules.Searches.Common; -using NadekoBot.Common.Collections; using NadekoBot.Services.Database.Models; using System.Linq; using Microsoft.EntityFrameworkCore; diff --git a/src/NadekoBot/Resources/CommandStrings.resx b/src/NadekoBot/Resources/CommandStrings.resx index 6336b76f..6a77668d 100644 --- a/src/NadekoBot/Resources/CommandStrings.resx +++ b/src/NadekoBot/Resources/CommandStrings.resx @@ -1278,6 +1278,15 @@ `{0}nunchi` + + connect4 con4 + + + Creates or joins an existing connect4 game. 2 players are required for the game. Objective of the game is to get 4 of your pieces next to each other in a vertical, horizontal or diagonal line. + + + `{0}connect4` + raffle diff --git a/src/NadekoBot/_strings/ResponseStrings.en-US.json b/src/NadekoBot/_strings/ResponseStrings.en-US.json index c96f9a68..8928ba6c 100644 --- a/src/NadekoBot/_strings/ResponseStrings.en-US.json +++ b/src/NadekoBot/_strings/ResponseStrings.en-US.json @@ -349,6 +349,11 @@ "games_category": "Category", "games_cleverbot_disabled": "Disabled cleverbot on this server.", "games_cleverbot_enabled": "Enabled cleverbot on this server.", + "games_connect4_created": "Created a Connect4 game. Waiting for a player to join.", + "games_connect4_player_to_move": "Player to move: {0}", + "games_connect4_failed_to_start": "Connect4 game failed to start because nobody joined.", + "games_connect4_draw": "Connect4 game ended in a draw.", + "games_connect4_won": "{0} won the game of Connect4 against {1}.", "games_curgen_disabled": "Currency generation has been disabled on this channel.", "games_curgen_enabled": "Currency generation has been enabled on this channel.", "games_curgen_pl": "{0} random {1} appeared!",