diff --git a/pom.xml b/pom.xml index b1d2179..ef5b782 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.tictactoebot TicTacToeBot - 1.0 + 3.0 17 @@ -14,6 +14,11 @@ UTF-8 + + org.mariadb.jdbc + mariadb-java-client + 3.0.7 + net.dv8tion JDA @@ -30,10 +35,11 @@ 2.0.0 - org.mongodb - mongodb-driver - 3.12.11 + com.zaxxer + HikariCP + 5.0.1 + diff --git a/src/main/java/com/tictactoebot/Database.java b/src/main/java/com/tictactoebot/Database.java index 43e1464..8db1d3d 100644 --- a/src/main/java/com/tictactoebot/Database.java +++ b/src/main/java/com/tictactoebot/Database.java @@ -1,16 +1,292 @@ package com.tictactoebot; -import com.mongodb.client.MongoClient; +/*import com.mongodb.client.MongoClient; import com.mongodb.client.MongoClients; import com.mongodb.client.MongoCollection; -import com.mongodb.client.MongoDatabase; +import com.mongodb.client.MongoDatabase;*/ +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.User; +import org.mariadb.jdbc.MariaDbPoolDataSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.*; import java.util.HashMap; import java.util.Map; -public class Database { - private static final MongoClient mongoClient = MongoClients.create("mongodb+srv://srin:dQum7hg9t6nNrza@cluster0.wk7r9tr.mongodb.net/?retryWrites=true&w=majority"); - private static final MongoDatabase database = mongoClient.getDatabase("tictactoe"); - public static final MongoCollection LEVELS = database.getCollection("levels"); +public class Database implements AutoCloseable { + private final MariaDbPoolDataSource pool; + public static final Map MATCH_DATA_MAP = new HashMap<>(); + private static final Logger LOGGER = LoggerFactory.getLogger(Database.class); + + public Database(String user, String password, String host, String name) { + String connectionStr = "jdbc:mariadb://" + host + "/" + name + "?user=" + user + "&password=" + password + "&maxPoolSize=20"; + try { + pool = new MariaDbPoolDataSource(connectionStr); + try (Connection connection = pool.getConnection()) { + try (Statement statement = connection.createStatement()) { + ResultSet res = statement.executeQuery("select TABLE_NAME from information_schema.TABLES where TABLE_NAME='levels'"); + if (res.next()) return; + statement.execute(""" + create table levels( + user_id varchar(18), + guild_id varchar(18), + draws int default 0, + wins int default 0, + loses int default 0, + level int default 0, + xp int default 0, + xp_limit int default 100, + tag varchar(20), + + constraint id primary key (user_id, guild_id) + ); + """); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + + } + + + public void insertNewUser(Guild guild, User user) { + if (user.isBot()) return; + String guildId = guild.getId(); + String userId = user.getId(); + try (Connection connection = pool.getConnection()) { + try (Statement statement = connection.createStatement()) { + ResultSet res = statement.executeQuery("select tag from levels where user_id='" + userId + "' and guild_id='" + guildId + '\''); + if (res.next()) { + return; + } + statement.execute("insert into levels(user_id, guild_id, tag) values ('" + userId + "', '" + guildId + "', '" + user.getAsTag() + "')"); + } + } catch (SQLException exception) { + LOGGER.error(exception.getSQLState(), exception); + } + /*Document result = Database.LEVELS.find( + Filters.and( + Filters.eq("guildId", guildId), + Filters.eq("userId", userId) + ) + ).first(); + if (result == null) { + Database.LEVELS.insertOne(new Document() + .append("guildId", guildId) + .append("userId", userId) + .append("tag", user.getAsTag()) + .append("game-level", 0) + .append("game-xp", 0) + .append("game-xp-limit", 100) + .append("wins", 0) + .append("loses", 0) + .append("draws", 0) + ); + }*/ + } + + public UserInfo getUserInfo(String id, String guildId) { + UserInfo info = UserInfo.NULL; + try (Connection connection = pool.getConnection()) { + try (Statement statement = connection.createStatement()) { + ResultSet res = statement.executeQuery("select * from levels where user_id='" + id + "' and guild_id='" + guildId + '\''); + if (res.next()) { + info = new UserInfo( + res.getString("user_id"), + res.getString("guild_id"), + res.getInt("draws"), + res.getInt("wins"), + res.getInt("loses"), + res.getInt("level"), + res.getInt("xp"), + res.getInt("xp_limit"), + res.getString("tag") + ); + } + } + } catch (SQLException exception) { + LOGGER.error(exception.getSQLState(), exception); + } + return info; + } + + public void resetXPIfCrossesLimit(UserInfo info) { + int xp = info.xp; + int limit = info.xp_limit; + int level = info.level; + if (xp >= limit) { + level++; + limit = level * 201; + + info.xp(xp % limit); + info.xp_limit(limit); + info.level(level); + + /*Database.LEVELS.updateOne( + Filters.and( + Filters.eq("userId", id), + Filters.eq("guildId", guildId) + ), + Filters.and( + Filters.eq("$set", Filters.eq("game-xp", xp % limit)), + Filters.eq("$set", Filters.eq("game-xp-limit", limit)), + Filters.eq("$set", Filters.eq("game-level", level)) + ) + );*/ + } + } + + public void update(UserInfo winner, UserInfo loser) { + try (Connection connection = pool.getConnection()) { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("update levels set wins=wins+1, xp=" + (winner.xp + 10) + ", xp_limit=" + winner.xp_limit + ", level=" + winner.level + " where guild_id='" + winner.guild_id + "' and user_id='" + winner.user_id + '\''); + statement.executeUpdate("update levels set loses=loses+1, xp=" + (loser.xp + 1) + ", xp_limit=" + loser.xp_limit + ", level=" + loser.level + " where guild_id='" + loser.guild_id + "' and user_id='" + loser.user_id + '\''); + } + } catch (SQLException e) { + LOGGER.error(e.getSQLState(), e); + } + } + + public void updateDraw(UserInfo winner, UserInfo loser) { + try (Connection connection = pool.getConnection()) { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("update levels set draws=draws+1, xp=" + (winner.xp + 5) + ", xp_limit=" + winner.xp_limit + ", level=" + winner.level + " where guild_id='" + winner.guild_id + "' and user_id='" + winner.user_id + '\''); + statement.executeUpdate("update levels set draws=draws+1, xp=" + (loser.xp + 5) + ", xp_limit=" + loser.xp_limit + ", level=" + loser.level + " where guild_id='" + loser.guild_id + "' and user_id='" + loser.user_id + '\''); + } + } catch (SQLException e) { + LOGGER.error(e.getSQLState(), e); + } + } + + public void forEach(String guildID, ILeaderboardInfo info) { + try (Connection connection = pool.getConnection()) { + try (Statement statement = connection.createStatement()) { + ResultSet res = statement.executeQuery("select tag, xp, xp_limit, level from levels where guild_id='" + guildID + "' order by level DESC, xp DESC, tag"); + while (res.next()) { + info.info( + res.getString("tag"), + res.getInt("xp"), + res.getInt("xp_limit"), + res.getInt("level") + ); + } + } + } catch (SQLException e) { + LOGGER.error(e.getSQLState(), e); + } + } + + @Override + public void close() { + pool.close(); + } + + @SuppressWarnings("unused") + public static class UserInfo { + public static final UserInfo NULL = null; + String user_id; + String guild_id; + int draws; + int wins; + int loses; + int level; + int xp; + int xp_limit; + String tag; + + public UserInfo(String user_id, String guild_id, int draws, int wins, int loses, int level, int xp, int xp_limit, String tag) { + this.user_id = user_id; + this.guild_id = guild_id; + this.draws = draws; + this.wins = wins; + this.loses = loses; + this.level = level; + this.xp = xp; + this.xp_limit = xp_limit; + this.tag = tag; + } + + public String user_id() { + return user_id; + } + + public void user_id(String user_id) { + this.user_id = user_id; + } + + public String guild_id() { + return guild_id; + } + + public void guild_id(String guild_id) { + this.guild_id = guild_id; + } + + public int draws() { + return draws; + } + + public void draws(int draws) { + this.draws = draws; + } + + public int wins() { + return wins; + } + + public void wins(int wins) { + this.wins = wins; + } + + public int loses() { + return loses; + } + + public void loses(int loses) { + this.loses = loses; + } + + public int level() { + return level; + } + + public void level(int level) { + this.level = level; + } + + public int xp() { + return xp; + } + + public void xp(int xp) { + this.xp = xp; + } + + public int xp_limit() { + return xp_limit; + } + + public void xp_limit(int xp_limit) { + this.xp_limit = xp_limit; + } + + public String tag() { + return tag; + } + + public void tag(String tag) { + this.tag = tag; + } + } + + @FunctionalInterface + public interface ILeaderboardInfo { + void info(String tag, int xp, int xp_limit, int level); + } } diff --git a/src/main/java/com/tictactoebot/Events.java b/src/main/java/com/tictactoebot/Events.java deleted file mode 100644 index 590547d..0000000 --- a/src/main/java/com/tictactoebot/Events.java +++ /dev/null @@ -1,406 +0,0 @@ -package com.tictactoebot; - -import com.mongodb.client.model.Filters; -import com.tictactoebot.game.Game; -import com.tictactoebot.game.Player; -import com.tictactoebot.game.TicTacToe; -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.entities.User; -import net.dv8tion.jda.api.events.ReadyEvent; -import net.dv8tion.jda.api.events.ShutdownEvent; -import net.dv8tion.jda.api.events.guild.GuildJoinEvent; -import net.dv8tion.jda.api.events.guild.GuildReadyEvent; -import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; -import net.dv8tion.jda.api.events.interaction.ButtonClickEvent; -import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.components.ActionRow; -import net.dv8tion.jda.api.interactions.components.Button; -import org.bson.Document; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; -import java.io.IOException; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicInteger; - -public class Events extends ListenerAdapter { - public static final Events INSTANCE = new Events(); - public static final Logger LOGGER = LoggerFactory.getLogger(Events.class); - - @Override - public void onReady(@NotNull ReadyEvent event) { - LOGGER.info("%s is ready!".formatted(event.getJDA().getSelfUser().getName())); - } - - @Override - public void onShutdown(@NotNull ShutdownEvent event) { - LOGGER.info("%s is shutting down...".formatted(event.getJDA().getSelfUser().getName())); - } - - @Override - public void onGuildReady(@NotNull GuildReadyEvent event) { - Guild guild = event.getGuild(); - upsertCommands(guild); - - guild.getMembers().forEach( member -> { - User user = member.getUser(); - insertNewUser(guild, user); - }); - } - - private static void upsertCommands(Guild guild) { - guild - .upsertCommand("play", "Play a game of TicTacToe with a mentioned user") - .addOption(OptionType.USER, "user", "The user to play with", true) - .queue(); - guild - .upsertCommand("leaderboard", "View the leaderboard").queue(); - guild - .upsertCommand("stats", "View your current level or the mentioned user") - .addOption(OptionType.USER, "user", "check level of this user", false) - .queue(); - - LOGGER.info("Registered commands for %s".formatted(guild.getName())); - } - - @Override - public void onGuildMemberJoin(@NotNull GuildMemberJoinEvent event) { - Guild guild = event.getGuild(); - User user = event.getUser(); - insertNewUser(guild, user); - } - - private static void insertNewUser(Guild guild, User user) { - if (user.isBot()) return; - Document result = Database.LEVELS.find( - Filters.and( - Filters.eq("guildId", guild.getId()), - Filters.eq("userId", user.getId()) - ) - ).first(); - if (result == null) { - Database.LEVELS.insertOne(new Document() - .append("guildId", guild.getId()) - .append("userId", user.getId()) - .append("tag", user.getAsTag()) - .append("game-level", 0) - .append("game-xp", 0) - .append("game-xp-limit", 100) - .append("wins", 0) - .append("loses", 0) - .append("draws", 0) - ); - } - } - - @Override - public void onGuildJoin(@NotNull GuildJoinEvent event) { - Guild guild = event.getGuild(); - upsertCommands(guild); - LOGGER.info("Joined guild: " + guild.getName()); - guild.getMembers().forEach( member -> { - User user = member.getUser(); - insertNewUser(guild, user); - }); - } - - @Override - public void onSlashCommand(@NotNull SlashCommandEvent event) { - List permissions = List.of( - Permission.USE_SLASH_COMMANDS, - Permission.MESSAGE_EMBED_LINKS, - Permission.MESSAGE_ATTACH_FILES, - Permission.MESSAGE_WRITE - ); - if (!Objects.requireNonNull(event.getGuild()).getSelfMember().hasPermission( - permissions - )) { - StringBuilder builder = new StringBuilder(); - builder.append("```").append('\n'); - permissions.forEach(permission -> builder.append(permission.getName()).append('\n')); - builder.append("```"); - event.reply("_I do not have one of the following permissions:_\n" + builder).setEphemeral(true).queue(); - return; - } - Guild guild = event.getGuild(); - if (guild == null) { - return; - } - String commandName = event.getName(); - User user = event.getUser(); - switch (commandName) { - case "play" -> { - User mentionedUser = Objects.requireNonNull(event.getOption("user")).getAsUser(); - if (mentionedUser.equals(user)) { - event.reply("You can't play with yourself!").setEphemeral(true).queue(); - return; - } - if (mentionedUser.isBot()) { - event.reply("You can't play with a bot!").setEphemeral(true).queue(); - return; - } - // confirm if the mentioned user is ready to play - event.deferReply().queue( - interactionHook -> - interactionHook.sendMessage("%s, will you accept a challenge of a game of TicTacToe from %s?".formatted( - mentionedUser.getAsMention(), - user.getAsMention() - )).addActionRow( - Button.success("ready", "Yes"), - Button.danger("not-ready", "No") - ).queue(message -> { - long messageID = message.getIdLong(); - MatchData matchData = MatchData.create(user, mentionedUser); - matchData.setPendingEdit(message); - Database.MATCH_DATA_MAP.put(messageID, matchData); - })); - } - case "stats" -> { - OptionMapping option = event.getOption("user"); - final var optionUser = new Object() { - User current = user; - }; - if (option != null) { - optionUser.current = option.getAsUser(); - if (optionUser.current.isBot()) { - event.reply("Bots don't participate in leveling").setEphemeral(true).queue(); - return; - } - } - - event.deferReply().queue(hook -> { - Document document = getUserInfo(optionUser.current.getId(), guild.getId()); - - int xp = document.getInteger("game-xp"); - double xpLimit = document.getInteger("game-xp-limit"); - int level = document.getInteger("game-level"); - double ratio = xp / xpLimit; - int progressLength = (int) (ratio * 10); - int empty = 10 - progressLength; - String progressBar = "%s%s %d%%".formatted("\u2588".repeat(progressLength), "_".repeat(empty), (int) (ratio * 100)); - String statsWindow = - "```js\n" + - "// %s's stats //\n\n".formatted(optionUser.current.getAsTag()) + - "game level: " + level + '\n' + - "game xp: %d/%d\n\nprogress bar\n".formatted(xp, (int) xpLimit) + - progressBar + "\n\n" + - "wins: " + document.getInteger("wins") + ", " + - "loses: " + document.getInteger("loses") + ", " + - "draws: " + document.getInteger("draws") + '\n' + - "```"; - hook.sendMessage(statsWindow).queue(); - }); - } - case "leaderboard" -> event.deferReply().queue(hook -> { - try (var result = Database.LEVELS.aggregate(List.of( - Filters.eq("$match", Filters.eq("guildId", guild.getId())), - Filters.eq("$group", Filters.and( - Filters.eq("_id", "$_id"), - Filters.eq("userId", Filters.eq("$first", "$userId")), - Filters.eq("tag", Filters.eq("$first", "$tag")), - Filters.eq("game-level", Filters.eq("$first", "$game-level")), - Filters.eq("game-xp", Filters.eq("$first", "$game-xp")), - Filters.eq("game-xp-limit", Filters.eq("$first", "$game-xp-limit")) - )), - Filters.eq("$sort", Filters.and( - Filters.eq("game-level", -1), - Filters.eq("game-xp", -1), - Filters.eq("tag", 1) - )) - )).iterator()) { - final StringBuilder leaderboard = new StringBuilder(); - AtomicInteger rank = new AtomicInteger(1); - leaderboard.append("```kt\nleaderboard\n\n"); - String cell = "%6s"; - leaderboard.append(cell.formatted("rank")) - .append(cell.formatted("level")) - .append("%13s".formatted("xp")) - .append(" name").append('\n'); - result.forEachRemaining(document -> { - String tag = document.getString("tag"); - int level = document.getInteger("game-level"); - int xp = document.getInteger("game-xp"); - int xpLimit = document.getInteger("game-xp-limit"); - String xpStr = "(%d/%d)".formatted(xp, xpLimit); - leaderboard.append(cell.formatted(rank.getAndIncrement())) - .append(cell.formatted(level)) - .append("%13s".formatted(xpStr)) - .append(" %s".formatted(tag)).append('\n'); - }); - leaderboard.append("\n```"); - hook.sendMessage(leaderboard.toString()).queue(); - } catch (Exception e) { - LOGGER.error(e.getMessage(), e); - } - }); - } - } - - @Override - public void onButtonClick(@NotNull ButtonClickEvent event) { - String buttonId = event.getComponentId(); - final User user = event.getUser(); - final long messageID = event.getMessage().getIdLong(); - - final boolean isPlayer = Database.MATCH_DATA_MAP.containsKey(messageID); - - if (!isPlayer) { - event.getInteraction().deferEdit().queue(); - return; - } - final MatchData matchData = Database.MATCH_DATA_MAP.get(messageID); - final User challenger = matchData.getChallenger(); - final User challengeAccepter = matchData.getChallengeAccepter(); - switch (buttonId) { - case "ready" -> { try { - if (!user.equals(challengeAccepter)) { - event.deferEdit().queue(); - return; - } - - Game game = matchData.acknowledge(); - File boardImage = game.getBoardImage(); - - event.editMessage("game started") - .setEmbeds(game.getEmbed()) - .addFile(boardImage) - .setActionRows(game.getRows()).queue(); - } catch (Exception e) { LOGGER.error(e.getMessage(), e); } } - case "not-ready" -> { - if (!user.equals(challengeAccepter)) { - event.deferEdit().queue(); - return; - } - event.editMessageFormat("%s declined the challenge!", user.getAsMention()).setActionRows(List.of()).queue(); - Database.MATCH_DATA_MAP.remove(messageID); - } - case "8", "7", "6", "5", "4", "3", "2", "1", "0" -> { try { - long userID = user.getIdLong(); - Game game = matchData.getGame(); - - if (!game.contains(userID)) { - event.deferEdit().queue(); - return; - } - Player.Type playerType = game.getPlayerType(user); - TicTacToe.Move result = game.select(buttonId, playerType); - final String guildId = Objects.requireNonNull(event.getGuild()).getId(); - List board = game.getRows(); - File boardImage = game.getBoardImage(); - MessageEmbed embed = game.getEmbed(); - switch (result) { - case WIN -> event.editMessage("game ended").queue(hook -> { - User winner = game.getCurrentUser(); - User loser = winner.getIdLong() == challenger.getIdLong() ? challengeAccepter : challenger; - hook.editOriginalComponents(List.of()) - .setEmbeds(embed) - .retainFilesById(List.of()) - .addFile(boardImage).queue(); - - resetXPIfCrossesLimit(winner.getId(), guildId); - resetXPIfCrossesLimit(loser.getId(), guildId); - - Database.LEVELS.updateOne( - Filters.and( - Filters.eq("userId", winner.getId()), - Filters.eq("guildId", guildId) - ), - Filters.eq("$inc", Filters.and(Filters.eq("wins", 1), Filters.eq("game-xp", 10))) - ); - Database.LEVELS.updateOne( - Filters.and( - Filters.eq("userId", loser.getId()), - Filters.eq("guildId", guildId) - ), - Filters.eq("$inc", Filters.and(Filters.eq("loses", 1), Filters.eq("game-xp", 1))) - ); - - Database.MATCH_DATA_MAP.remove(messageID); - }); - case DRAW -> event.editMessage("game ended").queue(hook -> { - hook.editOriginalComponents(List.of()) - .setEmbeds(embed) - .retainFilesById(List.of()) - .addFile(boardImage) - .queue(); - resetXPIfCrossesLimit(challenger.getId(), guildId); - Database.LEVELS.updateOne( - Filters.and( - Filters.eq("userId", challenger.getId()), - Filters.eq("guildId", guildId) - ), - Filters.eq("$inc", Filters.and(Filters.eq("draws", 1), Filters.eq("game-xp", 5))) - ); - - resetXPIfCrossesLimit(challengeAccepter.getId(), guildId); - Database.LEVELS.updateOne( - Filters.and( - Filters.eq("userId", challengeAccepter.getId()), - Filters.eq("guildId", guildId) - ), - Filters.eq("$inc", Filters.and(Filters.eq("draws", 1), Filters.eq("game-xp", 5))) - ); - Database.MATCH_DATA_MAP.remove(messageID); - }); - case NONE -> - event.deferEdit() - .queue(hook -> - hook.editOriginalComponents(board) - .setEmbeds(embed) - .retainFilesById(List.of()) - .addFile(boardImage).queue()); - case INVALID -> event.deferEdit().queue(); - } - } catch (IOException e) { LOGGER.error(e.getMessage(), e); } } - case "resign" -> { - Game game = matchData.getGame(); - if (!game.contains(user.getIdLong())) { - event.deferEdit().queue(); - return; - } - game.resign(user); - MessageEmbed embed = game.getEmbed(); - game.disableButtons(); - event.deferEdit().queue(hook -> hook.editOriginal("game ended").setActionRows(List.of()).setEmbeds(embed).queue()); - Database.MATCH_DATA_MAP.remove(messageID); - } - } - } - - private void resetXPIfCrossesLimit(String id, String guildId) { - Document info = getUserInfo(id, guildId); - double xp = info.getInteger("game-xp"); - double limit = info.getInteger("game-xp-limit"); - int level = info.getInteger("game-level"); - if (xp >= limit) { - level++; - limit = level * 201; - Database.LEVELS.updateOne( - Filters.and( - Filters.eq("userId", id), - Filters.eq("guildId", guildId) - ), - Filters.and( - Filters.eq("$set", Filters.eq("game-xp", xp % limit)), - Filters.eq("$set", Filters.eq("game-xp-limit", limit)), - Filters.eq("$set", Filters.eq("game-level", level)) - ) - ); - } - } - - private static Document getUserInfo(String id, String guildId) { - return Objects.requireNonNull(Database.LEVELS.find(Filters.and( - Filters.eq("userId", id), - Filters.eq("guildId", guildId) - )).first()); - } -} diff --git a/src/main/java/com/tictactoebot/Main.java b/src/main/java/com/tictactoebot/Main.java index 6a8f442..7561c1e 100644 --- a/src/main/java/com/tictactoebot/Main.java +++ b/src/main/java/com/tictactoebot/Main.java @@ -1,5 +1,6 @@ package com.tictactoebot; +import com.tictactoebot.events.*; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.OnlineStatus; @@ -13,10 +14,27 @@ public class Main { public static void main(String[] args) throws LoginException { - String token = args.length != 0? args[0] : System.getenv("BOT_TOKEN"); - if (token == null) { - throw new IllegalArgumentException("Either the bot token not been provided in command line argument or environment variable BOT_TOKEN is not set!"); + String bot_token_env = System.getenv("BOT_TOKEN"); + String token; + int index = 0; + int expectedLength = 5; + if (bot_token_env != null) { + token = bot_token_env; + expectedLength = 4; + } else token = args[index++]; + + if (args.length != expectedLength) { + System.out.println("Usage: java -jar TicTacToeBot.jar "); + return; } + + String dbUser = args[index++]; + String dbPassword = args[index++]; + String dbHost = args[index++]; + String dbName = args[index]; + + Database db = new Database(dbUser, dbPassword, dbHost, dbName); + JDA bot = JDABuilder.createDefault( token, GatewayIntent.GUILD_MESSAGES, @@ -29,7 +47,15 @@ public static void main(String[] args) throws LoginException { .setMemberCachePolicy(MemberCachePolicy.ALL) .enableCache(CacheFlag.CLIENT_STATUS) .disableCache(CacheFlag.VOICE_STATE, CacheFlag.EMOTE) - .addEventListeners(Events.INSTANCE) + .addEventListeners( + new BotReadyEvent(), + new BotShutdownEvent(db), + new GuildJoinEvent(db), + new GuildMemberJoinEvent(db), + new OnGuildReadyEvent(db), + new SlashCommandEvent(db), + new ButtonClickEvent(db) + ) .setStatus(OnlineStatus.ONLINE) .setActivity(Activity.playing("TicTacToe")) .build(); diff --git a/src/main/java/com/tictactoebot/events/BotReadyEvent.java b/src/main/java/com/tictactoebot/events/BotReadyEvent.java new file mode 100644 index 0000000..7dba20b --- /dev/null +++ b/src/main/java/com/tictactoebot/events/BotReadyEvent.java @@ -0,0 +1,20 @@ +package com.tictactoebot.events; + +import com.tictactoebot.Database; +import net.dv8tion.jda.api.events.ReadyEvent; +import org.jetbrains.annotations.NotNull; + +public class BotReadyEvent extends Event { + public BotReadyEvent(Database db) { + super(db); + } + + public BotReadyEvent() { + this(null); + } + + @Override + public void onReady(@NotNull ReadyEvent event) { + LOGGER.info("%s is ready!".formatted(event.getJDA().getSelfUser().getName())); + } +} diff --git a/src/main/java/com/tictactoebot/events/BotShutdownEvent.java b/src/main/java/com/tictactoebot/events/BotShutdownEvent.java new file mode 100644 index 0000000..7cad907 --- /dev/null +++ b/src/main/java/com/tictactoebot/events/BotShutdownEvent.java @@ -0,0 +1,17 @@ +package com.tictactoebot.events; + +import com.tictactoebot.Database; +import net.dv8tion.jda.api.events.ShutdownEvent; +import org.jetbrains.annotations.NotNull; + +public class BotShutdownEvent extends Event { + public BotShutdownEvent(Database db) { + super(db); + } + + @Override + public void onShutdown(@NotNull ShutdownEvent event) { + LOGGER.info("%s is shutting down...".formatted(event.getJDA().getSelfUser().getName())); + db.close(); + } +} diff --git a/src/main/java/com/tictactoebot/events/ButtonClickEvent.java b/src/main/java/com/tictactoebot/events/ButtonClickEvent.java new file mode 100644 index 0000000..9f50763 --- /dev/null +++ b/src/main/java/com/tictactoebot/events/ButtonClickEvent.java @@ -0,0 +1,160 @@ +package com.tictactoebot.events; + +import com.tictactoebot.Database; +import com.tictactoebot.MatchData; +import com.tictactoebot.game.Game; +import com.tictactoebot.game.Player; +import com.tictactoebot.game.TicTacToe; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +public class ButtonClickEvent extends Event { + public ButtonClickEvent(Database db) { + super(db); + } + + @Override + public void onButtonClick(@NotNull net.dv8tion.jda.api.events.interaction.ButtonClickEvent event) { + String buttonId = event.getComponentId(); + final User user = event.getUser(); + final long messageID = event.getMessage().getIdLong(); + + final boolean isPlayer = Database.MATCH_DATA_MAP.containsKey(messageID); + + if (!isPlayer) { + event.getInteraction().deferEdit().queue(); + return; + } + final MatchData matchData = Database.MATCH_DATA_MAP.get(messageID); + final User challenger = matchData.getChallenger(); + final User challengeAccepter = matchData.getChallengeAccepter(); + switch (buttonId) { + case "ready" -> { try { + if (!user.equals(challengeAccepter)) { + event.deferEdit().queue(); + return; + } + + Game game = matchData.acknowledge(); + File boardImage = game.getBoardImage(); + + event.editMessage("game started") + .setEmbeds(game.getEmbed()) + .addFile(boardImage) + .setActionRows(game.getRows()).queue(); + } catch (Exception e) { LOGGER.error(e.getMessage(), e); } } + case "not-ready" -> { + if (!user.equals(challengeAccepter)) { + event.deferEdit().queue(); + return; + } + event.editMessageFormat("%s declined the challenge!", user.getAsMention()).setActionRows(List.of()).queue(); + Database.MATCH_DATA_MAP.remove(messageID); + } + case "8", "7", "6", "5", "4", "3", "2", "1", "0" -> { try { + long userID = user.getIdLong(); + Game game = matchData.getGame(); + + if (!game.contains(userID)) { + event.deferEdit().queue(); + return; + } + Player.Type playerType = game.getPlayerType(user); + TicTacToe.Move result = game.select(buttonId, playerType); + final String guildId = Objects.requireNonNull(event.getGuild()).getId(); + List board = game.getRows(); + File boardImage = game.getBoardImage(); + MessageEmbed embed = game.getEmbed(); + switch (result) { + case WIN -> event.editMessage("game ended").queue(hook -> { + User winner = game.getCurrentUser(); + User loser = winner.getIdLong() == challenger.getIdLong() ? challengeAccepter : challenger; + hook.editOriginalComponents(List.of()) + .setEmbeds(embed) + .retainFilesById(List.of()) + .addFile(boardImage).queue(); + + Database.UserInfo winnerInfo = db.getUserInfo(winner.getId(), guildId); + Database.UserInfo loserInfo = db.getUserInfo(loser.getId(), guildId); + db.resetXPIfCrossesLimit(winnerInfo); + db.resetXPIfCrossesLimit(loserInfo); + db.update(winnerInfo, loserInfo); + /*Database.LEVELS.updateOne( + Filters.and( + Filters.eq("userId", winner.getId()), + Filters.eq("guildId", guildId) + ), + Filters.eq("$inc", Filters.and(Filters.eq("wins", 1), Filters.eq("game-xp", 10))) + ); + Database.LEVELS.updateOne( + Filters.and( + Filters.eq("userId", loser.getId()), + Filters.eq("guildId", guildId) + ), + Filters.eq("$inc", Filters.and(Filters.eq("loses", 1), Filters.eq("game-xp", 1))) + );*/ + + Database.MATCH_DATA_MAP.remove(messageID); + }); + case DRAW -> event.editMessage("game ended").queue(hook -> { + hook.editOriginalComponents(List.of()) + .setEmbeds(embed) + .retainFilesById(List.of()) + .addFile(boardImage) + .queue(); + Database.UserInfo challengerInfo = db.getUserInfo(challenger.getId(), guildId); + Database.UserInfo challengeAccepterInfo = db.getUserInfo(challengeAccepter.getId(), guildId); + db.resetXPIfCrossesLimit(challengerInfo); + db.resetXPIfCrossesLimit(challengeAccepterInfo); + db.updateDraw(challengerInfo, challengeAccepterInfo); + /*resetXPIfCrossesLimit(challenger.getId(), guildId); + Database.LEVELS.updateOne( + Filters.and( + Filters.eq("userId", challenger.getId()), + Filters.eq("guildId", guildId) + ), + Filters.eq("$inc", Filters.and(Filters.eq("draws", 1), Filters.eq("game-xp", 5))) + ); + + db.resetXPIfCrossesLimit(challengeAccepter.getId(), guildId); + Database.LEVELS.updateOne( + Filters.and( + Filters.eq("userId", challengeAccepter.getId()), + Filters.eq("guildId", guildId) + ), + Filters.eq("$inc", Filters.and(Filters.eq("draws", 1), Filters.eq("game-xp", 5))) + );*/ + Database.MATCH_DATA_MAP.remove(messageID); + }); + case NONE -> + event.deferEdit() + .queue(hook -> + hook.editOriginalComponents(board) + .setEmbeds(embed) + .retainFilesById(List.of()) + .addFile(boardImage).queue()); + case INVALID -> event.deferEdit().queue(); + } + } catch (IOException e) { LOGGER.error(e.getMessage(), e); } } + case "resign" -> { + Game game = matchData.getGame(); + if (!game.contains(user.getIdLong())) { + event.deferEdit().queue(); + return; + } + game.resign(user); + MessageEmbed embed = game.getEmbed(); + game.disableButtons(); + event.deferEdit().queue(hook -> hook.editOriginal("game ended").setActionRows(List.of()).setEmbeds(embed).queue()); + Database.MATCH_DATA_MAP.remove(messageID); + } + } + } +} diff --git a/src/main/java/com/tictactoebot/events/Event.java b/src/main/java/com/tictactoebot/events/Event.java new file mode 100644 index 0000000..d14074a --- /dev/null +++ b/src/main/java/com/tictactoebot/events/Event.java @@ -0,0 +1,15 @@ +package com.tictactoebot.events; + +import com.tictactoebot.Database; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class Event extends ListenerAdapter { + protected final Database db; + protected static final Logger LOGGER = LoggerFactory.getLogger(Event.class); + + public Event(Database db) { + this.db = db; + } +} diff --git a/src/main/java/com/tictactoebot/events/GuildEvent.java b/src/main/java/com/tictactoebot/events/GuildEvent.java new file mode 100644 index 0000000..18aa196 --- /dev/null +++ b/src/main/java/com/tictactoebot/events/GuildEvent.java @@ -0,0 +1,26 @@ +package com.tictactoebot.events; + +import com.tictactoebot.Database; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.interactions.commands.OptionType; + +public class GuildEvent extends Event { + public GuildEvent(Database db) { + super(db); + } + + protected static void upsertCommands(Guild guild) { + guild + .upsertCommand("play", "Play a game of TicTacToe with a mentioned user") + .addOption(OptionType.USER, "user", "The user to play with", true) + .queue(); + guild + .upsertCommand("leaderboard", "View the leaderboard").queue(); + guild + .upsertCommand("stats", "View your current level or the mentioned user") + .addOption(OptionType.USER, "user", "check level of this user", false) + .queue(); + + LOGGER.info("Registered commands for %s".formatted(guild.getName())); + } +} diff --git a/src/main/java/com/tictactoebot/events/GuildJoinEvent.java b/src/main/java/com/tictactoebot/events/GuildJoinEvent.java new file mode 100644 index 0000000..aa4f740 --- /dev/null +++ b/src/main/java/com/tictactoebot/events/GuildJoinEvent.java @@ -0,0 +1,23 @@ +package com.tictactoebot.events; + +import com.tictactoebot.Database; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.User; +import org.jetbrains.annotations.NotNull; + +public class GuildJoinEvent extends GuildEvent { + public GuildJoinEvent(Database db) { + super(db); + } + + @Override + public void onGuildJoin(@NotNull net.dv8tion.jda.api.events.guild.GuildJoinEvent event) { + Guild guild = event.getGuild(); + upsertCommands(guild); + LOGGER.info("Joined guild: " + guild.getName()); + guild.getMembers().forEach( member -> { + User user = member.getUser(); + db.insertNewUser(guild, user); + }); + } +} diff --git a/src/main/java/com/tictactoebot/events/GuildMemberJoinEvent.java b/src/main/java/com/tictactoebot/events/GuildMemberJoinEvent.java new file mode 100644 index 0000000..0a6505b --- /dev/null +++ b/src/main/java/com/tictactoebot/events/GuildMemberJoinEvent.java @@ -0,0 +1,19 @@ +package com.tictactoebot.events; + +import com.tictactoebot.Database; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.User; +import org.jetbrains.annotations.NotNull; + +public class GuildMemberJoinEvent extends GuildEvent { + public GuildMemberJoinEvent(Database db) { + super(db); + } + + @Override + public void onGuildMemberJoin(@NotNull net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent event) { + Guild guild = event.getGuild(); + User user = event.getUser(); + db.insertNewUser(guild, user); + } +} diff --git a/src/main/java/com/tictactoebot/events/OnGuildReadyEvent.java b/src/main/java/com/tictactoebot/events/OnGuildReadyEvent.java new file mode 100644 index 0000000..bf6627f --- /dev/null +++ b/src/main/java/com/tictactoebot/events/OnGuildReadyEvent.java @@ -0,0 +1,24 @@ +package com.tictactoebot.events; + +import com.tictactoebot.Database; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.guild.GuildReadyEvent; +import org.jetbrains.annotations.NotNull; + +public class OnGuildReadyEvent extends GuildEvent { + public OnGuildReadyEvent(Database db) { + super(db); + } + + @Override + public void onGuildReady(@NotNull GuildReadyEvent event) { + Guild guild = event.getGuild(); + upsertCommands(guild); + + guild.getMembers().forEach( member -> { + User user = member.getUser(); + db.insertNewUser(guild, user); + }); + } +} diff --git a/src/main/java/com/tictactoebot/events/SlashCommandEvent.java b/src/main/java/com/tictactoebot/events/SlashCommandEvent.java new file mode 100644 index 0000000..e60dde3 --- /dev/null +++ b/src/main/java/com/tictactoebot/events/SlashCommandEvent.java @@ -0,0 +1,161 @@ +package com.tictactoebot.events; + +import com.tictactoebot.Database; +import com.tictactoebot.MatchData; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.components.Button; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; + +public class SlashCommandEvent extends Event { + public SlashCommandEvent(Database db) { + super(db); + } + + @Override + public void onSlashCommand(@NotNull net.dv8tion.jda.api.events.interaction.SlashCommandEvent event) { + List permissions = List.of( + Permission.USE_SLASH_COMMANDS, + Permission.MESSAGE_EMBED_LINKS, + Permission.MESSAGE_ATTACH_FILES, + Permission.MESSAGE_WRITE + ); + if (!Objects.requireNonNull(event.getGuild()).getSelfMember().hasPermission( + permissions + )) { + StringBuilder builder = new StringBuilder(); + builder.append("```").append('\n'); + permissions.forEach(permission -> builder.append(permission.getName()).append('\n')); + builder.append("```"); + event.reply("_I do not have one of the following permissions:_\n" + builder).setEphemeral(true).queue(); + return; + } + Guild guild = event.getGuild(); + if (guild == null) { + return; + } + String commandName = event.getName(); + User user = event.getUser(); + switch (commandName) { + case "play" -> { + User mentionedUser = Objects.requireNonNull(event.getOption("user")).getAsUser(); + if (mentionedUser.equals(user)) { + event.reply("You can't play with yourself!").setEphemeral(true).queue(); + return; + } + if (mentionedUser.isBot()) { + event.reply("You can't play with a bot!").setEphemeral(true).queue(); + return; + } + // confirm if the mentioned user is ready to play + event.deferReply().queue( + interactionHook -> + interactionHook.sendMessage("%s, will you accept a challenge of a game of TicTacToe from %s?".formatted( + mentionedUser.getAsMention(), + user.getAsMention() + )).addActionRow( + Button.success("ready", "Yes"), + Button.danger("not-ready", "No") + ).queue(message -> { + long messageID = message.getIdLong(); + MatchData matchData = MatchData.create(user, mentionedUser); + matchData.setPendingEdit(message); + Database.MATCH_DATA_MAP.put(messageID, matchData); + })); + } + case "stats" -> { + OptionMapping option = event.getOption("user"); + final var optionUser = new Object() { + User current = user; + }; + if (option != null) { + optionUser.current = option.getAsUser(); + if (optionUser.current.isBot()) { + event.reply("Bots don't participate in leveling").setEphemeral(true).queue(); + return; + } + } + + event.deferReply().queue(hook -> { + Database.UserInfo info = db.getUserInfo(optionUser.current.getId(), guild.getId()); + + int xp = info.xp(); + double xpLimit = info.xp_limit(); + int level = info.level(); + double ratio = xp / xpLimit; + int progressLength = (int) (ratio * 10); + int empty = 10 - progressLength; + String progressBar = "%s%s %d%%".formatted("\u2588".repeat(progressLength), "_".repeat(empty), (int) (ratio * 100)); + String statsWindow = + "```js\n" + + "// %s's stats //\n\n".formatted(optionUser.current.getAsTag()) + + "game level: " + level + '\n' + + "game xp: %d/%d\n\nprogress bar\n".formatted(xp, (int) xpLimit) + + progressBar + "\n\n" + + "wins: " + info.wins() + ", " + + "loses: " + info.loses() + ", " + + "draws: " + info.draws() + '\n' + + "```"; + hook.sendMessage(statsWindow).queue(); + }); + } + case "leaderboard" -> event.deferReply().queue(hook -> { + final StringBuilder leaderboard = new StringBuilder(); + AtomicInteger rank = new AtomicInteger(1); + leaderboard.append("```kt\nleaderboard\n\n"); + String cell = "%6s"; + leaderboard.append(cell.formatted("rank")) + .append(cell.formatted("level")) + .append("%13s".formatted("xp")) + .append(" name").append('\n'); + db.forEach(guild.getId(), ((tag, xp, xp_limit, level) -> { + String xpStr = "(%d/%d)".formatted(xp, xp_limit); + leaderboard.append(cell.formatted(rank.getAndIncrement())) + .append(cell.formatted(level)) + .append("%13s".formatted(xpStr)) + .append(" %s".formatted(tag)).append('\n'); + })); + leaderboard.append("\n```"); + hook.sendMessage(leaderboard.toString()).queue(); + /*try (var result = Database.LEVELS.aggregate(List.of( + Filters.eq("$match", Filters.eq("guildId", guild.getId())), + Filters.eq("$group", Filters.and( + Filters.eq("_id", "$_id"), + Filters.eq("userId", Filters.eq("$first", "$userId")), + Filters.eq("tag", Filters.eq("$first", "$tag")), + Filters.eq("game-level", Filters.eq("$first", "$game-level")), + Filters.eq("game-xp", Filters.eq("$first", "$game-xp")), + Filters.eq("game-xp-limit", Filters.eq("$first", "$game-xp-limit")) + )), + Filters.eq("$sort", Filters.and( + Filters.eq("game-level", -1), + Filters.eq("game-xp", -1), + Filters.eq("tag", 1) + )) + )).iterator()) { + result.forEachRemaining(document -> { + String tag = document.getString("tag"); + int level = document.getInteger("game-level"); + int xp = document.getInteger("game-xp"); + int xpLimit = document.getInteger("game-xp-limit"); + String xpStr = "(%d/%d)".formatted(xp, xpLimit); + leaderboard.append(cell.formatted(rank.getAndIncrement())) + .append(cell.formatted(level)) + .append("%13s".formatted(xpStr)) + .append(" %s".formatted(tag)).append('\n'); + }); + leaderboard.append("\n```"); + hook.sendMessage(leaderboard.toString()).queue(); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + }*/ + }); + } + } +}