From 5b918d6e3eb210357c02a9dffcbd7a9a0ceef1c4 Mon Sep 17 00:00:00 2001 From: tresabhi Date: Sat, 13 May 2023 21:42:40 +0000 Subject: [PATCH] feat: /tankstats --- package.json | 1 + src/behaviors/interactionCreate.ts | 60 ++++--- src/commands/stats.ts | 13 +- src/commands/tankstats.ts | 169 ++++++++++++++++++ src/core/autocomplete/tanks.ts | 27 +++ src/core/blitztankstats/getPeriodicStats.ts | 36 ++++ src/{ => core}/embeds/badNicknameFormat.ts | 0 src/{ => core}/embeds/errorEmbed.ts | 2 +- src/{ => core}/embeds/noMember.ts | 0 .../embeds/notTrackedByBlitzStars.ts | 0 src/core/embeds/sklldEmbed.ts | 9 + src/utilities/addTankChoices.ts | 9 + src/utilities/cleanTable.ts | 8 + src/utilities/getBlitzStarsAccount.ts | 2 +- src/utilities/tankopedia.ts | 9 +- yarn.lock | 5 + 16 files changed, 317 insertions(+), 33 deletions(-) create mode 100644 src/commands/tankstats.ts create mode 100644 src/core/autocomplete/tanks.ts create mode 100644 src/core/blitztankstats/getPeriodicStats.ts rename src/{ => core}/embeds/badNicknameFormat.ts (100%) rename src/{ => core}/embeds/errorEmbed.ts (79%) rename src/{ => core}/embeds/noMember.ts (100%) rename src/{ => core}/embeds/notTrackedByBlitzStars.ts (100%) create mode 100644 src/core/embeds/sklldEmbed.ts create mode 100644 src/utilities/addTankChoices.ts create mode 100644 src/utilities/cleanTable.ts diff --git a/package.json b/package.json index 6ab28e2e9..fce09ff00 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@types/node": "^18.15.11", "discord.js": "^14.9.0", + "fuzzysort": "^2.0.4", "markdown-escape": "^2.0.0", "node-fetch": "^3.3.1", "typescript": "^5.0.4", diff --git a/src/behaviors/interactionCreate.ts b/src/behaviors/interactionCreate.ts index bc91bf41c..5ff1b298c 100644 --- a/src/behaviors/interactionCreate.ts +++ b/src/behaviors/interactionCreate.ts @@ -1,4 +1,5 @@ import { + AutocompleteInteraction, CacheType, ChatInputCommandInteraction, Collection, @@ -21,6 +22,7 @@ export interface CommandRegistry { command: Omit; execute: (interaction: ChatInputCommandInteraction) => void; + autocomplete?: (interaction: AutocompleteInteraction) => void; } const rest = new REST().setToken(args['discord-token']); @@ -68,28 +70,46 @@ try { export default async function interactionCreate( interaction: Interaction, ) { - if (!interaction.isChatInputCommand()) return; - const command = commandCollection.get(interaction.commandName); + if (interaction.isAutocomplete()) { + const command = commandCollection.get(interaction.commandName); - if (!command) { - console.error(`No command matching ${interaction.commandName} was found.`); - return; - } + if (!command) { + console.error( + `No command matching ${interaction.commandName} was found.`, + ); + return; + } - try { - await interaction.deferReply(); - command.execute(interaction); - } catch (error) { - console.error(error); - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: 'There was an error while executing this command!', - ephemeral: true, - }); - } else { - await interaction.editReply({ - content: 'There was an error while executing this command!', - }); + try { + if (command.autocomplete) command.autocomplete(interaction); + } catch (error) { + console.error(error); + } + } else if (interaction.isChatInputCommand()) { + const command = commandCollection.get(interaction.commandName); + + if (!command) { + console.error( + `No command matching ${interaction.commandName} was found.`, + ); + return; + } + + try { + await interaction.deferReply(); + command.execute(interaction); + } catch (error) { + console.error(error); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ + content: 'There was an error while executing this command!', + ephemeral: true, + }); + } else { + await interaction.editReply({ + content: 'There was an error while executing this command!', + }); + } } } } diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 7d1aaa79d..a0c301455 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -9,6 +9,7 @@ import addIGNOption from '../utilities/addIGNOption.js'; import addServerChoices from '../utilities/addServerChoices.js'; import { args } from '../utilities/args.js'; import blitzLinks from '../utilities/blitzLinks.js'; +import cleanTable from '../utilities/cleanTable.js'; import cmdName from '../utilities/cmdName.js'; import getBlitzAccount from '../utilities/getBlitzAccount.js'; import getBlitzStarsAccount from '../utilities/getBlitzStarsAccount.js'; @@ -134,7 +135,7 @@ export default { period === 'today' ? '**⚠ "Today" stats are in development. Some stats are not available/wrong.**\n\n' : '' - }${[ + }${cleanTable([ [ 'Winrate', `${(100 * (stats.all.wins / stats.all.battles)).toFixed( @@ -200,15 +201,7 @@ export default { (stats.all.battles - stats.all.survived_battles) ).toFixed(2), ], - ] - .filter((array) => array[1] !== `${-Infinity}`) - .map((array) => - array.length === 0 ? '' : `**${array[0]}**: ${array[1]}`, - ) - .join('\n')}\n\n${blitzLinks( - server, - blitzAccount.nickname, - )}` + ])}\n\n${blitzLinks(server, blitzAccount.nickname)}` : 'No battles played in this period.', ), ), diff --git a/src/commands/tankstats.ts b/src/commands/tankstats.ts new file mode 100644 index 000000000..4dcd87c2f --- /dev/null +++ b/src/commands/tankstats.ts @@ -0,0 +1,169 @@ +import { SlashCommandBuilder } from 'discord.js'; +import markdownEscape from 'markdown-escape'; +import { CommandRegistry } from '../behaviors/interactionCreate.js'; +import { BlitzServer } from '../constants/servers.js'; +import tanksAutocomplete from '../core/autocomplete/tanks.js'; +import getPeriodicStats, { + Period, +} from '../core/blitztankstats/getPeriodicStats.js'; +import errorEmbed from '../core/embeds/errorEmbed.js'; +import sklldEmbed from '../core/embeds/sklldEmbed.js'; +import addIGNOption from '../utilities/addIGNOption.js'; +import addServerChoices from '../utilities/addServerChoices.js'; +import addTankChoices from '../utilities/addTankChoices.js'; +import cleanTable from '../utilities/cleanTable.js'; +import cmdName from '../utilities/cmdName.js'; +import getBlitzAccount from '../utilities/getBlitzAccount.js'; +import { tankopedia } from '../utilities/tankopedia.js'; + +type Periods = '30' | '60' | '90'; + +export default { + inProduction: true, + inDevelopment: false, + inPublic: true, + + command: new SlashCommandBuilder() + .setName(cmdName('tankstats')) + .setDescription('Stats for a tank over a period') + .addStringOption(addTankChoices) + .addStringOption((option) => + option + .setName('period') + .setDescription('The period to get stats for') + .setChoices( + { name: '30 Days', value: '30' satisfies Periods }, + { name: '60 Days', value: '60' satisfies Periods }, + { name: '90 Days', value: '90' satisfies Periods }, + ) + .setRequired(true), + ) + .addStringOption(addServerChoices) + .addStringOption(addIGNOption), + + async execute(interaction) { + const server = interaction.options.getString('server') as BlitzServer; + const name = interaction.options.getString('name')!; + const tankId = interaction.options.getString('tank')!; + const period = interaction.options.getString('period') as Periods; + const blitzAccount = await getBlitzAccount(interaction, name, server); + if (!blitzAccount) return; + const tank = tankopedia.data[tankId as unknown as number]; + + if (tank) { + const periodicStats = ( + await getPeriodicStats(blitzAccount.account_id, Number(period)) + ).filter((stats) => stats.tank_id === tank.tank_id); + + function sum(summer: (period: Period) => number) { + return periodicStats.reduce( + (accumulative, period) => accumulative + summer(period), + 0, + ); + } + function weighted(summer: (period: Period) => number) { + return ( + periodicStats.reduce( + (accumulative, period) => + accumulative + summer(period) * period.battles, + 0, + ) / sum((period) => period.battles) + ); + } + + interaction.editReply({ + embeds: [ + sklldEmbed( + `${period} day stats for ${markdownEscape( + blitzAccount.nickname, + )}'s ${ + tank.name ? markdownEscape(tank.name) : `Unknown Tank ${tankId}` + }`, + periodicStats.length > 0 + ? cleanTable([ + [ + 'Winrate', + `${( + 100 * + (sum((a) => a.wins) / sum((a) => a.battles)) + ).toFixed(2)}%`, + ], + [ + 'Survival', + `${( + 100 * + (sum((a) => a.survived_battles) / sum((a) => a.battles)) + ).toFixed(2)}%`, + ], + [ + 'Accuracy', + `${( + 100 * + (sum((a) => a.hits) / sum((a) => a.shots)) + ).toFixed(2)}%`, + ], + ['WN8', weighted((a) => a.wn8).toFixed(0)], + [], + ['Battles', sum((a) => a.battles)], + ['Wins', sum((a) => a.wins)], + ['Losses', sum((a) => a.losses)], + [], + [ + 'Shots per battle', + (sum((a) => a.shots) / sum((a) => a.battles)).toFixed(2), + ], + [ + 'Hits per battle', + (sum((a) => a.hits) / sum((a) => a.battles)).toFixed(2), + ], + [ + 'Damage per battle', + ( + sum((a) => a.damage_dealt) / sum((a) => a.battles) + ).toFixed(0), + ], + [ + 'Kills per battle', + (sum((a) => a.frags) / sum((a) => a.battles)).toFixed(2), + ], + [ + 'Spots per battle', + (sum((a) => a.spotted) / sum((a) => a.battles)).toFixed(2), + ], + [ + 'XP per battle', + (sum((a) => a.xp) / sum((a) => a.battles)).toFixed(0), + ], + [], + [ + 'Damage ratio', + ( + sum((a) => a.damage_dealt) / sum((a) => a.damage_received) + ).toFixed(2), + ], + [ + 'Kills to death ratio', + ( + sum((a) => a.frags) / + sum((a) => a.battles - a.survived_battles) + ).toFixed(2), + ], + ]) + : 'No battles were played or recorded during this period.', + ), + ], + }); + } else { + interaction.editReply({ + embeds: [ + errorEmbed( + 'Tank not found', + `"${markdownEscape(tankId)}" does not exist in the game.`, + ), + ], + }); + } + }, + + autocomplete: tanksAutocomplete, +} satisfies CommandRegistry; diff --git a/src/core/autocomplete/tanks.ts b/src/core/autocomplete/tanks.ts new file mode 100644 index 000000000..aeb6dce0d --- /dev/null +++ b/src/core/autocomplete/tanks.ts @@ -0,0 +1,27 @@ +import { + ApplicationCommandOptionChoiceData, + AutocompleteInteraction, + CacheType, +} from 'discord.js'; +import { go } from 'fuzzysort'; +import { tanks } from '../../utilities/tankopedia.js'; + +export default async function tanksAutocomplete( + interaction: AutocompleteInteraction, +) { + const focusedValue = interaction.options.getFocused(); + + await interaction.respond( + focusedValue + ? go(focusedValue, tanks, { keys: ['name'], limit: 10 }).map( + (item) => + ({ + name: item.obj.name + ? item.obj.name + : `Unknown Tank ${item.obj.tank_id}`, + value: `${item.obj.tank_id}`, + } satisfies ApplicationCommandOptionChoiceData), + ) + : [], + ); +} diff --git a/src/core/blitztankstats/getPeriodicStats.ts b/src/core/blitztankstats/getPeriodicStats.ts new file mode 100644 index 000000000..d8dcf3d06 --- /dev/null +++ b/src/core/blitztankstats/getPeriodicStats.ts @@ -0,0 +1,36 @@ +export interface Period { + statsId: number; + account_id: number; + tank_id: number; + recordDate: string; + last_battle_time: number; + fromDate: string; + prior_battle_time: number; + battles: number; + wins: number; + losses: number; + spotted: number; + shots: number; + hits: number; + xp: number; + battle_life_time: number; + frags: number; + max_frags: number; + damage_dealt: number; + damage_received: number; + survived_battles: number; + win_and_survived: number; + capture_points: number; + dropped_capture_points: number; + killStealerValue: number; + isRating: boolean; + wn8: number; + region: string; +} + +export default async function getPeriodicStats(id: number, period: number) { + const response = await fetch( + `https://blitztankstats.com/api/Stats/period/${id}/${period}`, + ); + return (await response.json()) as Period[]; +} diff --git a/src/embeds/badNicknameFormat.ts b/src/core/embeds/badNicknameFormat.ts similarity index 100% rename from src/embeds/badNicknameFormat.ts rename to src/core/embeds/badNicknameFormat.ts diff --git a/src/embeds/errorEmbed.ts b/src/core/embeds/errorEmbed.ts similarity index 79% rename from src/embeds/errorEmbed.ts rename to src/core/embeds/errorEmbed.ts index 08b35ab18..e4e6ed1e1 100644 --- a/src/embeds/errorEmbed.ts +++ b/src/core/embeds/errorEmbed.ts @@ -1,5 +1,5 @@ import { EmbedBuilder } from 'discord.js'; -import { NEGATIVE_COLOR } from '../constants/colors.js'; +import { NEGATIVE_COLOR } from '../../constants/colors.js'; export default function errorEmbed(title: string, description: string) { return new EmbedBuilder() diff --git a/src/embeds/noMember.ts b/src/core/embeds/noMember.ts similarity index 100% rename from src/embeds/noMember.ts rename to src/core/embeds/noMember.ts diff --git a/src/embeds/notTrackedByBlitzStars.ts b/src/core/embeds/notTrackedByBlitzStars.ts similarity index 100% rename from src/embeds/notTrackedByBlitzStars.ts rename to src/core/embeds/notTrackedByBlitzStars.ts diff --git a/src/core/embeds/sklldEmbed.ts b/src/core/embeds/sklldEmbed.ts new file mode 100644 index 000000000..eebe46e35 --- /dev/null +++ b/src/core/embeds/sklldEmbed.ts @@ -0,0 +1,9 @@ +import { EmbedBuilder } from 'discord.js'; +import { SKILLED_COLOR } from '../../constants/colors.js'; + +export default function sklldEmbed(title: string, description: string) { + return new EmbedBuilder() + .setColor(SKILLED_COLOR) + .setTitle(title) + .setDescription(description); +} diff --git a/src/utilities/addTankChoices.ts b/src/utilities/addTankChoices.ts new file mode 100644 index 000000000..07b8d0e1a --- /dev/null +++ b/src/utilities/addTankChoices.ts @@ -0,0 +1,9 @@ +import { SlashCommandStringOption } from 'discord.js'; + +export default function addTankChoices(option: SlashCommandStringOption) { + return option + .setName('tank') + .setDescription('The tank to get stats for') + .setAutocomplete(true) + .setRequired(true); +} diff --git a/src/utilities/cleanTable.ts b/src/utilities/cleanTable.ts new file mode 100644 index 000000000..26c61919d --- /dev/null +++ b/src/utilities/cleanTable.ts @@ -0,0 +1,8 @@ +export type TableInput = ([] | [string, string | number])[]; + +export default function cleanTable(input: TableInput) { + return input + .filter((array) => array[1] !== `${-Infinity}`) + .map((array) => (array.length === 0 ? '' : `**${array[0]}**: ${array[1]}`)) + .join('\n'); +} diff --git a/src/utilities/getBlitzStarsAccount.ts b/src/utilities/getBlitzStarsAccount.ts index d107a4cea..882181cd6 100644 --- a/src/utilities/getBlitzStarsAccount.ts +++ b/src/utilities/getBlitzStarsAccount.ts @@ -1,6 +1,6 @@ import { CacheType, ChatInputCommandInteraction } from 'discord.js'; import fetch from 'node-fetch'; -import { notTrackedByBlitzStars } from '../embeds/notTrackedByBlitzStars.js'; +import { notTrackedByBlitzStars } from '../core/embeds/notTrackedByBlitzStars.js'; import { PlayerPeriodicStatsCollection } from '../types/statistics.js'; export default async function getBlitzStarsAccount( diff --git a/src/utilities/tankopedia.ts b/src/utilities/tankopedia.ts index 4fe8103f3..5e224ed1d 100644 --- a/src/utilities/tankopedia.ts +++ b/src/utilities/tankopedia.ts @@ -9,7 +9,7 @@ export interface Tankopedia { }; data: { [tankId: number]: { - name: string; + name?: string; nation: string; is_premium: boolean; tier: number; @@ -32,12 +32,19 @@ export const tankopedia = (await ( await fetch('https://www.blitzstars.com/bs-tankopedia.json') ).json()) as Tankopedia; +export const tankIds = Object.keys(tankopedia.data) as unknown as number[]; + +export const tankNames = tankIds.map((id) => tankopedia.data[id].name); + +export const tanks = tankIds.map((id) => tankopedia.data[id]); + export const TANK_TYPE_NAMES: Record = { 'AT-SPG': 'Tank Destroyer', heavyTank: 'Heavy Tank', mediumTank: 'Medium Tank', lightTank: 'Light Tank', }; + export const TANK_TYPE_EMOJIS: Record = { 'AT-SPG': '🔽', heavyTank: '🇭', diff --git a/yarn.lock b/yarn.lock index 104cb6901..11d847679 100644 --- a/yarn.lock +++ b/yarn.lock @@ -311,6 +311,11 @@ fsevents@~2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== +fuzzysort@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/fuzzysort/-/fuzzysort-2.0.4.tgz#a21d1ce8947eaf2797dc3b7c28c36db9d1165f84" + integrity sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw== + ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz"