Compare commits

...
66 Commits
Author SHA1 Message Date
TrèsAbhi fc4390d22f Merge pull request #13 from tresabhi/1.9.0 2023-06-30 12:50:45 -05:00
TresAbhi 4c2b0253de feat: implemented dist package.json 2023-06-30 17:38:44 +00:00
TresAbhi e34a6c6e4b feat: easier to read evolution 2023-06-29 18:39:59 +00:00
TresAbhi 90d4b76b31 feat: added auto refresh 2023-06-29 01:43:59 +00:00
TresAbhi 3fd90588a3 feat: renamed routers to routes 2023-06-29 01:34:02 +00:00
TresAbhi e81c0cbc1e feat: footer for stats 2023-06-28 19:34:35 +00:00
TresAbhi 4c9715988d feat: disabled embeds for non-routed commands 2023-06-28 19:27:47 +00:00
TresAbhi c2d2d076f0 fix: /statsfull now uses null tankId when not available 2023-06-28 18:48:10 +00:00
TresAbhi d1d8aad6c7 feat: added all buttons to dynamic commands 2023-06-28 18:45:14 +00:00
TresAbhi dd39563d6d feat: merged powered by 2023-06-28 18:07:49 +00:00
TresAbhi 64fd15c0a5 feat: new /stats 2023-06-28 17:58:36 +00:00
TresAbhi 5fb36352d2 feat: renamed stats to statsfull 2023-06-27 19:10:12 +00:00
TresAbhi 8bf8030157 feat: disabled normies from using dev commands 2023-06-27 19:09:01 +00:00
TresAbhi da950ad42f feat: implemented resolvePlayerFromRequest 2023-06-27 17:46:54 +00:00
TresAbhi f8f95af215 feat: rename to routers 2023-06-27 17:44:15 +00:00
TresAbhi a0ba60a3fb feat: cleaned constants 2023-06-27 17:23:57 +00:00
TresAbhi ab5061ba99 feat: cleaned node 2023-06-27 17:22:27 +00:00
TresAbhi 01ed20941d feat: cleaned discord 2023-06-27 02:22:28 +00:00
TresAbhi 169588a211 feat: cleaned blitzstars 2023-06-27 02:16:30 +00:00
TresAbhi c7821d03d2 feat: cleaned blitz 2023-06-27 02:13:57 +00:00
TresAbhi 9dc48c9125 feat: switched to parseInt 2023-06-27 02:13:07 +00:00
TresAbhi a67d1700fc feat: split resolvePlayer 2023-06-27 02:07:14 +00:00
TresAbhi c221c06511 feat: split resolvePeriod 2023-06-27 02:02:00 +00:00
TresAbhi abcb75f06e fix: type issues 2023-06-27 01:48:26 +00:00
TresAbhi 7dd3913ae8 feat: organized files 2023-06-27 01:46:08 +00:00
TresAbhi f2ae698438 feat: express responses 2023-06-27 01:43:30 +00:00
TresAbhi 0f46f333b5 feat: cache tankopedia and tankaverages during build 2023-06-26 18:08:07 +00:00
TresAbhi 79343438f0 feat: cleaned up build proccess 2023-06-26 17:36:43 +00:00
TresAbhi 8dd0ca9d10 feat: svg responses 2023-06-26 17:25:28 +00:00
TresAbhi aa0069a0a3 feat: server 2023-06-26 15:05:05 +00:00
TrèsAbhi 8882c28267 Merge pull request #12 from tresabhi/main 2023-06-24 21:47:28 -05:00
TrèsAbhi 24cf2c2cd9 Merge branch '1.9.0' into main 2023-06-24 21:47:04 -05:00
TrèsAbhi 8570559588 Merge pull request #11 from tresabhi/1.8.1 2023-06-24 21:45:19 -05:00
TresAbhi d8f53bff9f fix: x min and max dates 2023-06-25 02:40:54 +00:00
TresAbhi 58f5b894f9 feat: refresh button 2023-06-25 02:32:56 +00:00
TrèsAbhi ae2d12a629 Merge pull request #10 from tresabhi/1.7.8 2023-06-23 12:22:45 -05:00
TresAbhi 05a25c7454 feat: added server counts 2023-06-23 17:14:27 +00:00
TresAbhi b84f2e7adc feat: more subcommands for /ping 2023-06-23 17:10:18 +00:00
TresAbhi e7fafa6902 feat: abstracted today renderer 2023-06-23 17:04:05 +00:00
TresAbhi 8b9f665d47 fix: average tiers not showing up in /stats 2023-06-23 16:58:17 +00:00
TresAbhi f134efa1e8 feat: reduced collected data 2023-06-23 16:45:19 +00:00
TresAbhi d8a78bc25e feat: extracted evolution renderer 2023-06-23 01:45:44 +00:00
TresAbhi 752f9b339b feat: extracted renderer from /stats 2023-06-23 01:35:15 +00:00
TresAbhi bcbc1e259f fix: WN8 delta uses correct fixed decimals 2023-06-22 18:28:10 +00:00
TresAbhi 91da65196d feat: player evolution 2023-06-22 18:26:51 +00:00
TresAbhi e49bd6aa68 feat: command registry mismatch 2023-06-21 22:31:46 +00:00
TresAbhi 6f21789c46 feat: moved cmdName behaviour to interaction create 2023-06-21 02:01:12 +00:00
TresAbhi 3e8e198c88 feat: extracted sub command groups 2023-06-21 01:57:27 +00:00
TresAbhi 70145abe86 feat: evolution command for tanks 2023-06-21 01:52:26 +00:00
TresAbhi 35f0f614af feat: tier weights 2023-06-19 18:08:34 +00:00
TresAbhi d9690460d5 feat: merged stats and tankstats 2023-06-19 17:35:28 +00:00
TresAbhi 0cf0e63fda fix: Infinity and NaN values in GenericAllStats now render as -- 2023-06-18 01:59:32 +00:00
TresAbhi e0c1db2a0b feat: custom periods for /stats and /tankstats 2023-06-18 01:56:21 +00:00
TresAbhi 78424c1535 feat: ping command 2023-06-17 18:16:41 +00:00
TresAbhi 78d429808a feat: verbose logging for all commands 2023-06-17 18:05:04 +00:00
TresAbhi 1c4cb67a6a fix: host from root 2023-06-16 17:50:08 +00:00
TrèsAbhi 6f90d4fb75 Merge pull request #9 from tresabhi/1.7.6 2023-06-16 12:44:35 -05:00
TresAbhi 21c094a76b fix: faster builds in dev mode 2023-06-16 17:41:48 +00:00
TresAbhi 5e98bc988c feat: implemented psa system 2023-06-16 02:15:46 +00:00
TresAbhi f6480ad66f feat: fixed choices for owned tanks tier 2023-06-16 02:07:01 +00:00
TresAbhi 4d9dc067af feat: return-based interaction reply edits 2023-06-16 02:01:06 +00:00
TresAbhi fbcd9e82e3 fix: added node version matrix and removed unnecessary fetch 2023-06-13 00:02:03 +00:00
TrèsAbhi 568ffa06e3 fix: re-added concurrency 2023-06-11 21:36:44 -05:00
TresAbhi e3b1df0a46 fix: use repo name 2023-06-12 02:33:21 +00:00
TresAbhi 68ee95c327 fix: host curl 2023-06-12 02:31:37 +00:00
TrèsAbhi 858efbcdfe Merge pull request #8 from tresabhi/1.7.5 2023-06-11 21:26:50 -05:00
129 changed files with 3499 additions and 1412 deletions

No files matched your search

