commit f5b674b160f6f5e8540b754972eb9f6388a72e04 Author: Brandon <46827438+disclearing@users.noreply.github.com> Date: Sun May 21 16:27:56 2023 +0100 Nigger diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97008e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +yarn.lock \ No newline at end of file diff --git a/disabled/giveloss.js b/disabled/giveloss.js new file mode 100644 index 0000000..1bf1abc --- /dev/null +++ b/disabled/giveloss.js @@ -0,0 +1,31 @@ +import { giveLoss } from "../ranked/profile.js" +import Command from "../base/Command.js"; +import MessageHelper from "../base/MessageHelper.js"; + +export default class GiveLoss extends Command { + constructor (client) { + super(client, { + name: "giveloss", + description: "Gives a player a loss.", + options: [ + { + name: "player", + description: "The player that will recieve the loss.", + required: true, + type: "USER" + } + ] + }); + } + + run (interaction) { + const target = interaction.options.get("player").value + giveLoss(target) + .then(() => { + interaction.editReply({ embeds: [MessageHelper.success("A loss has been added.")] }) + }) + .catch(err => { + interaction.editReply({ embeds: [MessageHelper.error(err)] }) + }) + } +} diff --git a/disabled/givewin.js b/disabled/givewin.js new file mode 100644 index 0000000..ce50ce6 --- /dev/null +++ b/disabled/givewin.js @@ -0,0 +1,33 @@ +import { giveWin } from "../ranked/profile.js" +import Command from "../base/Command.js"; +import MessageHelper from "../base/MessageHelper.js"; +import RankedProfile from "../model/RankedProfile.js"; +import { updateRank } from "../ranked/profile.js"; + +export default class GiveWin extends Command { + constructor (client) { + super(client, { + name: "givewin", + description: "Gives a player a win.", + options: [ + { + name: "player", + description: "The player that will recieve the win.", + required: true, + type: "USER" + } + ] + }); + } + + run (interaction) { + const target = interaction.options.get("player").value + giveWin(target) + .then(() => { + interaction.editReply({ embeds: [MessageHelper.success("A win has been added")] }) + }) + .catch(err => { + interaction.editReply({ embeds: [MessageHelper.error(err)] }) + }) + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..aeed027 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "ranked-bot", + "version": "1.0.0", + "description": "A bot that manages ranked matches, ...", + "type": "module", + "main": "./src/index.js", + "author": "Beanes", + "dependencies": { + "discord.js": "^13.6.0", + "mongoose": "^6.2.10", + "node-fetch": "^3.2.3", + "redis": "^4.0.6" + } +} diff --git a/src/base/Client.js b/src/base/Client.js new file mode 100644 index 0000000..7c20cea --- /dev/null +++ b/src/base/Client.js @@ -0,0 +1,89 @@ +import { Client, Collection } from "discord.js"; +import { resolve, join } from "path" +import { readdir } from "fs/promises"; +import redis from 'redis'; +import mongoose from "mongoose"; +import config from "./Config.js" +import { onRedisMessage, startPing, startQueueCheck, cleanOldGames, startDodgeCheck } from "../ranked/game.js" + +class CustomClient extends Client { + constructor() { + super({ + intents: ["GUILDS", "GUILD_MESSAGES", "GUILD_VOICE_STATES", "GUILD_MEMBERS"] + }); + + this.events = new Collection(); + this.commands = new Collection(); + this.setupRedis(config.redis); + this.setupDatabase(config.mongo) + + this.on("ready", () => { + this.loadCommands("./src/commands"); + this.loadEvents("./src/events"); + startQueueCheck(this); + startPing(this); + cleanOldGames(this); + startDodgeCheck(this); + console.log(`Client started. Node version: ${process.version}. Platform: ${process.platform}. PID ID: ${process.pid}. User ID: ${this.user.id}`); + }).on("error", console.error).on("warn", console.warn); + } + + login(token) { + super.login(token); + } + + async setupRedis(url) { + this.redis = redis.createClient({ url }); + this.redis.on('error', (err) => console.log('Redis Client Error', err)); + await this.redis.connect(); + const subscriber = this.redis.duplicate(); + await subscriber.connect() + await subscriber.pSubscribe('rankedhcf:*', (message, channel) => { + onRedisMessage(this, channel, message) + }); + } + + async setupDatabase(url) { + await mongoose.connect(url, { + useNewUrlParser: true, + }) + } + + async loadCommands(path) { + const guild = this.guilds.cache.get(config.guild) + readdir(path) + .then(files => { + files.forEach(async file => { + const load = resolve(join(path, file)); + const CMD = await import("file://" + load); + const command = new CMD.default(this); // eslint-disable-line + console.log("Loaded command: " + command.constructor.name.toLowerCase()) + this.commands.set(command.constructor.name.toLowerCase(), command); + guild.commands.create(command.data) + }); + }) + + .catch(err => { + console.log(err); + }); + } + + loadEvents(path) { + readdir(path) + .then(files => { + files.forEach(async file => { + const load = resolve(join(path, file)); + const EVT = await import("file://" + load); + const event = new EVT.default(this); // eslint-disable-line + console.log("Loaded event: " + event.constructor.name.toLowerCase()) + this.events.set(event.constructor.name.toLowerCase(), event); + super.on(file.split(".")[0], (...args) => event.run(...args)); + }); + }) + .catch(err => { + console.log(err); + }); + } +} + +export default CustomClient; diff --git a/src/base/Command.js b/src/base/Command.js new file mode 100644 index 0000000..df2bc23 --- /dev/null +++ b/src/base/Command.js @@ -0,0 +1,6 @@ +export default class Command { + constructor(client, data) { + this.client = client; + this.data = data; + } +} diff --git a/src/base/Config.js b/src/base/Config.js new file mode 100644 index 0000000..b47330c --- /dev/null +++ b/src/base/Config.js @@ -0,0 +1,86 @@ +export default { + "token": "NzE5ODc1NDk2NzQxMDQ0MzE0.Xt9yTQ.AomxJyZiGypwpzq7D-qeOeWiq50", + "guild": "960192473928458250", + "redis": "redis://:penis69@51.222.244.184:6379", + "mongo": "mongodb://51.222.244.184:27017", + "category": "960192475467759688", + "queue": ["964998749749379082", "973927606678343731"], + "feed": "968171596432936960", + "dodgefeed": "969657767700865104", + "roles": { + "registered": "969334690626543687" + }, + "maps": [ + { "id": "NetherRoad2", "name": "Nether Road 2" }, + { "id": "NetherRoad4", "name": "Nether Road 4" }, + { "id": "CitadelAlphaMap1", "name": "Alpha Map 1" }, + { "id": "CitadelSand", "name": "Citadel Sand" }, + { "id": "JapCity", "name": "Jap City" } + ], + "ranks": [ + { + "name": "Bronze", + "role": "969659135220146206", + "min": 0, + "max": 100, + "gain": 30, + "loss": 10 + }, + { + "name": "Iron", + "role": "969658996141219900", + "min": 101, + "max": 200, + "gain": 25, + "loss": 10 + }, + { + "name": "Gold", + "role": "960260010003300372", + "min": 201, + "max": 300, + "gain": 20, + "loss": 15 + }, + { + "name": "Platinum", + "role": "961557649781059654", + "min": 301, + "max": 400, + "gain": 15, + "loss": 15 + }, + { + "name": "Diamond", + "role": "960260055737987172", + "min": 401, + "max": 600, + "gain": 15, + "loss": 20 + }, + { + "name": "Master", + "role": "961557597490663435", + "min": 601, + "max": 800, + "gain": 10, + "loss": 25 + }, + { + "name": "G-Master", + "role": "961562776810168320", + "min": 801, + "max": 1000, + "gain": 10, + "loss": 30 + }, + { + "name": "Challenger", + "role": "960258699291664414", + "min": 1001, + "max": 99999999, + "gain": 10, + "loss": 40 + } + ] +} \ No newline at end of file diff --git a/src/base/MessageHelper.js b/src/base/MessageHelper.js new file mode 100644 index 0000000..5189109 --- /dev/null +++ b/src/base/MessageHelper.js @@ -0,0 +1,42 @@ +import { MessageEmbed } from "discord.js"; + +const footer = '⚔️ Ranked Season 1 ⚔️' + +function blank() { + return new MessageEmbed(this.message); +} + +/** + * @returns {MessageEmbed} + */ +function error(message, title = "Error") { + return new MessageEmbed(this.message) + .setTitle(`» ${title}`) + .setDescription(message) + .setColor(15217152) + .setFooter({ text: footer }); +} + +/** + * @returns {MessageEmbed} + */ +function info(message, title) { + return new MessageEmbed(this.message) + .setTitle(title ? `» ${title}` : "") + .setDescription(message) + .setColor("#272727") + .setFooter({ text: footer }); +} + +/** + * @returns {MessageEmbed} + */ +function success(message, title) { + return new MessageEmbed(this.message) + .setTitle(title ? `» ${title}` : "") + .setDescription(message) + .setColor(572788) + .setFooter({ text: footer }); +} + +export default { blank, error, info, success } \ No newline at end of file diff --git a/src/commands/close.js b/src/commands/close.js new file mode 100644 index 0000000..ab3dab9 --- /dev/null +++ b/src/commands/close.js @@ -0,0 +1,20 @@ +import Command from "../base/Command.js"; +import MessageHelper from "../base/MessageHelper.js"; + +export default class Close extends Command { + constructor (client) { + super(client, { + name: "close", + description: "Close the ticket channel.", + options: [] + }); + } + + async run (interaction) { + if (interaction.channel.parent.name.toLowerCase() === "tickets") { + interaction.channel.delete() + } else { + interaction.reply({ embeds: [MessageHelper.error("You can only run this in ticket channels!")] }); + } + } +} diff --git a/src/commands/embed.js b/src/commands/embed.js new file mode 100644 index 0000000..f801903 --- /dev/null +++ b/src/commands/embed.js @@ -0,0 +1,48 @@ +import Command from "../base/Command.js"; +import MessageHelper from "../base/MessageHelper.js"; + +export default class Embed extends Command { + constructor(client) { + super(client, { + name: "embed", + description: "Sends a message as an embed.", + options: [ + { + name: "channel", + description: "The channel to send the message to.", + required: true, + type: "CHANNEL" + }, + { + name: "message", + description: "The text that will be in the embed.", + required: true, + type: "STRING" + } + ] + }); + } + + run(interaction) { + const channel = interaction.options.get("channel").channel; + + if (channel.type !== "GUILD_TEXT" && channel.type !== "GUILD_NEWS") { + return interaction.reply({ embeds: [MessageHelper.error("That is not a channel!")] }); + } + + const msg = interaction.options.get("message").value; + channel.send({ + embeds: [ + MessageHelper.blank() + .setAuthor({ + name: interaction.user.username, + iconURL: interaction.user.avatarURL() + }) + .setColor("#272727") + .setDescription(msg) + ] + }).then(() => { + interaction.editReply({ embeds: [MessageHelper.success("The message has been sent!")], ephemeral: true }) + }); + } +} diff --git a/src/commands/game.js b/src/commands/game.js new file mode 100644 index 0000000..1555ff6 --- /dev/null +++ b/src/commands/game.js @@ -0,0 +1,53 @@ +import Command from "../base/Command.js"; +import { states } from "../ranked/game.js" +import RankedGame from "../model/RankedGame.js"; +import MessageHelper from "../base/MessageHelper.js"; + +export default class Game extends Command { + constructor(client) { + super(client, { + name: "game", + description: "Get information of a game", + options: [ + { + name: "game", + description: "The id of the game you wish to get information from.", + required: true, + type: "STRING" + } + ] + }); + } + + async run(interaction) { + const _id = interaction.options.get("game").value + const game = await RankedGame.findOne({ _id }) + if (!game) { + return interaction.editReply({ embeds: [MessageHelper.error("That game was not found!")] }) + } + + const embed = MessageHelper.success("Invalid Data", `Game Info [${game._id}]`); + switch (game.state) { + case states.MOVING: + case states.PICKING: + case states.VOIDED: + case states.PREGAME: + case states.STARTED: + case states.VOTING: + embed.setDescription(`**State:** ${game.state} +**Team One**: +Captain: <@${game.team1.captain}> +${game.team1.players.filter(player => player != game.team1.captain).map(player => `<@${player}>`).join("\n")} +**Team Two:** +Captain: <@${game.team2.captain}> +${game.team2.players.filter(player => player != game.team2.captain).map(player => `<@${player}>`).join("\n")}`); + break; + case states.ENDED: + embed.setDescription(`**State:** ${game.state} +**Elo Changes**: +${Object.entries(game.eloChanges).map(eloChange => `<@${eloChange[0]}>: **${eloChange[1] > 0 ? "+" : ""}${eloChange[1]}**`).join("\n")}`); + break; + } + interaction.editReply({ embeds: [embed] }) + } +} diff --git a/src/commands/games.js b/src/commands/games.js new file mode 100644 index 0000000..6f4b08e --- /dev/null +++ b/src/commands/games.js @@ -0,0 +1,23 @@ +import Command from "../base/Command.js"; +import RankedGame from "../model/RankedGame.js" +import { states } from "../ranked/game.js"; +import config from "../base/Config.js" +import MessageHelper from "../base/MessageHelper.js"; + +export default class Games extends Command { + constructor(client) { + super(client, { + name: "games", + description: "All games that are not ended or voided." + }); + } + + async run(interaction) { + const games = await RankedGame.find({ "state": { "$nin": [states.ENDED, states.VOIDED] } }) + interaction.editReply({ + embeds: [MessageHelper.success(`${games.map(game => + `**Game:** ${game._id} **Map:** ${game.map} **State:** ${game.state} **Captain 1:** <@${game.team1.captain}> **Captain 2:** <@${game.team2.captain}>\n` + ).join("\n")}`, "Games")] + }) + } +} diff --git a/src/commands/leaderboard.js b/src/commands/leaderboard.js new file mode 100644 index 0000000..8dcbb2e --- /dev/null +++ b/src/commands/leaderboard.js @@ -0,0 +1,31 @@ +import Command from "../base/Command.js"; +import MessageHelper from "../base/MessageHelper.js"; +import { getLeaderboard } from "../ranked/profile.js"; + +export default class Leaderboard extends Command { + constructor (client) { + super(client, { + name: "leaderboard", + description: "Check the current leaderboard." + }); + } + + async run (interaction) { + const leaderboard = await getLeaderboard() + const lines = []; + const first = leaderboard.shift(); + lines.push(`🥇 <@${first.id}>`) + const second = leaderboard.shift(); + if (second) lines.push(`🥈 <@${second.id}>`) + const third = leaderboard.shift(); + if (third) lines.push(`🥉 <@${third.id}>`) + let i = 4; + while (leaderboard.length > 0) { + const other = leaderboard.shift() + lines.push(`**${i}.** <@${other.id}>`) + i++; + } + + interaction.editReply({ embeds: [MessageHelper.success(lines.join("\n"), "Leaderboard")] }) + } +} diff --git a/src/commands/ranks.js b/src/commands/ranks.js new file mode 100644 index 0000000..a5b2031 --- /dev/null +++ b/src/commands/ranks.js @@ -0,0 +1,18 @@ +import Command from "../base/Command.js"; +import config from "../base/Config.js" +import MessageHelper from "../base/MessageHelper.js"; + +export default class Ranks extends Command { + constructor (client) { + super(client, { + name: "ranks", + description: "View all the ranks with the amount of elo required" + }); + } + + async run (interaction) { + interaction.editReply({ embeds: [MessageHelper.success(`${config.ranks.map(r => + `${r.max == 99999999 ? `[${r.min - 1}+]` : `[${r.min > 0 ? r.min - 1 : r.min}-${r.max}]`} <@&${r.role}> **Win:** +${r.gain} **Loss:** -${r.loss}` + ).join("\n")}`, "All Ranks")] }) + } +} diff --git a/src/commands/register.js b/src/commands/register.js new file mode 100644 index 0000000..70f8b17 --- /dev/null +++ b/src/commands/register.js @@ -0,0 +1,49 @@ +import Command from "../base/Command.js"; +import Sync from "../model/Sync.js" +import { createProfile, updateRank } from "../ranked/profile.js"; +import config from "../base/Config.js" +import MessageHelper from "../base/MessageHelper.js"; + +export default class Register extends Command { + constructor (client) { + super(client, { + name: "register", + description: "This command links your discord account with your minecraft account.", + options: [ + { + name: "code", + description: "Type here the code you recieved on the server", + required: true, + type: "INTEGER" + } + ] + }); + } + + async run (interaction) { + const alreadyLinked = await Sync.findOne({ + discord: interaction.user.id + }) + if (alreadyLinked) { + return interaction.editReply({ embeds: [MessageHelper.error("You are already registered!")] }) + } + const code = interaction.options.get("code").value; + const minecraftUUID = await this.client.redis.get("rankedhcf.sync.code." + code) + if (minecraftUUID) { + this.client.redis.del("rankedhcf.sync.code." + code) + this.client.redis.del("rankedhcf.sync.player." + minecraftUUID) + + const link = new Sync({ minecraft: minecraftUUID, discord: interaction.user.id }); + await link.save() + + interaction.member.roles.add(await interaction.guild.roles.fetch(config.roles.registered)) + + await createProfile(interaction.user.id); + setTimeout(() => { updateRank(interaction.guild, interaction.user.id) }, 3000) + + interaction.editReply({ embeds: [MessageHelper.success("Welcome to Elevate Ranked!\nYour discord account is now synchronized with your minecraft account.\nFeel free to join the queue channel.").setThumbnail("https://crafthead.net/avatar/" + minecraftUUID)]}) + } else { + interaction.editReply({ embeds: [MessageHelper.error("Invalid code! **NOTE:** On the minecraft server its /discord not /register! You use /register for the website")] }) + } + } +} diff --git a/src/commands/reset.js b/src/commands/reset.js new file mode 100644 index 0000000..133ba34 --- /dev/null +++ b/src/commands/reset.js @@ -0,0 +1,37 @@ +import Command from "../base/Command.js"; +import MessageHelper from "../base/MessageHelper.js"; +import { getProfile, updateRank } from "../ranked/profile.js"; + +export default class Reset extends Command { + constructor (client) { + super(client, { + name: "reset", + description: "Resets a player stats.", + options: [ + { + name: "player", + description: "The player to reset.", + required: true, + type: "USER" + } + ] + }); + } + + async run (interaction) { + const target = interaction.options.get("player")?.value + const profile = await getProfile(target) + if (profile == null) { + interaction.editReply({ embeds: [MessageHelper.error(`There is no profile associated with <@${target}>`)] }) + return; + } + + profile.elo = 0; + profile.wins = 0; + profile.losses = 0; + await profile.save(); + + updateRank(interaction.guild, target) + interaction.editReply({ embeds: [MessageHelper.success(`User's elo has been rese`, `Stats Reset`)] }) + } +} diff --git a/src/commands/sendticketmsg.js b/src/commands/sendticketmsg.js new file mode 100644 index 0000000..b0af5e0 --- /dev/null +++ b/src/commands/sendticketmsg.js @@ -0,0 +1,44 @@ +import Command from "../base/Command.js"; + +import { MessageActionRow, MessageButton } from "discord.js"; +import MessageHelper from "../base/MessageHelper.js"; + +export default class SendTicketMSG extends Command { + constructor (client) { + super(client, { + name: "sendticketmsg", + description: "Sends the ticket message to a channel.", + options: [ + { + name: "channel", + description: "The channel to send the message to.", + required: true, + type: "CHANNEL" + } + ] + }); + } + + run (interaction) { + const channel = interaction.options.data[0].channel; + if (channel.type !== "GUILD_TEXT") { + const embed = this.helper.error("That is not a channel!"); + + return interaction.editReply({ embeds: [embed] }); + } + const row = new MessageActionRow() + .addComponents( + new MessageButton() + .setCustomId("ticket") + .setLabel("Create a ticket") + .setStyle("PRIMARY") + .setEmoji("🎫") + ); + + const ticketMsg = MessageHelper.info("If you require any assistance please click the button below.", "Ticket Support"); + channel.send({ embeds: [ticketMsg], components: [row] }); + + const embed = MessageHelper.success("Message sent!", "Success"); + interaction.editReply({ embeds: [embed] }); + } +} diff --git a/src/commands/stats.js b/src/commands/stats.js new file mode 100644 index 0000000..e9c8568 --- /dev/null +++ b/src/commands/stats.js @@ -0,0 +1,44 @@ +import Command from "../base/Command.js"; +import MessageHelper from "../base/MessageHelper.js"; +import RankedProfile from "../model/RankedProfile.js"; +import { getProfile, updateRank } from "../ranked/profile.js"; + +export default class Stats extends Command { + constructor (client) { + super(client, { + name: "stats", + description: "Check your current stats.", + options: [ + { + name: "player", + description: "If you wish to view someone else their stats type their name here.", + required: false, + type: "USER" + } + ] + }); + } + + async run (interaction) { + const target = interaction.options.get("player")?.value || interaction.user.id; + const profile = await getProfile(target) + if (profile == null) { + interaction.editReply({ embeds: [MessageHelper.error(`There is no profile associated with <@${target}>`)] }) + return; + } + + if (profile.elo < 0) { + console.log("Invalid elo found in profile", profile) + profile.elo = 0; + profile.save() + } + + let wlr = Math.round(profile.wins / profile.losses * 100) / 100 + if (isNaN(wlr)) { + wlr = 0; + } + updateRank(interaction.guild, target) + // updateRank(interaction.guild, interaction.user.id) + interaction.editReply({ embeds: [MessageHelper.success(`**User:** <@${target}>\n**Elo:** ${profile.elo}\n**Wins:** ${profile.wins}\n**Losses:** ${profile.losses}\n**WLR:** ${wlr}`, `Stats`)] }) + } +} diff --git a/src/commands/unsyncdc.js b/src/commands/unsyncdc.js new file mode 100644 index 0000000..7e95b36 --- /dev/null +++ b/src/commands/unsyncdc.js @@ -0,0 +1,41 @@ +import Command from "../base/Command.js"; +import MessageHelper from "../base/MessageHelper.js"; +import Sync from "../model/Sync.js"; +import { getProfile, updateRank } from "../ranked/profile.js"; + +export default class UnSyncDC extends Command { + constructor (client) { + super(client, { + name: "unsyncdc", + description: "Unsync a discord account.", + options: [ + { + name: "member", + description: "The member that will be unsynced. Profile will be reset.", + required: true, + type: "USER" + } + ] + }); + } + + async run (interaction) { + const target = interaction.options.get("member").value + const sync = await Sync.findOne({ discord: target }); + const profile = await getProfile(target) + if (sync == null) { + interaction.editReply({ embeds: [MessageHelper.error(`There is no sync associated with <@${target}>`)] }) + return; + } + + if (profile) { + await profile.remove(); + } + + await sync.remove(); + + updateRank(interaction.guild, target) + + interaction.editReply({ embeds: [MessageHelper.error(`<@${target}> is no longer synchronized!`, "Unsynchronized").setThumbnail("https://crafthead.net/avatar/" + sync.minecraft)]}) + } +} diff --git a/src/commands/unsyncmc.js b/src/commands/unsyncmc.js new file mode 100644 index 0000000..4ed9da6 --- /dev/null +++ b/src/commands/unsyncmc.js @@ -0,0 +1,54 @@ +import fetch from "node-fetch"; +import Command from "../base/Command.js"; +import MessageHelper from "../base/MessageHelper.js"; +import Sync from "../model/Sync.js"; +import { getProfile, updateRank } from "../ranked/profile.js"; + +export default class UnSyncMC extends Command { + constructor(client) { + super(client, { + name: "unsyncmc", + description: "Unsync a minecraft account.", + options: [ + { + name: "ign", + description: "The ign that will be unsynced.", + required: true, + type: "STRING" + } + ] + }); + } + + async run(interaction) { + const ign = interaction.options.get("ign").value + fetch(`https://api.mojang.com/users/profiles/minecraft/${ign}`) + .then((res) => { + if (res.status != 200) { + interaction.editReply({ embeds: [MessageHelper.error(`There is no minecraft account with that ign`)] }); + return; + } + return res.json() + }) + .then(async json => { + if (json.error) return interaction.editReply({ embeds: [MessageHelper.error(`Mojang return an error`)] }); + const id = json.id; + const sync = await Sync.findOne({ minecraft: id.substr(0, 8) + "-" + id.substr(8, 4) + "-" + id.substr(12, 4) + "-" + id.substr(16, 4) + "-" + id.substr(20) }); + if (sync == null) { + return interaction.editReply({ embeds: [MessageHelper.error(`There is no sync associated with ${ign}`)] }); + } + const target = sync.discord; + const profile = await getProfile(target) + if (profile) { + await profile.remove(); + } + await sync.remove(); + updateRank(interaction.guild, target) + interaction.editReply({ embeds: [MessageHelper.error(`<@${target}> is no longer synchronized!`, "Unsynchronized").setThumbnail("https://crafthead.net/avatar/" + sync.minecraft)] }) + }) + .catch((e) => { + console.log(e) + interaction.editReply({ embeds: [MessageHelper.error(`The code failed to unsync`)] }) + }) + } +} diff --git a/src/commands/void.js b/src/commands/void.js new file mode 100644 index 0000000..b6a1d19 --- /dev/null +++ b/src/commands/void.js @@ -0,0 +1,53 @@ +import { states, voidGame } from "../ranked/game.js" +import Command from "../base/Command.js"; +import RankedGame from "../model/RankedGame.js"; +import MessageHelper from "../base/MessageHelper.js"; + +export default class Void extends Command { + constructor (client) { + super(client, { + name: "void", + description: "Voids a game", + options: [ + { + name: "game", + description: "The id of the game that will be voided.", + required: true, + type: "STRING" + }, + { + name: "revertwin", + description: "If the win should be reverted", + required: true, + type: "BOOLEAN" + } + ] + }); + } + + async run (interaction) { + const _id = interaction.options.get("game").value + const revertwin = interaction.options.get("revertwin").value + const game = await RankedGame.findOne({ _id }) + if (!game) { + return interaction.editReply({ embeds: [MessageHelper.error("That game was not found!")] }) + } + if (game.state == states.VOIDED) { + return interaction.editReply({ embeds: [MessageHelper.error("That game is already voided!")] }) + } + + if (game.state == states.ENDED) { + interaction.editReply({ embeds: [MessageHelper.success(`That game has been voided!\n\n**Elo Reverts:**\n${Object.entries(game.eloChanges).filter(eloChange => { + if (revertwin === false && eloChange[1] > 0) { + return false; + } else { + return true; + } + }).map(eloChange => `<@${eloChange[0]}>: ${-eloChange[1] > 0 ? "+" : ""}**${-eloChange[1]}**`).join("\n")}`, "Voided")] }) + } else { + interaction.editReply({ embeds: [MessageHelper.success("That game has been voided!", "Voided")] }) + } + + voidGame(this.client, game, revertwin) + } +} diff --git a/src/commands/voidgames.js b/src/commands/voidgames.js new file mode 100644 index 0000000..ac94342 --- /dev/null +++ b/src/commands/voidgames.js @@ -0,0 +1,21 @@ +import Command from "../base/Command.js"; +import RankedGame from "../model/RankedGame.js" +import { states, voidGame } from "../ranked/game.js"; +import MessageHelper from "../base/MessageHelper.js"; + +export default class VoidGames extends Command { + constructor(client) { + super(client, { + name: "voidgames", + description: "All current games will be voided." + }); + } + + async run(interaction) { + const games = await RankedGame.find({ "state": { "$nin": [states.ENDED, states.VOIDED] } }) + games.forEach(game => { + voidGame(this.client, game) + }) + interaction.editReply({ embeds: [MessageHelper.success(`All current games are voided!`, "Games Voided")] }) + } +} diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js new file mode 100644 index 0000000..e67fe8d --- /dev/null +++ b/src/events/guildMemberAdd.js @@ -0,0 +1,11 @@ +import { updateRank } from "../ranked/profile.js"; + +export default class guildMemberAdd { + constructor(client) { + this.client = client; + } + + run (member) { + updateRank(member.guild, member.user.id) + } +} diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js new file mode 100644 index 0000000..2c0d8e1 --- /dev/null +++ b/src/events/interactionCreate.js @@ -0,0 +1,36 @@ +import MessageHelper from "../base/MessageHelper.js"; + +export default class interactionCreate { + constructor(client) { + this.client = client; + } + + run(interaction) { + if (interaction.isCommand()) { + const command = this.client.commands.get(interaction.commandName); + if (command) { + interaction.deferReply() + .then(() => command.run(interaction)); + + } + }; + if (interaction.customId === "ticket" && interaction.componentType === "BUTTON") { + if (interaction.guild.channels.cache.some(x => x.name.toLowerCase() === `ticket-${interaction.user.username.toLowerCase().replace(/[^a-z0-9]/g, "")}` === true)) { + interaction.reply({ embeds: [MessageHelper.error("You already have a ticket!")], ephemeral: true }); + return; + } + interaction.guild.channels.create("ticket-" + interaction.user.username.toLowerCase().replace(/[^a-z0-9]/g, ""), { + parent: interaction.guild.channels.cache.find(c => c.name.toLowerCase() === "tickets" && c.type === "GUILD_CATEGORY"), + lockPermissions: true + }).then(async channel => { + await channel.permissionOverwrites.create(interaction.user, { + VIEW_CHANNEL: true, + SEND_MESSAGES: true + }); + channel.send(`${interaction.guild.roles.cache.find(x => x.name === 'Support')}`).then(msg => msg.delete()) + await channel.send({ embeds: [MessageHelper.success(`Hello ${interaction.user.toString()}, You have successfully created a ticket. Please specify what you're in need of assistance with, we will get back to you as soon as possible.\n\nThanks.`, "Ticket")] }); + interaction.reply({ embeds: [MessageHelper.success(`Your ticket has been created ${channel.toString()}!`, "Ticket Created")], ephemeral: true }); + }); + } + } +} diff --git a/src/events/voiceStateUpdate.js b/src/events/voiceStateUpdate.js new file mode 100644 index 0000000..d7b3ea9 --- /dev/null +++ b/src/events/voiceStateUpdate.js @@ -0,0 +1,11 @@ +export default class voiceStateUpdate { + constructor (client) { + this.client = client; + } + + run (oldState, newState) { + if (oldState.channel?.id && oldState.channel?.id != newState.channel?.id) { + // checkDodge(this.client, oldState.guild, newState.id, oldState.channel?.id, newState.channel?.id) + } + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..eac0062 --- /dev/null +++ b/src/index.js @@ -0,0 +1,15 @@ +import Client from "./base/Client.js"; +import config from "./base/Config.js" + + +process + .on("uncaughtException", err => console.error(err.stack)) + .on("unhandledRejection", err => console.error(err.stack)); +console.log("Starting..."); + +const client = new Client({ + config: "./config" +}); + +// Login with config token +client.login(config.token); diff --git a/src/model/RankedGame.js b/src/model/RankedGame.js new file mode 100644 index 0000000..4861003 --- /dev/null +++ b/src/model/RankedGame.js @@ -0,0 +1,27 @@ +import mongoose from 'mongoose'; + +const database = mongoose.connection.useDb('practice') + +export default database.model('RankedGame', new mongoose.Schema({ + _id: String, + players: [String], + team1: { + captain: String, + players: [String], + voice: String + }, + team2: { + captain: String, + players: [String], + voice: String + }, + winner: Number, + map: String, + match: String, + voice: String, + text: String, + state: String, + eloChanges: Object +}, { + versionKey: false +}), 'rankedgames') \ No newline at end of file diff --git a/src/model/RankedProfile.js b/src/model/RankedProfile.js new file mode 100644 index 0000000..af95e8e --- /dev/null +++ b/src/model/RankedProfile.js @@ -0,0 +1,13 @@ +import mongoose from 'mongoose'; + +const database = mongoose.connection.useDb('practice') + +export default database.model('RankedProfile', new mongoose.Schema({ + id: String, + wins: Number, + losses: Number, + elo: Number, + dodges: Number +}, { + versionKey: false +}), 'rankedprofiles') \ No newline at end of file diff --git a/src/model/Sync.js b/src/model/Sync.js new file mode 100644 index 0000000..ef029e0 --- /dev/null +++ b/src/model/Sync.js @@ -0,0 +1,10 @@ +import mongoose from 'mongoose'; + +const database = mongoose.connection.useDb('practice') + +export default database.model('Sync', new mongoose.Schema({ + minecraft: String, + discord: String +}, { + versionKey: false +}), 'syncs') \ No newline at end of file diff --git a/src/ranked/game.js b/src/ranked/game.js new file mode 100644 index 0000000..5fcdc3b --- /dev/null +++ b/src/ranked/game.js @@ -0,0 +1,689 @@ +import RankedGame from "../model/RankedGame.js" +import MessageHelper from "../base/MessageHelper.js"; +import { MessageActionRow, MessageSelectMenu, MessageButton } from "discord.js" +import { randomUUID } from "crypto" +import { getProfile, giveLoss, giveWin, updateRank } from "./profile.js" +import config from "../base/Config.js" + +// States +const states = { + "MOVING": "MOVING", + "PICKING": "PICKING", + "VOTING": "VOTING", + "PREGAME": "PREGAME", + "STARTED": "STARTED", + "ENDED": "ENDED", + "VOIDED": "VOIDED" +} + +// Constants +const PING_UPDATE_INTERVAL = 1; +const QUEUE_UPDATE_INTERVAL = 25; +const DODGE_CHECK_DELAY = QUEUE_UPDATE_INTERVAL / 2; +const TIME_MAP_VOTING = 30; +const TIME_PICK = 15; +const IDLE_PICK = 3; +const GAME_SIZE = 12; + +const gameCollectors = {} + +// Online values +let online = true; +let recieved = true; + +// Ping to check online value +async function startPing(client) { + setInterval(async () => { + if (recieved) { + online = true; + recieved = false; + } else { + online = false; + } + + client.user.setPresence({ activities: [{ name: online ? 'elevatemc.com' : 'offline' }], status: 'dnd' }); + + client.redis.publish("rankedhcf:ping") + }, PING_UPDATE_INTERVAL * 1000) +} + +// Queue loop +async function startQueueCheck(client) { + const guild = client.guilds.cache.get(config.guild); + setInterval(async () => { + if (!online) return; + + config.queue.forEach(async q => { + const queue = await guild.channels.fetch(q); + if (queue.members.size == GAME_SIZE) { + console.log("[QUEUE] Starting a new game") + const members = queue.members.clone() + const shuffled = members.sort(() => 0.5 - Math.random()); + + createGame(client, shuffled) + } + }) + + }, QUEUE_UPDATE_INTERVAL * 1000) +} + +// Function to clean up old games +async function cleanOldGames(client) { + const games = await RankedGame.find({ state: { $nin: [states.ENDED, states.VOIDED] } }) + if (games.length > 0) { + games.forEach(game => { voidGame(client, game) }) + console.log(`Voided ${games.length} games to make sure there are no glitches.`) + } +} + +async function startDodgeCheck(client) { + const guild = client.guilds.cache.get(config.guild); + setTimeout(() => { + setInterval(async () => { + const games = await RankedGame.find({ state: { $nin: [states.STARTED, states.ENDED, states.VOIDED, states.MOVING] } }) + games.forEach(async game => { + const players = [...game.players]; + let allIn = []; + const team1Voice = await guild.channels.fetch(game.team1.voice).catch(() => { }); + if (team1Voice?.id) { + allIn = allIn.concat(team1Voice.members.map(m => m.user.id)) + } + const team2Voice = await guild.channels.fetch(game.team2.voice).catch(() => { }); + if (team2Voice?.id) { + allIn = allIn.concat(team2Voice.members.map(m => m.user.id)) + } + const voice = await guild.channels.fetch(game.voice).catch(() => { }); + if (voice?.id) { + allIn = allIn.concat(voice.members.map(m => m.user.id)) + } + + const absent = players.filter(pl => !allIn.includes(pl)); + + if (absent.length > 0) { + game.state = states.VOIDED; + dodge(client, game, guild, absent) + } + }) + }, QUEUE_UPDATE_INTERVAL * 1000) + }, 1000 * DODGE_CHECK_DELAY) + +} + +// Check if the disconnect was a dodge +/* async function checkDodge(client, guild, id, oldChannel, newChannel) { + if (oldChannel == config.queue) return; + const games = gameCache.filter(x => x.state != states.ENDED && x.state != states.VOIDED && x.players.includes(id)); + games.forEach(game => { + switch (game.state) { + case states.PICKING: + if (newChannel == game.team1.voice || newChannel == game.team2.voice) { + // Fine move -> from lobby to team voice + return; + } else { + dodge(client, game, guild, id) + } + break; + case states.PREGAME: + case states.VOTING: + dodge(client, game, guild, id) + break; + case states.STARTED: + break; + } + }) +} */ + +// Activates when someone dodged +async function dodge(client, game, guild, ids) { + game.state = states.VOIDED; + await game.save() + const channel = await guild.channels.fetch(game.text).catch(() => { }); + if (channel) { + channel.send({ embeds: [MessageHelper.error(`${ids.map(id => `<@${id}>`).join(" ")} has dodged this game.`, `Game Dodged [${game._id}]`)] }) + } + const feed = await guild.channels.fetch(config.feed) + console.log(`[${game._id}] Game Dodged`) + feed.send({ embeds: [MessageHelper.error(`${ids.map(id => `<@${id}>`).join(" ")} has dodged this game.`, `Game Dodged [${game._id}]`)] }) + + const dodgefeed = await guild.channels.fetch(config.dodgefeed) + dodgefeed.send({ content: `${ids.map(id => `<@${id}>`).join(" ")} has dodged ${game._id} [auto timed out for ${ids.length > 1 ? "20" : "60"}m]` }) + + ids.forEach(async id => { + const member = await guild.members.fetch(id).catch(() => {}); + if (member) { + if (ids.length == 1) { + member.timeout(60 * 60 * 1000, `Dodged ${game._id}`) + .then(() => {}) + .catch(() => {}); + } else { + member.timeout(20 * 60 * 1000, `Dodged ${game._id}`) + .then(() => {}) + .catch(() => {}); + } + } else { + dodgefeed.send({ content: `<@${id}> left the discord while playing ${game._id}` }) + } + }) + + + voidGame(client, game) +} + + +// Creates a new game +async function createGame(client, members) { + // Verify it they arent in a game yet + for (const member of members.toJSON()) { + const game = await RankedGame.findOne({ players: member.user.id, state: { "$nin": [states.ENDED, states.VOIDED, states.STARTED] } }) + if (game) { + console.log("Failed to make a game because", member.nickname, "is already in a game") + return; + } + } + + // Create game object + const game = new RankedGame({ + players: members.map(x => x.id), + team1: { + captain: "", + players: [] + }, + team2: { + captain: "", + players: [] + }, + map: "", + match: "", + state: states.MOVING + }) + + // Generate id + const id = randomUUID().toString().substring(0, 7); + game._id = id; + await game.save(); + + // Create channels + const guild = await client.guilds.cache.get(config.guild); + const parent = await guild.channels.fetch(config.category); + const textChannel = await guild.channels.create("game-" + id, { parent }) + const voiceChannel = await guild.channels.create(`[${id}] Game`, { + type: "GUILD_VOICE", + parent + }) + game.text = textChannel.id; + game.voice = voiceChannel.id; + + // Move everyone to the pregame voice channel + for (const member of members.toJSON()) { + voiceChannel.permissionOverwrites.create(member, { + VIEW_CHANNEL: true, + CONNECT: true + }); + textChannel.permissionOverwrites.create(member, { + VIEW_CHANNEL: true + }); + await move(member, voiceChannel) + } + + const eloMap = {}; + for (const m of members.toJSON()) { + const profile = await getProfile(m.user.id); + eloMap[m.user.id] = profile.elo; + } + members = members.sort((a, b) => { + return eloMap[b.user.id] - eloMap[a.user.id] + }) + + const random = Math.random() < 0.5; + if (random) { + // Random team 1 captain + const randomUser1 = members.firstKey(); + game.team1.captain = randomUser1; + addToTeam(game, randomUser1, 1) + members.delete(randomUser1) + // Random team 2 captain + const randomUser2 = members.firstKey(); + game.team2.captain = randomUser2; + addToTeam(game, randomUser2, 2) + members.delete(randomUser2) + } else { + // Random team 2 captain + const randomUser2 = members.firstKey(); + game.team2.captain = randomUser2; + addToTeam(game, randomUser2, 2) + members.delete(randomUser2) + // Random team 1 captain + const randomUser1 = members.firstKey(); + game.team1.captain = randomUser1; + addToTeam(game, randomUser1, 1) + members.delete(randomUser1) + } + + + console.log(`[${game._id}] Game Created`) + const feed = await guild.channels.fetch(config.feed); + feed.send({ + embeds: [MessageHelper.info(`**Team One**: +Captain: <@${game.team1.captain}> + +**Team Two:** +Captain: <@${game.team2.captain}> + +**All Players:** + ${game.players.map(x => `<@${x}>`).join("\n")}`, `Game Created [${game._id}]`)] + }) + + + // Start picking round + game.state = states.PICKING + await game.save(); + await doPicking(game, textChannel, members) + .then(async () => { + game.state = states.MOVING; + await game.save() + await movePlayersToTeamChannels(game, guild) + setTimeout(() => { voiceChannel.delete(); }, 1500) + game.state = states.VOTING + await game.save(); + await doMapVote(game, textChannel) + .then(async () => { + game.state = states.PREGAME + sendPlayMessage(game, textChannel) + await game.save() + client.redis.publish("rankedhcf:loadgame", game._id) + }) + .catch(err => { + if (err != "VOID") { + console.log(err) + } + }) + }) + .catch(err => { + if (err == "TIME") { + voidGame(client, game) + } + }) + + +} + +// Does the picking round +async function doPicking(game, channel, pickables) { + return new Promise(async (resolve, reject) => { + const generatedMessage = generatePickingMessage(game, pickables) + const pickingMessage = await channel.send(generatedMessage); + if (pickables.size < 1) { + return resolve(); + } + const collector = pickingMessage.createMessageComponentCollector({ componentType: "SELECT_MENU", time: TIME_PICK * 60 * 1000, idle: IDLE_PICK * 60 * 1000 }); + gameCollectors[game._id] = collector; + let turn = true; + collector.on('collect', async i => { + if (i.user.id != game.team1.captain && i.user.id != game.team2.captain) { + return i.reply({ embeds: [MessageHelper.error("You are not a captain!")], ephemeral: true }); + } + if (turn && i.user.id == game.team1.captain) { + i.deferUpdate() + turn = !turn; + pick(game, pickables, i.values[0], 1, collector, turn) + await pickingMessage.edit(generatePickingMessage(game, pickables)) + } else if (!turn && i.user.id == game.team2.captain) { + i.deferUpdate() + turn = !turn; + pick(game, pickables, i.values[0], 2, collector, turn) + await pickingMessage.edit(generatePickingMessage(game, pickables)) + } else { + return i.reply({ embeds: [MessageHelper.error("It is not your turn!")], ephemeral: true }); + } + + if (pickables.size < 1) { + collector.stop() + resolve() + } + }); + collector.on('end', (collected, reason) => { + if (reason == "VOID") { + pickingMessage.delete(); + return reject("VOID"); + } + if (reason == "time" || reason == "idle") { + pickingMessage.delete(); + return reject("TIME"); + } + }) + }) + +} + +function addToTeam(game, player, team) { + if (team === 1) { + game.team1.players.addToSet(player) + } else if (team === 2) { + game.team2.players.addToSet(player) + } +} + +function pick(game, pickables, pick, team, collector, turn) { + addToTeam(game, pick, team) + pickables.delete(pick) + if (pickables.size === 1) { + const last = pickables.firstKey(); + if (turn) { + addToTeam(game, last, 1); + pickables.delete(last); + } else { + addToTeam(game, last, 2); + pickables.delete(last); + } + collector.stop() + } +} + +function shuffleAndMode(arr) { + return arr + .map(value => ({ value, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ value }) => value) + .sort((a, b) => + arr.filter(v => v === a).length - arr.filter(v => v === b).length) + .pop(); +} + +async function doMapVote(game, channel) { + return new Promise(async (resolve, reject) => { + const votes = {}; + const generatedMessage = generateMapVoteMessage(game, votes) + const voteMessage = await channel.send(generatedMessage); + const collector = voteMessage.createMessageComponentCollector({ componentType: 'BUTTON', time: 1000 * TIME_MAP_VOTING }); + gameCollectors[game._id] = collector; + collector.on('collect', async i => { + if (!game.players.includes(i.user.id)) { + return i.reply({ embeds: [MessageHelper.error("You are not part of this game!")], ephemeral: true }); + } + i.deferUpdate() + votes[i.user.id] = i.customId; + voteMessage.edit(generateMapVoteMessage(game, votes)) + }); + collector.on('end', async (collected, reason) => { + if (reason == "VOID") { + voteMessage.delete(); + return reject("VOID"); + } + const values = Object.values(votes); + if (values.length > 0) { + game.map = shuffleAndMode(values); + } else { + game.map = config.maps[Math.floor(Math.random() * config.maps.length)].id; + } + + voteMessage.edit({ embeds: [MessageHelper.success(`${config.maps.find(x => x.id === game.map).name} won the map vote.`, `Map Vote`)], components: [] }) + resolve() + }) + }) + +} + +async function movePlayersToTeamChannels(game, guild) { + const parent = await guild.channels.fetch(config.category); + const team1VoiceChannel = await guild.channels.create(`[${game._id}] Team 1`, { + type: 'GUILD_VOICE', + parent + }) + game.team1.voice = team1VoiceChannel.id; + await new Promise(resolve => { setTimeout(() => resolve(), 3000) }) + const team2VoiceChannel = await guild.channels.create(`[${game._id}] Team 2`, { + type: 'GUILD_VOICE', + parent + }) + game.team2.voice = team2VoiceChannel.id; + for (const id of game.team1.players) { + const member = await guild.members.fetch(id); + team1VoiceChannel.permissionOverwrites.create(member, { + VIEW_CHANNEL: true, + CONNECT: true + }); + team2VoiceChannel.permissionOverwrites.create(member, { + VIEW_CHANNEL: true + }); + await move(member, team1VoiceChannel) + } + for (const id of game.team2.players) { + const member = await guild.members.fetch(id); + team2VoiceChannel.permissionOverwrites.create(member, { + VIEW_CHANNEL: true, + CONNECT: true + }); + team1VoiceChannel.permissionOverwrites.create(member, { + VIEW_CHANNEL: true + }); + await move(member, team2VoiceChannel) + } +} + +async function move(member, channel) { + const voice = member.voice + if (voice?.channelId) { + await voice.setChannel(channel).catch(() => {}) + } +} + +async function deleteChannels(client, game) { + const guild = client.guilds.cache.get(config.guild); + const channel = await guild.channels.fetch(game.text).catch(() => { }); + if (channel) channel.delete().catch(() => { }); + const team1Voice = await guild.channels.fetch(game.team1.voice).catch(() => { }); + if (team1Voice?.id) team1Voice.delete().catch(() => { }); + const team2Voice = await guild.channels.fetch(game.team2.voice).catch(() => { }); + if (team2Voice?.id) team2Voice.delete().catch(() => { }); + const voice = await guild.channels.fetch(game.voice).catch(() => { }); + if (voice?.id) voice.delete().catch(() => { }); +} + +async function voidGame(client, game, revertWin = true) { + client.redis.publish("rankedhcf:voidgame", game._id) + if (game.state === states.ENDED) { + Object.entries(game.eloChanges).forEach(async eloChange => { + const user = eloChange[0]; + const elo = eloChange[1]; + if (elo > 0 && revertWin === false) return + const profile = await getProfile(user); + if (profile) { + if (elo > 0) { + profile.wins -= 1; + } else { + profile.losses -= 1; + } + + profile.elo -= elo; + if (profile < 0) { + profile.elo = 0; + } + + await profile.save(); + updateRank(client.guilds.cache.get(config.guild), eloChange[0]) + } + }) + } + game.state = states.VOIDED; + game.save(); + const guild = client.guilds.cache.get(config.guild); + const channel = await guild.channels.fetch(game.text).catch(() => { }); + if (channel) channel.send({ embeds: [MessageHelper.error(`This game has been voided. All channels will close in 10 seconds.`, `Game Voided [${game._id}]`)] }); + const feed = await guild.channels.fetch(config.feed) + feed.send({ + embeds: [MessageHelper.info(` +**Team One**: +Captain: <@${game.team1.captain}> +${game.team1.players.filter(x => x != game.team1.captain).map(x => `<@${x}>`).join("\n")} +**Team Two:** +Captain: <@${game.team2.captain}> +${game.team2.players.filter(x => x != game.team2.captain).map(x => `<@${x}>`).join("\n")} +If you recieved or lost any elo during this game it will be automatically reverted. +`, `Game Voided [${game._id}]`)] + }) + + gameCollectors[game._id]?.stop("VOID"); + delete gameCollectors[game._id]; + setTimeout(() => deleteChannels(client, game), 10 * 1000); +} + +async function winGame(client, game, winnerTeam) { + const winners = winnerTeam === 1 ? game.team1.players : game.team2.players + const losers = winnerTeam === 1 ? game.team2.players : game.team1.players + const eloChanges = {} + for (const w of winners) { + const firstElo = (await getProfile(w)).elo; + await giveWin(w).catch(err => { console.log("Failed to give win", err) }) + const secondElo = (await getProfile(w)).elo; + eloChanges[w] = secondElo - firstElo; + } + for (const l of losers) { + const firstElo = (await getProfile(l)).elo; + await giveLoss(l).catch(err => { console.log("Failed to give win", err) }) + const secondElo = (await getProfile(l)).elo; + eloChanges[l] = secondElo - firstElo; + } + game.eloChanges = eloChanges; + game.state = states.ENDED; + game.save(); + const guild = client.guilds.cache.get(config.guild); + game.players.forEach(id => { updateRank(guild, id) }) + const channel = await guild.channels.fetch(game.text).catch(() => { }); + if (channel) channel.send({ + embeds: [MessageHelper.success(`**Elo Changes:** +${Object.entries(eloChanges).map(x => `<@${x[0]}>: **${x[1] > 0 ? "+" : ""}${x[1]}**`).join("\n")} + +All the game channels will be deleted automatically in 30 seconds.`, `Game Ended`)] + }); + + + const feed = await guild.channels.fetch(config.feed) + console.log(`[${game._id}] A game was won Winner Team: ${winnerTeam}`) + feed.send({ + embeds: [MessageHelper.info(`**Elo Changes:** +${Object.entries(eloChanges).map(x => `<@${x[0]}>: **${x[1] > 0 ? "+" : ""}${x[1]}**`).join("\n")} + +All the game channels will be deleted automatically in 30 seconds.`, `Game Ended [${game._id}]`)] + }); + + setTimeout(() => deleteChannels(client, game), 30 * 1000) +} + +async function sendPlayMessage(game, channel) { + channel.send({ + embeds: [MessageHelper.success(`1) Log on **elevatemc.com** or **cavepvp.org** +2) Connect to **practice** +3) Type **/ranked** in the chat to join your game +4) **Good luck!**`, `Game Started`)] + }); +} + +async function onRedisMessage(client, channel, message) { + switch (channel) { + case "rankedhcf:startgame": { + const data = message.split(":") + if (data.length < 2) { + return console.log("Invalid game start data: " + message) + } + const _id = data[0]; + const match = data[1]; + const game = await RankedGame.findOne({ _id }) + if (game) { + if (game.state != states.PREGAME) return console.log("Invalid game start state: " + message + " state: " + game.state) + game.state = states.STARTED; + game.match = match; + game.save() + const feed = await client.guilds.cache.get(config.guild).channels.fetch(config.feed); + console.log(`[${game._id}] Game Started Match: ${match}`) + feed.send({ + embeds: [MessageHelper.info(`This game has started. Match id: ${match} +${game.players.map(x => `<@${x}>`).join("\n")}`, `Game Started [${game._id}]`)] + }) + } else { + console.log("Invalid game start id: " + message) + } + break; + } + case "rankedhcf:wingame": { + const data = message.split(":") + if (data.length < 2) { + return console.log("Invalid game win data: " + message) + } + const _id = data[0]; + const winnerTeam = data[1]; + const game = await RankedGame.findOne({ _id }) + if (game) { + if (game.state == states.PREGAME) console.log(game) + if (game.state != states.STARTED && game.state != states.PREGAME) return console.log("Invalid game win state: " + message + " state: " + game.state) + winGame(client, game, parseInt(winnerTeam)) + } else { + console.log("Invalid game win id: " + message) + } + break; + } + case "rankedhcf:pong": { + recieved = true; + break; + } + } +} + +function generatePickingMessage(game, pickables) { + if (pickables.size > 0) { + const options = pickables.map(m => { + return { + label: m.nickname || m.user.username, + description: "", + value: m.user.id + } + }); + const row = new MessageActionRow() + .addComponents( + new MessageSelectMenu() + .setCustomId('select') + .setPlaceholder('Select a player here') + .setMinValues(1) + .setMaxValues(1) + .addOptions(options) + ) + const embed = MessageHelper.success(`**Team One**: +Captain: <@${game.team1.captain}> +${game.team1.players.filter(x => x != game.team1.captain).map(x => `<@${x}>`).join("\n")} +**Team Two:** +Captain: <@${game.team2.captain}> +${game.team2.players.filter(x => x != game.team2.captain).map(x => `<@${x}>`).join("\n")}`, `Player Picking`) + return { content: game.players.map(x => `<@${x}>`).join(" "), embeds: [embed], components: [row] } + } else { + const embed = MessageHelper.success(`**Team One**: +Captain: <@${game.team1.captain}> +${game.team1.players.filter(x => x != game.team1.captain).map(x => `<@${x}>`).join("\n")} +**Team Two:** +Captain: <@${game.team2.captain}> +${game.team2.players.filter(x => x != game.team2.captain).map(x => `<@${x}>`).join("\n")}`, `Final Team`) + + return { content: game.players.map(x => `<@${x}>`).join(" "), embeds: [embed], components: [] } + } + +} + +function generateMapVoteMessage(game, votes) { + const buttons = config.maps.map(map => + new MessageButton() + .setCustomId(map.id) + .setLabel(map.name) + .setStyle('PRIMARY') + ) + const row = new MessageActionRow() + .addComponents( + ...buttons + ); + const embed = MessageHelper.success(` + ${config.maps.map(map => `**${map.name}**\nVotes: ${Object.values(votes).filter(v => v === map.id).length}`).join("\n\n")}\n\nYou have ${TIME_MAP_VOTING} seconds to vote for the map you wish to play on.` + , `Map Vote`) + + return { embeds: [embed], components: [row] } +} + +export { + createGame, onRedisMessage, voidGame, states, startQueueCheck, startPing, cleanOldGames, startDodgeCheck +} \ No newline at end of file diff --git a/src/ranked/profile.js b/src/ranked/profile.js new file mode 100644 index 0000000..37ebc59 --- /dev/null +++ b/src/ranked/profile.js @@ -0,0 +1,127 @@ +import RankedProfile from "../model/RankedProfile.js" +import Sync from "../model/Sync.js" +import config from "../base/Config.js" +import fetch from "node-fetch" + +function createProfile(id) { + const profile = new RankedProfile({ id, wins: 0, losses: 0, elo: 0, dodges: 0 }); + profile.save() + return profile +} + +async function getProfile(id) { + const profile = await RankedProfile.findOne({ id }) + return profile; +} + +async function getLeaderboard() { + const leaderboard = await RankedProfile.find().sort({ elo: "desc" }).limit(10) + return leaderboard; +} + +function getRank(elo) { + return config.ranks.find(r => elo >= r.min && elo <= r.max); +} + + +async function updateRank(guild, id) { + const profile = await getProfile(id) + const member = await guild.members.fetch(id).catch((err) => { console.log(err); console.log("[RANKUPDATE] Failed to fetch member", id )}); + if (profile && member) { + let current = member.roles.cache.filter(r => config.ranks.map(x => x.role).includes(r.id)).map(r => r.id) + if (current.length > 1) { + current.forEach(r => member.roles.remove(r)); + current = []; + } + + if (!member.roles.cache.some(r => r.id == config.roles.registered)) { + const registeredRole = await guild.roles.fetch(config.roles.registered); + member.roles.add(registeredRole) + } + + const rank = getRank(profile.elo); + if (!current.includes(rank.role)) { + current.forEach(r => member.roles.remove(r)); + member.roles.add(await guild.roles.fetch(rank.role)) + } + const sync = await Sync.findOne({ "discord": id }) + if (!sync) return; + fetch(`https://api.mojang.com/user/profiles/${sync.minecraft}/names`) + .then(res => res.json()) + .then(json => { + if (json.error) return console.log("[RANKUPDATE] Mojang failed", json.error); + if (id == "261885314933587969") { + profile.elo = 0; + profile.wins = 0; + profile.losses = 0; + profile.save(); + return member.setNickname(`[-1] ${json[json.length - 1].name}`).catch((err) => { console.log(err) }) + } + member.setNickname(`[${profile.elo}] ${json[json.length - 1].name}`).catch((err) => { console.log(err) }) + }) + .catch((err) => { console.log("[RANKUPDATE] fail", err) }) + } else if (member) { + // Member exists but has no profile + let current = member.roles.cache.filter(r => config.ranks.map(x => x.role).includes(r.id)).map(r => r.id) + if (current.length > 0) { + current.forEach(r => member.roles.remove(r)); + current = []; + } + const registeredRole = await guild.roles.fetch(config.roles.registered); + member.roles.remove(registeredRole) + member.setNickname(null) + } +} + +async function giveWin(id) { + return new Promise(async (resolve, reject) => { + const profile = await getProfile(id) + if (profile) { + const rank = getRank(profile.elo); + if (rank) { + profile.elo += rank.gain; + profile.wins += 1; + profile.save() + .then(() => resolve()) + .catch(() => reject("Failed to give a win")) + } else { + console.log("Invalid rank:", + rank) + console.log("Profile --->", profile) + reject("The rank was invalid") + } + + } else { + reject("That person has no profile associated with their discord account") + } + }) +} + +async function giveLoss(id) { + return new Promise(async (resolve, reject) => { + const profile = await getProfile(id) + if (profile) { + const rank = getRank(profile.elo); + if (rank) { + profile.elo -= rank.loss; + if (profile.elo < 0) { + profile.elo = 0; + } + profile.losses += 1; + profile.save() + .then(() => resolve()) + .catch(() => reject("Failed to give a loss")) + } else { + console.log("Invalid rank:", + rank) + console.log("Profile --->", profile) + reject("The rank was invalid") + } + } else { + reject("That person has no discord account associated with their discord account") + } + }) +} + + +export { + getProfile, getLeaderboard, giveWin, giveLoss, createProfile, updateRank +} \ No newline at end of file