From 4f0b35c7ad98c37d0cfa46b6a2d8f6691b5626df Mon Sep 17 00:00:00 2001 From: ethanf Date: Sun, 28 Jan 2024 17:24:18 -0600 Subject: [PATCH] feat: autocaptain teams --- index.js | 465 +++++++++++++++++++++++++++++++++++++++++++++++++++- register.js | 8 + 2 files changed, 464 insertions(+), 9 deletions(-) diff --git a/index.js b/index.js index 50a223e..4ffc907 100644 --- a/index.js +++ b/index.js @@ -68,6 +68,128 @@ const getApplicableName = (player) => { ); }; +const avgDiff = (arr) => { + // average absolute difference between all pairs of rank properties in a given array + let sum = 0; + for (let i = 0; i < arr.length; i++) { + for (let j = 0; j < arr.length; j++) { + if (isNaN(arr[i].rank) || isNaN(arr[j].rank)) { + console.error(`Invalid array passed to avgDiff ${arr}`); + return; + } + sum += Math.abs(arr[i].rank - arr[j].rank); + } + } + return sum / (arr.length * (arr.length - 1)) / 2; +}; + +// values used for balancing, modifiable +// FUN value: Fluctuating Unfairness Normalization value +// PASSION: Player Ability/Skill Separation Index Offset Number +let funInput = 4; // 1-10 +let passionInput = 2; // 1-5 + +// balance attempt counter, not modifiable +let attempts = 0; + +const balanceArrays = (a1, a2) => { + // check to make sure arrays are not empty and that rank properties exist + if (a1.length === 0 || a2.length === 0) { + console.error(`Empty array(s) passed to balanceArrays ${a1} ${a2}`); + return [a1, a2]; + } + if (!a1[0].rank || !a2[0].rank) { + console.error(`Rank property missing ${a1} ${a2}`); + return [a1, a2]; + } + + let modArr1 = [...a1]; + let modArr2 = [...a2]; + let sum1 = modArr1.reduce((acc, player) => acc + player.rank, 0); + let sum2 = modArr2.reduce((acc, player) => acc + player.rank, 0); + + let funValue = funInput * 0.01 + 0.025; + let passion = passionInput * 0.1; + let unequalAllowance = Math.floor((sum1 + sum2) * funValue); + + // rebalance until teams fall within constraints + attempts = 0; + while ( + Math.abs(avgDiff(modArr1) - avgDiff(modArr2)) > passion || + (Math.abs(sum1 - sum2) > unequalAllowance && attempts < 1000) + ) { + attempts++; + if (attempts > 1000) { + console.log( + `Too many attempts to balance teams + ${attempts} ${unequalAllowance} ${passion} + ${avgDiff(modArr1)} ${avgDiff(modArr2)}` + ); + return [modArr1, modArr2]; + } else if (attempts > 600) { + unequalAllowance = (sum1 + sum2) * funValue + 2; + } else if (attempts > 300) { + unequalAllowance = (sum1 + sum2) * funValue + 1; + } + + let rerolls = 0; + let ran1 = Math.floor(Math.random() * modArr1.length); + let ran2 = Math.floor(Math.random() * modArr2.length); + // swap randomly if teams seem even + if (Math.abs(sum1 - sum2) < unequalAllowance) { + while (modArr1[ran1].rank === modArr2[ran2].rank && rerolls < 100) { + rerolls++; + ran1 = Math.floor(Math.random() * modArr1.length); + ran2 = Math.floor(Math.random() * modArr2.length); + } + } + // else swap players to minimize difference + else if (sum1 > sum2) { + while (modArr1[ran1].rank <= modArr2[ran2].rank && rerolls < 100) { + rerolls++; + ran1 = Math.floor(Math.random() * modArr1.length); + ran2 = Math.floor(Math.random() * modArr2.length); + } + } else if (sum1 < sum2) { + while (modArr1[ran1].rank >= modArr2[ran2].rank && rerolls < 100) { + rerolls++; + ran1 = Math.floor(Math.random() * modArr1.length); + ran2 = Math.floor(Math.random() * modArr2.length); + } + } + + // make the swap + let temp = modArr1[ran1]; + modArr1[ran1] = modArr2[ran2]; + modArr2[ran2] = temp; + sum1 = modArr1.reduce((acc, player) => acc + player.rank, 0); + sum2 = modArr2.reduce((acc, player) => acc + player.rank, 0); + + // if array lengths are uneven, act as if the shorter array is padded with the average of all values + // a length difference of more than 1 between the two arrays should be disallowed elsewhere + if (modArr1.length > modArr2.length) { + let avg = sum2 / modArr2.length; + sum2 += avg; + } else if (modArr1.length < modArr2.length) { + let avg = sum1 / modArr1.length; + sum1 += avg; + } + } + modArr1.sort((a, b) => b.rank - a.rank); + modArr2.sort((a, b) => b.rank - a.rank); + console.log( + `${ + attempts > 999 ? "forced" : "successful" + } sort after ${attempts} attempts: + ${modArr1.map((i) => i.rank)} + ${modArr2.map((i) => i.rank)} + ${sum1} ${sum2} (${unequalAllowance}) + ${avgDiff(modArr1)} ${avgDiff(modArr2)} (${passion})` + ); + + return [modArr1, modArr2]; +}; + client.on("ready", () => { console.log(`Logged in as ${client.user.tag}!`); console.log( @@ -94,7 +216,7 @@ client.on("error", (error) => { if (!channel) return console.error("Can't find command channel"); client.channels.cache .get(COMMAND_CHANNEL_ID) - .send(`Error: ${error.message}`) + .send("Internal error") .catch(console.error); }); @@ -290,6 +412,148 @@ client.on("interactionCreate", async (interaction) => { ); } + if (command === "pick" || command === "autocaptain") { + await interaction.reply("Picking teams..."); + + // get voice channels + const picking = interaction.guild.channels.cache.find( + (channel) => channel.name === "picking" || channel.id === PICKING_ID + ); + if (!picking) return console.error("Can't find channel 'picking'!"); + const blu = interaction.guild.channels.cache.find( + (channel) => channel.name === "blu" || channel.id === BLU_ID + ); + if (!blu) return console.error("Can't find channel 'blu'!"); + const red = interaction.guild.channels.cache.find( + (channel) => channel.name === "red" || channel.id === RED_ID + ); + if (!red) return console.error("Can't find channel 'red'!"); + + // get players in voice channel + const players = picking.members; + if (players.size !== 18) { + return await interaction.followUp( + `Found ${players.size} players in picking, expected 18` + ); + } + + // get rankings + let rankings = {}; + try { + console.log(`Getting rankings at ${rankingsPath}...`); + if (!fs.existsSync(rankingsPath)) { + fs.writeFileSync(rankingsPath, "{}"); + } + rankings = JSON.parse(fs.readFileSync(rankingsPath)); + } catch (error) { + console.error(error); + return await message.reply("Error sorting teams"); + } + + // distribute unranked players evenly, then randomly assign ranked players + let rankedPlayers = [], + bluPlayers = [], + redPlayers = [], + rng = 0; + + for (const [playerId, player] of players) { + if (!rankings[playerId]) { + rng = Math.random(); + // if teams are even, randomly assign + if (bluPlayers.length === redPlayers.length) { + if (rng < 0.5) { + bluPlayers.push({ player, rank: 0 }); + } else { + redPlayers.push({ player, rank: 0 }); + } + } + // otherwise, equalize teams + else if (bluPlayers.length < redPlayers.length) { + bluPlayers.push({ player, rank: 0 }); + } else { + redPlayers.push({ player, rank: 0 }); + } + } + // prepare ranked players for random assignment + else { + rankedPlayers.push(player); + } + } + + // slots for ranked players are limited by unranked players, who are sorted differently + let bluRankedPlayers = [], + redRankedPlayers = [], + bluRankSlots = 9 - bluPlayers.length, + redRankSlots = 9 - redPlayers.length; + + // create array with players and their ranks + for (const player of rankedPlayers) { + rng = Math.random(); + if (rng < 0.5) { + if (bluRankedPlayers.length < bluRankSlots) { + bluRankedPlayers.push({ player, rank: rankings[player.id] }); + } else { + redRankedPlayers.push({ player, rank: rankings[player.id] }); + } + } else { + if (redRankedPlayers.length < redRankSlots) { + redRankedPlayers.push({ player, rank: rankings[player.id] }); + } else { + bluRankedPlayers.push({ player, rank: rankings[player.id] }); + } + } + } + + if ( + bluPlayers.length + bluRankedPlayers.length !== 9 || + redPlayers.length + redRankedPlayers.length !== 9 + ) { + console.error( + `Invalid number of players: ${bluPlayers.length} ${bluRankedPlayers.length} ${redPlayers.length} ${redRankedPlayers.length}` + ); + return await message.reply("Error sorting teams"); + } + + // bring teams to reasonable balance + let [bluBalanced, redBalanced] = balanceArrays( + bluRankedPlayers, + redRankedPlayers + ); + bluPlayers = bluPlayers.concat(bluBalanced); + redPlayers = redPlayers.concat(redBalanced); + + let moveErr = 0; + + // move to team voice channels + while (bluPlayers.length > 0) { + const idx = Math.floor(Math.random() * bluPlayers.length); + const player = bluPlayers.splice(idx, 1)[0]; + try { + await player.voice.setChannel(blu); + } catch (error) { + console.error(error); + moveErr++; + } + } + + while (redPlayers.length > 0) { + const idx = Math.floor(Math.random() * redPlayers.length); + const player = redPlayers.splice(idx, 1)[0]; + try { + await player.voice.setChannel(red); + } catch (error) { + console.error(error); + moveErr++; + } + } + + interaction.followUp( + `Teams selected and moved${ + moveErr > 0 ? ` (error moving ${moveErr} members)` : "" + }` + ); + } + if (command === "clear" || command === "bclear") { await interaction.reply("Clearing messages..."); let channel = client.channels.cache.get(COMMAND_CHANNEL_ID); @@ -308,14 +572,15 @@ client.on("interactionCreate", async (interaction) => { }); /* - * DM commands - * - setrank: saves a player's rank - * - getrank: prints a player's rank - * - rankings: prints all players' ranks - * - whitelist: adds an admin to the whitelist to use DM commands - * - getwhitelist: prints the whitelist - * - clearwhitelist: clears the whitelist completely, security measure -*/ + * DM commands + * - setrank: saves a player's rank + * - getrank: prints a player's rank + * - rankings: prints all players' ranks + * - simulateteams: simulates autocaptain results + * - whitelist: adds an admin to the whitelist to use DM commands + * - getwhitelist: prints the whitelist + * - clearwhitelist: clears the whitelist completely, security measure + */ client.on("messageCreate", async (message) => { if (message.author.bot || message.guild) return; @@ -470,6 +735,188 @@ client.on("messageCreate", async (message) => { } } + if (args[0] === "simulateteams") { + await message.reply("Simulating teams..."); + + // get players in voice channel + // const players = picking.members; + // if (players.size !== 18) { + // return await message.reply( + // `Found ${players.size} players in picking, expected 18` + // ); + // } + + // simulate input is 18 player display names separated by commas + const playerInputs = args[1].split(","); + if (playerInputs.length !== 18) { + return await message.reply( + `Found ${playerInputs.length} players in input, expected 18 + \nUsage: \`simulateteams ,,...,\`` + ); + } + + // get players from input + let players = new Map(); + for (const input of playerInputs) { + const player = findPlayer(pickupGuild, input); + if (!player) { + await message.reply(`Could not find player ${input}`); + return; + } + players.set(player.id, player); + } + + // get rankings + let rankings = {}; + try { + console.log(`Getting rankings at ${rankingsPath}...`); + if (!fs.existsSync(rankingsPath)) { + fs.writeFileSync(rankingsPath, "{}"); + } + rankings = JSON.parse(fs.readFileSync(rankingsPath)); + } catch (error) { + console.error(error); + return await message.reply("Error sorting teams"); + } + + // distribute unranked players evenly, then randomly assign ranked players + let rankedPlayers = [], + bluPlayers = [], + redPlayers = [], + rng = 0; + + for (const [playerId, player] of players) { + if (!rankings[playerId]) { + rng = Math.random(); + // if teams are even, randomly assign + if (bluPlayers.length === redPlayers.length) { + if (rng < 0.5) { + bluPlayers.push({ player, rank: 0 }); + } else { + redPlayers.push({ player, rank: 0 }); + } + } + // otherwise, equalize teams + else if (bluPlayers.length < redPlayers.length) { + bluPlayers.push({ player, rank: 0 }); + } else { + redPlayers.push({ player, rank: 0 }); + } + } + // prepare ranked players for random assignment + else { + rankedPlayers.push(player); + } + } + console.log( + `bluUnranked: ${bluPlayers.length} redUnranked: ${redPlayers.length}` + ); + + // slots for ranked players are limited by unranked players, who are sorted differently + let bluRankedPlayers = [], + redRankedPlayers = [], + bluRankSlots = 9 - bluPlayers.length, + redRankSlots = 9 - redPlayers.length; + + console.log(`bluRankSlots: ${bluRankSlots} redRankSlots: ${redRankSlots}`); + + // create array with players and their ranks + for (const player of rankedPlayers) { + rng = Math.random(); + console.log( + `rng: ${rng}, bluRankedPlayers: ${bluRankedPlayers.length}, redRankedPlayers: ${redRankedPlayers.length}` + ); + if (rng < 0.5) { + if (bluRankedPlayers.length < bluRankSlots) { + bluRankedPlayers.push({ player, rank: rankings[player.id] }); + } else { + redRankedPlayers.push({ player, rank: rankings[player.id] }); + } + } else { + if (redRankedPlayers.length < redRankSlots) { + redRankedPlayers.push({ player, rank: rankings[player.id] }); + } else { + bluRankedPlayers.push({ player, rank: rankings[player.id] }); + } + } + } + + if ( + bluPlayers.length + bluRankedPlayers.length !== 9 || + redPlayers.length + redRankedPlayers.length !== 9 + ) { + console.error( + `Invalid number of players: ${bluPlayers.length} ${bluRankedPlayers.length} ${redPlayers.length} ${redRankedPlayers.length}` + ); + return await message.reply("Error sorting teams"); + } + + // bring teams to reasonable balance + let [bluBalanced, redBalanced] = balanceArrays( + bluRankedPlayers, + redRankedPlayers + ); + bluPlayers = bluPlayers.concat(bluBalanced); + redPlayers = redPlayers.concat(redBalanced); + + let moveErr = 0; + + // for sim, print teams after sorting by rank then name + bluPlayers.sort((a, b) => { + if (a.rank === b.rank) { + return a.player.displayName.localeCompare(b.player.displayName); + } + return b.rank - a.rank; + }); + redPlayers.sort((a, b) => { + if (a.rank === b.rank) { + return a.player.displayName.localeCompare(b.player.displayName); + } + return b.rank - a.rank; + }); + let simString = `${backticks}BLU:`; + + // move to team voice channels + while (bluPlayers.length > 0) { + //let idx = Math.floor(Math.random() * bluPlayers.length); + //if (idx === bluPlayers.length) idx--; + let idx = 0; + const player = bluPlayers.splice(idx, 1)[0]; + try { + //await player.voice.setChannel(blu); + simString += `\n${getApplicableName(player.player)} - ${"*".repeat( + player.rank + )}`; + } catch (error) { + console.error(error); + moveErr++; + } + } + + simString += `\n\nRED:`; + while (redPlayers.length > 0) { + //let idx = Math.floor(Math.random() * redPlayers.length); + //if (idx === redPlayers.length) idx--; + let idx = 0; + const player = redPlayers.splice(idx, 1)[0]; + try { + //await player.voice.setChannel(red); + simString += `\n${getApplicableName(player.player)} - ${"*".repeat( + player.rank + )}`; + } catch (error) { + console.error(error); + moveErr++; + } + } + + message.reply( + `Simulated teams:\n${simString}${backticks}${ + moveErr > 0 ? ` (error moving ${moveErr} members)` : "" + }${attempts > 999 ? "(forced)" : ""}` + ); + } + if (args[0] === "whitelist") { // add user to config.json whitelist if (args.length < 2) { diff --git a/register.js b/register.js index f618296..1267342 100644 --- a/register.js +++ b/register.js @@ -78,6 +78,14 @@ const commands = [ name: "spy", description: "Lists picking channel members with spy role", }, + { + name: "pick", + description: "Automatically picks teams", + }, + { + name: "autocaptain", + description: "Automatically picks teams", + }, ]; const rest = new REST({ version: "10" }).setToken(TOKEN);