+14 -14
View File
@@ -2,15 +2,22 @@ name: Host
on:
schedule:
- cron: '50 */5 * * *'
- cron: '0 */5 * * *'
push:
branches:
- 'main'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
host:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- name: Use node
@@ -18,21 +25,14 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Get latest release
uses: actions/github-script@v4
with:
script: |
const response = await github.repos.getLatestRelease({
owner: context.repo.owner,
repo: context.repo.repo
});
console.log(response.data.assets[0].browser_download_url);
core.setOutput("download_url", response.data.assets[0].browser_download_url);
- name: Download release assets
run: |
curl -L -o dist.zip ${{ steps.get-latest-release.outputs.download_url }}
DOWNLOAD_URL=$(curl -s https://api.github.com/repos/tresabhi/blitzkrieg/releases/latest \
| grep "browser_download_url.*dist.zip" \
| cut -d : -f 2,3 \
| tr -d \")
curl -L -o dist.zip $DOWNLOAD_URL
unzip dist.zip
- name: Run index.cjs
run: NODE_ENV=production DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} WARGAMING_APPLICATION_ID=${{ secrets.WARGAMING_APPLICATION_ID }} node --enable-source-maps dist/index.cjs
run: NODE_ENV=production DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} WARGAMING_APPLICATION_ID=${{ secrets.WARGAMING_APPLICATION_ID }} node --enable-source-maps --trace-warnings bot.cjs
+3 -1
View File
@@ -6,4 +6,6 @@ package-lock.json
/playwright/.cache/
dist/
yarn-error.log
dist.zip
dist.zip
commands.txt
/test.*
+10
View File
@@ -3,18 +3,28 @@
"Abhi",
"accountinfo",
"bepaint",
"blitzstars",
"cleandist",
"customperiod",
"fuzzysort",
"kranvagn",
"outfile",
"ownedtanks",
"playerachievements",
"playerinfo",
"Resvg",
"roboto",
"searchclans",
"searchplayers",
"searchtanks",
"SKLLD",
"Smri",
"startcase",
"statsfull",
"strvk",
"tankaverages",
"tankopedia",
"tankstats",
"Unicum",
"Unregistering",
"vickers",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"main": "server.js",
"main": "server.cjs",
"scripts": {
"start": "node --enable-source-maps server.cjs"
"start": "node --enable-source-maps --trace-warnings server.cjs"
}
}
+12 -6
View File
@@ -1,14 +1,18 @@
{
"name": "blitzkrieg",
"version": "1.7.4",
"version": "1.9.0",
"description": "",
"main": "src/index.ts",
"type": "module",
"scripts": {
"dev": "yarn build && yarn start",
"start": "node --enable-source-maps dist/index.cjs",
"build:bot": "node scripts/build --build=bot",
"build:server": "node scripts/build --build=server",
"build": "yarn lint && node scripts/build --build=all",
"dev:bot": "yarn build:bot --dev && yarn start:bot",
"dev:server": "yarn build:server --dev && yarn start:server",
"start:bot": "node --enable-source-maps --trace-warnings dist/bot.cjs",
"start:server": "node --enable-source-maps --trace-warnings dist/server.cjs",
"zip": "zip -r dist.zip dist",
"build": "node scripts/build",
"lint": "tsc"
},
"license": "MIT",
@@ -18,8 +22,9 @@
"bepaint": "^0.1.0-beta.3",
"discord.js": "^14.9.0",
"escape-html": "^1.0.3",
"express": "^4.18.2",
"fuzzysort": "^2.0.4",
"lodash.startcase": "^4.4.0",
"lodash": "^4.17.21",
"markdown-escape": "^2.0.0",
"react": "^18.2.0",
"satori": "^0.9.1"
@@ -27,7 +32,8 @@
"devDependencies": {
"@total-typescript/ts-reset": "^0.4.2",
"@types/escape-html": "^1.0.2",
"@types/lodash.startcase": "^4.4.7",
"@types/express": "^4.17.17",
"@types/lodash": "^4.14.195",
"@types/markdown-escape": "^1.1.0",
"@types/node": "^20.2.1",
"@types/react": "^18.2.7",
+74 -10
View File
@@ -1,14 +1,52 @@
import { build } from 'esbuild';
import { rmSync } from 'fs';
import { copyFile, mkdir, rm, writeFile } from 'fs/promises';
import { argv } from 'process';
console.log('Removing dist...');
rmSync('dist', { recursive: true, force: true });
const isProd = !argv.includes('--dev');
const buildAll = argv.includes('--build=all');
const buildBot = argv.includes('--build=bot') | buildAll;
const buildServer = argv.includes('--build=server') | buildAll;
console.log('Creating context...');
build({
entryPoints: ['src/index.ts'],
outfile: 'dist/index.cjs',
if (isProd) {
// only remove in production to ensure no time is wasted in dev
console.log('🟡 Removing dist...');
await rm('dist', { recursive: true, force: true });
console.log('✅ Dist removed');
console.log('🟡 Creating dist...');
await mkdir('dist');
console.log('✅ Dist created');
console.log('🟡 Caching tankopedia...');
fetch('https://www.blitzstars.com/bs-tankopedia.json')
.then((response) => response.text())
.then((tankopedia) => writeFile('./dist/tankopedia.json', tankopedia))
.then(() => console.log('✅ Tankopedia cached'));
console.log('🟡 Caching tank averages...');
fetch('https://www.blitzstars.com/api/tankaverages.json')
.then((response) => response.json())
.then((individualTankAverages) => {
const tankAverages = {};
individualTankAverages.forEach((individualTankAverage) => {
tankAverages[individualTankAverage.tank_id] = individualTankAverage;
});
return writeFile(
'./dist/tankaverages.json',
JSON.stringify(tankAverages),
);
})
.then(() => console.log('✅ Tank averages cached'));
console.log('🟡 Copying package.json...');
copyFile('package.dist.json', 'dist/package.json').then(() =>
console.log('✅ package.json copied'),
);
}
const commonOptions = {
platform: 'node',
loader: {
'.node': 'copy',
@@ -18,6 +56,32 @@ build({
bundle: true,
sourcemap: true,
minifyIdentifiers: false, // causes errors
minifySyntax: true,
minifyWhitespace: true,
});
minifySyntax: isProd,
minifyWhitespace: isProd,
logLevel: isProd ? 'info' : 'silent',
};
if (buildBot) {
console.log('🟡 Building bot...');
build({
...commonOptions,
entryPoints: ['src/bot.ts'],
outfile: 'dist/bot.cjs',
}).then(() => {
console.log('✅ Bot built');
});
}
if (buildServer) {
console.log('🟡 Building server...');
build({
...commonOptions,
entryPoints: ['src/server.ts'],
outfile: 'dist/server.cjs',
}).then(() => {
console.log('✅ Server built');
});
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { Client, GatewayIntentBits } from 'discord.js';
import { DISCORD_TOKEN } from './core/process/args.js';
import { DISCORD_TOKEN } from './core/node/arguments.js';
import { registerErrorHandlers } from './events/error.js';
import guildMemberAdd from './events/guildMemberAdd.js';
import interactionCreate from './events/interactionCreate.js';
import interactionCreate from './events/interactionCreate/index.js';
import ready from './events/ready.js';
export const client = new Client({
+25 -30
View File
@@ -1,13 +1,12 @@
import { SlashCommandBuilder } from 'discord.js';
import packageJSON from '../../package.json' assert { type: 'json' };
import { client } from '../bot.js';
import { tankopedia } from '../core/blitz/tankopedia.js';
import { tankAverages } from '../core/blitzstars/tankAverages.js';
import cleanTable from '../core/interaction/cleanTable.js';
import cmdName from '../core/interaction/cmdName.js';
import infoEmbed from '../core/interaction/infoEmbed.js';
import getClientId from '../core/process/getClientId.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import { client } from '../index.js';
import embedInfo from '../core/discord/embedInfo.js';
import markdownTable from '../core/discord/markdownTable.js';
import getClientId from '../core/node/getClientId.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
const executionStart = new Date().getTime();
@@ -17,34 +16,30 @@ export default {
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('debug'))
.setName('debug')
.setDescription('Debug information about the bot'),
async execute(interaction) {
handler(interaction) {
const currentTime = new Date().getTime();
const uptime = currentTime - executionStart;
await interaction.editReply({
embeds: [
infoEmbed(
`${client.user?.username} debug information`,
cleanTable([
['Version', packageJSON.version],
['Client ID', getClientId()],
['Tag', interaction.client.user.tag],
[
'Uptime',
`${Math.floor(uptime / 1000 / 60 / 60 / 24)}d ${Math.floor(
(uptime / 1000 / 60 / 60) % 24,
)}h ${Math.floor((uptime / 1000 / 60) % 60)}m ${Math.floor(
(uptime / 1000) % 60,
)}s ${Math.floor(uptime % 1000)}ms`,
],
['Tankopedia cached', `${tankopedia !== undefined}`],
['Tank averages cached', `${tankAverages !== undefined}`],
]),
),
],
});
return embedInfo(
`${client.user?.username} debug information`,
markdownTable([
['Version', packageJSON.version],
['Client ID', getClientId()],
['Tag', interaction.client.user.tag],
[
'Uptime',
`${Math.floor(uptime / 1000 / 60 / 60 / 24)}d ${Math.floor(
(uptime / 1000 / 60 / 60) % 24,
)}h ${Math.floor((uptime / 1000 / 60) % 60)}m ${Math.floor(
(uptime / 1000) % 60,
)}s ${Math.floor(uptime % 1000)}ms`,
],
['Tankopedia cached', `${tankopedia !== undefined}`],
['Tank averages cached', `${tankAverages !== undefined}`],
]),
);
},
} satisfies CommandRegistry;
+24 -27
View File
@@ -1,20 +1,19 @@
import { SlashCommandBuilder } from 'discord.js';
import getBlitzAccount from '../core/blitz/getBlitzAccount.js';
import getWN8 from '../core/blitz/getWN8.js';
import calculateWN8 from '../core/blitz/calculateWN8.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import sumStats from '../core/blitz/sumStats.js';
import { tankopedia } from '../core/blitz/tankopedia.js';
import getPeriodNow from '../core/blitzstars/getPeriodNow.js';
import getPeriodicStart from '../core/blitzstars/getPeriodStart.js';
import getTankStatsOverTime from '../core/blitzstars/getTankStatsOverTime.js';
import getPeriodStart from '../core/blitzstars/getPeriodStart.js';
import getTankStatsDiffed from '../core/blitzstars/getTankStatsDiffed.js';
import { tankAverages } from '../core/blitzstars/tankAverages.js';
import cleanTable, { TableInput } from '../core/interaction/cleanTable.js';
import cmdName from '../core/interaction/cmdName.js';
import negativeEmbed from '../core/interaction/negativeEmbed.js';
import positiveEmbed from '../core/interaction/positiveEmbed.js';
import addUsernameOption from '../core/options/addUsernameOption.js';
import { WARGAMING_APPLICATION_ID } from '../core/process/args.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import addUsernameChoices from '../core/discord/addUsernameChoices.js';
import embedNegative from '../core/discord/embedNegative.js';
import embedPositive from '../core/discord/embedPositive.js';
import markdownTable, { TableInput } from '../core/discord/markdownTable.js';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand.js';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
import { AccountInfo } from '../types/accountInfo.js';
export type SkilledClan = 'SKLLD' | 'SMRI';
@@ -30,7 +29,7 @@ export default {
inPublic: false,
command: new SlashCommandBuilder()
.setName(cmdName('eligible'))
.setName('eligible')
.setDescription('Checks eligibility for any Skilled clan')
.addStringOption((option) =>
option
@@ -45,11 +44,11 @@ export default {
)
.setRequired(true),
)
.addStringOption(addUsernameOption),
.addStringOption(addUsernameChoices),
async execute(interaction) {
async handler(interaction) {
const clan = interaction.options.getString('clan') as SkilledClan;
const { id, server } = await getBlitzAccount(interaction);
const { id, server } = await resolvePlayerFromCommand(interaction);
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
@@ -59,11 +58,10 @@ export default {
let body = '';
if (clan === 'SKLLD') {
const tankStatsOverTime = await getTankStatsOverTime(
interaction,
const tankStatsOverTime = await getTankStatsDiffed(
server,
id,
getPeriodicStart('30'),
getPeriodStart('30'),
getPeriodNow(),
);
const entries = Object.entries(tankStatsOverTime);
@@ -81,7 +79,7 @@ export default {
// edge case where new tanks don't have averages
if (tankAverages[tankId]) {
const tankWN8 = getWN8(tankAverages[tankId].all, stats);
const tankWN8 = calculateWN8(tankAverages[tankId].all, stats);
if (isNaN(tankWN8)) return accumulator;
return accumulator + tankWN8 * stats.battles;
@@ -124,7 +122,10 @@ export default {
]);
}
if (WN8 < 2450) {
problems.push(['WN8', `${WN8.toFixed(0)} (${WN8 - 2450})`]);
problems.push([
'WN8',
`${WN8.toFixed(0)} (${(WN8 - 2450).toFixed(0)})`,
]);
}
if (tier10Damage < 1250) {
problems.push([
@@ -136,7 +137,7 @@ export default {
isEligible = problems.length === 0;
body = isEligible
? 'Player is fully eligible.'
: `Player is not eligible. Shortcomings in 30-day statistics:\n\n${cleanTable(
: `Player is not eligible. Shortcomings in 30-day statistics:\n\n${markdownTable(
problems,
)}`;
} else if (clan === 'SMRI') {
@@ -161,15 +162,11 @@ export default {
isEligible = problems.length === 0;
body = isEligible
? 'Player is fully eligible.'
: `Player is not eligible. Shortcomings in career statistics:\n\n${cleanTable(
: `Player is not eligible. Shortcomings in career statistics:\n\n${markdownTable(
problems,
)}`;
}
await interaction.editReply({
embeds: isEligible
? [positiveEmbed(title, body)]
: [negativeEmbed(title, body)],
});
return (isEligible ? embedPositive : embedNegative)(title, body);
},
} satisfies CommandRegistry;
+86
View File
@@ -0,0 +1,86 @@
import { SlashCommandBuilder } from 'discord.js';
import { CYCLIC_API } from '../constants/cyclic.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import resolveTankId from '../core/blitz/resolveTankId.js';
import addStatTypeSubCommandGroups from '../core/discord/addStatTypeSubCommandGroups.js';
import autocompleteTanks from '../core/discord/autocompleteTanks.js';
import autocompleteUsername from '../core/discord/autocompleteUsername.js';
import interactionToURL from '../core/discord/interactionToURL.js';
import linkButton from '../core/discord/linkButton.js';
import primaryButton from '../core/discord/primaryButton.js';
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton.js';
import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand.js';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton.js';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand.js';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
import evolution from '../renderers/evolution.js';
import { StatType } from '../renderers/statsfull.js';
import { AccountInfo } from '../types/accountInfo.js';
export default {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: addStatTypeSubCommandGroups(
new SlashCommandBuilder()
.setName('evolution')
.setDescription('Evolution of statistics'),
),
async handler(interaction) {
const commandGroup = interaction.options.getSubcommandGroup(
true,
) as StatType;
const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(interaction);
const tankIdRaw = interaction.options.getString('tank')!;
const tankId = commandGroup === 'tank' ? resolveTankId(tankIdRaw) : null;
const start = interaction.options.getInteger('start');
const end = interaction.options.getInteger('end');
const { nickname } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
const path = interactionToURL(interaction, {
...player,
tankId,
start,
end,
});
return [
await evolution(commandGroup, period, player, tankId),
primaryButton(path, 'Refresh'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.server}/${nickname}${
commandGroup === 'tank' ? `/tank/${tankId!}` : ''
}`,
'BlitzStars',
),
];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const url = new URL(`${CYCLIC_API}/${interaction.customId}`);
const path = url.pathname.split('/').filter(Boolean);
const commandGroup = path[1] as StatType;
const period = resolvePeriodFromButton(interaction);
const player = await resolvePlayerFromButton(interaction);
return await evolution(
commandGroup,
period,
player,
parseInt(url.searchParams.get('tankId')!),
);
},
} satisfies CommandRegistry;
+21 -28
View File
@@ -1,43 +1,36 @@
import { EmbedBuilder, SlashCommandBuilder } from 'discord.js';
import { SlashCommandBuilder } from 'discord.js';
import discord from '../../discord.json' assert { type: 'json' };
import { ACCENT_COLOR } from '../constants/colors.js';
import cmdName from '../core/interaction/cmdName.js';
import { client } from '../bot.js';
import embedInfo from '../core/discord/embedInfo.js';
import {
CommandRegistry,
guildCommands,
publicCommands,
} from '../events/interactionCreate.js';
import { client } from '../index.js';
} from '../events/interactionCreate/index.js';
export default {
inProduction: true,
inDevelopment: true,
inDevelopment: false,
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('help'))
.setName('help')
.setDescription('All the help you need about the bot'),
async execute(interaction) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setTitle(`${client.user?.username} help`)
.setColor(ACCENT_COLOR)
.setDescription(
`**About**\n${
client.user?.username
} automates many mundane tasks and provide statistics in numerous flexible ways.\n\nMade by TresAbhi from the Skilled [SKLLD] clan.\nGitHub: https://github.com/tresabhi/blitzkrieg\nDiscord server: https://discord.gg/nDt7AjGJQH\nSkilled server: https://discord.gg/ZPvcEG7DS8\n\n**Commands**\n${(interaction.guildId ===
discord.sklld_guild_id
? [...guildCommands, ...publicCommands]
: publicCommands
)
.map((command) => `**/${command.name}**: ${command.description}`)
.join(
'\n',
)}\n\n*I am a new bot; I may make mistakes and go offline unexpectedly from time to time.*`,
),
],
});
handler(interaction) {
return embedInfo(
`${client.user?.username} help`,
`**About**\n${
client.user?.username
} automates many mundane tasks and provide statistics in numerous flexible ways.\n\nMade by TresAbhi from the Skilled [SKLLD] clan.\nGitHub: https://github.com/tresabhi/blitzkrieg\nDiscord server: https://discord.gg/nDt7AjGJQH\nSkilled server: https://discord.gg/ZPvcEG7DS8\n\n**Commands**\n${(interaction.guildId ===
discord.sklld_guild_id
? [...guildCommands, ...publicCommands]
: publicCommands
)
.map((command) => `**/${command.name}**: ${command.description}`)
.join(
'\n',
)}\n\n*I am a new bot; I may make mistakes and go offline unexpectedly from time to time.*`,
);
},
} satisfies CommandRegistry;
+12 -16
View File
@@ -1,18 +1,16 @@
import { SlashCommandBuilder } from 'discord.js';
import GenericStats from '../components/GenericStats/index.js';
import NoData, { NoDataType } from '../components/NoData.js';
import PoweredByWargaming from '../components/PoweredByWargaming.js';
import PoweredBy, { PoweredByType } from '../components/PoweredBy.js';
import TitleBar from '../components/TitleBar.js';
import Wrapper from '../components/Wrapper.js';
import { BLITZ_SERVERS } from '../constants/servers.js';
import clanAutocomplete from '../core/autocomplete/clan.js';
import getBlitzClan from '../core/blitz/getBlitzClan.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import cmdName from '../core/interaction/cmdName.js';
import addClanChoices from '../core/options/addClanChoices.js';
import { WARGAMING_APPLICATION_ID } from '../core/process/args.js';
import render from '../core/ui/render.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import addClanChoices from '../core/discord/addClanChoices.js';
import autocompleteClan from '../core/discord/autocompleteClan.js';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
import { AccountInfo } from '../types/accountInfo.js';
import { ClanInfo } from '../types/clanInfo.js';
@@ -24,7 +22,7 @@ export default {
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('inactive'))
.setName('inactive')
.setDescription('Lists all inactive players')
.addStringOption(addClanChoices)
.addNumberOption((option) =>
@@ -36,9 +34,9 @@ export default {
.setMinValue(0),
),
async execute(interaction) {
async handler(interaction) {
const clanName = interaction.options.getString('clan')!;
const { server, id } = await getBlitzClan(interaction, clanName);
const { server, id } = await getBlitzClan(clanName);
const threshold =
interaction.options.getNumber('threshold')! ?? DEFAULT_THRESHOLD;
const time = new Date().getTime() / 1000;
@@ -67,7 +65,7 @@ export default {
);
const hasInactiveMembers = inactive.length > 0;
const image = await render(
return (
<Wrapper>
<TitleBar
name={clanData.name}
@@ -81,12 +79,10 @@ export default {
{!hasInactiveMembers && <NoData type={NoDataType.PlayersInPeriod} />}
{hasInactiveMembers && <GenericStats stats={inactive} />}
<PoweredByWargaming />
</Wrapper>,
<PoweredBy type={PoweredByType.Wargaming} />
</Wrapper>
);
await interaction.editReply({ files: [image] });
},
autocomplete: clanAutocomplete,
autocomplete: autocompleteClan,
} satisfies CommandRegistry;
+36 -26
View File
@@ -1,20 +1,22 @@
import { SlashCommandBuilder } from 'discord.js';
import NoData, { NoDataType } from '../components/NoData.js';
import PoweredByWargaming from '../components/PoweredByWargaming.js';
import PoweredBy, { PoweredByType } from '../components/PoweredBy.js';
import * as Tanks from '../components/Tanks/index.js';
import TitleBar from '../components/TitleBar.js';
import Wrapper from '../components/Wrapper.js';
import { BLITZ_SERVERS } from '../constants/servers.js';
import usernameAutocomplete from '../core/autocomplete/username.js';
import getBlitzAccount from '../core/blitz/getBlitzAccount.js';
import getTankStats from '../core/blitz/getTankStats.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import { TIER_ROMAN_NUMERALS, tankopedia } from '../core/blitz/tankopedia.js';
import cmdName from '../core/interaction/cmdName.js';
import addUsernameOption from '../core/options/addUsernameOption.js';
import { WARGAMING_APPLICATION_ID } from '../core/process/args.js';
import render from '../core/ui/render.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import {
TIER_ROMAN_NUMERALS,
Tier,
tankopedia,
} from '../core/blitz/tankopedia.js';
import addUsernameChoices from '../core/discord/addUsernameChoices.js';
import autocompleteUsername from '../core/discord/autocompleteUsername.js';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand.js';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
import { AccountInfo } from '../types/accountInfo.js';
import { PlayerClanData } from '../types/playerClanData.js';
@@ -41,30 +43,40 @@ const COMP_TANKS = [
export default {
inProduction: true,
inDevelopment: true,
inDevelopment: false,
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('ownedtanks'))
.setName('ownedtanks')
.setDescription("Shows a player's owned tanks")
.addIntegerOption((option) =>
.addStringOption((option) =>
option
.setName('tier')
.setDescription('The tier you want to see')
.setMinValue(1)
.setMaxValue(10)
.setChoices(
{ name: 'Tier I', value: '1' },
{ name: 'Tier II', value: '2' },
{ name: 'Tier III', value: '3' },
{ name: 'Tier IV', value: '4' },
{ name: 'Tier V', value: '5' },
{ name: 'Tier VI', value: '6' },
{ name: 'Tier VII', value: '7' },
{ name: 'Tier VIII', value: '8' },
{ name: 'Tier IX', value: '9' },
{ name: 'Tier X', value: '10' },
)
.setRequired(true),
)
.addStringOption(addUsernameOption),
.addStringOption(addUsernameChoices),
async execute(interaction) {
const tier = interaction.options.getInteger('tier')!;
const account = await getBlitzAccount(interaction);
async handler(interaction) {
const tier = Number(interaction.options.getString('tier'));
const account = await resolvePlayerFromCommand(interaction);
const { id, server } = account;
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const tankStats = await getTankStats(interaction, server, id);
const tankStats = await getTankStats(server, id);
const tanks = tankStats
.map((tankData) => tankopedia[tankData.tank_id])
.filter((tank) => tank?.tier === tier);
@@ -72,12 +84,12 @@ export default {
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
);
const image = await render(
return (
<Wrapper>
{/* TODO: integrate some of these into title bar */}
<TitleBar
name={accountInfo[id].nickname}
nameDiscriminator={`(Tier ${TIER_ROMAN_NUMERALS[tier]})`}
nameDiscriminator={`(Tier ${TIER_ROMAN_NUMERALS[tier as Tier]})`}
image={
clanData[id]?.clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png`
@@ -103,12 +115,10 @@ export default {
</Tanks.Root>
)}
<PoweredByWargaming />
</Wrapper>,
<PoweredBy type={PoweredByType.Wargaming} />
</Wrapper>
);
await interaction.editReply({ files: [image] });
},
autocomplete: usernameAutocomplete,
autocomplete: autocompleteUsername,
} satisfies CommandRegistry;
+33
View File
@@ -0,0 +1,33 @@
import { SlashCommandBuilder } from 'discord.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
export default {
inProduction: true,
inDevelopment: false,
inPublic: true,
handlesInteraction: true,
command: new SlashCommandBuilder()
.setName('ping')
.setDescription('Check if the bot is alive')
.addSubcommand((option) =>
option.setName('blitzkrieg').setDescription('Ping Blitzkrieg'),
)
.addSubcommand((option) =>
option.setName('blitz').setDescription('Ping Blitz'),
),
async handler(interaction) {
const subcommand = interaction.options.getSubcommand();
const executionStart = Date.now();
if (subcommand === 'blitzkrieg') {
await interaction.editReply('Pong 🏓');
} else {
await fetch('https://api.wotblitz.com/');
}
const executionTime = Date.now() - executionStart;
interaction.editReply(`Pong 🏓 - ${executionTime}ms`);
},
} satisfies CommandRegistry<true>;
+33 -36
View File
@@ -1,15 +1,16 @@
import { SlashCommandBuilder } from 'discord.js';
import startCase from 'lodash.startcase';
import { startCase } from 'lodash';
import markdownEscape from 'markdown-escape';
import usernameAutocomplete from '../core/autocomplete/username.js';
import getBlitzAccount from '../core/blitz/getBlitzAccount.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import cleanTable, { TableInputEntry } from '../core/interaction/cleanTable.js';
import cmdName from '../core/interaction/cmdName.js';
import infoEmbed from '../core/interaction/infoEmbed.js';
import addUsernameOption from '../core/options/addUsernameOption.js';
import { WARGAMING_APPLICATION_ID } from '../core/process/args.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import addUsernameChoices from '../core/discord/addUsernameChoices.js';
import autocompleteUsername from '../core/discord/autocompleteUsername.js';
import embedInfo from '../core/discord/embedInfo.js';
import markdownTable, {
TableInputEntry,
} from '../core/discord/markdownTable.js';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand.js';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
import { AccountAchievements } from '../types/accountAchievements.js';
import { AccountInfo } from '../types/accountInfo.js';
@@ -21,9 +22,9 @@ export default {
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('playerachievements'))
.setName('playerachievements')
.setDescription("All the player's achievements")
.addStringOption(addUsernameOption)
.addStringOption(addUsernameChoices)
.addStringOption((option) =>
option
.setName('sort')
@@ -34,9 +35,9 @@ export default {
),
),
async execute(interaction) {
async handler(interaction) {
const sortBy = (interaction.options.getString('sort') ?? 'name') as SortBy;
const account = await getBlitzAccount(interaction);
const account = await resolvePlayerFromCommand(interaction);
const { id, server } = account;
const accounts = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
@@ -51,31 +52,27 @@ export default {
...accountAchievements.max_series,
};
interaction.editReply({
embeds: [
infoEmbed(
`${markdownEscape(accounts[id].nickname)}'s information`,
return embedInfo(
`${markdownEscape(accounts[id].nickname)}'s information`,
cleanTable(
Object.keys(compound)
.filter((achievement) => compound[achievement] > 0)
.sort(
sortBy === 'count'
? (a, b) => compound[b] - compound[a]
: undefined,
)
.map(
(achievement) =>
[
startCase(achievement),
compound[achievement],
] as TableInputEntry,
),
markdownTable(
Object.keys(compound)
.filter((achievement) => compound[achievement] > 0)
.sort(
sortBy === 'count'
? (a, b) => compound[b] - compound[a]
: undefined,
)
.map(
(achievement) =>
[
startCase(achievement),
compound[achievement],
] as TableInputEntry,
),
),
],
});
),
);
},
autocomplete: usernameAutocomplete,
autocomplete: autocompleteUsername,
} satisfies CommandRegistry;
+37 -42
View File
@@ -1,14 +1,13 @@
import { SlashCommandBuilder } from 'discord.js';
import markdownEscape from 'markdown-escape';
import usernameAutocomplete from '../core/autocomplete/username.js';
import getBlitzAccount from '../core/blitz/getBlitzAccount.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import cleanTable from '../core/interaction/cleanTable.js';
import cmdName from '../core/interaction/cmdName.js';
import infoEmbed from '../core/interaction/infoEmbed.js';
import addUsernameOption from '../core/options/addUsernameOption.js';
import { WARGAMING_APPLICATION_ID } from '../core/process/args.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import addUsernameChoices from '../core/discord/addUsernameChoices.js';
import autocompleteUsername from '../core/discord/autocompleteUsername.js';
import embedInfo from '../core/discord/embedInfo.js';
import markdownTable from '../core/discord/markdownTable.js';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand.js';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
import { AccountInfo } from '../types/accountInfo.js';
export default {
@@ -17,49 +16,45 @@ export default {
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('playerinfo'))
.setName('playerinfo')
.setDescription('Basic information about a player')
.addStringOption(addUsernameOption),
.addStringOption(addUsernameChoices),
async execute(interaction) {
const account = await getBlitzAccount(interaction);
async handler(interaction) {
const account = await resolvePlayerFromCommand(interaction);
const { id, server } = account;
const accounts = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const accountInfo = accounts[id];
interaction.editReply({
embeds: [
infoEmbed(
`${markdownEscape(accountInfo.nickname)}'s information`,
return embedInfo(
`${markdownEscape(accountInfo.nickname)}'s information`,
cleanTable([
['Nickname', `${accountInfo.nickname}`],
['Battles', `${accountInfo.statistics.all.battles}`],
[
'Winrate',
`${(
100 *
(accountInfo.statistics.all.wins /
accountInfo.statistics.all.battles)
).toFixed(2)}%`,
],
[],
['Account ID', `${accountInfo.account_id}`],
[
'Created',
`${new Date(accountInfo.created_at * 1000).toDateString()}`,
],
[
'Last battle',
`${new Date(accountInfo.last_battle_time * 1000).toDateString()}`,
],
]),
),
],
});
markdownTable([
['Nickname', `${accountInfo.nickname}`],
['Battles', `${accountInfo.statistics.all.battles}`],
[
'Winrate',
`${(
100 *
(accountInfo.statistics.all.wins /
accountInfo.statistics.all.battles)
).toFixed(2)}%`,
],
[],
['Account ID', `${accountInfo.account_id}`],
[
'Created',
`${new Date(accountInfo.created_at * 1000).toDateString()}`,
],
[
'Last battle',
`${new Date(accountInfo.last_battle_time * 1000).toDateString()}`,
],
]),
);
},
autocomplete: usernameAutocomplete,
autocomplete: autocompleteUsername,
} satisfies CommandRegistry;
+15 -22
View File
@@ -2,12 +2,11 @@ import { SlashCommandBuilder } from 'discord.js';
import markdownEscape from 'markdown-escape';
import { BLITZ_SERVERS, BlitzServer } from '../constants/servers.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import cmdName from '../core/interaction/cmdName.js';
import infoEmbed from '../core/interaction/infoEmbed.js';
import addClanChoices from '../core/options/addClanChoices.js';
import addServerChoices from '../core/options/addServerChoices.js';
import { WARGAMING_APPLICATION_ID } from '../core/process/args.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import addClanChoices from '../core/discord/addClanChoices.js';
import addServerChoices from '../core/discord/addServerChoices.js';
import embedInfo from '../core/discord/embedInfo.js';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
import { ClanList } from '../types/clanList.js';
export default {
@@ -16,7 +15,7 @@ export default {
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('searchclans'))
.setName('searchclans')
.setDescription('Search clans')
.addStringOption(addServerChoices)
.addStringOption((option) => addClanChoices(option).setAutocomplete(false))
@@ -28,7 +27,7 @@ export default {
.setMaxValue(100),
),
async execute(interaction) {
async handler(interaction) {
const server = interaction.options.getString('server') as BlitzServer;
const clan = interaction.options.getString('clan')!;
const limit = interaction.options.getInteger('limit') ?? 25;
@@ -36,19 +35,13 @@ export default {
`https://api.wotblitz.${server}/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${clan}&limit=${limit}`,
);
await interaction.editReply({
embeds: [
infoEmbed(
`Clan search for "${markdownEscape(clan)}" in ${
BLITZ_SERVERS[server]
}`,
clanList.length === 0
? 'No clans found.'
: `\`\`\`\n${clanList
.map((clan) => `${clan.name} [${clan.tag}]`)
.join('\n')}\n\`\`\``,
),
],
});
return embedInfo(
`Clan search for "${markdownEscape(clan)}" in ${BLITZ_SERVERS[server]}`,
clanList.length === 0
? 'No clans found.'
: `\`\`\`\n${clanList
.map((clan) => `${clan.name} [${clan.tag}]`)
.join('\n')}\n\`\`\``,
);
},
} satisfies CommandRegistry;
+17 -24
View File
@@ -2,13 +2,12 @@ import { SlashCommandBuilder } from 'discord.js';
import markdownEscape from 'markdown-escape';
import { BLITZ_SERVERS, BlitzServer } from '../constants/servers.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import { usernamePattern } from '../core/blitz/listAccountsPanServer.js';
import cmdName from '../core/interaction/cmdName.js';
import infoEmbed from '../core/interaction/infoEmbed.js';
import addServerChoices from '../core/options/addServerChoices.js';
import addUsernameOption from '../core/options/addUsernameOption.js';
import { WARGAMING_APPLICATION_ID } from '../core/process/args.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import { usernamePattern } from '../core/blitz/listPlayers.js';
import addServerChoices from '../core/discord/addServerChoices.js';
import addUsernameChoices from '../core/discord/addUsernameChoices.js';
import embedInfo from '../core/discord/embedInfo.js';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
import { AccountList } from '../types/accountList.js';
export default {
@@ -17,11 +16,11 @@ export default {
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('searchplayers'))
.setName('searchplayers')
.setDescription('Search players in a Blitz server')
.addStringOption(addServerChoices)
.addStringOption((option) =>
addUsernameOption(option).setAutocomplete(false).setRequired(true),
addUsernameChoices(option).setAutocomplete(false).setRequired(true),
)
.addIntegerOption((option) =>
option
@@ -31,7 +30,7 @@ export default {
.setMaxValue(100),
),
async execute(interaction) {
async handler(interaction) {
const server = interaction.options.getString('server') as BlitzServer;
const name = interaction.options.getString('username')!;
const limit = interaction.options.getInteger('limit') ?? 25;
@@ -44,19 +43,13 @@ export default {
)
: [];
await interaction.editReply({
embeds: [
infoEmbed(
`Player search for "${markdownEscape(name)}" in ${
BLITZ_SERVERS[server]
}`,
`\`\`\`${
players.length === 0
? 'No players found.'
: players.map((player) => player.nickname).join('\n')
}\`\`\``,
),
],
});
return embedInfo(
`Player search for "${markdownEscape(name)}" in ${BLITZ_SERVERS[server]}`,
`\`\`\`${
players.length === 0
? 'No players found.'
: players.map((player) => player.nickname).join('\n')
}\`\`\``,
);
},
} satisfies CommandRegistry;
+11 -16
View File
@@ -2,10 +2,9 @@ import { SlashCommandBuilder } from 'discord.js';
import { go } from 'fuzzysort';
import markdownEscape from 'markdown-escape';
import { TANK_NAMES } from '../core/blitz/tankopedia.js';
import cmdName from '../core/interaction/cmdName.js';
import infoEmbed from '../core/interaction/infoEmbed.js';
import addTankChoices from '../core/options/addTankChoices.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import addTankChoices from '../core/discord/addTankChoices.js';
import embedInfo from '../core/discord/embedInfo.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
export default {
inProduction: true,
@@ -13,7 +12,7 @@ export default {
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('searchtanks'))
.setName('searchtanks')
.setDescription('Search tanks')
.addStringOption((option) => addTankChoices(option).setAutocomplete(false))
.addIntegerOption((option) =>
@@ -24,22 +23,18 @@ export default {
.setMaxValue(100),
),
async execute(interaction) {
async handler(interaction) {
const tank = interaction.options.getString('tank')!;
const limit = interaction.options.getInteger('limit') ?? 25;
const results = go(tank, TANK_NAMES, { limit }).map(
(result) => result.target,
);
await interaction.editReply({
embeds: [
infoEmbed(
`Tank search for "${markdownEscape(tank)}"`,
results.length === 0
? 'No tanks found.'
: `\`\`\`\n${results.join('\n')}\n\`\`\``,
),
],
});
return embedInfo(
`Tank search for "${markdownEscape(tank)}"`,
results.length === 0
? 'No tanks found.'
: `\`\`\`\n${results.join('\n')}\n\`\`\``,
);
},
} satisfies CommandRegistry;
+73 -167
View File
@@ -1,178 +1,84 @@
import {
ActionRowBuilder,
ButtonBuilder,
SlashCommandBuilder,
} from 'discord.js';
import GenericAllStats from '../components/GenericAllStats.js';
import NoData, { NoDataType } from '../components/NoData.js';
import PoweredByBlitzStars from '../components/PoweredByBlitzStars.js';
import TitleBar from '../components/TitleBar.js';
import Wrapper from '../components/Wrapper.js';
import { BLITZ_SERVERS } from '../constants/servers.js';
import usernameAutocomplete from '../core/autocomplete/username.js';
import getBlitzAccount from '../core/blitz/getBlitzAccount.js';
import getTankStats from '../core/blitz/getTankStats.js';
import getWN8 from '../core/blitz/getWN8.js';
import { SlashCommandBuilder } from 'discord.js';
import { CYCLIC_API } from '../constants/cyclic.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import sumStats from '../core/blitz/sumStats.js';
import { tankopedia } from '../core/blitz/tankopedia.js';
import getPeriodNow from '../core/blitzstars/getPeriodNow.js';
import getPeriodicStart from '../core/blitzstars/getPeriodStart.js';
import getTankStatsOverTime from '../core/blitzstars/getTankStatsOverTime.js';
import { tankAverages } from '../core/blitzstars/tankAverages.js';
import cmdName from '../core/interaction/cmdName.js';
import fullBlitzStarsStats from '../core/interaction/fullBlitzStarsStats.js';
import { supportBlitzStars } from '../core/interaction/supportBlitzStars.js';
import addStatPeriodChoices, {
StatPeriod,
statPeriodNames,
} from '../core/options/addStatPeriodChoices.js';
import addUsernameOption from '../core/options/addUsernameOption.js';
import { WARGAMING_APPLICATION_ID } from '../core/process/args.js';
import render from '../core/ui/render.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import {
AccountInfo,
AllStats,
SupplementaryStats,
} from '../types/accountInfo.js';
import { PlayerClanData } from '../types/playerClanData.js';
import resolveTankId from '../core/blitz/resolveTankId.js';
import addStatTypeSubCommandGroups from '../core/discord/addStatTypeSubCommandGroups.js';
import autocompleteTanks from '../core/discord/autocompleteTanks.js';
import autocompleteUsername from '../core/discord/autocompleteUsername.js';
import interactionToURL from '../core/discord/interactionToURL.js';
import linkButton from '../core/discord/linkButton.js';
import primaryButton from '../core/discord/primaryButton.js';
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton.js';
import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand.js';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton.js';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand.js';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
import stats from '../renderers/stats.js';
import { StatType } from '../renderers/statsfull.js';
import { AccountInfo } from '../types/accountInfo.js';
export default {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('stats'))
.setDescription("Gets the user's in-game statistics")
.addStringOption(addStatPeriodChoices)
.addStringOption(addUsernameOption),
command: addStatTypeSubCommandGroups(
new SlashCommandBuilder()
.setName('stats')
.setDescription('In-game statistics'),
),
async execute(interaction) {
const period = interaction.options.getString('period') as StatPeriod;
const { id, server } = await getBlitzAccount(interaction);
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const clanData = await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
);
const tankStats = await getTankStats(interaction, server, id);
let stats: AllStats;
let supplementaryStats: SupplementaryStats | undefined;
let hasWN8AccumulatedAtAll = false;
if (period === 'career') {
stats = accountInfo[id].statistics.all;
const totalBattles = tankStats.reduce(
(accumulator, { all }) => accumulator + all.battles,
0,
);
supplementaryStats = {
tier:
tankStats.reduce(
(accumulator, { tank_id, all }) =>
accumulator + tankopedia[tank_id].tier * all.battles,
0,
) / totalBattles,
WN8:
tankStats.reduce((accumulator, { tank_id, all }) => {
if (tankAverages[tank_id]) {
const tankWN8 = getWN8(tankAverages[tank_id].all, all);
if (isNaN(tankWN8)) return accumulator;
hasWN8AccumulatedAtAll = true;
return accumulator + tankWN8 * all.battles;
} else return accumulator;
}, 0) / totalBattles,
};
} else {
const tankStatsOverTime = await getTankStatsOverTime(
interaction,
server,
id,
getPeriodicStart(period),
getPeriodNow(),
);
const entries = Object.entries(tankStatsOverTime);
const totalBattles = entries.reduce(
(accumulator, [, stats]) => accumulator + stats.battles,
0,
);
supplementaryStats = {
tier:
entries.reduce((accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString);
const tankTier = tankopedia[tankId].tier;
return accumulator + tankTier * stats.battles;
}, 0) / totalBattles,
WN8:
entries.reduce((accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString);
// edge case where new tanks don't have averages
if (tankAverages[tankId]) {
const tankWN8 = getWN8(tankAverages[tankId].all, stats);
if (isNaN(tankWN8)) return accumulator;
hasWN8AccumulatedAtAll = true;
return accumulator + tankWN8 * stats.battles;
} else return accumulator;
}, 0) / totalBattles,
};
stats = sumStats(entries.map(([, stats]) => stats));
}
if (!hasWN8AccumulatedAtAll) supplementaryStats.WN8 = undefined;
const image = await render(
<Wrapper>
<TitleBar
name={accountInfo[id].nickname}
nameDiscriminator={
clanData[id]?.clan ? `[${clanData[id]?.clan?.tag}]` : undefined
}
image={
clanData[id]?.clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png`
: undefined
}
description={`${
statPeriodNames[period]
} • ${new Date().toDateString()} • ${BLITZ_SERVERS[server]}`}
/>
{stats.battles === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
{stats.battles > 0 && (
<GenericAllStats
stats={stats}
supplementaryStats={supplementaryStats}
/>
)}
<PoweredByBlitzStars />
</Wrapper>,
);
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
fullBlitzStarsStats(server, accountInfo[id].nickname),
supportBlitzStars,
);
await interaction.editReply({
files: [image],
components: [actionRow],
async handler(interaction) {
const commandGroup = interaction.options.getSubcommandGroup(
true,
) as StatType;
const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(interaction);
const tankIdRaw = interaction.options.getString('tank')!;
const tankId = commandGroup === 'tank' ? resolveTankId(tankIdRaw) : null;
const start = interaction.options.getInteger('start');
const end = interaction.options.getInteger('end');
const path = interactionToURL(interaction, {
...player,
tankId,
start,
end,
});
const { nickname } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
return [
await stats(commandGroup, period, player, tankId),
primaryButton(path, 'Refresh'),
linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.server}/${nickname}`,
'BlitzStars',
),
];
},
autocomplete: usernameAutocomplete,
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const url = new URL(`${CYCLIC_API}/${interaction.customId}`);
const path = url.pathname.split('/').filter(Boolean);
const commandGroup = path[1] as StatType;
const period = resolvePeriodFromButton(interaction);
const player = await resolvePlayerFromButton(interaction);
return await stats(
commandGroup,
period,
player,
parseInt(url.searchParams.get('tankId')!),
);
},
} satisfies CommandRegistry;
+83
View File
@@ -0,0 +1,83 @@
import { SlashCommandBuilder } from 'discord.js';
import { CYCLIC_API } from '../constants/cyclic.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import resolveTankId from '../core/blitz/resolveTankId.js';
import addStatTypeSubCommandGroups from '../core/discord/addStatTypeSubCommandGroups.js';
import autocompleteTanks from '../core/discord/autocompleteTanks.js';
import autocompleteUsername from '../core/discord/autocompleteUsername.js';
import interactionToURL from '../core/discord/interactionToURL.js';
import linkButton from '../core/discord/linkButton.js';
import primaryButton from '../core/discord/primaryButton.js';
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton.js';
import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand.js';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton.js';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand.js';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
import statsfull, { StatType } from '../renderers/statsfull.js';
import { AccountInfo } from '../types/accountInfo.js';
export default {
inProduction: true,
inDevelopment: false,
inPublic: true,
command: addStatTypeSubCommandGroups(
new SlashCommandBuilder()
.setName('statsfull')
.setDescription('Full in-game statistics'),
),
async handler(interaction) {
const commandGroup = interaction.options.getSubcommandGroup(
true,
) as StatType;
const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(interaction);
const tankIdRaw = interaction.options.getString('tank')!;
const tankId = commandGroup === 'tank' ? resolveTankId(tankIdRaw) : null;
const start = interaction.options.getInteger('start');
const end = interaction.options.getInteger('end');
const path = interactionToURL(interaction, {
...player,
tankId,
start,
end,
});
const { nickname } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
return [
await statsfull(commandGroup, period, player, tankId),
primaryButton(path, 'Refresh'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.server}/${nickname}`,
'BlitzStars',
),
];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const url = new URL(`${CYCLIC_API}/${interaction.customId}`);
const path = url.pathname.split('/').filter(Boolean);
const commandGroup = path[1] as StatType;
const period = resolvePeriodFromButton(interaction);
const player = await resolvePlayerFromButton(interaction);
return await statsfull(
commandGroup,
period,
player,
parseInt(url.searchParams.get('tankId')!),
);
},
} satisfies CommandRegistry;
-125
View File
@@ -1,125 +0,0 @@
import {
ActionRowBuilder,
ButtonBuilder,
SlashCommandBuilder,
} from 'discord.js';
import GenericAllStats from '../components/GenericAllStats.js';
import NoData, { NoDataType } from '../components/NoData.js';
import PoweredByBlitzStars from '../components/PoweredByBlitzStars.js';
import TitleBar from '../components/TitleBar.js';
import Wrapper from '../components/Wrapper.js';
import { BLITZ_SERVERS } from '../constants/servers.js';
import tanksAutocomplete from '../core/autocomplete/tanks.js';
import usernameAutocomplete from '../core/autocomplete/username.js';
import getBlitzAccount from '../core/blitz/getBlitzAccount.js';
import getTankStats from '../core/blitz/getTankStats.js';
import getWN8 from '../core/blitz/getWN8.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import resolveTankId from '../core/blitz/resolveTankId.js';
import resolveTankName from '../core/blitz/resolveTankName.js';
import { tankopedia } from '../core/blitz/tankopedia.js';
import getPeriodNow from '../core/blitzstars/getPeriodNow.js';
import getPeriodicStart from '../core/blitzstars/getPeriodStart.js';
import getTankStatsOverTime, {
emptyAllStats,
} from '../core/blitzstars/getTankStatsOverTime.js';
import { tankAverages } from '../core/blitzstars/tankAverages.js';
import cmdName from '../core/interaction/cmdName.js';
import fullBlitzStarsStats from '../core/interaction/fullBlitzStarsStats.js';
import { supportBlitzStars } from '../core/interaction/supportBlitzStars.js';
import addStatPeriodChoices, {
StatPeriod,
statPeriodNames,
} from '../core/options/addStatPeriodChoices.js';
import addTankChoices from '../core/options/addTankChoices.js';
import addUsernameOption from '../core/options/addUsernameOption.js';
import { WARGAMING_APPLICATION_ID } from '../core/process/args.js';
import render from '../core/ui/render.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import { AccountInfo, AllStats } from '../types/accountInfo.js';
export default {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('tankstats'))
.setDescription('Stats for a tank over a period')
.addStringOption(addTankChoices)
.addStringOption(addStatPeriodChoices)
.addStringOption(addUsernameOption),
async execute(interaction) {
const tank = await resolveTankId(
interaction,
interaction.options.getString('tank')!,
);
const period = interaction.options.getString('period') as StatPeriod;
const blitzAccount = await getBlitzAccount(interaction);
const { id, server } = blitzAccount;
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const tankStats = await getTankStats(interaction, server, id);
let stats: AllStats;
const tankId = Number(tank);
if (period === 'career') {
stats =
tankStats.find((stats) => stats.tank_id === tankId)?.all ??
emptyAllStats;
} else {
const tankStatsOverTime = await getTankStatsOverTime(
interaction,
server,
id,
getPeriodicStart(period),
getPeriodNow(),
);
stats = tankStatsOverTime[tankId] ?? emptyAllStats;
}
const image = await render(
<Wrapper>
<TitleBar
name={accountInfo[id].nickname}
nameDiscriminator={`(${resolveTankName(tankId)})`}
image={tankopedia[tankId].images.normal}
description={`${
statPeriodNames[period]
} • ${new Date().toDateString()} • ${BLITZ_SERVERS[server]}`}
/>
{stats.battles === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
{stats.battles > 0 && (
<GenericAllStats
stats={stats}
supplementaryStats={{
tier: tankopedia[tankId].tier,
WN8: getWN8(tankAverages[tankId].all, stats),
}}
/>
)}
<PoweredByBlitzStars />
</Wrapper>,
);
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
fullBlitzStarsStats(server, accountInfo[id].nickname),
supportBlitzStars,
);
await interaction.editReply({
files: [image],
components: [actionRow],
});
},
autocomplete: (interaction) => {
tanksAutocomplete(interaction);
usernameAutocomplete(interaction);
},
} satisfies CommandRegistry;
+38 -184
View File
@@ -1,34 +1,16 @@
import {
ActionRowBuilder,
ButtonBuilder,
SlashCommandBuilder,
} from 'discord.js';
import * as Breakdown from '../components/Breakdown/index.js';
import NoData, { NoDataType } from '../components/NoData.js';
import PoweredByBlitzStars from '../components/PoweredByBlitzStars.js';
import TitleBar from '../components/TitleBar.js';
import Wrapper from '../components/Wrapper.js';
import { BLITZ_SERVERS } from '../constants/servers.js';
import usernameAutocomplete from '../core/autocomplete/username.js';
import getBlitzAccount from '../core/blitz/getBlitzAccount.js';
import getTankStats from '../core/blitz/getTankStats.js';
import getWN8 from '../core/blitz/getWN8.js';
import { SlashCommandBuilder } from 'discord.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import resolveTankName from '../core/blitz/resolveTankName.js';
import sumStats from '../core/blitz/sumStats.js';
import { tankopedia } from '../core/blitz/tankopedia.js';
import getTankStatsOverTime from '../core/blitzstars/getTankStatsOverTime.js';
import last5AM from '../core/blitzstars/last5AM.js';
import { tankAverages } from '../core/blitzstars/tankAverages.js';
import cmdName from '../core/interaction/cmdName.js';
import fullBlitzStarsStats from '../core/interaction/fullBlitzStarsStats.js';
import { supportBlitzStars } from '../core/interaction/supportBlitzStars.js';
import addUsernameOption from '../core/options/addUsernameOption.js';
import { WARGAMING_APPLICATION_ID } from '../core/process/args.js';
import render from '../core/ui/render.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import { AccountInfo, AllStats } from '../types/accountInfo.js';
import { PlayerClanData } from '../types/playerClanData.js';
import addUsernameChoices from '../core/discord/addUsernameChoices.js';
import autocompleteUsername from '../core/discord/autocompleteUsername.js';
import interactionToURL from '../core/discord/interactionToURL.js';
import linkButton from '../core/discord/linkButton.js';
import primaryButton from '../core/discord/primaryButton.js';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton.js';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand.js';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
import today from '../renderers/today.js';
import { AccountInfo } from '../types/accountInfo.js';
export default {
inProduction: true,
@@ -36,163 +18,35 @@ export default {
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('today'))
.setName('today')
.setDescription('A general daily breakdown of your performance')
.addStringOption(addUsernameOption),
.addStringOption(addUsernameChoices),
async execute(interaction) {
const { id, server } = await getBlitzAccount(interaction);
const tankStatsOverTime = await getTankStatsOverTime(
interaction,
server,
id,
last5AM().getTime() / 1000,
new Date().getTime() / 1000,
);
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const clanData = await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
);
const careerTankStatsRaw = await getTankStats(interaction, server, id);
const careerStats: Record<number, AllStats> = {
0: accountInfo[id].statistics.all,
};
const allStatsToAccumulate: AllStats[] = [];
async handler(interaction) {
const player = await resolvePlayerFromCommand(interaction);
const { nickname } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
const path = interactionToURL(interaction, player);
Object.entries(tankStatsOverTime).forEach(([, tankStats]) => {
allStatsToAccumulate.push(tankStats);
});
Object.entries(careerTankStatsRaw).forEach(([, tankStats]) => {
careerStats[tankStats.tank_id] = tankStats.all;
});
const accumulatedStats = sumStats(allStatsToAccumulate);
if (Object.keys(tankStatsOverTime).length > 0) {
tankStatsOverTime[0] = accumulatedStats;
}
const tankStatsOverTimeEntries = Object.entries(tankStatsOverTime);
const todayWN8s = tankStatsOverTimeEntries.reduce<Record<number, number>>(
(accumulator, [tankIdString, tankStats]) => {
const tankId = Number(tankIdString);
return tankId === 0 || tankAverages[tankId] === undefined
? accumulator
: {
...accumulator,
[tankId]: getWN8(tankAverages[tankId].all, tankStats),
};
},
{},
);
const careerWN8s = careerTankStatsRaw.reduce<Record<number, number>>(
(accumulator, { tank_id }) => {
return tank_id === 0 || tankAverages[tank_id] === undefined
? accumulator
: {
...accumulator,
[tank_id]: getWN8(
tankAverages[tank_id].all,
careerStats[tank_id],
),
};
},
{},
);
const todayWN8sEntries = Object.entries(todayWN8s);
const careerWN8sEntries = Object.entries(careerWN8s);
todayWN8s[0] =
todayWN8sEntries.reduce(
(accumulator, [tankIdString, wn8]) =>
accumulator + wn8 * tankStatsOverTime[Number(tankIdString)].battles,
0,
) /
todayWN8sEntries.reduce(
(accumulator, [tankIdString]) =>
accumulator + tankStatsOverTime[Number(tankIdString)].battles,
0,
);
careerWN8s[0] =
careerWN8sEntries.reduce(
(accumulator, [tankIdString, WN8]) =>
isNaN(WN8)
? accumulator
: accumulator + WN8 * careerStats[Number(tankIdString)].battles,
0,
) /
careerWN8sEntries.reduce(
(accumulator, [tankIdString, WN8]) =>
isNaN(WN8)
? accumulator
: accumulator + careerStats[Number(tankIdString)].battles,
0,
);
const rows = tankStatsOverTimeEntries.map(([tankIdString, tankStats]) => {
const tankId = Number(tankIdString);
const career = careerStats[tankId];
return (
<Breakdown.Row
key={tankId}
name={tankId === 0 ? 'Total' : resolveTankName(Number(tankIdString))}
winrate={tankStats.wins / tankStats.battles}
careerWinrate={career.wins / career.battles}
WN8={isNaN(todayWN8s[tankId]) ? undefined : todayWN8s[tankId]}
careerWN8={isNaN(careerWN8s[tankId]) ? undefined : careerWN8s[tankId]}
damage={tankStats.damage_dealt / tankStats.battles}
careerDamage={career.damage_dealt / career.battles}
survival={tankStats.survived_battles / tankStats.battles}
careerSurvival={career.survived_battles / career.battles}
battles={tankStats.battles}
careerBattles={career.battles}
icon={
tankId === 0
? undefined
: tankopedia[tankIdString as unknown as number].images.normal
}
/>
);
});
const image = await render(
<Wrapper>
<TitleBar
name={accountInfo[id].nickname}
nameDiscriminator={
clanData[id]?.clan ? `[${clanData[id]?.clan?.tag}]` : undefined
}
image={
clanData[id]?.clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png`
: undefined
}
description={`Today's breakdown • ${new Date().toDateString()} • ${
BLITZ_SERVERS[server]
}`}
/>
{rows.length === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
{rows.length > 0 && <Breakdown.Root>{rows}</Breakdown.Root>}
<PoweredByBlitzStars />
</Wrapper>,
);
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
fullBlitzStarsStats(server, accountInfo[id].nickname),
supportBlitzStars,
);
await interaction.editReply({
files: [image],
components: [actionRow],
});
return [
await today(player),
primaryButton(path, 'Refresh'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.server}/${nickname}`,
'BlitzStars',
),
];
},
autocomplete: usernameAutocomplete,
autocomplete: autocompleteUsername,
async button(interaction) {
const player = await resolvePlayerFromButton(interaction);
return await today(player);
},
} satisfies CommandRegistry;
+49 -84
View File
@@ -1,14 +1,14 @@
import { EmbedBuilder, SlashCommandBuilder } from 'discord.js';
import { SlashCommandBuilder } from 'discord.js';
import markdownEscape from 'markdown-escape';
import discord from '../../discord.json' assert { type: 'json' };
import { NEGATIVE_COLOR, POSITIVE_COLOR } from '../constants/colors.js';
import usernameAutocomplete from '../core/autocomplete/username.js';
import getBlitzAccount from '../core/blitz/getBlitzAccount.js';
import getWargamingResponse from '../core/blitz/getWargamingResponse.js';
import cmdName from '../core/interaction/cmdName.js';
import addUsernameOption from '../core/options/addUsernameOption.js';
import { WARGAMING_APPLICATION_ID } from '../core/process/args.js';
import { CommandRegistry } from '../events/interactionCreate.js';
import addUsernameChoices from '../core/discord/addUsernameChoices.js';
import autocompleteUsername from '../core/discord/autocompleteUsername.js';
import embedNegative from '../core/discord/embedNegative.js';
import embedPositive from '../core/discord/embedPositive.js';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand.js';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js';
import { CommandRegistry } from '../events/interactionCreate/index.js';
import { AccountInfo } from '../types/accountInfo.js';
import { PlayerClanData } from '../types/playerClanData.js';
@@ -18,12 +18,12 @@ export default {
inPublic: true,
command: new SlashCommandBuilder()
.setName(cmdName('verify'))
.setName('verify')
.setDescription("Set's the user's username to their in-game name")
.addStringOption((option) => addUsernameOption(option).setRequired(true)),
.addStringOption((option) => addUsernameChoices(option).setRequired(true)),
async execute(interaction) {
const blitzAccount = await getBlitzAccount(interaction);
async handler(interaction) {
const blitzAccount = await resolvePlayerFromCommand(interaction);
const { id, server } = blitzAccount;
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
@@ -33,86 +33,51 @@ export default {
);
const clan = clanData[id]?.clan;
const clanTag = clan ? ` [${clan!.tag}]` : '';
const member =
interaction.member &&
interaction.guild?.members.cache.get(interaction.member?.user.id);
if (!interaction.guild?.members.me?.permissions.has('ManageNicknames')) {
interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(NEGATIVE_COLOR)
.setTitle(
`${markdownEscape(interaction.user.username)} failed to verify`,
)
.setDescription(
"I don't have the permission to change your nickname.",
),
],
});
return;
return embedNegative(
`${markdownEscape(interaction.user.username)} failed to verify`,
"I don't have the permission to change your nickname.",
);
}
if (!interaction.member || !member) {
return embedNegative(
`${interaction.user.username} is not in this server`,
"I couldn't find this person in this server. Did they leave?",
);
}
if (interaction.member) {
const member = interaction.guild?.members.cache.get(
interaction.member?.user.id,
);
try {
await member.setNickname(`${accountInfo[id].nickname}${clanTag}`);
if (member) {
try {
await member.setNickname(`${accountInfo[id].nickname}${clanTag}`);
if (interaction.guildId === discord.sklld_guild_id) {
if (
!interaction.guild?.members.me?.permissions.has('ManageRoles')
) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(NEGATIVE_COLOR)
.setTitle(
`${markdownEscape(
interaction.user.username,
)} failed to verify`,
)
.setDescription(
"I don't have the permission to change your manage roles.",
),
],
});
return;
}
await member.roles.remove(discord.sklld_verify_role);
await member.roles.add(discord.sklld_peasant_role);
}
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(POSITIVE_COLOR)
.setTitle(`${interaction.user.username} is verified`)
.setDescription(
`The user is now verified as ${markdownEscape(
accountInfo[id].nickname,
)}${markdownEscape(clanTag)}`,
),
],
});
} catch (error) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(NEGATIVE_COLOR)
.setTitle(`${interaction.user.username} failed to verify`)
.setDescription(
"I can't change your nickname because you have higher permissions than me. Try setting your nickname to your Blitz username manually.",
),
],
});
if (interaction.guildId === discord.sklld_guild_id) {
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
return embedNegative(
`${markdownEscape(interaction.user.username)} failed to verify`,
"I don't have the permission to change your manage roles.",
);
}
await member.roles.remove(discord.sklld_verify_role);
await member.roles.add(discord.sklld_peasant_role);
}
return embedPositive(
`${interaction.user.username} is verified`,
`The user is now verified as ${markdownEscape(
accountInfo[id].nickname,
)}${markdownEscape(clanTag)}`,
);
} catch (error) {
return embedNegative(
`${interaction.user.username} failed to verify`,
"I can't change your nickname because you have higher permissions than me. Try setting your nickname to your Blitz username manually.",
);
}
},
autocomplete: usernameAutocomplete,
autocomplete: autocompleteUsername,
} satisfies CommandRegistry;
@@ -0,0 +1,39 @@
import { Percentile } from '../../../../../constants/percentiles.js';
import hslToHex from '../../../../../core/node/hslToHex.js';
import { PERCENTILE_COLORS } from '../../../../PercentileIndicator.js';
export enum GlowSide {
Top,
Bottom,
}
export interface GlowProps {
percentile: Percentile;
side: GlowSide;
}
export function Glow({ percentile, side }: GlowProps) {
const color = PERCENTILE_COLORS[percentile];
const color1 = hslToHex(color);
const color2 = `${color1}00`;
const background = `linear-gradient(${
side === GlowSide.Top ? 0 : 180
}deg, ${color1} 0%, ${color2} 100%)`;
return (
<div
style={{
display: 'flex',
alignItems: side === GlowSide.Top ? 'flex-end' : 'flex-start',
justifyContent: 'center',
}}
>
<div style={{ height: 54, width: 16, background }} />
<div style={{ height: 72, width: 16, background }} />
<div style={{ height: 80, width: 16, background }} />
<div style={{ height: 72, width: 16, background }} />
<div style={{ height: 54, width: 16, background }} />
</div>
);
}
@@ -0,0 +1,77 @@
import { Percentile } from '../../../../constants/percentiles.js';
import getWN8Percentile from '../../../../core/blitz/getWN8Percentile.js';
import { theme } from '../../../../stitches.config.js';
import { PERCENTILE_COLORS } from '../../../PercentileIndicator.js';
import { Glow, GlowSide } from './components/Glow.js';
export interface WN8DisplayProps {
WN8?: number;
}
export const PERCENTILE_NAMES = {
[Percentile.VeryBad]: 'Very Bad',
[Percentile.Bad]: 'Bad',
[Percentile.BelowAverage]: 'Below Average',
[Percentile.Average]: 'Average',
[Percentile.AboveAverage]: 'Above Average',
[Percentile.Good]: 'Good',
[Percentile.VeryGood]: 'Very Good',
[Percentile.Great]: 'Great',
[Percentile.Unicum]: 'Unicum',
[Percentile.SuperUnicum]: 'Super Unicum',
};
export function WN8Display({ WN8 }: WN8DisplayProps) {
const percentile =
WN8 === undefined ? Percentile.VeryBad : getWN8Percentile(WN8);
const color = PERCENTILE_COLORS[percentile];
return (
<div
style={{
width: 128 * 2, // idk why twice the width turns into the correct width
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: -16,
}}
>
<Glow percentile={percentile} side={GlowSide.Top} />
<div
style={{
width: 128,
height: 80,
backgroundColor: color,
borderRadius: 40,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: -4,
}}
>
<span
style={{
fontWeight: 900,
fontSize: 32,
color: theme.colors.textHighContrast,
}}
>
{WN8 === undefined ? '--' : WN8.toFixed(0)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{PERCENTILE_NAMES[percentile]}
</span>
</div>
<Glow percentile={percentile} side={GlowSide.Bottom} />
</div>
);
}
@@ -0,0 +1,47 @@
import { theme } from '../../../stitches.config.js';
export enum WN8SurroundingStatAlign {
Left,
Right,
}
export interface WN8SurroundingStatProps {
label: string;
value: string | number;
align?: WN8SurroundingStatAlign;
padded?: boolean;
}
export function WN8SurroundingStat({
label,
value,
align = WN8SurroundingStatAlign.Left,
padded = false,
}: WN8SurroundingStatProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems:
align === WN8SurroundingStatAlign.Left ? 'flex-start' : 'flex-end',
paddingLeft: padded && align === WN8SurroundingStatAlign.Left ? 32 : 0,
paddingRight:
padded && align === WN8SurroundingStatAlign.Right ? 32 : 0,
}}
>
<span
style={{
color: theme.colors.textHighContrast,
fontSize: 24,
fontWeight: 'bold',
}}
>
{value}
</span>
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
{label}
</span>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { AllStats, SupplementaryStats } from '../../types/accountInfo.js';
import { WN8Display } from './components/WN8Display/index.js';
import {
WN8SurroundingStat,
WN8SurroundingStatAlign,
} from './components/WN8SurroundingStat.js';
export interface AllStatsOverviewProps {
stats: AllStats;
supplementaryStats: SupplementaryStats;
}
export default function AllStatsOverview({
stats,
supplementaryStats,
}: AllStatsOverviewProps) {
return (
<div
style={{
display: 'flex',
gap: -64,
width: '100%',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
alignItems: 'flex-end',
flex: 1,
}}
>
<WN8SurroundingStat
label="Battles"
value={stats.battles.toLocaleString()}
align={WN8SurroundingStatAlign.Right}
/>
<WN8SurroundingStat
label="Winrate"
value={`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`}
align={WN8SurroundingStatAlign.Right}
padded
/>
<WN8SurroundingStat
label="Damage"
value={Math.round(
stats.damage_dealt / stats.battles,
).toLocaleString()}
align={WN8SurroundingStatAlign.Right}
padded
/>
<WN8SurroundingStat
label="Dmg. ratio"
value={(stats.damage_dealt / stats.damage_received).toFixed(2)}
align={WN8SurroundingStatAlign.Right}
/>
</div>
<WN8Display WN8={supplementaryStats.WN8} />
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
flex: 1,
}}
>
<WN8SurroundingStat
label="Tier"
value={supplementaryStats.tier.toFixed(2)}
/>
<WN8SurroundingStat
label="Survival"
value={`${(100 * (stats.survived_battles / stats.battles)).toFixed(
2,
)}%`}
padded
/>
<WN8SurroundingStat
label="Accuracy"
value={`${(100 * (stats.hits / stats.shots)).toFixed(2)}%`}
padded
/>
<WN8SurroundingStat
label="Kill ratio"
value={(
stats.frags /
(stats.battles - stats.survived_battles)
).toFixed(2)}
/>
</div>
</div>
);
}
+13 -11
View File
@@ -1,4 +1,5 @@
import getWN8Percentile from '../core/blitz/getWN8Percentile.js';
import isNumber from '../core/node/isNumber.js';
import { AllStats, SupplementaryStats } from '../types/accountInfo.js';
import GenericStats from './GenericStats/index.js';
@@ -11,6 +12,10 @@ export default function GenericAllStats({
stats,
supplementaryStats,
}: GenericAllStatsProps) {
const damageRatio = stats.damage_dealt / stats.damage_received;
const killsToDeathRatio =
stats.frags / (stats.battles - stats.survived_battles);
return (
<GenericStats
stats={[
@@ -18,13 +23,13 @@ export default function GenericAllStats({
[
'WN8',
supplementaryStats
? supplementaryStats.WN8 === undefined
? '--'
: supplementaryStats.WN8.toFixed(0)
? isNumber(supplementaryStats.WN8)
? supplementaryStats.WN8!.toFixed(0)
: '--'
: undefined,
isNumber(supplementaryStats?.WN8)
? getWN8Percentile(supplementaryStats!.WN8!)
: undefined,
supplementaryStats?.WN8 === undefined
? undefined
: getWN8Percentile(supplementaryStats.WN8),
],
[
'Survival',
@@ -41,13 +46,10 @@ export default function GenericAllStats({
['Average hits', (stats.hits / stats.battles).toFixed(2)],
['Average kills', (stats.frags / stats.battles).toFixed(2)],
['Average spots', (stats.spotted / stats.battles).toFixed(2)],
[
'Damage ratio',
(stats.damage_dealt / stats.damage_received).toFixed(2),
],
['Damage ratio', isFinite(damageRatio) ? damageRatio.toFixed(2) : '--'],
[
'Kills to death ratio',
(stats.frags / (stats.battles - stats.survived_battles)).toFixed(2),
isFinite(killsToDeathRatio) ? killsToDeathRatio.toFixed(2) : '--',
],
]}
/>
@@ -1,8 +1,6 @@
import { theme } from '../../../stitches.config.js';
import { Stat } from '../index.js';
import '../../../core/blitz/getWN8Percentile.js';
import PercentileIndicator from '../../PercentileIndicator.js';
import { Stat } from '../index.js';
export interface GenericStatsRowProps {
stat: Stat;
+77
View File
@@ -0,0 +1,77 @@
import { blue, green, red, slateDark } from '@radix-ui/colors';
import { GRAPH_HEIGHT, GRAPH_WIDTH } from './Root.js';
export enum LineColor {
Red,
Green,
Blue,
White,
}
const LINE_COLORS = {
[LineColor.Red]: red.red9,
[LineColor.Green]: green.green9,
[LineColor.Blue]: blue.blue9,
[LineColor.White]: slateDark.slate11,
};
export enum LineStyle {
Solid,
Dashes,
DashesAndDots,
}
const LINE_STYLES = {
[LineStyle.Solid]: undefined,
[LineStyle.Dashes]: '8, 8',
[LineStyle.DashesAndDots]: '1, 4, 8, 4',
};
export type PlotItem = [number, number];
export type Plot = PlotItem[];
export interface LineProps {
plot: Plot;
minY?: number;
maxY?: number;
color?: LineColor;
style?: LineStyle;
lineWidth?: number;
}
export function Line({
plot,
minY: providedMinY,
maxY: providedMaxY,
color = LineColor.Red,
style = LineStyle.Solid,
lineWidth = 2,
}: LineProps) {
const xs = plot.map(([x]) => x);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const ys = plot.map(([, y]) => y);
const minY = providedMinY ?? Math.min(...ys);
const maxY = providedMaxY ?? Math.max(...ys);
const rangeX = maxX - minX;
const rangeY = maxY - minY;
const points = plot
.map(
([x, y]) =>
`${(x - minX) * (GRAPH_WIDTH / rangeX)},${
GRAPH_HEIGHT - (y - minY) * (GRAPH_HEIGHT / rangeY)
}`,
)
.join(' ');
return (
<polyline
vector-effect="non-scaling-stroke"
fill="none"
stroke={LINE_COLORS[color]}
stroke-width={lineWidth}
stroke-dasharray={LINE_STYLES[style]}
points={points}
/>
);
}
@@ -0,0 +1,53 @@
import { times } from 'lodash';
import { theme } from '../../../stitches.config.js';
import { MarginInputProps } from './Root.js';
export enum MarginOrientation {
Vertical,
Horizontal,
}
export interface MarginProps extends MarginInputProps {
verticalSeparations: number;
orientation?: MarginOrientation;
}
export function Margin({
verticalSeparations,
max,
min,
suffix,
precision = 0,
orientation = MarginOrientation.Vertical,
}: MarginProps) {
const isVertical = orientation === MarginOrientation.Vertical;
return (
<div
style={{
paddingLeft: isVertical ? 0 : 64,
width: isVertical ? 48 : '100%',
display: 'flex',
flexDirection: isVertical ? 'column' : 'row-reverse',
justifyContent: 'space-between',
}}
>
{times(verticalSeparations, (index) => (
<span
key={index}
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
{`${(
(max - min) *
((verticalSeparations - (index + 1)) /
(verticalSeparations - 1)) +
min
).toFixed(precision)}${suffix ?? ''}`}
</span>
))}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { times } from 'lodash';
import { ReactNode } from 'react';
import { theme } from '../../../stitches.config.js';
import { Margin, MarginOrientation } from './Margin.js';
export const GRAPH_WIDTH = 544;
export const GRAPH_HEIGHT = 320;
export interface MarginInputProps {
min: number;
max: number;
suffix?: string;
precision?: number;
}
export interface RootProps {
children: ReactNode;
verticalMargin?: MarginInputProps;
horizontalMargin?: MarginInputProps;
separations?: number;
xMinLabel: string;
xMaxLabel: string;
}
export function Root({
children,
verticalMargin,
horizontalMargin,
separations = 5,
xMinLabel,
xMaxLabel,
}: RootProps) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div
style={{
paddingLeft: 64,
display: 'flex',
justifyContent: 'space-between',
}}
>
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
{xMinLabel}
</span>
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
{xMaxLabel}
</span>
</div>
<div style={{ display: 'flex', gap: 16 }}>
{verticalMargin && (
<Margin {...verticalMargin} verticalSeparations={separations} />
)}
<svg
viewBox={`0 0 ${GRAPH_WIDTH} ${GRAPH_HEIGHT}`}
preserveAspectRatio="none"
style={{
flex: 1,
backgroundColor: theme.colors.appBackground2,
borderRadius: 4,
border: theme.borderStyles.nonInteractive,
}}
>
{times(separations - 2, (index) => {
const y = GRAPH_HEIGHT * ((index + 1) / (separations - 1));
return (
<polyline
key={index}
vector-effect="non-scaling-stroke"
fill="none"
stroke={theme.colors.borderNonInteractive}
stroke-width="2"
points={`0,${y}, ${GRAPH_WIDTH},${y}`}
/>
);
})}
{children}
</svg>
</div>
{horizontalMargin && (
<Margin
{...horizontalMargin}
verticalSeparations={separations}
orientation={MarginOrientation.Horizontal}
/>
)}
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export * from './components/Line.js';
export * from './components/Margin.js';
export * from './components/Root.js';
+38
View File
@@ -0,0 +1,38 @@
import { ReactNode } from 'react';
import { theme } from '../stitches.config.js';
export enum PoweredByType {
BlitzStars,
Wargaming,
}
export interface PoweredByProps {
type: PoweredByType;
footer?: ReactNode;
}
const POWERED_BY_IMAGES: Record<PoweredByType, string> = {
[PoweredByType.BlitzStars]:
'https://www.blitzstars.com/assets/images/4b4cb591.TankyMcPewpew.png',
[PoweredByType.Wargaming]: 'https://i.imgur.com/jADGWm0.png',
};
const POWERED_BY_NAMES: Record<PoweredByType, string> = {
[PoweredByType.BlitzStars]: 'BlitzStars',
[PoweredByType.Wargaming]: 'Wargaming',
};
export default function PoweredBy({ type, footer }: PoweredByProps) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ display: 'flex' }}>{footer}</div>
<div style={{ display: 'flex', gap: 4 }}>
<img src={POWERED_BY_IMAGES[type]} style={{ height: 16, width: 16 }} />
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
Powered by {POWERED_BY_NAMES[type]}
</span>
</div>
</div>
);
}
-15
View File
@@ -1,15 +0,0 @@
import { theme } from '../stitches.config.js';
export default function PoweredByBlitzStars() {
return (
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<img
src="https://www.blitzstars.com/assets/images/4b4cb591.TankyMcPewpew.png"
style={{ height: 16, width: 16 }}
/>
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
Powered by BlitzStars
</span>
</div>
);
}
-15
View File
@@ -1,15 +0,0 @@
import { theme } from '../stitches.config.js';
export default function PoweredByWargaming() {
return (
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<img
src="https://i.imgur.com/jADGWm0.png"
style={{ height: 16, width: 16 }}
/>
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
Powered by Wargaming
</span>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { TIER_ROMAN_NUMERALS, Tier } from '../core/blitz/tankopedia.js';
import { themeLight } from '../stitches.config.js';
export type TierWeightsRecord = Partial<Record<Tier, number>>;
export interface TierWeightsProps {
weights: TierWeightsRecord;
}
export default function TierWeights({ weights }: TierWeightsProps) {
return (
<div style={{ display: 'flex', gap: 8 }}>
{Object.entries(weights).map(([tier, weight]) => (
<div
key={tier}
style={{
backgroundColor: themeLight.colors.appBackground1,
flex: weight,
color: themeLight.colors.textHighContrast,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
overflow: 'hidden',
borderRadius: 8,
minWidth: 16,
}}
>
{TIER_ROMAN_NUMERALS[parseInt(tier) as Tier]}
</div>
))}
</div>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import isDev from '../core/process/isDev.js';
import isDev from '../core/node/isDev.js';
import { theme } from '../stitches.config.js';
export interface TitleBarProps {
+2 -1
View File
@@ -1,3 +1,4 @@
export const ACCENT_COLOR = '#ecedee'; // theme.colors.textHighContrast
export const ACCENT_COLOR = '#ecedee';
export const POSITIVE_COLOR = '#82ff29';
export const NEGATIVE_COLOR = '#ff4747';
export const WARNING_COLOR = '#ffab00';
+1
View File
@@ -0,0 +1 @@
export const CYCLIC_API = 'https://blitzkrieg.cyclic.app';
+3 -15
View File
@@ -1,19 +1,7 @@
export const BLITZ_SERVERS = {
export type BlitzServer = 'com' | 'eu' | 'asia';
export const BLITZ_SERVERS: Record<BlitzServer, string> = {
com: 'North America',
eu: 'Europe',
asia: 'Asia',
};
export const BLITZ_SERVERS_SHORT = {
com: 'NA',
eu: 'EU',
asia: 'ASIA',
};
export const BLITZ_SERVERS_SHORT_INVERSE = {
NA: 'com',
EU: 'eu',
ASIA: 'asia',
};
export type BlitzServer = keyof typeof BLITZ_SERVERS;
@@ -3,7 +3,7 @@ import { AllStats } from '../../types/accountInfo.js';
/**
* Formula from https://web.archive.org/web/20190105064817/http://wiki.wnefficiency.net/pages/WN8
*/
export default function getWN8(expected: AllStats, average: AllStats) {
export default function calculateWN8(expected: AllStats, average: AllStats) {
const avgDmg = average.damage_dealt / average.battles;
const avgSpot = average.spotted / average.battles;
const avgFrag = average.frags / average.battles;
+6 -10
View File
@@ -1,23 +1,19 @@
import { CacheType, ChatInputCommandInteraction } from 'discord.js';
import { BlitzServer } from '../../constants/servers.js';
import errorWithCause from '../process/errorWithCause.js';
import { serverAndIdPattern } from './getBlitzAccount.js';
import listClansPanServer from './listClansPanServer.js';
import { serverAndIdPattern } from '../discord/resolvePlayerFromCommand.js';
import throwError from '../node/throwError.js';
import listClans from './listClans.js';
export default async function getBlitzClan(
interaction: ChatInputCommandInteraction<CacheType>,
clan: string,
) {
export default async function getBlitzClan(clan: string) {
if (serverAndIdPattern.test(clan)) {
const [server, accountId] = clan.split('/');
return { server: server as BlitzServer, id: Number(accountId) };
} else {
const accounts = await listClansPanServer(clan);
const accounts = await listClans(clan);
if (accounts[0]) {
return { server: accounts[0].server, id: accounts[0].clan_id };
} else {
throw errorWithCause(
throw throwError(
'Could not find clan',
`I couldn't find clan \`${clan}\`. Try selecting a username from the search result.`,
);
+4 -9
View File
@@ -1,21 +1,16 @@
import { CacheType, ChatInputCommandInteraction } from 'discord.js';
import { BLITZ_SERVERS, BlitzServer } from '../../constants/servers.js';
import { TanksStats } from '../../types/tanksStats.js';
import { WARGAMING_APPLICATION_ID } from '../process/args.js';
import errorWithCause from '../process/errorWithCause.js';
import { WARGAMING_APPLICATION_ID } from '../node/arguments.js';
import throwError from '../node/throwError.js';
import getWargamingResponse from './getWargamingResponse.js';
export default async function getTankStats(
interaction: ChatInputCommandInteraction<CacheType>,
server: BlitzServer,
id: number,
) {
export default async function getTankStats(server: BlitzServer, id: number) {
const tankStats = await getWargamingResponse<TanksStats>(
`https://api.wotblitz.${server}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
if (tankStats[id] === null) {
throw errorWithCause(
throw throwError(
'No tank stats available',
`Wargaming says there is no tank stats for this account. This account may not have any battles/tanks or exist in the ${BLITZ_SERVERS[server]} server.`,
);
+12 -3
View File
@@ -1,5 +1,14 @@
import { WargamingResponse } from '../../types/wargamingResponse.js';
import errorWithCause from '../process/errorWithCause.js';
import throwError from '../node/throwError.js';
export type WargamingResponse<Data extends object> =
| {
status: 'error';
error: { field: string; message: string; code: 402; value: null };
}
| {
status: 'ok';
data: Data;
};
export default async function getWargamingResponse<Data extends object>(
url: string,
@@ -12,7 +21,7 @@ export default async function getWargamingResponse<Data extends object>(
if (parsed.status === 'ok') {
return parsed.data;
} else {
throw errorWithCause(
throw throwError(
`Wargaming response error status:"${parsed.status}"`,
`Message: "${parsed.error.message}"\nURL: "${url}"`,
);
@@ -1,13 +1,13 @@
import { BlitzServer } from '../../constants/servers.js';
import { Clan, ClanList } from '../../types/clanList.js';
import { WARGAMING_APPLICATION_ID } from '../process/args.js';
import { WARGAMING_APPLICATION_ID } from '../node/arguments.js';
import getWargamingResponse from './getWargamingResponse.js';
export type ClanListWithServer = (Clan & {
server: 'com' | 'eu' | 'asia';
})[];
export default async function listClansPanServer(search: string, limit = 9) {
export default async function listClans(search: string, limit = 9) {
const trimmed = search.trim();
if (trimmed.length < 3 && trimmed.length > 100) return [];
const normalizedLimit = Math.round(limit / 3);
@@ -1,6 +1,6 @@
import { BlitzServer } from '../../constants/servers.js';
import { Account, AccountList } from '../../types/accountList.js';
import { WARGAMING_APPLICATION_ID } from '../process/args.js';
import { WARGAMING_APPLICATION_ID } from '../node/arguments.js';
import getWargamingResponse from './getWargamingResponse.js';
export type AccountListWithServer = (Account & {
@@ -10,7 +10,7 @@ export type AccountListWithServer = (Account & {
export const usernamePattern = /^[a-zA-Z0-9_]{3,24}$/;
export const usernamePatternWithoutPosition = /[a-zA-Z0-9_]{3,24}/;
export default async function listAccountsPanServer(search: string, limit = 9) {
export default async function listPlayers(search: string, limit = 9) {
const trimmedSearch = search.trim();
const normalizedLimit = Math.round(limit / 3);
const encodedSearch = encodeURIComponent(trimmedSearch);
+5 -9
View File
@@ -1,19 +1,15 @@
import { CacheType, ChatInputCommandInteraction } from 'discord.js';
import { go } from 'fuzzysort';
import errorWithCause from '../process/errorWithCause.js';
import throwError from '../node/throwError.js';
import { TANKS, tankopedia } from './tankopedia.js';
export default async function resolveTankId(
interaction: ChatInputCommandInteraction<CacheType>,
tank: string,
) {
const number = Number(tank);
export default function resolveTankId(tank: string) {
const number = parseInt(tank);
if (Number.isNaN(number)) {
const searchResult = go(tank, TANKS, { keys: ['name'], limit: 1 });
if (searchResult.length === 0) {
throw errorWithCause(
throw throwError(
'Tank not found',
`Could not find tank by the name "${tank}".`,
);
@@ -24,7 +20,7 @@ export default async function resolveTankId(
if (tankopedia[number]) {
return number;
} else {
throw errorWithCause(
throw throwError(
'Tank not found',
`Could not find tank by the ID "${number}".`,
);
+1 -1
View File
@@ -1,5 +1,5 @@
import { AllStats } from '../../types/accountInfo.js';
import { emptyAllStats } from '../blitzstars/getTankStatsOverTime.js';
import { emptyAllStats } from '../blitzstars/getTankStatsDiffed.js';
export default function sumStats(allStatsCollection: AllStats[]) {
return allStatsCollection.reduce((accumulated, current) => {
+14 -26
View File
@@ -1,4 +1,5 @@
import { WargamingResponse } from '../../types/wargamingResponse.js';
import { readFileSync } from 'fs';
import { join } from 'path';
export type TankType = 'AT-SPG' | 'lightTank' | 'mediumTank' | 'heavyTank';
@@ -18,30 +19,15 @@ export interface Tankopedia {
[id: number]: TankopediaEntry;
}
export const tankopedia: Tankopedia = {};
export const TANK_IDS: number[] = [];
export const TANKS: TankopediaEntry[] = [];
export const TANK_NAMES: string[] = [];
console.log('Caching tankopedia...');
fetch('https://www.blitzstars.com/bs-tankopedia.json')
.then(
(response) =>
response.json() as Promise<
WargamingResponse<Tankopedia> & { status: 'ok' }
>,
)
.then((wargamingResponse) => {
Object.assign(tankopedia, wargamingResponse.data);
Object.entries(tankopedia).forEach(([idString, entry]) => {
const id = Number(idString);
TANK_IDS.push(id);
TANKS.push(entry);
TANK_NAMES.push(entry.name);
}),
console.log('Cached tankopedia');
});
export const tankopedia = (
JSON.parse(
readFileSync(join(__dirname, 'tankopedia.json')) as unknown as string,
) as { data: Tankopedia }
).data;
const entries = Object.entries(tankopedia);
export const TANK_IDS = entries.map(([, { tank_id }]) => tank_id as number);
export const TANKS = entries.map(([, entry]) => entry as TankopediaEntry);
export const TANK_NAMES = entries.map(([, { name }]) => name);
export const TANK_ICONS: Record<TankType, string> = {
'AT-SPG': 'https://i.imgur.com/BIHSEH0.png',
@@ -50,7 +36,9 @@ export const TANK_ICONS: Record<TankType, string> = {
heavyTank: 'https://i.imgur.com/ECeqlZa.png',
};
export const TIER_ROMAN_NUMERALS: Record<number, string> = {
export type Tier = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
export const TIER_ROMAN_NUMERALS: Record<Tier, string> = {
1: 'I',
2: 'II',
3: 'III',
-26
View File
@@ -1,26 +0,0 @@
import {
CacheType,
ChatInputCommandInteraction,
EmbedBuilder,
} from 'discord.js';
import { NEGATIVE_COLOR } from '../../constants/colors.js';
import { handleError } from '../../events/error.js';
import { client } from '../../index.js';
export default async function wargamingError(
interaction: ChatInputCommandInteraction<CacheType>,
error: Error,
command: string,
) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(NEGATIVE_COLOR)
.setTitle(`${client.user?.username} ran into an issue`)
.setDescription('Wargaming returned an error. Please try again later.'),
],
});
console.log(`Wargaming returned an error`);
handleError(error, command);
}
-28
View File
@@ -1,28 +0,0 @@
import {
CacheType,
ChatInputCommandInteraction,
EmbedBuilder,
} from 'discord.js';
import { NEGATIVE_COLOR } from '../../constants/colors.js';
import { handleError } from '../../events/error.js';
import { client } from '../../index.js';
export default async function blitzStarsError(
interaction: ChatInputCommandInteraction<CacheType>,
error: Error,
command: string,
) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(NEGATIVE_COLOR)
.setTitle(`${client.user?.username} ran into an issue`)
.setDescription(
'BlitzStars returned an error. Please try again later.',
),
],
});
console.log(`BlitzStars returned an error`);
handleError(error, command);
}
-5
View File
@@ -1,5 +0,0 @@
import { BlitzServer } from '../../constants/servers.js';
export default function blitzStarsLinks(server: BlitzServer, nickname: string) {
return `[View full stats](https://www.blitzstars.com/player/${server}/${nickname}) • [Support BlitzStars](https://www.blitzstars.com/supporters)`;
}
+1 -1
View File
@@ -1,3 +1,3 @@
export default function getPeriodNow() {
return new Date().getTime() / 1000;
return Date.now() / 1000;
}
+5 -16
View File
@@ -1,23 +1,12 @@
import { StatPeriod } from '../options/addStatPeriodChoices.js';
import { Period } from '../discord/addPeriodSubCommands.js';
import getTimeDaysAgo from './getTimeDaysAgo.js';
export default function getPeriodicStart(period: StatPeriod) {
export default function getPeriodStart(period: Exclude<Period, 'custom'>) {
if (period === 'career') {
return -Infinity;
} else if (period === 'today') {
const now = new Date();
now.setHours(5, 0, 0, 0);
// If it's after 5:00 AM today, subtract one day
if (now.getTime() > Date.now()) now.setDate(now.getDate() - 1);
return now.getTime() / 1000;
return getTimeDaysAgo(0);
} else {
const daysAgo = Number(period) - 1;
const periodStart = new Date();
periodStart.setDate(periodStart.getDate() - daysAgo);
periodStart.setHours(5, 0, 0, 0);
return periodStart.getTime() / 1000;
return getTimeDaysAgo(parseInt(period));
}
}
+92
View File
@@ -0,0 +1,92 @@
import { BlitzServer } from '../../constants/servers.js';
import { AccountInfo, AllStats } from '../../types/accountInfo.js';
import {
GetHistoriesOptions,
Histories,
History,
getHistoriesDefaultOptions,
} from '../../types/histories.js';
import getWargamingResponse from '../blitz/getWargamingResponse.js';
import { WARGAMING_APPLICATION_ID } from '../node/arguments.js';
import { emptyAllStats } from './getTankStatsDiffed.js';
export interface PlayerHistoryRaw {
clan: { clan_id: number; name: string; tag: string };
statistics: {
all: AllStats;
wn8: number;
wn7: number;
average_tier: number;
};
last_battle_time: number;
nickname: string;
account_id: number;
achievements: {
account_id: number;
[key: string]: number;
};
region: BlitzServer;
}
export type PlayerHistoriesRaw = PlayerHistoryRaw[];
export default async function getPlayerHistories(
server: BlitzServer,
id: number,
options?: Partial<GetHistoriesOptions>,
) {
const mergedOptions = { ...getHistoriesDefaultOptions, ...options };
const response = await fetch(
`https://www.blitzstars.com/api/playerstats/${id}`,
);
const playerHistories = ((await response.json()) as PlayerHistoriesRaw).map(
(history) =>
({
all: history.statistics.all,
last_battle_time: history.last_battle_time,
} satisfies History),
);
let lastNonCompliantIndex = -1;
let hasCompliedOnce = false;
const filtered = playerHistories.filter(({ last_battle_time }, index) => {
const complies =
last_battle_time >= mergedOptions.start &&
last_battle_time <= mergedOptions.end;
if (complies && !hasCompliedOnce) hasCompliedOnce = true;
if (!hasCompliedOnce) lastNonCompliantIndex = index;
return complies;
});
const latestNodes: Histories = [];
if (mergedOptions.includeLatestHistories) {
const {
last_battle_time,
statistics: { all },
} = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
)
)[id];
latestNodes.push({ all, last_battle_time });
}
const previousNodes: Histories = [];
if (mergedOptions.includePreviousHistories) {
if (lastNonCompliantIndex !== -1) {
previousNodes.push(
playerHistories[lastNonCompliantIndex] ??
({
all: emptyAllStats,
last_battle_time: -Infinity,
} satisfies History),
);
}
}
return [...latestNodes, ...filtered, ...previousNodes] satisfies Histories;
}
+113
View File
@@ -0,0 +1,113 @@
import { BlitzServer } from '../../constants/servers.js';
import {
GetHistoriesOptions,
Histories,
History,
getHistoriesDefaultOptions,
} from '../../types/histories.js';
import getTankStats from '../blitz/getTankStats.js';
import { emptyAllStats } from './getTankStatsDiffed.js';
export interface TankHistory extends History {
tank_id: number;
}
export type TankHistories = TankHistory[];
export interface TankHistoryRaw extends TankHistory {
mark_of_mastery: number;
battle_life_time: number;
account_id: number;
}
export type TankHistoriesRaw = TankHistoryRaw[];
export interface GetTankHistoriesOptions extends GetHistoriesOptions {
tankId?: number;
}
export const getTankHistoriesDefaultOptions: GetTankHistoriesOptions = {
...getHistoriesDefaultOptions,
tankId: undefined,
};
export default async function getTankHistories(
server: BlitzServer,
id: number,
options?: Partial<GetTankHistoriesOptions>,
) {
const mergedOptions = { ...getTankHistoriesDefaultOptions, ...options };
const tankHistoriesResponse = await fetch(
`https://www.blitzstars.com/api/tankhistories/for/${id}/`,
);
const tankHistories = (
(await tankHistoriesResponse.json()) as TankHistoriesRaw
).map(
(history) =>
({
all: history.all,
last_battle_time: history.last_battle_time,
tank_id: history.tank_id,
} satisfies TankHistory),
);
const compliantTanks: number[] = [];
const lastBattles: Record<number, number> = {};
const lastNonCompliantIndex: Record<number, number> = {};
const hasCompliedOnce: Record<number, boolean> = {};
const filtered = tankHistories.filter(
({ last_battle_time, tank_id, all: { battles } }, index) => {
const complies =
last_battle_time >= mergedOptions.start &&
last_battle_time <= mergedOptions.end &&
(mergedOptions.tankId === undefined ||
tank_id === mergedOptions.tankId);
if (complies && !hasCompliedOnce[tank_id]) {
hasCompliedOnce[tank_id] = true;
compliantTanks.push(tank_id);
}
if (!hasCompliedOnce[tank_id]) lastNonCompliantIndex[tank_id] = index;
lastBattles[tank_id] = battles;
return complies;
},
);
const latestNodes: TankHistories = [];
if (mergedOptions.includeLatestHistories) {
(await getTankStats(server, id)).forEach((tankHistory) => {
if (
tankHistory.all.battles > (lastBattles[tankHistory.tank_id] ?? 0) &&
(mergedOptions.tankId === undefined ||
tankHistory.tank_id === mergedOptions.tankId)
) {
if (!hasCompliedOnce[tankHistory.tank_id]) {
// was played today for the first time... I think???
hasCompliedOnce[tankHistory.tank_id] = true;
compliantTanks.push(tankHistory.tank_id);
}
latestNodes.push(tankHistory);
}
});
}
const previousNodes: TankHistories = [];
if (mergedOptions.includePreviousHistories) {
compliantTanks.forEach((tankId) => {
if (tankId in lastNonCompliantIndex) {
previousNodes.push(
tankHistories[lastNonCompliantIndex[tankId]] ??
({
all: emptyAllStats,
last_battle_time: -Infinity,
tank_id: tankId,
} satisfies TankHistory),
);
}
});
}
return [...previousNodes, ...filtered, ...latestNodes] satisfies Histories;
}
@@ -1,16 +1,9 @@
import { CacheType, ChatInputCommandInteraction } from 'discord.js';
import { BlitzServer } from '../../constants/servers.js';
import { AllStats } from '../../types/accountInfo.js';
import getTankStats from '../blitz/getTankStats.js';
export interface TankHistoryNode {
all: AllStats;
last_battle_time: number;
mark_of_mastery: number;
battle_life_time: number;
account_id: number;
tank_id: number;
}
import getTankHistories, {
TankHistories,
TankHistoryRaw,
} from './getTankHistories.js';
export const emptyAllStats: AllStats = {
battles: 0,
@@ -32,7 +25,7 @@ export const emptyAllStats: AllStats = {
wins: 0,
};
export const emptyTankHistoryNode: TankHistoryNode = {
export const emptyTankHistoryNode: TankHistoryRaw = {
account_id: 0,
battle_life_time: 0,
last_battle_time: 0,
@@ -41,24 +34,18 @@ export const emptyTankHistoryNode: TankHistoryNode = {
all: emptyAllStats,
};
export type TankHistory = TankHistoryNode[];
export default async function getTankStatsOverTime(
interaction: ChatInputCommandInteraction<CacheType>,
export default async function getTankStatsDiffed(
server: BlitzServer,
id: number,
start: number,
end: number,
) {
const latestTankStatsRaw = await getTankStats(interaction, server, id);
const tankHistoriesResponse = await fetch(
`https://www.blitzstars.com/api/tankhistories/for/${id}/`,
);
const tankHistories = (await tankHistoriesResponse.json()) as TankHistory;
const history = [...tankHistories, ...latestTankStatsRaw];
const history = await getTankHistories(server, id, {
includeLatestHistories: true,
});
// sort them even though most won't be used
const tankSortedHistory: Record<number, TankHistory> = {};
const tankSortedHistory: Record<number, TankHistories> = {};
const tankIds: number[] = [];
history.forEach((node) => {
if (!tankSortedHistory[node.tank_id]) {
+12
View File
@@ -0,0 +1,12 @@
export default function getTimeDaysAgo(daysAgo: number) {
const now = new Date();
now.setHours(5, 0, 0, 0);
// if it's after 5:00 AM today, subtract one day
if (now.getTime() > Date.now()) now.setDate(now.getDate() - 1);
// subtract the period number of days from now
now.setDate(now.getDate() - daysAgo);
return now.getTime() / 1000;
}
-11
View File
@@ -1,11 +0,0 @@
export default function last5AM() {
const fiveAM = new Date();
fiveAM.setHours(5, 0, 0, 0); // Set the time to 5:00 AM today
if (fiveAM > new Date()) {
// If it's now before 5:00 AM, go back one day
fiveAM.setDate(fiveAM.getDate() - 1);
}
return fiveAM;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import sumStats from '../blitz/sumStats.js';
import { emptyAllStats } from './getTankStatsOverTime.js';
import { emptyAllStats } from './getTankStatsDiffed.js';
import { tankAverages } from './tankAverages.js';
console.log('Calculating player average...');
@@ -1,8 +0,0 @@
import { EmbedBuilder } from 'discord.js';
export default function poweredByBlitzStars(embed: EmbedBuilder) {
return embed.setTimestamp().setFooter({
text: 'Powered by BlitzStars',
iconURL: 'https://www.blitzstars.com/assets/images/TankyMcPewpew.png',
});
}
+6 -10
View File
@@ -1,3 +1,5 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { AllStats } from '../../types/accountInfo.js';
export interface SpecialStats {
@@ -78,14 +80,8 @@ export interface IndividualTankAverage {
last_update: number;
}
export const tankAverages: Record<number, IndividualTankAverage> = {};
export type TankAverages = Record<number, IndividualTankAverage>;
console.log('Caching tank averages...');
fetch('https://www.blitzstars.com/api/tankaverages.json')
.then((response) => response.json() as Promise<IndividualTankAverage[]>)
.then((individualTankAverages) => {
individualTankAverages.forEach((individualTankAverage) => {
tankAverages[individualTankAverage.tank_id] = individualTankAverage;
});
console.log('Tank averages cached');
});
export const tankAverages = JSON.parse(
readFileSync(join(__dirname, 'tankaverages.json')) as unknown as string,
) as TankAverages;
File renamed without changes.
+52
View File
@@ -0,0 +1,52 @@
import {
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from 'discord.js';
import { PERIOD_NAMES } from '../discord/resolvePeriodFromCommand.js';
export type Period = 'today' | '30' | '60' | '90' | 'career' | 'custom';
export default function addPeriodSubCommands(
option: SlashCommandSubcommandGroupBuilder,
extra: (
option: SlashCommandSubcommandBuilder,
) => SlashCommandSubcommandBuilder = (option) => option,
) {
return option
.addSubcommand((option) =>
extra(option.setName('today').setDescription(PERIOD_NAMES.today)),
)
.addSubcommand((option) =>
extra(option.setName('30').setDescription(PERIOD_NAMES[30])),
)
.addSubcommand((option) =>
extra(option.setName('60').setDescription(PERIOD_NAMES[60])),
)
.addSubcommand((option) =>
extra(option.setName('90').setDescription(PERIOD_NAMES[90])),
)
.addSubcommand((option) =>
extra(option.setName('career').setDescription(PERIOD_NAMES.career)),
)
.addSubcommand((option) =>
extra(
option
.setName('custom')
.setDescription(PERIOD_NAMES.custom)
.addIntegerOption((option) =>
option
.setName('start')
.setDescription('Start of period in days ago from today')
.setMinValue(0)
.setRequired(true),
)
.addIntegerOption((option) =>
option
.setName('end')
.setDescription('End of period in days ago from today')
.setMinValue(0)
.setRequired(true),
),
),
);
}
File renamed without changes.
@@ -0,0 +1,26 @@
import { SlashCommandBuilder } from 'discord.js';
import addPeriodSubCommands from './addPeriodSubCommands.js';
import addTankChoices from './addTankChoices.js';
import addUsernameChoices from './addUsernameChoices.js';
export default function addStatTypeSubCommandGroups(
option: SlashCommandBuilder,
) {
return option
.addSubcommandGroup((option) =>
addPeriodSubCommands(option, (option) =>
option.addStringOption(addUsernameChoices),
)
.setName('player')
.setDescription("Player's statistics"),
)
.addSubcommandGroup((option) =>
addPeriodSubCommands(option, (option) =>
option
.addStringOption(addTankChoices)
.addStringOption(addUsernameChoices),
)
.setName('tank')
.setDescription("Tank's statistics"),
);
}
File renamed without changes.
+10
View File
@@ -0,0 +1,10 @@
import { SlashCommandStringOption } from 'discord.js';
export default function addUsernameChoices(option: SlashCommandStringOption) {
return option
.setName('username')
.setDescription(
'The username you use in Blitz (inferred from Discord nickname by default)',
)
.setAutocomplete(true);
}
@@ -4,14 +4,14 @@ import {
CacheType,
} from 'discord.js';
import { BLITZ_SERVERS } from '../../constants/servers.js';
import listClansPanServer from '../blitz/listClansPanServer.js';
import listClans from '../blitz/listClans.js';
export default async function clanAutocomplete(
export default async function autocompleteClan(
interaction: AutocompleteInteraction<CacheType>,
) {
const focusedOption = interaction.options.getFocused(true);
if (focusedOption.name !== 'clan') return;
const players = await listClansPanServer(focusedOption.value);
const players = await listClans(focusedOption.value);
await interaction.respond(
players
@@ -7,7 +7,7 @@ import { go } from 'fuzzysort';
import resolveTankName from '../blitz/resolveTankName.js';
import { TANKS } from '../blitz/tankopedia.js';
export default async function tanksAutocomplete(
export default async function autocompleteTanks(
interaction: AutocompleteInteraction<CacheType>,
) {
const focusedOption = interaction.options.getFocused(true);
@@ -4,14 +4,14 @@ import {
CacheType,
} from 'discord.js';
import { BLITZ_SERVERS } from '../../constants/servers.js';
import listAccountsPanServer from '../blitz/listAccountsPanServer.js';
import listPlayers from '../blitz/listPlayers.js';
export default async function usernameAutocomplete(
export default async function autocompleteUsername(
interaction: AutocompleteInteraction<CacheType>,
) {
const focusedOption = interaction.options.getFocused(true);
if (focusedOption.name !== 'username') return;
const players = await listAccountsPanServer(focusedOption.value);
const players = await listPlayers(focusedOption.value);
try {
await interaction.respond(
@@ -1,7 +1,7 @@
import { EmbedBuilder } from 'discord.js';
import { ACCENT_COLOR } from '../../constants/colors.js';
export default function infoEmbed(title: string, description: string) {
export default function embedInfo(title: string, description: string) {
return new EmbedBuilder()
.setColor(ACCENT_COLOR)
.setTitle(title)
@@ -1,7 +1,7 @@
import { EmbedBuilder } from 'discord.js';
import { NEGATIVE_COLOR } from '../../constants/colors.js';
export default function negativeEmbed(title: string, description: string) {
export default function embedNegative(title: string, description: string) {
return new EmbedBuilder()
.setColor(NEGATIVE_COLOR)
.setTitle(title)
@@ -1,7 +1,7 @@
import { EmbedBuilder } from 'discord.js';
import { POSITIVE_COLOR } from '../../constants/colors.js';
export default function positiveEmbed(title: string, description: string) {
export default function embedPositive(title: string, description: string) {
return new EmbedBuilder()
.setColor(POSITIVE_COLOR)
.setTitle(title)
+9
View File
@@ -0,0 +1,9 @@
import { EmbedBuilder } from 'discord.js';
import { WARNING_COLOR } from '../../constants/colors.js';
export default function embedWarning(title: string, description: string) {
return new EmbedBuilder()
.setColor(WARNING_COLOR)
.setTitle(title)
.setDescription(description);
}
+33
View File
@@ -0,0 +1,33 @@
import {
CacheType,
ChatInputCommandInteraction,
CommandInteractionOption,
} from 'discord.js';
export interface Parameters {
[key: string]: CommandInteractionOption['value'] | undefined | null;
}
export default function interactionToURL(
interaction: ChatInputCommandInteraction<CacheType>,
parameters?: Parameters,
) {
const pathname = [
interaction.commandName,
interaction.options.getSubcommandGroup(false),
interaction.options.getSubcommand(false),
]
.filter(Boolean)
.join('/');
const params = [
...interaction.options.data.map(
(option) => [option.name, option.value] as const,
),
...Object.entries(parameters ?? {}),
]
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${key}=${value}`)
.join('&');
return `${pathname}${params.length > 0 ? `?${params}` : ''}`;
}
+20
View File
@@ -0,0 +1,20 @@
import {
ButtonBuilder,
ButtonStyle,
ComponentEmojiResolvable,
} from 'discord.js';
export default function linkButton(
url: string,
label: string,
emoji?: ComponentEmojiResolvable,
) {
const button = new ButtonBuilder()
.setStyle(ButtonStyle.Link)
.setURL(url)
.setLabel(label);
if (emoji) button.setEmoji(emoji);
return button;
}
@@ -1,7 +1,7 @@
export type TableInputEntry = [string, string | number] | [];
export type TableInput = TableInputEntry[];
export default function cleanTable(input: TableInput) {
export default function markdownTable(input: TableInput) {
return input
.filter((array) => array[1] !== undefined)
.map((array) => (array.length === 0 ? '' : `**${array[0]}**: ${array[1]}`))
@@ -0,0 +1,40 @@
import {
ActionRowBuilder,
ButtonBuilder,
EmbedBuilder,
InteractionEditReplyOptions,
} from 'discord.js';
import { InteractionReturnable } from '../../events/interactionCreate/index.js';
import jsxToPng from '../node/jsxToPng.js';
export default async function normalizeInteractionReturnable(
returnable: InteractionReturnable,
) {
const reply: InteractionEditReplyOptions = {};
const awaitedReturnable = await returnable;
const normalizedReturnable = Array.isArray(awaitedReturnable)
? awaitedReturnable
: [awaitedReturnable];
await Promise.all(
normalizedReturnable.map(async (item) => {
if (item instanceof EmbedBuilder) {
if (!reply.embeds) reply.embeds = [];
reply.embeds.push(item);
} else if (item instanceof ButtonBuilder) {
if (!reply.components)
reply.components = [new ActionRowBuilder<ButtonBuilder>()];
(reply.components[0] as ActionRowBuilder<ButtonBuilder>).addComponents(
item,
);
} else {
const image = await jsxToPng(item);
if (!reply.files) reply.files = [];
reply.files.push(image);
}
}),
);
return reply;
}
+20
View File
@@ -0,0 +1,20 @@
import {
ButtonBuilder,
ButtonStyle,
ComponentEmojiResolvable,
} from 'discord.js';
export default function primaryButton(
id: string,
label: string,
emoji?: ComponentEmojiResolvable,
) {
const button = new ButtonBuilder()
.setCustomId(id)
.setStyle(ButtonStyle.Primary)
.setLabel(label);
if (emoji) button.setEmoji(emoji);
return button;
}
+20
View File
@@ -0,0 +1,20 @@
export interface PSAData {
title: string;
description: string;
}
export interface PSA {
data: PSAData | null;
}
export const psa: PSA = { data: null };
console.log('Caching PSA...');
fetch(
'https://gist.githubusercontent.com/tresabhi/ed4b136f08856e615212e573aabe1968/raw',
)
.then((response) => response.json())
.then((data) => {
psa.data = data as PSAData;
console.log('PSA cached');
});
@@ -0,0 +1,9 @@
import { ButtonInteraction, CacheType } from 'discord.js';
import { CYCLIC_API } from '../../constants/cyclic.js';
import resolvePeriodFromURL from '../express/resolvePeriodFromURL.js';
export default function resolvePeriodFromButton(
interaction: ButtonInteraction<CacheType>,
) {
return resolvePeriodFromURL(`${CYCLIC_API}/${interaction.customId}`);
}
@@ -0,0 +1,64 @@
import { CacheType, ChatInputCommandInteraction } from 'discord.js';
import getPeriodNow from '../blitzstars/getPeriodNow.js';
import getPeriodStart from '../blitzstars/getPeriodStart.js';
import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo.js';
import { Period } from '../discord/addPeriodSubCommands.js';
export const PERIOD_NAMES: Record<Period, string> = {
today: "Today's statistics",
30: "30 days' statistics",
60: "60 days' statistics",
90: "90 days' statistics",
career: 'Career statistics',
custom: 'Custom period',
};
export const EVOLUTION_PERIOD_NAMES: Record<Period, string> = {
today: "Today's evolution",
30: "30 days' evolution",
60: "60 days' evolution",
90: "90 days' evolution",
career: 'Career evolution',
custom: 'Custom period',
};
export interface ResolvedPeriod {
statsName: string;
evolutionName: string;
start: number;
end: number;
}
export default function resolvePeriodFromCommand(
interaction: ChatInputCommandInteraction<CacheType>,
) {
let statsName: string;
let evolutionName: string;
let start: number;
let end: number;
const period = interaction.options.getSubcommand() as Period;
if (period === 'custom') {
const startRaw = interaction.options.getInteger('start')!;
const endRaw = interaction.options.getInteger('end')!;
const startMin = Math.min(startRaw, endRaw);
const endMax = Math.max(startRaw, endRaw);
statsName = `${startMin} to ${endMax} days' statistics`;
evolutionName = `${startMin} to ${endMax} days' evolution`;
start = getTimeDaysAgo(startMin);
end = getTimeDaysAgo(endMax);
} else {
statsName = PERIOD_NAMES[period];
evolutionName = EVOLUTION_PERIOD_NAMES[period];
start = getPeriodStart(period);
end = getPeriodNow();
}
return {
statsName,
evolutionName,
start,
end,
} satisfies ResolvedPeriod;
}
@@ -0,0 +1,9 @@
import { ButtonInteraction, CacheType } from 'discord.js';
import { CYCLIC_API } from '../../constants/cyclic.js';
import resolvePlayerFromURL from '../express/resolvePlayerFromURL.js';
export default async function resolvePlayerFromButton(
interaction: ButtonInteraction<CacheType>,
) {
return resolvePlayerFromURL(`${CYCLIC_API}/${interaction.customId}`);
}
@@ -1,13 +1,18 @@
import { CacheType, ChatInputCommandInteraction } from 'discord.js';
import { BlitzServer } from '../../constants/servers.js';
import errorWithCause from '../process/errorWithCause.js';
import listAccountsPanServer, {
import listPlayers, {
usernamePatternWithoutPosition,
} from './listAccountsPanServer.js';
} from '../blitz/listPlayers.js';
import throwError from '../node/throwError.js';
export const serverAndIdPattern = /(com|eu|asia)\/[0-9]+/;
export default async function getBlitzAccount(
export interface ResolvedPlayer {
server: BlitzServer;
id: number;
}
export default async function resolvePlayerFromCommand(
interaction: ChatInputCommandInteraction<CacheType>,
) {
const commandUsername = interaction.options.getString('username');
@@ -17,7 +22,7 @@ export default async function getBlitzAccount(
const username = commandUsername ?? displayName;
if (username === undefined) {
throw errorWithCause(
throw throwError(
'Username could not be inferred',
"You didn't provide me a username in the command nor was I able to infer your Blitz username from your Discord nickname. Try providing a username manually in the command or using the `/verify` command to set your Discord nickname to your Blitz username.",
);
@@ -25,14 +30,20 @@ export default async function getBlitzAccount(
if (serverAndIdPattern.test(username)) {
const [server, accountId] = username.split('/');
return { server: server as BlitzServer, id: Number(accountId) };
return {
server: server as BlitzServer,
id: Number(accountId),
} satisfies ResolvedPlayer;
} else {
const accounts = await listAccountsPanServer(username);
const accounts = await listPlayers(username);
if (accounts[0]) {
return { server: accounts[0].server, id: accounts[0].account_id };
return {
server: accounts[0].server,
id: accounts[0].account_id,
} satisfies ResolvedPlayer;
} else {
throw errorWithCause(
throw throwError(
'Could not find user',
`I couldn't find user \`${username}\`. Try providing a valid username manually or using the \`/verify\` command to set your Discord nickname to your Blitz username.`,
);
@@ -1,5 +1,6 @@
import { ButtonBuilder, ButtonStyle } from 'discord.js';
// TODO: ADD THIS AGAIN IMMEDIATELY
export const supportBlitzStars = new ButtonBuilder()
.setLabel('Support BlitzStars')
.setURL('https://www.blitzstars.com/supporters')
@@ -0,0 +1,11 @@
import { RouteReturnable } from '../../server.js';
import jsxToSvg from '../node/jsxToSvg.js';
export default async function normalizeRouteReturnable(
returnable: RouteReturnable,
) {
const awaitedReturnable = await returnable;
const svg = await jsxToSvg(awaitedReturnable);
return svg;
}
@@ -0,0 +1,7 @@
import { Request } from 'express';
import { CYCLIC_API } from '../../constants/cyclic.js';
import resolvePeriodFromURL from './resolvePeriodFromURL.js';
export default function resolvePeriodFromRequest(request: Request) {
return resolvePeriodFromURL(`${CYCLIC_API}${request.path}`);
}
+43
View File
@@ -0,0 +1,43 @@
import getPeriodNow from '../blitzstars/getPeriodNow.js';
import getPeriodStart from '../blitzstars/getPeriodStart.js';
import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo.js';
import { Period } from '../discord/addPeriodSubCommands.js';
import {
EVOLUTION_PERIOD_NAMES,
PERIOD_NAMES,
ResolvedPeriod,
} from '../discord/resolvePeriodFromCommand.js';
export default function resolvePeriodFromURL(urlString: string) {
let statsName: string;
let evolutionName: string;
let start: number;
let end: number;
const url = new URL(urlString);
const path = url.pathname.split('/').filter(Boolean);
const period = path[path.length - 1] as Period;
if (period === 'custom') {
const startRaw = parseInt(url!.searchParams.get('start')!);
const endRaw = parseInt(url!.searchParams.get('end')!);
const startMin = Math.min(startRaw, endRaw);
const endMax = Math.max(startRaw, endRaw);
statsName = `${startMin} to ${endMax} days' statistics`;
evolutionName = `${startMin} to ${endMax} days' evolution`;
start = getTimeDaysAgo(startMin);
end = getTimeDaysAgo(endMax);
} else {
statsName = PERIOD_NAMES[period];
evolutionName = EVOLUTION_PERIOD_NAMES[period];
start = getPeriodStart(period);
end = getPeriodNow();
}
return {
statsName,
evolutionName,
start,
end,
} satisfies ResolvedPeriod;
}
@@ -0,0 +1,7 @@
import { Request } from 'express';
import { CYCLIC_API } from '../../constants/cyclic.js';
import resolvePlayerFromURL from './resolvePlayerFromURL.js';
export default function resolvePlayerFromRequest(request: Request) {
return resolvePlayerFromURL(`${CYCLIC_API}${request.originalUrl}`);
}
+11
View File
@@ -0,0 +1,11 @@
import { BlitzServer } from '../../constants/servers.js';
import { ResolvedPlayer } from '../discord/resolvePlayerFromCommand.js';
export default function resolvePlayerFromURL(urlString: string) {
const url = new URL(urlString);
return {
id: parseInt(url.searchParams.get('id')!),
server: url.searchParams.get('server') as BlitzServer,
} satisfies ResolvedPlayer;
}
+23
View File
@@ -0,0 +1,23 @@
export default function withAutoRefresh(svg: string, refreshAfter: number) {
return `
<html>
<head>
<script>
setTimeout(() => {
window.location.reload();
}, ${refreshAfter});
</script>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
${svg}
</body>
</html>
`;
}
-5
View File
@@ -1,5 +0,0 @@
import isDev from '../process/isDev.js';
export default function cmdName(name: string) {
return isDev() ? `${name}dev` : name;
}
@@ -1,12 +0,0 @@
import { ButtonBuilder, ButtonStyle } from 'discord.js';
import { BlitzServer } from '../../constants/servers.js';
export default function fullBlitzStarsStats(
server: BlitzServer,
username: string,
) {
return new ButtonBuilder()
.setLabel('View on BlitzStars')
.setURL(`https://www.blitzstars.com/player/${server}/${username}`)
.setStyle(ButtonStyle.Link);
}
-6
View File
@@ -1,6 +0,0 @@
import negativeEmbed from './negativeEmbed.js';
export const noMember = negativeEmbed(
'User of command is not a member',
'Discord claims that the user of this command does not have an account... strange!',
);
@@ -1,6 +0,0 @@
import negativeEmbed from './negativeEmbed.js';
export const notTrackedByBlitzStars = negativeEmbed(
'No data to display',
'Player is not tracked by BlitzStars.',
);
File renamed without changes.
File renamed without changes.
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more