feat: autocaptain teams
This commit is contained in:
parent
52a5c63230
commit
4f0b35c7ad
465
index.js
465
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 <player1>,<player2>,...,<player18>\``
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user