Compare commits

..
78 Commits
Author SHA1 Message Date
TrèsAbhi b8be916c18 Merge pull request #43 from tresabhi/1.14.2 2023-10-17 22:08:51 -05:00
TrèsAbhi 24d0313fd3 feat: changelogs 2023-10-17 22:08:39 -05:00
TrèsAbhi c3d2a81327 fix: generic stats name overflow 2023-10-17 22:06:50 -05:00
TrèsAbhi dfeb117663 fix: getClanInfo now uses clan_id 2023-10-17 22:04:27 -05:00
TrèsAbhi 495a830506 Merge pull request #42 from tresabhi/1.14.1 2023-10-17 21:14:13 -05:00
TrèsAbhi a730f67ca5 feat: changelogs 2023-10-17 21:14:30 -05:00
TrèsAbhi 9a3867770d feat: reduced render workers to 2 2023-10-17 21:01:26 -05:00
TrèsAbhi f8a66bb5e7 feat: simpler /link message 2023-10-17 20:59:33 -05:00
TrèsAbhi 4b00e1342e feat: user error discrimination 2023-10-17 20:58:36 -05:00
TrèsAbhi f9e0584f00 fix: memory leak in render threads 2023-10-17 20:45:45 -05:00
TrèsAbhi 900c290e75 Update cd-host.yml 2023-10-16 20:50:40 -05:00
TrèsAbhi 69e1577d18 fix: prisma copy over 2023-10-16 20:44:47 -05:00
TrèsAbhi a6d9044c3a Merge pull request #41 from tresabhi/1.14.0 2023-10-16 20:34:08 -05:00
TrèsAbhi 9e23d60ef1 fix: isolated session functions 2023-10-16 20:33:12 -05:00
TrèsAbhi f12449119c feat: changelog 2023-10-16 20:31:00 -05:00
TrèsAbhi 533bfc4458 fix: clan images 2023-10-16 20:16:23 -05:00
TrèsAbhi 1317c2879c feat: switch to 6 hour hostings 2023-10-16 20:11:19 -05:00
TrèsAbhi f9e51f894a feat: removed sharding as it's not needed for now 2023-10-16 20:10:35 -05:00
TrèsAbhi 6fdd4ba833 feat: render threading 2023-10-16 20:08:08 -05:00
TrèsAbhi 9495ae2336 feat: simpler logging 2023-10-15 15:52:35 -05:00
TrèsAbhi 43f634e198 feat: added sharding 2023-10-15 15:42:36 -05:00
TrèsAbhi ad51be7afc feat: added /breakdown 2023-10-15 15:24:07 -05:00
TrèsAbhi 15b8bfdf8f fix: use extra clan feilds 2023-10-14 19:16:07 -05:00
TrèsAbhi cb8ae47be6 feat: removed tank icons in owned tanks 2023-10-14 19:06:26 -05:00
TrèsAbhi db846c0680 fix: provide clan_id 2023-10-14 18:50:08 -05:00
TrèsAbhi 630a37025e feat: added reset in options 2023-10-14 12:04:11 -05:00
TrèsAbhi d3bbc7f2b0 feat: merge renderers 2023-10-14 11:40:16 -05:00
TrèsAbhi 4e0321ca5f fix: removed unused pallets 2023-10-14 11:38:07 -05:00
TrèsAbhi ec00bdf9de feat: core chunk 8 2023-10-14 11:36:48 -05:00
TrèsAbhi 01dd7ad9c6 feat: core chunk 7 2023-10-14 11:36:10 -05:00
TrèsAbhi d726005c38 feat: core chunk 6 2023-10-14 11:32:02 -05:00
TrèsAbhi 4f3e868367 feat: added mightnight leaderboard cache 2023-10-14 11:28:41 -05:00
TrèsAbhi ed8bab7df8 feat: core chunk 5 2023-10-14 11:25:42 -05:00
TrèsAbhi 9bdb14e13a feat: core chunk 4 2023-10-14 11:15:42 -05:00
TrèsAbhi b827f7f730 feat: swap core and _core 2023-10-14 11:06:03 -05:00
TrèsAbhi 07e3660388 feat: core chunk 3 2023-10-14 10:58:48 -05:00
TrèsAbhi 970ab9004e feat: cached archived ratings 2023-10-14 10:55:00 -05:00
TrèsAbhi 862445259e feat: core chunk 2 2023-10-14 10:52:05 -05:00
TrèsAbhi e96538bae0 feat: core chunk 1 2023-10-12 13:00:33 -05:00
TrèsAbhi 7447ccc487 fix: evolution refresh button 2023-10-12 12:07:45 -05:00
TrèsAbhi bcd3a00f91 feat: updated /evolution 2023-10-12 11:56:11 -05:00
TrèsAbhi 8c3675546f feat: sorted commands in /about commands 2023-10-12 11:45:43 -05:00
TrèsAbhi ec64dab720 fix: use webp images in layout 2023-10-11 21:26:33 -05:00
TrèsAbhi db976c4ae9 feat: added future features 👀 2023-10-11 20:18:51 -05:00
TrèsAbhi 0db96795b3 fix: a few type erros 2023-10-11 19:04:58 -05:00
TrèsAbhi 59bae96c1b feat: change tier weights to arabic numerals 2023-10-11 19:01:00 -05:00
TrèsAbhi d062a0da1b feat: updated full stats 2023-10-11 18:57:51 -05:00
TrèsAbhi d2668974f7 feat: code split a lot 2023-10-11 18:42:36 -05:00
TrèsAbhi 78e4c82f20 feat: simpler subcommands 2023-10-11 18:26:35 -05:00
TrèsAbhi f965452c5d feat: re-added manual username entry 2023-10-10 21:45:00 -05:00
TrèsAbhi be644bb3d9 feat: refined welcome 2023-10-10 21:37:17 -05:00
TrèsAbhi 97cc9509c0 feat: updated welcome message 2023-10-10 21:33:11 -05:00
TrèsAbhi 6dc7a10c2e feat: completed link 2023-10-10 21:20:01 -05:00
TrèsAbhi 19298b9ad6 feat: link command basic functionaility 2023-10-10 20:33:24 -05:00
TrèsAbhi 97d427a6a8 feat: better guild command handling 2023-10-08 18:53:21 -05:00
TrèsAbhi bff6eb0503 feat: simplified no ongoing ratings season 2023-10-08 17:53:24 -05:00
TrèsAbhi dc9ab2067d Merge pull request #40 from tresabhi/1.13.3 2023-10-08 17:35:06 -05:00
TrèsAbhi e8e1f7bafb feat: 1.13.3 2023-10-08 17:34:02 -05:00
TrèsAbhi e50a456025 Update cd-host.yml 2023-10-05 11:10:19 -05:00
TrèsAbhi d442bbca1a Update cd-host.yml 2023-10-05 11:06:48 -05:00
TrèsAbhi 92e35bd0ac feat: 1.13.2 2023-10-05 11:04:46 -05:00
TrèsAbhi cb9d283f62 Merge pull request #39 from tresabhi/1.13.1 2023-10-01 19:03:54 -05:00
TrèsAbhi 8aba19e05b fix: duplicate name 2023-10-01 19:02:47 -05:00
TrèsAbhi b7b4035492 feat: show career options 2023-10-01 19:00:05 -05:00
TrèsAbhi 3667fd82b7 feat: docs 2023-10-01 18:56:25 -05:00
TrèsAbhi 691d5cde27 feat: updated embed docs 2023-10-01 18:52:11 -05:00
TrèsAbhi 7f431b07e1 feat: added options 2023-10-01 18:43:40 -05:00
TrèsAbhi 8264f19ad4 fix: center aligned text in breakdown 2023-10-01 17:45:53 -05:00
TrèsAbhi e947cfba45 feat: ico 2023-10-01 17:43:48 -05:00
TrèsAbhi e69590c035 feat: added favicon 2023-10-01 17:38:28 -05:00
TrèsAbhi 8b19393d7c feat: updated README to be better lol 2023-10-01 17:36:03 -05:00
TrèsAbhi 9480603572 feat: added title and meta desc 2023-10-01 17:26:08 -05:00
TrèsAbhi 90c6535a4c fix: center aligned blitzkrieg logo 2023-10-01 17:24:02 -05:00
TrèsAbhi 258588921a Update cd-host.yml 2023-10-01 17:16:16 -05:00
TrèsAbhi 525b309037 Update ci-build-bot.yml 2023-10-01 17:15:47 -05:00
TrèsAbhi 2f941b9224 Update ci-build-docs.yml 2023-10-01 17:15:36 -05:00
TrèsAbhi 259c4240b5 Update ci-build-website.yml 2023-10-01 17:15:25 -05:00
TrèsAbhi 9eb7157fce Update cd-host.yml 2023-10-01 17:15:02 -05:00
158 changed files with 2961 additions and 2367 deletions

No files matched your search

+2 -2
View File
@@ -2,7 +2,7 @@ name: CD Host
on:
schedule:
- cron: '0 */5 * * *'
- cron: '0 */6 * * *'
workflow_dispatch:
concurrency:
@@ -29,4 +29,4 @@ jobs:
unzip bot.zip
- name: Run bot
run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} WARGAMING_APPLICATION_ID=${{ WARGAMING_APPLICATION_ID }} node --enable-source-maps --trace-warnings bot/index.cjs
run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} node --enable-source-maps index.cjs
+1 -1
View File
@@ -1,4 +1,4 @@
name: CI Build
name: CI Build Bot
on: [push, pull_request]
+1 -1
View File
@@ -1,4 +1,4 @@
name: CI Build
name: CI Build Docs
on: [push, pull_request]
+1 -1
View File
@@ -1,4 +1,4 @@
name: CI Build
name: CI Build Website
on: [push, pull_request]
+4
View File
@@ -5,6 +5,7 @@
"bepaint",
"blitzstars",
"cleandist",
"cockroackdb",
"customperiod",
"deburr",
"diacriticless",
@@ -14,6 +15,8 @@
"kranvagn",
"Leaderboard",
"leaderboards",
"Oopsy",
"outdir",
"outfile",
"ownedtanks",
"playerachievements",
@@ -42,6 +45,7 @@
"wargaming",
"webp",
"winrate",
"wotblitz",
"zustand"
],
"deno.enable": false,
+31 -8
View File
@@ -1,21 +1,44 @@
# blitzkrieg
# Blitzkrieg
A Discord bot for anything and everything related to World of Tanks Blitz.
[![built with Codeium](https://codeium.com/badges/main)](https://codeium.com)
## Invite the Bot
Tools for everything World of Tanks Blitz
Join the [Discord server](https://discord.gg/nDt7AjGJQH), click on the bot's profile, and click "Add to Server".
## Invite the bot
## Using
Go to [application directory for Blitzkrieg](https://discord.com/application-directory/1097673957865443370) and click "Add to Server." Voila! You're ready to go.
### Installing dependencies
![Blitzkrieg application directory](https://i.imgur.com/JlEA0J1.png)
## Visit the website
### https://blitz-krieg.vercel.app/
![Blitzkrieg website](https://i.imgur.com/QOXUP63.png)
## Visit the documentation website
### https://tresabhi.github.io/blitzkrieg/
![Blitzkrieg documentation website](https://i.imgur.com/mCZCZEI.png)
## Run the bot
### Install dependencies
```bash
yarn
```
### Running
### Create `.env`
```env
GH_TOKEN={YOUR_GITHUB_TOKEN_HERE}
DISCORD_TOKEN={YOUR_DISCORD_TOKE_HERE}
```
### Run the command
```bash
yarn start discord-token=<token> wargaming-application-id=<id>
yarn dev:bot
```
+3 -1
View File
@@ -8,5 +8,7 @@
"sklld_peasant_role": "896878529512370228",
"tres_guild_id": "734786591205359697",
"tres_log_channel": "1109262265850544148"
"tres_log_channel": "1109262265850544148",
"test_guild_id": "1124729294829142116"
}
+17
View File
@@ -0,0 +1,17 @@
# Blitzkrieg 1.13.1
The quickest minor update in town 🤠
## Changes
- Added a title, description, and icon to the website
- Added display options to the session tracker
## Fixes
- Center aligned stats in breakdown to work better on low width devices
- Center aligned the Blitzkrieg logo in the navbar
## Technical Changes
- Added up-to-date information to `README.md` and embed docs
+7
View File
@@ -0,0 +1,7 @@
# Blitzkrieg 1.13.2
One lonely albeit important fix.
## Fixes
- Gradients for `/stats` now use Radix's new hex values
+7
View File
@@ -0,0 +1,7 @@
# Blitzkrieg 1.13.3
Streamer's delight!
## Features
- Added config context menu for the session tracker embed
+45
View File
@@ -0,0 +1,45 @@
# Blitzkrieg 1.14.0
Elegance has never been met like this before
## Features
- Added `/link`
- Unlike `/verify`, `/link` links you Discord and Blitz accounts across all servers
- This command does not change your nickname
- `/link` now simply displays a deprecation message
- Usernames can still be inputted per-command to override your linked account
- `/stats` and `/full-stats` are now way simpler
- `/stats <type> <period>` has been replaced with `/stats <30|60|90|...>` (likewise, `/full-stats`)
- These commands now have filters without having to opt into `multi-tank`
- Added `/breakdown`
- Unlike `/today`, `/breakdown` lets you select any period of time
- `/breakdown` now simply displays a deprecation message
- `/about commands` are now sorted alphabetically
- Added a `/reset` button to the options dropdown and embed context-menu
## Changes
- Simplified no ongoing ratings season edge case
- Updated welcome message for Skilled server to use `/link` and other up-to-date information
- Tier weights are now reported in Arabic numerals instead of Roman
- Removed tank images from `/owned-tanks`
- Wargaming messed up their API and almost no tanks have any images anymore
## Fixes
- Banners now use a more economical `.webp` format just like the home page
- Removed unused color palettes
## Technical changes
- Guild commands are now handled more efficiently
- Removed nickname-based Blitz username inference
- Code split a lot of common functionalities
- Re-arranged `core` folder
- Added midnight and latest ratings leaderboard caches
- Merged renderers into their primary user files
- Added sharding
- Disabled for now but good for future proofing
- Switched to a way cleaner logging schema
- Hosting is now done in 6 hour periods instead of 5
+17
View File
@@ -0,0 +1,17 @@
# Blitzkrieg 1.14.1
Oopsy daisy, a memory leak!
## Changes
- Simplified message for users to use `/link`
## Fixes
- Fixed a memory leak in the render thread manager
## Technical changes
- Added `UserError` extending `Error`
- This lets the bot avoid spamming the debug console for mistakes users make
- Reduced render threads to 2
+8
View File
@@ -0,0 +1,8 @@
# Blitzkrieg 1.14.1
A late night hot fix for my homies
## Fixes
- Clan info is now fetched using `clan_id` instead of `account_id`
- Generic stats row names are now bounded by a max width
+12 -12
View File
@@ -1,23 +1,23 @@
# Embeds
Embeds are generated by a few commands as a URL that can be loaded in a browser/streaming software.
Embeds are URLs generated by commands or tools that represent the "naked" version of the respective command or tool
## Creating Embeds
1. Use a command of your liking (for example, `/stats`).
2. Customize the command however you like and the URL generated for the embed will match your needs.
3. Send the command and when the bot is "done thinking," an "Embed" button will appear below the bot's reply.
1. Use a command or tool and click on "Embed" if it's available
2. Make sure to tweak the settings of the command or tool because embeds replicate everything
## Using Embeds in Streaming Softwares
1. Click on the "Embed" button.
2. Copy the URL from your browser.
3. Paste the URL into a Browser Source.
4. Set the refresh rate/FPS to something very low like `0.0167` (that's 1 frame every 60 seconds).
1. Click on the "Embed" button
- If this was generated by a command, copy the URL from the browser
- If this was generated by a tool, the URL has been copied to your clipboard already
2. Paste this URL into your streaming service as a "Browser Source"
- Tool embeds use local storage so data won't be transferred from you browser to your streaming software
- Make sure to visit the tool's page in your streaming software (using the "Interact" feature) and then open the embed
- Set your browser source refresh rate to `1Hz` because that's how fast embeds refresh
## How Embeds Behave
- They load like any normal website albeit with a significant delay.
- They auto refresh every `60s`.
- All embeds and images generated by Blitzkrieg have a width of `640px` but the height varies and must be found manually through trial and error.
- It's okay to have a Browser Source that has a height larger than needed as Blitzkrieg embeds are transparent.
- They load like any normal website
- They auto refresh every `1s`
+1 -1
View File
@@ -2,6 +2,6 @@
"main": "server.js",
"type": "module",
"scripts": {
"start": "node --enable-source-maps --trace-warnings server.cjs"
"start": "node --enable-source-maps server.cjs"
}
}
+6 -3
View File
@@ -1,7 +1,7 @@
{
"name": "blitzkrieg",
"version": "1.13.0",
"description": "🎉 All-in-one Discord bot for everything World of Tanks Blitz",
"version": "1.14.0",
"description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts",
"type": "module",
"scripts": {
@@ -12,7 +12,7 @@
"dev:bot": "yarn build:bot && yarn start:bot",
"dev:docs": "vitepress dev docs",
"dev:website": "next dev",
"start:bot": "node --enable-source-maps --trace-warnings dist/bot/index.cjs",
"start:bot": "node --enable-source-maps dist/bot/index.cjs",
"start:website": "next start",
"host:docs": "vitepress preview docs",
"publish:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard --publish",
@@ -26,6 +26,7 @@
"license": "MIT",
"dependencies": {
"@octokit/rest": "^20.0.1",
"@prisma/client": "^5.4.1",
"@radix-ui/colors": "^3.0.0-rc.4",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/themes": "^1.1.2",
@@ -37,6 +38,7 @@
"esbuild": "^0.19.3",
"express": "^4.18.2",
"fuzzysort": "^2.0.4",
"immer": "^10.0.2",
"lodash": "^4.17.21",
"markdown-escape": "^2.0.0",
"next": "^13.5.2",
@@ -61,6 +63,7 @@
"compare-versions": "^6.1.0",
"knip": "^2.27.1",
"prettier": "^3.0.3",
"prisma": "^5.4.1",
"typescript": "^5.2.2"
}
}
@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "discord_blitz" (
"discord" INT8 NOT NULL,
"region" STRING NOT NULL,
"blitz" INT4 NOT NULL,
CONSTRAINT "discord_blitz_pkey" PRIMARY KEY ("discord")
);
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "cockroachdb"
+14
View File
@@ -0,0 +1,14 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "cockroachdb"
url = env("DISCORD_BLITZ_DB")
}
model discord_blitz {
discord BigInt @id
region String
blitz Int
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

+36 -19
View File
@@ -1,27 +1,44 @@
import { build } from 'esbuild';
import { cp, readdir } from 'fs/promises';
console.log('Building bot...');
readdir('src/workers').then((workers) => {
const files = [
'bot',
'index',
await build({
entryPoints: ['src/bot.ts'],
outfile: 'dist/bot/index.cjs',
...workers.map((file) => `workers/${file.replace('.ts', '')}`),
];
platform: 'node',
tsconfig: 'tsconfig.esbuild.json',
loader: {
'.node': 'copy',
'.ttf': 'file',
},
format: 'cjs',
jsx: 'transform',
build({
entryPoints: files.map((file) => `src/${file}.ts`),
outdir: 'dist/bot',
bundle: true,
sourcemap: true,
minifyIdentifiers: false, // cause goofy errors
minifySyntax: true,
minifyWhitespace: true,
platform: 'node',
tsconfig: 'tsconfig.esbuild.json',
loader: {
'.node': 'copy',
'.ttf': 'file',
},
format: 'cjs',
jsx: 'transform',
outExtension: {
'.js': '.cjs',
},
logLevel: 'info',
bundle: true,
sourcemap: true,
minifyIdentifiers: false, // cause goofy errors
minifySyntax: true,
minifyWhitespace: true,
logLevel: 'info',
});
});
console.log('Built bot');
// copy schema.prisma
cp('prisma/schema.prisma', 'dist/bot/schema.prisma');
readdir('node_modules/prisma').then((files) =>
files
.filter((file) => file.endsWith('.node'))
.map((file) => cp(`node_modules/prisma/${file}`, `dist/bot/${file}`)),
);
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { tankopedia } from '../../../core/blitz/tankopedia';
import { tankopedia } from '../../../core/blitzstars/tankopedia';
export async function GET() {
return NextResponse.json(await tankopedia);
+192 -136
View File
@@ -1,17 +1,23 @@
'use client';
import { ContextMenu } from '@radix-ui/themes';
import { useEffect, useState } from 'react';
import * as Breakdown from '../../../components/Breakdown';
import { TreeTypeEnum } from '../../../components/Tanks';
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
import calculateWN8 from '../../../core/blitz/calculateWN8';
import { diffNormalizedTankStats } from '../../../core/blitz/diffNormalizedTankStats';
import getWN8Percentile from '../../../core/blitz/getWN8Percentile';
import getWargamingResponse from '../../../core/blitz/getWargamingResponse';
import { TankopediaEntry, tankopedia } from '../../../core/blitz/tankopedia';
import fetchBlitz from '../../../core/blitz/fetchWargaming';
import { tankAverages } from '../../../core/blitzstars/tankAverages';
import { useSession } from '../../../stores/session';
import {
TankopediaEntry,
tankopedia,
} from '../../../core/blitzstars/tankopedia';
import calculateWN8 from '../../../core/statistics/calculateWN8';
import { deltaTankStats } from '../../../core/statistics/deltaTankStats';
import getWN8Percentile from '../../../core/statistics/getWN8Percentile';
import { resetSession, useSession } from '../../../stores/session';
import { IndividualTankStats, TanksStats } from '../../../types/tanksStats';
import { CustomColumnDisplay } from '../../tools/session/components/CustomColumn';
import { Menu } from '../../tools/session/components/Menu';
const REFRESH_RATE = 1 / 5;
@@ -70,7 +76,7 @@ export default function SessionPage() {
async function recalculateDiff() {
if (session.isTracking) {
const { id, region } = session;
const careerRaw = await getWargamingResponse<TanksStats>(
const careerRaw = await fetchBlitz<TanksStats>(
`https://api.wotblitz.${region}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const career = careerRaw[id].reduce<
@@ -108,7 +114,7 @@ export default function SessionPage() {
list: (
Object.values(
diffNormalizedTankStats(session.tankStats, career),
deltaTankStats(session.tankStats, career),
) as IndividualTankStats[]
).map((stats) => {
return {
@@ -139,136 +145,186 @@ export default function SessionPage() {
return () => clearInterval(interval);
}, [session]);
const totalRowStats: Record<
CustomColumnDisplay,
Breakdown.RowStatItem | undefined
> = {
battles: {
title: 'Battles',
current: sessionBattles?.toLocaleString(),
career: tankStatsArray
.reduce((accumulator, stats) => accumulator + stats.all.battles, 0)
.toLocaleString(),
},
winrate: {
title: 'Winrate',
current: sessionBattles ? `${(100 * sessionWinrate).toFixed(2)}%` : '--',
career: `${(100 * careerWinrate).toFixed(2)}%`,
delta: sessionBattles === 0 ? 0 : sessionWinrate - careerWinrate,
},
wn8: (() => {
if (!diff) return undefined;
const battlesWithWN8 = diff.list.reduce(
(accumulator, { currentWN8, stats }) =>
(currentWN8 === undefined ? 0 : stats.all.battles) + accumulator,
0,
);
const currentWN8 =
diff.list.reduce(
(accumulator, { currentWN8, stats }) =>
stats.all.battles * (currentWN8 ?? 0) + accumulator,
0,
) / battlesWithWN8!;
return {
title: 'WN8',
current: battlesWithWN8
? Math.round(currentWN8!).toLocaleString()
: '--',
percentile: battlesWithWN8 ? getWN8Percentile(currentWN8!) : undefined,
career: Math.round(diff.careerWN8).toLocaleString(),
};
})(),
damage: {
title: 'Damage',
delta: sessionBattles === 0 ? 0 : currentDamage - careerDamage,
current: sessionBattles
? Math.round(currentDamage).toLocaleString()
: '--',
career: Math.round(careerDamage).toLocaleString(),
},
none: undefined,
};
return (
<Breakdown.Root>
{session.isTracking && since !== undefined && diff !== undefined && (
<Breakdown.Row
title={`Since ${since.toLocaleString(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
month: 'short',
year: 'numeric',
day: 'numeric',
})}`}
stats={[
{
title: 'Battles',
current: sessionBattles!.toLocaleString(),
career: tankStatsArray
.reduce(
(accumulator, stats) => accumulator + stats.all.battles,
0,
)
.toLocaleString(),
},
{
title: 'Winrate',
current: sessionBattles
? `${(100 * sessionWinrate).toFixed(2)}%`
: '--',
career: `${(100 * careerWinrate).toFixed(2)}%`,
delta: sessionBattles === 0 ? 0 : sessionWinrate - careerWinrate,
},
(() => {
const battlesWithWN8 = diff.list.reduce(
(accumulator, { currentWN8, stats }) =>
(currentWN8 === undefined ? 0 : stats.all.battles) +
accumulator,
0,
);
const currentWN8 =
diff.list.reduce(
(accumulator, { currentWN8, stats }) =>
stats.all.battles * (currentWN8 ?? 0) + accumulator,
0,
) / battlesWithWN8;
<ContextMenu.Root>
<ContextMenu.Trigger>
<div>
<Breakdown.Root>
{session.showTotal &&
session.isTracking &&
since !== undefined &&
diff !== undefined && (
<Breakdown.Row
minimized={!session.showCareer}
title={`Since ${since.toLocaleString(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
month: 'short',
year: 'numeric',
day: 'numeric',
})}`}
stats={session.customColumns.map((customColumn) => {
const rowStat = totalRowStats[customColumn.display];
return {
title: 'WN8',
current: battlesWithWN8
? Math.round(currentWN8).toLocaleString()
: '--',
percentile: battlesWithWN8
? getWN8Percentile(currentWN8)
: undefined,
career: Math.round(diff.careerWN8).toLocaleString(),
};
})(),
{
title: 'Damage',
delta: sessionBattles === 0 ? 0 : currentDamage - careerDamage,
current: sessionBattles
? Math.round(currentDamage).toLocaleString()
: '--',
career: Math.round(careerDamage).toLocaleString(),
},
]}
/>
)}
if (!rowStat) return undefined;
{diff?.list
.sort((a, b) => b.stats.last_battle_time - a.stats.last_battle_time)
.map(({ stats, tankopedia, career, careerWN8, currentWN8 }) => (
<Breakdown.Row
key={stats.tank_id}
title={tankopedia?.name ?? `Unknown tank ${stats.tank_id}`}
type="tank"
tankType={tankopedia?.type}
treeType={(() => {
if (tankopedia?.is_collectible) return TreeTypeEnum.Collector;
if (tankopedia?.is_premium) return TreeTypeEnum.Premium;
})()}
stats={[
{
title: 'Battles',
current: stats.all.battles.toLocaleString(),
career: career.all.battles.toLocaleString(),
},
{
title: 'Winrate',
current: `${(
100 *
(stats.all.wins / stats.all.battles)
).toFixed(2)}%`,
career: `${(
100 *
(career.all.wins / career.all.battles)
).toFixed(2)}%`,
delta:
stats.all.wins / stats.all.battles -
career.all.wins / career.all.battles,
},
{
title: 'WN8',
current:
currentWN8 === undefined
? undefined
: Math.round(currentWN8).toLocaleString(),
career:
careerWN8 === undefined
? undefined
: Math.round(careerWN8).toLocaleString(),
percentile:
currentWN8 === undefined
? undefined
: getWN8Percentile(currentWN8),
},
{
title: 'Damage',
current: Math.round(
stats.all.damage_dealt / stats.all.battles,
).toLocaleString(),
career: Math.round(
career.all.damage_dealt / career.all.battles,
).toLocaleString(),
delta:
stats.all.damage_dealt / stats.all.battles -
career.all.damage_dealt / career.all.battles,
},
]}
/>
))}
</Breakdown.Root>
return {
...rowStat,
delta:
customColumn.showDelta &&
rowStat.percentile === undefined
? rowStat.delta
: undefined,
};
})}
/>
)}
{diff?.list
.sort(
(a, b) => b.stats.last_battle_time - a.stats.last_battle_time,
)
.map(({ stats, tankopedia, career, careerWN8, currentWN8 }) => {
const rowStats: Record<
CustomColumnDisplay,
Breakdown.RowStatItem | undefined
> = {
battles: {
title: 'Battles',
current: stats.all.battles.toLocaleString(),
career: career.all.battles.toLocaleString(),
},
winrate: {
title: 'Winrate',
current: `${(
100 *
(stats.all.wins / stats.all.battles)
).toFixed(2)}%`,
career: `${(
100 *
(career.all.wins / career.all.battles)
).toFixed(2)}%`,
delta:
stats.all.wins / stats.all.battles -
career.all.wins / career.all.battles,
},
wn8: {
title: 'WN8',
current:
currentWN8 === undefined
? undefined
: Math.round(currentWN8).toLocaleString(),
career:
careerWN8 === undefined
? undefined
: Math.round(careerWN8).toLocaleString(),
percentile:
currentWN8 === undefined
? undefined
: getWN8Percentile(currentWN8),
},
damage: {
title: 'Damage',
current: Math.round(
stats.all.damage_dealt / stats.all.battles,
).toLocaleString(),
career: Math.round(
career.all.damage_dealt / career.all.battles,
).toLocaleString(),
delta:
stats.all.damage_dealt / stats.all.battles -
career.all.damage_dealt / career.all.battles,
},
none: undefined,
};
return (
<Breakdown.Row
key={stats.tank_id}
minimized={!session.showCareer}
title={tankopedia?.name ?? `Unknown tank ${stats.tank_id}`}
type="tank"
tankType={tankopedia?.type}
treeType={(() => {
if (tankopedia?.is_collectible)
return TreeTypeEnum.Collector;
if (tankopedia?.is_premium) return TreeTypeEnum.Premium;
})()}
stats={session.customColumns.map((customColumn) => {
const rowStat = rowStats[customColumn.display];
if (!rowStat) return undefined;
return {
...rowStat,
delta:
customColumn.showDelta &&
rowStat.percentile === undefined
? rowStat.delta
: undefined,
};
})}
/>
);
})}
</Breakdown.Root>
</div>
</ContextMenu.Trigger>
<Menu reset={resetSession} Builder={ContextMenu} />
</ContextMenu.Root>
);
}
+8
View File
@@ -25,6 +25,14 @@ export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en" className={robotoFlex.className}>
<head>
<title>Blitzkrieg</title>
<meta
name="description"
content="🎉 Tools for everything World of Tanks Blitz"
/>
</head>
<body
style={{
margin: 0,
+18 -3
View File
@@ -19,17 +19,32 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
<div
style={{
height: 128,
backgroundImage: `url(/assets/banners/${toolId}.png)`,
backgroundSize: 'cover',
backgroundPosition: 'center',
boxShadow: `inset 0 -192px 128px -128px ${theme.colors.appBackground1}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
backgroundImage: `url(/assets/banners/${toolId}.webp)`,
backgroundSize: 'cover',
backgroundPosition: 'center',
width: '100%',
height: '100%',
top: 0,
left: 0,
zIndex: -1,
filter: 'blur(4px)',
transform: 'scale(calc((128 + 8) / 128))',
}}
/>
<span
style={{
color: theme.colors.textHighContrast,
@@ -0,0 +1,63 @@
import { ContextMenu, DropdownMenu } from '@radix-ui/themes';
import mutateSession, { useSession } from '../../../../stores/session';
export type CustomColumnDisplay =
| 'battles'
| 'winrate'
| 'wn8'
| 'damage'
| 'none';
const NO_DELTA: CustomColumnDisplay[] = ['battles', 'wn8', 'none'];
interface CustomColumn {
Builder: typeof DropdownMenu | typeof ContextMenu;
column: number;
}
export function CustomColumn({ column, Builder }: CustomColumn) {
const customColumn = useSession((state) => state.customColumns[column]);
return (
<Builder.Sub>
<Builder.SubTrigger>Column {column + 1}</Builder.SubTrigger>
<Builder.SubContent>
<Builder.Label>Display</Builder.Label>
<Builder.RadioGroup
value={customColumn.display}
onValueChange={(event) =>
mutateSession((draft) => {
draft.customColumns[column].display =
event as CustomColumnDisplay;
})
}
>
<Builder.RadioItem value="battles">Battles</Builder.RadioItem>
<Builder.RadioItem value="winrate">Winrate</Builder.RadioItem>
<Builder.RadioItem value="wn8">WN8</Builder.RadioItem>
<Builder.RadioItem value="damage">Damage</Builder.RadioItem>
<Builder.RadioItem value="none">None</Builder.RadioItem>
</Builder.RadioGroup>
{!NO_DELTA.includes(customColumn.display) && (
<>
<Builder.Separator />
<Builder.CheckboxItem
checked={customColumn.showDelta}
onCheckedChange={(checked) =>
mutateSession((draft) => {
draft.customColumns[column].showDelta = checked;
})
}
>
Show delta
</Builder.CheckboxItem>
</>
)}
</Builder.SubContent>
</Builder.Sub>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { ContextMenu, DropdownMenu } from '@radix-ui/themes';
import { useSession } from '../../../../stores/session';
import { CustomColumn } from './CustomColumn';
interface ContextMenuProps {
Builder: typeof DropdownMenu | typeof ContextMenu;
reset: () => void;
}
export function Menu({ Builder, reset }: ContextMenuProps) {
const session = useSession();
return (
<Builder.Content>
<CustomColumn Builder={Builder} column={0} />
<CustomColumn Builder={Builder} column={1} />
<CustomColumn Builder={Builder} column={2} />
<CustomColumn Builder={Builder} column={3} />
<Builder.Separator />
<Builder.CheckboxItem
checked={session.showTotal}
onCheckedChange={(checked) =>
useSession.setState({ showTotal: checked })
}
>
Show total
</Builder.CheckboxItem>
<Builder.CheckboxItem
checked={session.showCareer}
onCheckedChange={(checked) =>
useSession.setState({ showCareer: checked })
}
>
Show career
</Builder.CheckboxItem>
<Builder.Separator />
<Builder.Item color="red" onClick={reset}>
Reset
</Builder.Item>
</Builder.Content>
);
}
+84 -58
View File
@@ -1,6 +1,11 @@
'use client';
import { CopyIcon, PersonIcon, ReloadIcon } from '@radix-ui/react-icons';
import {
CaretDownIcon,
CopyIcon,
PersonIcon,
ReloadIcon,
} from '@radix-ui/react-icons';
import {
AlertDialog,
Button,
@@ -9,24 +14,19 @@ import {
Flex,
Text,
TextField,
Tooltip,
} from '@radix-ui/themes';
import { debounce } from 'lodash';
import { ChangeEvent, useRef, useState } from 'react';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import { REGION_NAMES, Region } from '../../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
import getWargamingResponse from '../../../core/blitz/getWargamingResponse';
import listPlayers, {
import { REGION_NAMES } from '../../../constants/regions';
import searchPlayersAcrossRegions, {
AccountListWithServer,
} from '../../../core/blitz/listPlayers';
import { useSession } from '../../../stores/session';
import { NormalizedTankStats, TanksStats } from '../../../types/tanksStats';
} from '../../../core/blitz/searchPlayersAcrossRegions';
import { resetSession, setSession, useSession } from '../../../stores/session';
import SessionPage from '../../embeds/session/page';
import { Menu } from './components/Menu';
import * as styles from './page.css';
// 1 once per 5 seconds
export default function Page() {
const input = useRef<HTMLInputElement>(null);
const [searchResults, setSearchResults] = useState<
@@ -36,7 +36,7 @@ export default function Page() {
const handleChange = debounce(
async (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.value) {
setSearchResults(await listPlayers(event.target.value));
setSearchResults(await searchPlayersAcrossRegions(event.target.value));
} else {
setSearchResults(undefined);
setShowSearchResults(false);
@@ -46,37 +46,9 @@ export default function Page() {
);
const session = useSession();
async function setSession(region: Region, id: number, nickname: string) {
input.current!.value = nickname;
const rawTankStats = (
await getWargamingResponse<TanksStats>(
`https://api.wotblitz.${region}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
)
)[id];
const tankStats = rawTankStats.reduce<NormalizedTankStats>(
(accumulator, tank) => ({
...accumulator,
[tank.tank_id]: tank,
}),
{},
);
useSession.setState({
isTracking: true,
id,
region,
nickname,
tankStats,
time: Date.now(),
});
}
function reset() {
const session = useSession.getState();
if (!session.isTracking) return;
setSession(session.region, session.id, session.nickname);
}
useEffect(() => {
if (session.isTracking) input.current!.value = session.nickname;
}, [session]);
return (
<PageWrapper>
@@ -150,10 +122,11 @@ export default function Page() {
<AlertDialog.Root>
<AlertDialog.Trigger>
<Button
variant="soft"
onClick={(event) => {
if (!session.promptBeforeReset) {
event.preventDefault();
reset();
resetSession();
}
}}
className={styles.toolbarButton}
@@ -196,7 +169,7 @@ export default function Page() {
</AlertDialog.Cancel>
<AlertDialog.Action>
<Button variant="solid" color="red" onClick={reset}>
<Button variant="solid" color="red" onClick={resetSession}>
Reset
</Button>
</AlertDialog.Action>
@@ -204,18 +177,71 @@ export default function Page() {
</AlertDialog.Content>
</AlertDialog.Root>
<Tooltip content="Copy to clipboard">
<Button
className={styles.toolbarButton}
onClick={() =>
navigator.clipboard.writeText(
`${location.origin}/embeds/session`,
)
}
>
<CopyIcon width="16" height="16" /> Embed
</Button>
</Tooltip>
<AlertDialog.Root>
<AlertDialog.Trigger>
<Button
variant="soft"
className={styles.toolbarButton}
onClick={(event) => {
if (!session.showEmbedPrompt) {
event.preventDefault();
resetSession();
}
navigator.clipboard.writeText(
`${location.origin}/embeds/session`,
);
}}
>
<CopyIcon width="16" height="16" /> Embed
</Button>
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Title>
Copied link to your clipboard
</AlertDialog.Title>
<AlertDialog.Description>
Keep in mind:
<ul>
<li>Paste this link into your browser to view the embed</li>
<li>Embeds abide your custom options</li>
<li>
Options do not transfer across websites/streaming softwares
</li>
</ul>
</AlertDialog.Description>
<Text size="2">
<Checkbox
mr="1"
onCheckedChange={(checked) =>
useSession.setState({
showEmbedPrompt: !checked,
})
}
/>
Don't show this again
</Text>
<Flex gap="3" mt="4">
<AlertDialog.Cancel>
<Button variant="soft">Got it!</Button>
</AlertDialog.Cancel>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft" className={styles.toolbarButton}>
Options
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<Menu Builder={DropdownMenu} reset={resetSession} />
</DropdownMenu.Root>
</div>
</div>
+5 -3
View File
@@ -1,6 +1,8 @@
import { registerProcesses } from './core/blitzkrieg/processes';
import { secrets } from './core/blitzkrieg/secrets';
import { client } from './core/discord/client';
import { secrets } from './core/node/secrets';
import { registerErrorHandlers } from './events/error';
registerErrorHandlers();
// console.log(`🟡 Launching bot ${client.shard?.ids[0]}`);
registerProcesses();
client.login(secrets.DISCORD_TOKEN);
+1
View File
@@ -51,6 +51,7 @@ export const aboutCommand: CommandRegistry = {
await Promise.all(COMMANDS_RAW)
)
.filter((registry) => registry.inPublic && registry.inProduction)
.sort((a, b) => (a.command.name < b.command.name ? -1 : 1))
.map(
(registry) =>
`- \`/${registry.command.name}\`: ${registry.command.description}`,
+310
View File
@@ -0,0 +1,310 @@
import { SlashCommandBuilder } from 'discord.js';
import * as Breakdown from '../components/Breakdown';
import NoData, { NoDataType } from '../components/NoData';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { AllStats, getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getTankStats from '../core/blitz/getTankStats';
import getTreeType from '../core/blitz/getTreeType';
import resolveTankName from '../core/blitz/resolveTankName';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import { tankAverages } from '../core/blitzstars/tankAverages';
import { tankopedia } from '../core/blitzstars/tankopedia';
import addFilterOptions from '../core/discord/addFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import buttonPrimary from '../core/discord/buttonPrimary';
import commandToURL from '../core/discord/commandToURL';
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
import resolvePeriodFromCommand, {
ResolvedPeriod,
} from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand, {
ResolvedPlayer,
} from '../core/discord/resolvePlayerFromCommand';
import calculateWN8 from '../core/statistics/calculateWN8';
import { StatFilters, filterStats } from '../core/statistics/filterStats';
import getWN8Percentile from '../core/statistics/getWN8Percentile';
import { CommandRegistryRaw } from '../events/interactionCreate';
async function render(
{ region, id }: ResolvedPlayer,
{ start, end, name }: ResolvedPeriod,
filters: StatFilters,
) {
const awaitedTankopedia = await tankopedia;
const awaitedTankAverages = await tankAverages;
const statsInPeriod = await getStatsInPeriod(region, id, start, end);
const { filteredOrder } = await filterStats(statsInPeriod, filters);
const accountInfo = await getAccountInfo(region, id);
const clanData = await getClanAccountInfo(region, id, ['clan']);
const tankStats = await getTankStats(region, id);
const filterDescriptions = await filtersToDescription(filters);
const orderedCurrentStats: AllStats[] = [];
const orderedCareerStats: AllStats[] = [];
const orderedCurrentWN8: (number | undefined)[] = [];
const orderedCareerWN8: (number | undefined)[] = [];
const orderedCareerWN8Full: (number | undefined)[] = [];
filteredOrder.forEach((id) => {
const tankAveragesEntry = awaitedTankAverages[id];
const current = statsInPeriod.diff[id];
const career = tankStats.find((tank) => tank.tank_id === id)!.all;
const currentWN8 = tankAveragesEntry
? calculateWN8(tankAveragesEntry.all, current)
: undefined;
const careerWN8 = tankAveragesEntry
? calculateWN8(tankAveragesEntry.all, career)
: undefined;
orderedCurrentStats.push(current);
orderedCareerStats.push(career);
orderedCurrentWN8.push(currentWN8);
orderedCareerWN8.push(careerWN8);
});
tankStats.forEach((tank) => {
const tankAveragesEntry = awaitedTankAverages[tank.tank_id];
orderedCareerWN8Full.push(
tankAveragesEntry
? calculateWN8(tankAveragesEntry.all, tank.all)
: undefined,
);
});
const currentBattles = orderedCurrentStats.reduce(
(accumulator, current) => accumulator + current.battles,
0,
);
const careerBattles = tankStats.reduce(
(accumulator, current) => accumulator + current.all.battles,
0,
);
const currentWinrate =
orderedCurrentStats.reduce(
(accumulator, current) => accumulator + current.wins,
0,
) / currentBattles;
const careerWinrate =
tankStats.reduce(
(accumulator, current) => accumulator + current.all.wins,
0,
) / careerBattles;
const currentWN8 =
orderedCurrentWN8.reduce<number>(
(accumulator, current, index) =>
accumulator + (current ?? 0) * orderedCurrentStats[index].battles,
0,
) /
orderedCurrentStats.reduce(
(accumulator, current, index) =>
accumulator +
(orderedCurrentWN8[index] === undefined ? 0 : current.battles),
0,
);
const careerWN8 =
orderedCareerWN8Full.reduce<number>(
(accumulator, current) => accumulator + (current ?? 0),
0,
) /
tankStats.reduce(
(accumulator, current, index) =>
accumulator +
(orderedCareerWN8Full[index] === undefined ? 0 : current.all.battles),
0,
);
const currentDamage =
orderedCurrentStats.reduce(
(accumulator, current) => accumulator + current.damage_dealt,
0,
) / currentBattles;
const careerDamage =
tankStats.reduce(
(accumulator, current) => accumulator + current.all.damage_dealt,
0,
) / careerBattles;
return (
<Wrapper>
<TitleBar
name={accountInfo.nickname}
image={
clanData?.clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData?.clan?.emblem_set_id}.png`
: undefined
}
description={`${name} • ${filterDescriptions}`}
/>
{filteredOrder.length === 0 && (
<NoData type={NoDataType.BattlesInPeriod} />
)}
{filteredOrder.length > 0 && (
<Breakdown.Root>
<Breakdown.Row
key={id}
type="summary"
title="Total"
stats={[
{
title: 'Battles',
current: currentBattles,
career: careerBattles,
},
{
title: 'Winrate',
current: `${(100 * currentWinrate).toFixed(2)}%`,
career: `${(100 * careerWinrate).toFixed(2)}%`,
delta: currentWinrate - careerWinrate,
},
{
title: 'WN8',
current:
currentWN8 === undefined
? undefined
: Math.round(currentWN8).toLocaleString(),
career:
careerWN8 === undefined
? undefined
: Math.round(careerWN8).toLocaleString(),
percentile:
currentWN8 === undefined
? undefined
: getWN8Percentile(currentWN8),
},
{
title: 'Damage',
current: Math.round(currentDamage).toLocaleString(),
career: Math.round(careerDamage).toLocaleString(),
delta: currentDamage - careerDamage,
},
]}
/>
{await Promise.all(
filteredOrder.map(async (id, index) => {
const tankopediaEntry = awaitedTankopedia[id];
const current = orderedCurrentStats[index];
const career = orderedCareerStats[index];
const currentWN8 = orderedCurrentWN8[index];
const careerWN8 = orderedCareerWN8[index];
return (
<Breakdown.Row
key={id}
type="tank"
tankType={tankopediaEntry?.type}
treeType={tankopediaEntry ? await getTreeType(id) : undefined}
title={await resolveTankName(id)}
stats={[
{
title: 'Battles',
current: current.battles.toLocaleString(),
career: career.battles.toLocaleString(),
},
{
title: 'Winrate',
current: `${(
100 *
(current.wins / current.battles)
).toFixed(2)}%`,
career: `${(100 * (career.wins / career.battles)).toFixed(
2,
)}%`,
delta:
current.wins / current.battles -
career.wins / career.battles,
},
{
title: 'WN8',
current:
currentWN8 === undefined
? undefined
: Math.round(currentWN8).toLocaleString(),
career:
careerWN8 === undefined
? undefined
: Math.round(careerWN8).toLocaleString(),
percentile:
currentWN8 === undefined
? undefined
: getWN8Percentile(currentWN8),
},
{
title: 'Damage',
current: Math.round(
current.damage_dealt / current.battles,
).toLocaleString(),
career: Math.round(
career.damage_dealt / career.battles,
).toLocaleString(),
delta:
current.damage_dealt / current.battles -
career.damage_dealt / career.battles,
},
]}
/>
);
}),
)}
</Breakdown.Root>
)}
</Wrapper>
);
}
export const breakdownCommand = new Promise<CommandRegistryRaw>(
async (resolve) => {
const command = await addFilterOptions(
new SlashCommandBuilder()
.setName('breakdown')
.setDescription("A period's breakdown by tanks played"),
(option) => option.addStringOption(addUsernameChoices),
);
resolve({
inProduction: true,
inPublic: true,
command,
async handler(interaction) {
const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(player.region, interaction);
const filters = await getFiltersFromCommand(interaction);
const path = commandToURL(interaction, {
...player,
...getCustomPeriodParams(interaction),
...filters,
});
return Promise.all([
render(player, period, filters),
buttonPrimary(path, 'Refresh'),
getBlitzStarsLinkButton(player.region, player.id),
]);
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction);
const filters = getFiltersFromButton(interaction);
return await render(player, period, filters);
},
});
},
);
+87 -91
View File
@@ -1,97 +1,93 @@
import { SlashCommandBuilder, escapeMarkdown } from 'discord.js';
import markdownEscape from 'markdown-escape';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import { PlayerHistoriesRaw } from '../core/blitzstars/getPlayerHistories';
import getTimeDaysAgo from '../core/blitzstars/getTimeDaysAgo';
import addClanChoices from '../core/discord/addClanChoices';
import autocompleteClan from '../core/discord/autocompleteClan';
import embedWarning from '../core/discord/embedWarning';
import resolveClanFromCommand from '../core/discord/resolveClanFromCommand';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountAchievements } from '../types/accountAchievements';
import { AccountInfo } from '../types/accountInfo';
import { ClanInfo } from '../types/clanInfo';
// _____ _____ _ _ _ _ _____ ____ __ __ ______ ____ _____ _ __
// |_ _| / ____| | | | /\ | | | | / ____/ __ \| \/ | ____| | _ \ /\ / ____| |/ /
// | | | (___ | |__| | / \ | | | | | | | | | | \ / | |__ | |_) | / \ | | | ' /
// | | \___ \| __ | / /\ \ | | | | | | | | | | |\/| | __| | _ < / /\ \| | | <
// _| |_ ____) | | | |/ ____ \| |____| |____ | |___| |__| | | | | |____ | |_) / ____ \ |____| . \
// |_____| |_____/|_| |_/_/ \_\______|______| \_____\____/|_| |_|______| |____/_/ \_\_____|_|\_\
export const clanEventCommand: CommandRegistry = {
inProduction: false,
inPublic: true,
// import { SlashCommandBuilder, escapeMarkdown } from 'discord.js';
// import markdownEscape from 'markdown-escape';
// import { getAccountAchievements } from '../core/blitz/getAccountAchievements';
// import { getAccountInfo } from '../core/blitz/getAccountInfo';
// import { getClanInfo } from '../core/blitz/getClanInfo';
// import { PlayerHistoriesRaw } from '../LEGACY_core/blitzstars/getPlayerHistories';
// import getTimeDaysAgo from '../LEGACY_core/blitzstars/getTimeDaysAgo';
// import addClanChoices from '../LEGACY_core/discord/addClanChoices';
// import autocompleteClan from '../LEGACY_core/discord/autocompleteClan';
// import embedWarning from '../LEGACY_core/discord/embedWarning';
// import resolveClanFromCommand from '../LEGACY_core/discord/resolveClanFromCommand';
// import { CommandRegistry } from '../events/interactionCreate';
command: new SlashCommandBuilder()
.setName('clan-event')
.setDescription('Lists all inactive players')
.addStringOption(addClanChoices),
// export const clanEventCommand: CommandRegistry = {
// inProduction: false,
// inPublic: true,
async handler(interaction) {
const { region, id } = await resolveClanFromCommand(interaction);
const clan = (
await getWargamingResponse<ClanInfo>(
`https://api.wotblitz.${region}/wotb/clans/info/?application_id=${WARGAMING_APPLICATION_ID}&clan_id=${id}`,
)
)[id];
const memberIds = clan.members_ids.join(',');
const [players, achievements, previousJointVictories] = await Promise.all([
getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${memberIds}`,
),
getWargamingResponse<AccountAchievements>(
`https://api.wotblitz.${region}/wotb/account/achievements/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${memberIds}`,
),
Promise.all(
clan.members_ids.map((id) =>
fetch(`https://www.blitzstars.com/api/playerstats/${id}`)
.then((response) => response.json() as Promise<PlayerHistoriesRaw>)
.then((data) => ({
id,
jointVictory: data[0]?.achievements.jointVictoryCount as
| number
| undefined,
})),
),
).then((data) =>
data.reduce<Record<number, number | undefined>>(
(accumulator, value) => ({
...accumulator,
[value.id]: value.jointVictory,
}),
{},
),
),
]);
const jointVictories = clan.members_ids.map((id) => ({
id,
joinVictory:
players[id].last_battle_time > getTimeDaysAgo(region, 1)
? previousJointVictories[id] === undefined
? 0
: achievements[id].max_series.jointVictory -
previousJointVictories[id]!
: 0,
}));
// command: new SlashCommandBuilder()
// .setName('clan-event')
// .setDescription('Lists all inactive players')
// .addStringOption(addClanChoices),
return [
`# ${escapeMarkdown(clan.name)} [${escapeMarkdown(
clan.tag,
)}]'s platoon wins today: ${(
jointVictories.reduce(
(accumulator, { joinVictory }) => accumulator + joinVictory,
0,
) / 2
).toFixed(0)}\n${jointVictories
.filter(({ joinVictory }) => joinVictory)
.sort((a, b) => b.joinVictory - a.joinVictory)
.map(
({ id, joinVictory }) =>
`- ${markdownEscape(players[id].nickname)}: ${joinVictory}`,
)
.join('\n')}`,
// async handler(interaction) {
// const { region, id } = await resolveClanFromCommand(interaction);
// const clan = await getClanInfo(region, id);
// const [players, achievements, previousJointVictories] = await Promise.all([
// getAccountInfo(region, clan.members_ids),
// getAccountAchievements(region, clan.members_ids),
// Promise.all(
// clan.members_ids.map((id) =>
// fetch(`https://www.blitzstars.com/api/playerstats/${id}`)
// .then((response) => response.json() as Promise<PlayerHistoriesRaw>)
// .then((data) => ({
// id,
// jointVictory: data[0]?.achievements.jointVictoryCount as
// | number
// | undefined,
// })),
// ),
// ).then((data) =>
// data.reduce<Record<number, number | undefined>>(
// (accumulator, value) => ({
// ...accumulator,
// [value.id]: value.jointVictory,
// }),
// {},
// ),
// ),
// ]);
// const jointVictories = clan.members_ids.map((id) => ({
// id,
// joinVictory:
// players[id].last_battle_time > getTimeDaysAgo(region, 1)
// ? previousJointVictories[id] === undefined
// ? 0
// : achievements[id].max_series.jointVictory -
// previousJointVictories[id]!
// : 0,
// }));
embedWarning(
'This is an approximation!',
"Wargaming provides very little information about platoons publicly. Caveats:\n- Players in two different clans platooning artificially inflates the total count\n- Game-mode battles aren't counted since Wargaming doesn't publicly share that data\n- This command is also subject to Blitzkrieg's reset times (use `/about timezones` for more info)",
),
];
},
// return [
// `# ${escapeMarkdown(clan.name)} [${escapeMarkdown(
// clan.tag,
// )}]'s platoon wins today: ${(
// jointVictories.reduce(
// (accumulator, { joinVictory }) => accumulator + joinVictory,
// 0,
// ) / 2
// ).toFixed(0)}\n${jointVictories
// .filter(({ joinVictory }) => joinVictory)
// .sort((a, b) => b.joinVictory - a.joinVictory)
// .map(
// ({ id, joinVictory }) =>
// `- ${markdownEscape(players[id].nickname)}: ${joinVictory}`,
// )
// .join('\n')}`,
autocomplete: autocompleteClan,
};
// embedWarning(
// 'This is an approximation!',
// "Wargaming provides very little information about platoons publicly. Caveats:\n- Players in two different clans platooning artificially inflates the total count\n- Game-mode battles aren't counted since Wargaming doesn't publicly share that data\n- This command is also subject to Blitzkrieg's reset times (use `/about timezones` for more info)",
// ),
// ];
// },
// autocomplete: autocompleteClan,
// };
+2 -2
View File
@@ -1,11 +1,11 @@
import { SlashCommandBuilder } from 'discord.js';
import packageJSON from '../../package.json' assert { type: 'json' };
import { tankopedia } from '../core/blitz/tankopedia';
import getClientId from '../core/blitzkrieg/getClientId';
import { tankAverages } from '../core/blitzstars/tankAverages';
import { tankopedia } from '../core/blitzstars/tankopedia';
import { client } from '../core/discord/client';
import embedInfo from '../core/discord/embedInfo';
import markdownTable from '../core/discord/markdownTable';
import getClientId from '../core/node/getClientId';
import { CommandRegistry } from '../events/interactionCreate';
const executionStart = new Date().getTime();
+14 -18
View File
@@ -1,20 +1,18 @@
import { SlashCommandBuilder } from 'discord.js';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import calculateWN8 from '../core/blitz/calculateWN8';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import sumStats from '../core/blitz/sumStats';
import { tankopedia } from '../core/blitz/tankopedia';
import getDiffedTankStats from '../core/blitzstars/getDiffedTankStats';
import getPeriodNow from '../core/blitzstars/getPeriodNow';
import getTimeDaysAgo from '../core/blitzstars/getTimeDaysAgo';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import getPeriodNow from '../core/blitzkrieg/getPeriodNow';
import getTimeDaysAgo from '../core/blitzkrieg/getTimeDaysAgo';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import { tankAverages } from '../core/blitzstars/tankAverages';
import { tankopedia } from '../core/blitzstars/tankopedia';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import embedNegative from '../core/discord/embedNegative';
import embedPositive from '../core/discord/embedPositive';
import markdownTable, { TableInput } from '../core/discord/markdownTable';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import calculateWN8 from '../core/statistics/calculateWN8';
import sumStats from '../core/statistics/sumStats';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
import { PossiblyPromise } from '../types/possiblyPromise';
export type SkilledClan = 'SKLLD' | 'SMRI';
@@ -48,20 +46,18 @@ export const eligibleCommand: CommandRegistry = {
async handler(interaction) {
const clan = interaction.options.getString('clan') as SkilledClan;
const { id, region: server } = await resolvePlayerFromCommand(interaction);
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const { id, region: region } = await resolvePlayerFromCommand(interaction);
const accountInfo = await getAccountInfo(region, id);
const problems: TableInput = [];
const title = `${accountInfo[id].nickname}'s eligibility for ${SKILLED_CLANS[clan]}`;
const title = `${accountInfo.nickname}'s eligibility for ${SKILLED_CLANS[clan]}`;
let isEligible = false;
let body = '';
if (clan === 'SKLLD') {
const { diffed } = await getDiffedTankStats(
server,
const { diff: diffed } = await getStatsInPeriod(
region,
id,
getTimeDaysAgo(server, 30),
getTimeDaysAgo(region, 30),
getPeriodNow(),
);
const entries = Object.entries(diffed);
@@ -171,7 +167,7 @@ export const eligibleCommand: CommandRegistry = {
problems,
)}`;
} else if (clan === 'SMRI') {
const stats = accountInfo[id].statistics.all;
const stats = accountInfo.statistics.all;
if (stats.battles < 1500) {
problems.push([
+149 -47
View File
@@ -1,31 +1,142 @@
import { SlashCommandBuilder } from 'discord.js';
import { CYCLIC_API } from '../constants/cyclic';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import {
SlashCommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from 'discord.js';
import * as Graph from '../components/Graph';
import NoData, { NoDataType } from '../components/NoData';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import resolveTankId from '../core/blitz/resolveTankId';
import addStatTypeSubCommandGroups from '../core/discord/addStatTypeSubCommandGroups';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
import getTankHistories from '../core/blitzkrieg/getTankHistories';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getPlayerHistories from '../core/blitzstars/getPlayerHistories';
import addPeriodSubCommands from '../core/discord/addPeriodSubCommands';
import addTankChoices from '../core/discord/addTankChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import interactionToURL from '../core/discord/interactionToURL';
import linkButton from '../core/discord/linkButton';
import primaryButton from '../core/discord/primaryButton';
import buttonPrimary from '../core/discord/buttonPrimary';
import commandToURL from '../core/discord/commandToURL';
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
import resolvePeriodFromCommand, {
ResolvedPeriod,
} from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import resolvePlayerFromCommand, {
ResolvedPlayer,
} from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistryRaw } from '../events/interactionCreate';
import evolution from '../renderers/evolution';
import { StatType } from '../renderers/stats';
import { AccountInfo } from '../types/accountInfo';
type EvolutionStatType = 'player' | 'tank';
async function render(
{ region, id }: ResolvedPlayer,
{ start, end, name }: ResolvedPeriod,
type: 'player' | 'tank',
tankId: number | null,
) {
const accountInfo = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const logo = clan ? emblemIdToURL(clan.emblem_set_id) : undefined;
const histories = await (type === 'player'
? getPlayerHistories(region, id, {
start,
end,
includeLatestHistories: true,
includePreviousHistories: true,
})
: getTankHistories(region, id, {
tankId: tankId!,
start,
end,
includeLatestHistories: true,
includePreviousHistories: true,
}));
const plot = histories.map(
(history) =>
[
history.all.battles,
(history.all.wins / history.all.battles) * 100,
] as Graph.PlotItem,
);
const times = histories.map((history) => history.last_battle_time);
const minTime = Math.min(...times);
const maxTime = Math.max(...times);
const ys = plot.map(([, y]) => y);
const xs = plot.map(([x]) => x);
const maxY = Math.max(...ys);
const minY = Math.min(...ys);
const maxX = Math.max(...xs);
const minX = Math.min(...xs);
return (
<Wrapper>
<TitleBar name={accountInfo.nickname} image={logo} description={name} />
{plot.length > 0 && (
<Graph.Root
verticalMargin={{
min: minY,
max: maxY,
suffix: '%',
precision: 2,
}}
horizontalMargin={{
min: minX,
max: maxX,
precision: 0,
}}
xMinLabel={new Date(minTime * 1000).toDateString()}
xMaxLabel={new Date(maxTime * 1000).toDateString()}
>
{Graph.Line({
color: Graph.LineColor.Red,
plot: plot,
minY,
maxY,
})}
</Graph.Root>
)}
{plot.length === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
</Wrapper>
);
}
export const evolutionCommand = new Promise<CommandRegistryRaw>(
async (resolve) => {
const command = await addStatTypeSubCommandGroups(
new SlashCommandBuilder()
.setName('evolution')
.setDescription('Evolution of statistics'),
false,
);
let playerGroup: SlashCommandSubcommandGroupBuilder;
let tankGroup: SlashCommandSubcommandGroupBuilder;
const command = new SlashCommandBuilder()
.setName('evolution')
.setDescription('Evolution of statistics')
.addSubcommandGroup((option) => {
playerGroup = option
.setName('player')
.setDescription('Player statistics');
return playerGroup;
})
.addSubcommandGroup((option) => {
tankGroup = option.setName('tank').setDescription('Tank statistics');
return tankGroup;
});
await Promise.all([
addPeriodSubCommands(playerGroup!, (option) =>
option.addStringOption(addUsernameChoices),
),
addPeriodSubCommands(tankGroup!, (option) =>
option
.addStringOption(addTankChoices)
.addStringOption(addUsernameChoices),
),
]);
resolve({
inProduction: true,
@@ -34,37 +145,28 @@ export const evolutionCommand = new Promise<CommandRegistryRaw>(
command,
async handler(interaction) {
const commandGroup = interaction.options.getSubcommandGroup(
true,
) as StatType;
const commandGroup =
interaction.options.getSubcommandGroup() as EvolutionStatType;
const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(player.region, interaction);
const tankIdRaw = interaction.options.getString('tank')!;
const tankId =
commandGroup === 'tank' ? await resolveTankId(tankIdRaw) : null;
const start = interaction.options.getInteger('start');
const end = interaction.options.getInteger('end');
const { nickname } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
const path = interactionToURL(interaction, {
const path = commandToURL(interaction, {
...player,
...getCustomPeriodParams(interaction),
tankId,
start,
end,
});
getBlitzStarsLinkButton;
return [
await evolution(commandGroup, period, player, tankId),
primaryButton(path, 'Refresh'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.region}/${nickname}${
commandGroup === 'tank' ? `/tank/${tankId!}` : ''
}`,
'BlitzStars',
await render(player, period, commandGroup, tankId),
buttonPrimary(path, 'Refresh'),
await getBlitzStarsLinkButton(
player.region,
player.id,
tankId ?? undefined,
),
];
},
@@ -75,17 +177,17 @@ export const evolutionCommand = new Promise<CommandRegistryRaw>(
},
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 url = new URL(`https://example.com/${interaction.customId}`);
const path = url.pathname.split('/');
const commandGroup = path[2] as EvolutionStatType;
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction);
return await evolution(
commandGroup,
period,
return await render(
player,
parseInt(url.searchParams.get('tankId')!),
period,
commandGroup,
parseInt(url.searchParams.get('tankId') ?? '0') || null,
);
},
});
+73 -74
View File
@@ -1,30 +1,76 @@
import { SlashCommandBuilder } from 'discord.js';
import { TreeTypeString } from '../components/Tanks';
import { CYCLIC_API } from '../constants/cyclic';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankId from '../core/blitz/resolveTankId';
import addStatTypeSubCommandGroups from '../core/discord/addStatTypeSubCommandGroups';
import GenericAllStats from '../components/GenericAllStats';
import NoData, { NoDataType } from '../components/NoData';
import TierWeights from '../components/TierWeights';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import addFilterOptions from '../core/discord/addFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import interactionToURL from '../core/discord/interactionToURL';
import linkButton from '../core/discord/linkButton';
import primaryButton from '../core/discord/primaryButton';
import { buttonRefresh } from '../core/discord/buttonRefresh';
import commandToURL from '../core/discord/commandToURL';
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
import resolvePeriodFromCommand, {
ResolvedPeriod,
} from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import resolvePlayerFromCommand, {
ResolvedPlayer,
} from '../core/discord/resolvePlayerFromCommand';
import { StatFilters, filterStats } from '../core/statistics/filterStats';
import { getTierWeights } from '../core/statistics/getTierWeights';
import { CommandRegistryRaw } from '../events/interactionCreate';
import fullStats from '../renderers/fullStats';
import { MultiTankFilters, StatType } from '../renderers/stats';
import { AccountInfo } from '../types/accountInfo';
async function render(
{ region, id }: ResolvedPlayer,
{ start, end, name }: ResolvedPeriod,
filters: StatFilters,
) {
const { nickname } = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined;
const diffedTankStats = await getStatsInPeriod(region, id, start, end);
const { stats, supplementary, filteredOrder } = await filterStats(
diffedTankStats,
filters,
);
const filterDescriptions = await filtersToDescription(filters);
const tierWeights = await getTierWeights(diffedTankStats.diff, filteredOrder);
return (
<Wrapper>
<TitleBar
name={nickname}
image={clanImage}
description={`${name} • ${filterDescriptions}`}
/>
{stats.battles === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
{stats.battles > 0 && <TierWeights weights={tierWeights!} />}
{stats.battles > 0 && (
<GenericAllStats stats={stats} supplementaryStats={supplementary} />
)}
</Wrapper>
);
}
export const fullStatsCommand = new Promise<CommandRegistryRaw>(
async (resolve) => {
const command = await addStatTypeSubCommandGroups(
const command = await addFilterOptions(
new SlashCommandBuilder()
.setName('full-stats')
.setDescription('Full in-game statistics'),
(option) => option.addStringOption(addUsernameChoices),
);
resolve({
@@ -34,60 +80,20 @@ export const fullStatsCommand = new Promise<CommandRegistryRaw>(
command,
async handler(interaction) {
const commandGroup = interaction.options.getSubcommandGroup(
true,
) as StatType;
const player = await resolvePlayerFromCommand(interaction);
const resolvedPeriod = resolvePeriodFromCommand(
player.region,
interaction,
);
const period = interaction.options.getString('period');
const tankIdRaw = interaction.options.getString('tank')!;
const tankId =
commandGroup === 'tank' ? await resolveTankId(tankIdRaw) : null;
const start = interaction.options.getInteger('start');
const end = interaction.options.getInteger('end');
const path = interactionToURL(interaction, {
const period = resolvePeriodFromCommand(player.region, interaction);
const filters = await getFiltersFromCommand(interaction);
const path = commandToURL(interaction, {
...player,
tankId,
start,
end,
period,
...getCustomPeriodParams(interaction),
...filters,
});
const { nickname } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
const tierOption = interaction.options.getString('tier');
return [
await fullStats(
commandGroup,
resolvedPeriod,
player,
commandGroup === 'tank'
? tankId
: commandGroup === 'multi-tank'
? ({
'tank-type':
interaction.options.getString('tank-type') ?? undefined,
nation: interaction.options.getString('nation') ?? undefined,
tier: tierOption ? parseInt(tierOption) : undefined,
'tree-type': (interaction.options.getString('tree-type') ??
undefined) as TreeTypeString | undefined,
} satisfies Partial<MultiTankFilters>)
: null,
),
primaryButton(path, 'Refresh'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.region}/${nickname}`,
'BlitzStars',
),
];
return Promise.all([
render(player, period, filters),
buttonRefresh(interaction, path),
getBlitzStarsLinkButton(player.region, player.id),
]);
},
autocomplete: (interaction) => {
@@ -96,18 +102,11 @@ export const fullStatsCommand = new Promise<CommandRegistryRaw>(
},
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 player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction);
const filters = getFiltersFromButton(interaction);
return await fullStats(
commandGroup,
period,
player,
parseInt(url.searchParams.get('tankId')!),
);
return await render(player, period, filters);
},
});
},
+13 -23
View File
@@ -3,14 +3,12 @@ import GenericStats from '../components/GenericStats';
import NoData, { NoDataType } from '../components/NoData';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanInfo } from '../core/blitz/getClanInfo';
import addClanChoices from '../core/discord/addClanChoices';
import autocompleteClan from '../core/discord/autocompleteClan';
import resolveClanFromCommand from '../core/discord/resolveClanFromCommand';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
import { ClanInfo } from '../types/clanInfo';
const DEFAULT_THRESHOLD = 7;
@@ -36,24 +34,16 @@ export const inactiveCommand: CommandRegistry = {
const threshold =
interaction.options.getNumber('threshold')! ?? DEFAULT_THRESHOLD;
const time = new Date().getTime() / 1000;
const clanData = (
await getWargamingResponse<ClanInfo>(
`https://api.wotblitz.${region}/wotb/clans/info/?application_id=${WARGAMING_APPLICATION_ID}&clan_id=${id}`,
const clanInfo = await getClanInfo(region, id);
const accountInfo = await getAccountInfo(region, clanInfo.members_ids);
const inactive = accountInfo
.map(
(account) =>
[
account.nickname,
(time - account.last_battle_time) / 60 / 60 / 24,
] as [string, number],
)
)[id];
const memberIds = clanData.members_ids;
const accounts = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${memberIds.join(
',',
)}`,
);
const inactive = memberIds
.map((memberId) => {
const member = accounts[memberId];
const inactiveDays = (time - member.last_battle_time) / 60 / 60 / 24;
return [member.nickname, inactiveDays] as [string, number];
})
.filter(([, inactiveDays]) => inactiveDays >= threshold)
.sort((a, b) => b[1] - a[1])
.map(
@@ -64,8 +54,8 @@ export const inactiveCommand: CommandRegistry = {
return (
<Wrapper>
<TitleBar
name={clanData.name}
image={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData.emblem_set_id}.png`}
name={clanInfo.name}
image={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanInfo.emblem_set_id}.png`}
description={`Inactive for ${threshold}+ Days • ${new Date().toDateString()}`}
/>
+223
View File
@@ -0,0 +1,223 @@
import { teal } from '@radix-ui/colors';
import {
AttachmentBuilder,
GuildMemberRoleManager,
SlashCommandBuilder,
} from 'discord.js';
import markdownEscape from 'markdown-escape';
import discord from '../../discord.json' assert { type: 'json' };
import { Glow } from '../components/AllStatsOverview/components/WN8Display/components/Glow';
import Wrapper from '../components/Wrapper';
import { REGION_NAMES_SHORT, Region } from '../constants/regions';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedNegative from '../core/discord/embedNegative';
import { CommandRegistry } from '../events/interactionCreate';
import { theme } from '../stitches.config';
const serverAndIdPattern = /(com|eu|asia)\/[0-9]+/;
export const verifyCommand: CommandRegistry = {
inProduction: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName('link')
.setDescription('Links your Blitz and Discord account')
.addStringOption((option) => addUsernameChoices(option).setRequired(true)),
async handler(interaction) {
const username = interaction.options.getString('username', true);
if (serverAndIdPattern.test(username)) {
const [region, idString] = username.split('/');
const id = parseInt(idString);
const discordId = parseInt(interaction.user.id);
const accountInfo = await getAccountInfo(region as Region, id);
const clanAccountInfo = await getClanAccountInfo(region as Region, id, [
'clan',
]);
await linkBlitzAndDiscord(discordId, region as Region, id);
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 (interaction.member!.roles as GuildMemberRoleManager).remove(
discord.sklld_verify_role,
);
await (interaction.member!.roles as GuildMemberRoleManager).add(
discord.sklld_peasant_role,
);
}
return (
<Wrapper fat>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Glow color={teal.teal9} rotation={90} />
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
}}
>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
src={
interaction.user.avatarURL({ extension: 'png' }) ??
'https://i.stack.imgur.com/l60Hf.png'
}
style={{
width: 64,
height: 64,
borderRadius: 32,
objectFit: 'cover',
}}
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<span
style={{
fontSize: 32,
color: theme.colors.textHighContrast,
fontWeight: 900,
maxWidth: 240,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{interaction.user.displayName}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
@{interaction.user.username}
</span>
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
color: theme.colors.textLowContrast_teal,
}}
>
<img
src="https://i.imgur.com/jIcRgog.png"
style={{
width: 16,
height: 16,
}}
/>
<span style={{ fontSize: 16 }}>Accounts linked</span>
</div>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
justifyContent: 'center',
}}
>
{clanAccountInfo?.clan ? (
<img
style={{
width: 64,
height: 64,
borderRadius: 32,
objectFit: 'cover',
}}
src={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanAccountInfo?.clan?.emblem_set_id}.png`}
/>
) : null}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<span
style={{
fontSize: 32,
color: theme.colors.textHighContrast,
fontWeight: 900,
maxWidth: 240,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{accountInfo.nickname}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
{clanAccountInfo?.clan
? `[${clanAccountInfo?.clan?.tag}] - `
: ''}
{REGION_NAMES_SHORT[region as Region]}
</span>
</div>
</div>
</div>
<Glow color={teal.teal9} rotation={-90} />
</div>
</Wrapper>
);
} else {
return [
embedNegative(
'Please select from the options',
'Follow the procedure like the video above. The search results may take a while to show up.',
),
new AttachmentBuilder('https://i.imgur.com/2p6GFgC.gif'),
];
}
},
autocomplete: autocompleteUsername,
};
+10 -55
View File
@@ -3,46 +3,24 @@ import NoData, { NoDataType } from '../components/NoData';
import * as Tanks from '../components/Tanks';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getTankStats from '../core/blitz/getTankStats';
import getTreeType from '../core/blitz/getTreeType';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankName from '../core/blitz/resolveTankName';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
import {
TIER_ROMAN_NUMERALS,
TankopediaEntry,
Tier,
tankopedia,
tankopediaInfo,
} from '../core/blitz/tankopedia';
} from '../core/blitzstars/tankopedia';
import addTierChoices from '../core/discord/addTierChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
import { PlayerClanData } from '../types/playerClanData';
const COMP_TANKS = [
// light tanks
24321, // t-100 lt
20257, // sheridan
3649, // bat chat
19537, // vickers
// heavy tanks
6145, // is-4
7169, // is-7
10785, // t110e5
12161, // strvk
4481, // kranvagn
22817, // m6 yoh
6225, // fv215b
5425, // wz 113
7297, // 60tp
58641, // vk 72
6753, // type 71
];
export const ownedTanksCommand: CommandRegistry = {
inProduction: true,
@@ -58,9 +36,7 @@ export const ownedTanksCommand: CommandRegistry = {
const tier = Number(interaction.options.getString('tier'));
const account = await resolvePlayerFromCommand(interaction);
const { id, region: server } = account;
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const accountInfo = await getAccountInfo(server, id);
const tankStats = await getTankStats(server, id);
const filteredTanks = (
await Promise.all(
@@ -70,9 +46,7 @@ export const ownedTanksCommand: CommandRegistry = {
})),
)
).filter((tank) => tank.tankopedia?.tier === tier);
const clanData = await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
);
const clanAccountInfo = await getClanAccountInfo(server, id, ['clan']);
const groupedTanks: Record<string, TankopediaEntry[]> = {};
const nations: string[] = [];
@@ -90,18 +64,13 @@ export const ownedTanksCommand: CommandRegistry = {
return (
<Wrapper>
<TitleBar
name={accountInfo[id].nickname}
nameDiscriminator={
clanData[id]?.clan ? `[${clanData[id]?.clan?.tag}]` : undefined
}
name={accountInfo.nickname}
image={
clanData[id]?.clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png`
clanAccountInfo?.clan
? emblemIdToURL(clanAccountInfo.clan.emblem_set_id)
: undefined
}
description={`Tier ${
TIER_ROMAN_NUMERALS[tier as Tier]
} tanks • ${new Date().toDateString()}`}
description={`Tier ${TIER_ROMAN_NUMERALS[tier as Tier]} tanks`}
/>
{filteredTanks.length === 0 && <NoData type={NoDataType.TanksFound} />}
@@ -158,17 +127,3 @@ export const ownedTanksCommand: CommandRegistry = {
autocomplete: autocompleteUsername,
};
// TODO: make this work in the future
// console.log('Caching tank images...');
// TANKS.then((tanks) =>
// jsxToSvg(
// <div style={{ display: 'flex' }}>
// {tanks.map(({ images, tank_id }) => {
// return (
// images.normal && <img key={tank_id} src={images.normal} width={0} />
// );
// })}
// </div>,
// ),
// ).then(() => console.log('Cached tank images'));
+7 -14
View File
@@ -1,16 +1,14 @@
import { SlashCommandBuilder } from 'discord.js';
import { startCase } from 'lodash';
import markdownEscape from 'markdown-escape';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import { getAccountAchievements } from '../core/blitz/getAccountAchievements';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedInfo from '../core/discord/embedInfo';
import markdownTable, { TableInputEntry } from '../core/discord/markdownTable';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountAchievements } from '../types/accountAchievements';
import { AccountInfo } from '../types/accountInfo';
type SortBy = 'name' | 'count';
@@ -35,22 +33,17 @@ export const playerAchievementsCommand: CommandRegistry = {
async handler(interaction) {
const sortBy = (interaction.options.getString('sort') ?? 'name') as SortBy;
const account = await resolvePlayerFromCommand(interaction);
const { id, region: server } = account;
const accounts = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const accountsAchievements =
await getWargamingResponse<AccountAchievements>(
`https://api.wotblitz.${server}/wotb/account/achievements/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const accountAchievements = accountsAchievements[id];
const { id, region: region } = account;
const accountInfo = await getAccountInfo(region, id);
const accountsAchievements = await getAccountAchievements(region, id);
const accountAchievements = accountsAchievements;
const compound = {
...accountAchievements.achievements,
...accountAchievements.max_series,
};
return embedInfo(
`${markdownEscape(accounts[id].nickname)}'s information`,
`${markdownEscape(accountInfo.nickname)}'s information`,
markdownTable(
Object.keys(compound)
+3 -8
View File
@@ -1,14 +1,12 @@
import { SlashCommandBuilder } from 'discord.js';
import markdownEscape from 'markdown-escape';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedInfo from '../core/discord/embedInfo';
import markdownTable from '../core/discord/markdownTable';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
export const playerInfoCommand: CommandRegistry = {
inProduction: true,
@@ -21,11 +19,8 @@ export const playerInfoCommand: CommandRegistry = {
async handler(interaction) {
const account = await resolvePlayerFromCommand(interaction);
const { id, region: 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];
const { id, region: region } = account;
const accountInfo = await getAccountInfo(region, id);
return embedInfo(
`${markdownEscape(accountInfo.nickname)}'s information`,
+100 -114
View File
@@ -9,23 +9,22 @@ import * as Leaderboard from '../components/Leaderboard';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { REGION_NAMES_SHORT, Region } from '../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import getArchivedRatingsInfo from '../core/blitz/getArchivedRatingsInfo';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getRatingsInfo from '../core/blitz/getRatingsInfo';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import regionToRegionSubdomain from '../core/blitz/regionToRegionSubdomain';
import getArchivedRatingsInfo from '../core/blitzkrieg/getArchivedRatingsInfo';
import getMidnightLeaderboard, {
DATABASE_REPO,
} from '../core/database/getMidnightLeaderboard';
} from '../core/blitzkrieg/getMidnightLeaderboard';
import { octokit } from '../core/blitzkrieg/octokit';
import { UserError } from '../core/blitzkrieg/userError';
import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedNegative from '../core/discord/embedNegative';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { octokit } from '../core/github/octokit';
import throwError from '../core/node/throwError';
import { CommandRegistryRaw } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
import { PlayerClanData } from '../types/playerClanData';
export interface RatingsPlayer {
spa_id: number;
@@ -172,6 +171,11 @@ function addSubcommands(
);
}
const noOngoingSeason = embedNegative(
'No ongoing season',
"Wargaming didn't provide any data for this season.",
);
export const ratingsCommand = new Promise<CommandRegistryRaw>(
async (resolve) => {
const seasonChoices = await octokit.repos
@@ -181,7 +185,7 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
})
.then(({ data }) => {
if (!Array.isArray(data)) {
throw throwError('Archived ratings data is malformed');
throw new Error('Archived ratings data is malformed');
}
return data.map(
(folder) =>
@@ -259,27 +263,28 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
regionRatingsInfo = await (subcommandGroup === 'current'
? getRatingsInfo(region)
: getArchivedRatingsInfo(season!, region));
if (regionRatingsInfo.detail !== undefined) return noOngoingSeason;
const regionSubdomain = regionToRegionSubdomain(region);
const result =
subcommandGroup === 'current'
? regionRatingsInfo.detail === undefined
? await fetch(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${leagueIndex}/top/`,
? await fetch(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${leagueIndex}/top/`,
)
.then((response) => response.json() as Promise<LeagueTop>)
.then(async ({ result }) =>
result.slice(0, limit).map(
(player) =>
({
id: player.spa_id,
score: player.score,
position: player.number,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
.then((response) => response.json() as Promise<LeagueTop>)
.then(async ({ result }) =>
result.slice(0, limit).map(
(player) =>
({
id: player.spa_id,
score: player.score,
position: player.number,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
: undefined
: await octokit.repos
.getContent({
...DATABASE_REPO,
@@ -287,7 +292,7 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
})
.then(async ({ data }) => {
if (Array.isArray(data) || data.type !== 'file') {
throw throwError('Archived ratings data is malformed');
throw new Error('Archived ratings data is malformed');
}
const players = (await fetch(data.download_url!).then(
@@ -305,9 +310,10 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
firstPlayerIndex,
lastPlayerIndex + 1,
);
const ids = trimmed.map((player) => player.id).join(',');
const clanData = await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${ids}&extra=clan`,
const clanData = await getClanAccountInfo(
region,
trimmed.map(({ id }) => id),
['clan'],
);
return trimmed.map(
@@ -323,34 +329,25 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
}) satisfies SimplifiedPlayer,
);
});
const leagueInfo =
regionRatingsInfo.detail === undefined
? regionRatingsInfo.leagues[leagueIndex]
: undefined;
const leagueInfo = regionRatingsInfo.leagues[leagueIndex];
midnightLeaderboard =
subcommandGroup === 'current'
? regionRatingsInfo.detail === undefined
? await getMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: undefined
? await getMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: await getMidnightLeaderboard(region, season!);
players = result;
playersBefore = result ? result[0].position - 1 : 0;
playersAfter =
regionRatingsInfo.detail === undefined && result !== undefined
? regionRatingsInfo.count - result[result.length - 1].position
: 0;
regionRatingsInfo.count - result[result.length - 1].position;
titleName = `${
leagueInfo ? leagueInfo.title : 'No Ongoing Season'
} - ${REGION_NAMES_SHORT[region]}`;
titleImage = leagueInfo
? leagueInfo.big_icon.startsWith('http')
? leagueInfo.big_icon
: `https:${leagueInfo.big_icon}`
: undefined;
titleImage = leagueInfo.big_icon.startsWith('http')
? leagueInfo.big_icon
: `https:${leagueInfo.big_icon}`;
titleDescription = `Top ${limit} players`;
} else {
const { region, id } = await resolvePlayerFromCommand(interaction);
@@ -364,7 +361,7 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
})
.then(async ({ data }) => {
if (Array.isArray(data) || data.type !== 'file') {
throw throwError('Archived ratings info is malformed');
throw new Error('Archived ratings info is malformed');
}
const response = fetch(data.download_url!);
@@ -372,40 +369,34 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
response.json(),
)) as RatingsInfo;
});
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const clan = (
await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
)
)[id]?.clan;
if (regionRatingsInfo.detail !== undefined) return noOngoingSeason;
const accountInfo = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const regionSubdomain = regionToRegionSubdomain(region);
const neighbors =
subcommandGroup === 'current'
? regionRatingsInfo.detail === undefined
? await fetch(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${Math.round(
limit / 2,
)}`,
? await fetch(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${Math.round(
limit / 2,
)}`,
)
.then(
(response) => response.json() as Promise<RatingsNeighbors>,
)
.then((players) =>
players.neighbors.map(
(player) =>
({
id: player.spa_id,
position: player.number,
score: player.score,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
.then(
(response) =>
response.json() as Promise<RatingsNeighbors>,
)
.then((players) =>
players.neighbors.map(
(player) =>
({
id: player.spa_id,
position: player.number,
score: player.score,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
: undefined
: await octokit.repos
.getContent({
...DATABASE_REPO,
@@ -413,7 +404,7 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
})
.then(async ({ data }) => {
if (Array.isArray(data) || data.type !== 'file') {
throw throwError(
throw new Error(
'Archived ratings latest data is malformed',
);
}
@@ -427,9 +418,12 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
);
if (playerIndex === -1) {
throw throwError(
`${accountInfo[id].nickname} didn't player ratings in season ${season}`,
'This player did not participate in this season or did not get past calibration.',
throw new UserError(
`${accountInfo.nickname} didn't player ratings in season ${season}`,
{
cause:
'This player did not participate in this season or did not get past calibration.',
},
);
}
@@ -438,9 +432,10 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
playerIndex - halfRange,
playerIndex + halfRange + 1,
);
const ids = trimmed.map((player) => player.id).join(',');
const clanData = await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${ids}&extra=clan`,
const clanData = await getClanAccountInfo(
region,
trimmed.map((player) => player.id),
['clan'],
);
return trimmed.map((player, index) => ({
@@ -453,46 +448,38 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
});
midnightLeaderboard =
subcommandGroup === 'current'
? regionRatingsInfo.detail === undefined
? await getMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: undefined
? await getMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: await getMidnightLeaderboard(region, season!);
if (clan && neighbors) titleNameDiscriminator = `[${clan.tag}]`;
players = neighbors;
titleImage = neighbors
? clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
: undefined
titleImage = clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
: undefined;
titleName = neighbors
? accountInfo[id].nickname
? accountInfo.nickname
: `No Ongoing Season - ${REGION_NAMES_SHORT[region]}`;
titleDescription = 'Ratings neighbours';
playersBefore = neighbors ? neighbors[0].position - 1 : 0;
playersAfter =
regionRatingsInfo.detail === undefined
? neighbors
? regionRatingsInfo.count -
neighbors[neighbors.length - 1].position
: 0
: 0;
regionRatingsInfo.count - neighbors[neighbors.length - 1].position;
playerId = id;
}
const items = players?.map((player) => {
const reward =
regionRatingsInfo.detail === undefined
? regionRatingsInfo.rewards.find(
(reward) =>
player.position >= reward.from_position &&
player.position <= reward.to_position,
)
: undefined;
const resolvedRegionRatingsInfo: typeof regionRatingsInfo & {
detail: undefined;
} = regionRatingsInfo;
const items = players.map((player) => {
const reward = resolvedRegionRatingsInfo.rewards.find(
(reward) =>
player.position >= reward.from_position &&
player.position <= reward.to_position,
);
const midnightIndex = midnightLeaderboard?.findIndex(
(item) => item.id === player.id,
);
@@ -523,8 +510,7 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
<TitleBar
name={titleName}
image={titleImage}
nameDiscriminator={titleNameDiscriminator}
description={`${titleDescription} • ${new Date().toDateString()}`}
description={titleDescription}
/>
{items && (
+2 -2
View File
@@ -2,7 +2,7 @@ import { SlashCommandBuilder } from 'discord.js';
import markdownEscape from 'markdown-escape';
import { REGION_NAMES, Region } from '../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import fetchBlitz from '../core/blitz/fetchWargaming';
import addClanChoices from '../core/discord/addClanChoices';
import addRegionChoices from '../core/discord/addRegionChoices';
import embedInfo from '../core/discord/embedInfo';
@@ -30,7 +30,7 @@ export const searchClansCommand: CommandRegistry = {
const server = interaction.options.getString('region') as Region;
const clan = interaction.options.getString('clan')!;
const limit = interaction.options.getInteger('limit') ?? 25;
const clanList = await getWargamingResponse<ClanList>(
const clanList = await fetchBlitz<ClanList>(
`https://api.wotblitz.${server}/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${clan}&limit=${limit}`,
);
+6 -3
View File
@@ -2,8 +2,11 @@ import { SlashCommandBuilder } from 'discord.js';
import markdownEscape from 'markdown-escape';
import { REGION_NAMES, Region } from '../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import { AccountList, usernamePattern } from '../core/blitz/listPlayers';
import fetchBlitz from '../core/blitz/fetchWargaming';
import {
AccountList,
usernamePattern,
} from '../core/blitz/searchPlayersAcrossRegions';
import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import embedInfo from '../core/discord/embedInfo';
@@ -34,7 +37,7 @@ export const searchPlayersCommand: CommandRegistry = {
const limit = interaction.options.getInteger('limit') ?? 25;
const trimmedSearch = name.trim();
const players = usernamePattern.test(trimmedSearch)
? await getWargamingResponse<AccountList>(
? await fetchBlitz<AccountList>(
`https://api.wotblitz.${server}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodeURIComponent(
trimmedSearch,
)}&limit=${limit}`,
+1 -1
View File
@@ -1,7 +1,7 @@
import { SlashCommandBuilder } from 'discord.js';
import { go } from 'fuzzysort';
import markdownEscape from 'markdown-escape';
import { TANK_NAMES_DIACRITICS } from '../core/blitz/tankopedia';
import { TANK_NAMES_DIACRITICS } from '../core/blitzstars/tankopedia';
import addTankChoices from '../core/discord/addTankChoices';
import embedInfo from '../core/discord/embedInfo';
import { CommandRegistry } from '../events/interactionCreate';
+66 -82
View File
@@ -1,28 +1,68 @@
import { SlashCommandBuilder } from 'discord.js';
import { TreeTypeString } from '../components/Tanks';
import { CYCLIC_API } from '../constants/cyclic';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankId from '../core/blitz/resolveTankId';
import addStatTypeSubCommandGroups from '../core/discord/addStatTypeSubCommandGroups';
import AllStatsOverview from '../components/AllStatsOverview';
import NoData, { NoDataType } from '../components/NoData';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import addFilterOptions from '../core/discord/addFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import interactionToURL from '../core/discord/interactionToURL';
import linkButton from '../core/discord/linkButton';
import primaryButton from '../core/discord/primaryButton';
import { buttonRefresh } from '../core/discord/buttonRefresh';
import commandToURL from '../core/discord/commandToURL';
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
import resolvePeriodFromCommand, {
ResolvedPeriod,
} from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import resolvePlayerFromCommand, {
ResolvedPlayer,
} from '../core/discord/resolvePlayerFromCommand';
import { StatFilters, filterStats } from '../core/statistics/filterStats';
import { CommandRegistryRaw } from '../events/interactionCreate';
import stats, { MultiTankFilters, StatType } from '../renderers/stats';
import { AccountInfo } from '../types/accountInfo';
async function render(
{ region, id }: ResolvedPlayer,
{ start, end, name }: ResolvedPeriod,
filters: StatFilters,
) {
const { nickname } = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined;
const diffedTankStats = await getStatsInPeriod(region, id, start, end);
const { stats, supplementary } = await filterStats(diffedTankStats, filters);
const filterDescriptions = await filtersToDescription(filters);
return (
<Wrapper>
<TitleBar
name={nickname}
image={clanImage}
description={`${name} • ${filterDescriptions}`}
/>
{!stats.battles && <NoData type={NoDataType.BattlesInPeriod} />}
{stats.battles > 0 && (
<AllStatsOverview stats={stats} supplementaryStats={supplementary} />
)}
</Wrapper>
);
}
export const statsCommand = new Promise<CommandRegistryRaw>(async (resolve) => {
const command = await addStatTypeSubCommandGroups(
const command = await addFilterOptions(
new SlashCommandBuilder()
.setName('stats')
.setDescription('Regular battles statistics'),
(option) => option.addStringOption(addUsernameChoices),
);
resolve({
@@ -32,60 +72,20 @@ export const statsCommand = new Promise<CommandRegistryRaw>(async (resolve) => {
command,
async handler(interaction) {
const commandGroup = interaction.options.getSubcommandGroup(
true,
) as StatType;
const player = await resolvePlayerFromCommand(interaction);
const resolvedPeriod = resolvePeriodFromCommand(
player.region,
interaction,
);
const period = interaction.options.getString('period');
const tankIdRaw = interaction.options.getString('tank')!;
const tankId =
commandGroup === 'tank' ? await resolveTankId(tankIdRaw) : null;
const start = interaction.options.getInteger('start');
const end = interaction.options.getInteger('end');
const path = interactionToURL(interaction, {
const period = resolvePeriodFromCommand(player.region, interaction);
const filters = await getFiltersFromCommand(interaction);
const path = commandToURL(interaction, {
...player,
tankId,
start,
end,
period,
...getCustomPeriodParams(interaction),
...filters,
});
const { nickname } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
const tierOption = interaction.options.getString('tier');
return [
await stats(
commandGroup,
resolvedPeriod,
player,
commandGroup === 'tank'
? tankId
: commandGroup === 'multi-tank'
? ({
'tank-type':
interaction.options.getString('tank-type') ?? undefined,
nation: interaction.options.getString('nation') ?? undefined,
tier: tierOption ? parseInt(tierOption) : undefined,
'tree-type': (interaction.options.getString('tree-type') ??
undefined) as TreeTypeString | undefined,
} satisfies Partial<MultiTankFilters>)
: null,
),
primaryButton(path, 'Refresh'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.region}/${nickname}`,
'BlitzStars',
),
];
return Promise.all([
render(player, period, filters),
buttonRefresh(interaction, path),
getBlitzStarsLinkButton(player.region, player.id),
]);
},
autocomplete: (interaction) => {
@@ -94,27 +94,11 @@ export const statsCommand = new Promise<CommandRegistryRaw>(async (resolve) => {
},
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 player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction);
const filters = getFiltersFromButton(interaction);
return await stats(
commandGroup,
period,
player,
commandGroup === 'tank'
? parseInt(url.searchParams.get('tankId')!)
: commandGroup === 'multi-tank'
? ({
'tank-type': url.searchParams.get('tank-type')!,
nation: url.searchParams.get('nation')!,
tier: parseInt(url.searchParams.get('tier')!),
'tree-type': url.searchParams.get('tree-type') as TreeTypeString,
} satisfies MultiTankFilters)
: null,
);
return await render(player, period, filters);
},
});
});
+6 -71
View File
@@ -1,83 +1,18 @@
import { SlashCommandBuilder } from 'discord.js';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import interactionToURL from '../core/discord/interactionToURL';
import linkButton from '../core/discord/linkButton';
import primaryButton from '../core/discord/primaryButton';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistry } from '../events/interactionCreate';
import today from '../renderers/today';
import { AccountInfo } from '../types/accountInfo';
/**
* TODO: @deprecated
*/
export const todayCommand: CommandRegistry = {
inProduction: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName('today')
.setDescription('A general daily breakdown of your performance')
.addStringOption(addUsernameChoices)
.addIntegerOption((option) =>
option
.setName('cutoff')
.setDescription(
'The maximum number of tanks to display (default: Infinity)',
)
.setRequired(false)
.setMinValue(1),
)
.addIntegerOption((option) =>
option
.setName('maximized')
.setDescription(
'The number of rows with full stats after which all are collapsed (default: 4)',
)
.setMinValue(0)
.setRequired(false),
)
.addBooleanOption((option) =>
option
.setName('show-total')
.setDescription('Show the total stats at the top (default: true)')
.setRequired(false),
),
.setDescription('This command has been renamed to `/breakdown today`'),
async handler(interaction) {
const player = await resolvePlayerFromCommand(interaction);
const cutoff = interaction.options.getInteger('cutoff') ?? undefined;
const maximized = interaction.options.getInteger('maximized') ?? undefined;
const showTotal = interaction.options.getBoolean('show-total') ?? undefined;
const { nickname } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
const path = interactionToURL(interaction, {
...player,
cutoff,
maximized,
'show-total': showTotal,
});
return [
await today(player, cutoff, maximized, showTotal, false),
primaryButton(path, 'Refresh'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.region}/${nickname}`,
'BlitzStars',
),
];
},
autocomplete: autocompleteUsername,
async button(interaction) {
const player = await resolvePlayerFromButton(interaction);
return await today(player);
handler() {
return '# This command has been renamed to `/breakdown today`\n`/today` now comes with filters, periods and more! Use `/breakdown today` for same old functionality but also do checkout the other options.';
},
};
-82
View File
@@ -1,82 +0,0 @@
import { SlashCommandBuilder } from 'discord.js';
import markdownEscape from 'markdown-escape';
import discord from '../../discord.json' assert { type: 'json' };
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedNegative from '../core/discord/embedNegative';
import embedPositive from '../core/discord/embedPositive';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
import { PlayerClanData } from '../types/playerClanData';
export const verifyCommand: CommandRegistry = {
inProduction: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName('verify')
.setDescription("Set's the user's username to their in-game name")
.addStringOption((option) => addUsernameChoices(option).setRequired(true)),
async handler(interaction) {
const blitzAccount = await resolvePlayerFromCommand(interaction);
const { id, region: server } = blitzAccount;
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 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')) {
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?",
);
}
try {
await member.setNickname(`${accountInfo[id].nickname}${clanTag}`);
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: autocompleteUsername,
};
@@ -1,32 +1,28 @@
import { Percentile } from '../../../../../constants/percentiles';
import hslToHex from '../../../../../core/node/hslToHex';
import { PERCENTILE_COLORS } from '../../../../PercentileIndicator';
export enum GlowSide {
Top,
Bottom,
}
export interface GlowProps {
percentile: Percentile;
side: GlowSide;
color: string;
direction?: 'default' | 'reverse';
rotation?: number;
}
export function Glow({ percentile, side }: GlowProps) {
const color = PERCENTILE_COLORS[percentile];
const color1 = hslToHex(color);
const color2 = `${color1}00`;
export function Glow({
color,
direction = 'default',
rotation = 0,
}: GlowProps) {
const color1 = color;
const color2 = `${color}00`;
const background = `linear-gradient(${
side === GlowSide.Top ? 0 : 180
direction === 'default' ? 0 : 180
}deg, ${color1} 0%, ${color2} 100%)`;
return (
<div
style={{
display: 'flex',
alignItems: side === GlowSide.Top ? 'flex-end' : 'flex-start',
alignItems: direction === 'default' ? 'flex-end' : 'flex-start',
justifyContent: 'center',
transform: `rotate(${rotation}deg)`,
}}
>
<div style={{ height: 54, width: 16, background }} />
@@ -1,8 +1,8 @@
import { Percentile } from '../../../../constants/percentiles';
import getWN8Percentile from '../../../../core/blitz/getWN8Percentile';
import getWN8Percentile from '../../../../core/statistics/getWN8Percentile';
import { theme } from '../../../../stitches.config';
import { PERCENTILE_COLORS } from '../../../PercentileIndicator';
import { Glow, GlowSide } from './components/Glow';
import { Glow } from './components/Glow';
export interface WN8DisplayProps {
WN8?: number;
@@ -37,7 +37,7 @@ export function WN8Display({ WN8 }: WN8DisplayProps) {
gap: -16,
}}
>
<Glow percentile={percentile} side={GlowSide.Top} />
<Glow color={PERCENTILE_COLORS[percentile]} />
<div
style={{
@@ -71,7 +71,7 @@ export function WN8Display({ WN8 }: WN8DisplayProps) {
</span>
</div>
<Glow percentile={percentile} side={GlowSide.Bottom} />
<Glow color={PERCENTILE_COLORS[percentile]} direction="reverse" />
</div>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { AllStats, SupplementaryStats } from '../../types/accountInfo';
import { AllStats, SupplementaryStats } from '../../core/blitz/getAccountInfo';
import { WN8Display } from './components/WN8Display';
import {
WN8SurroundingStat,
+23 -19
View File
@@ -1,23 +1,24 @@
import { Percentile } from '../../../constants/percentiles';
import { TankType } from '../../../core/blitz/tankopedia';
import { theme } from '../../../stitches.config';
import { TREE_TYPE_ICONS, TreeTypeEnum } from '../../Tanks';
import { RowStat } from './RowStat';
export interface RowStatItem {
title: string;
current?: number | string;
career?: number | string;
delta?: number;
percentile?: Percentile;
}
interface RowProps {
type?: 'tank' | 'summary';
title: string;
minimized?: boolean;
treeType?: TreeTypeEnum;
tankType?: TankType;
tankType?: string;
stats: {
title: string;
current?: number | string;
career?: number | string;
delta?: number;
percentile?: Percentile;
}[];
stats: (RowStatItem | undefined)[];
}
export function Row(props: RowProps) {
@@ -77,16 +78,19 @@ export function Row(props: RowProps) {
paddingBottom: 8,
}}
>
{props.stats.map((row, index) => (
<RowStat
minimized={props.minimized}
key={index}
name={`${row.title} • ${row.career ?? '--'}`}
value={row.current ?? '--'}
delta={row.delta}
percentile={row.percentile}
/>
))}
{props.stats.map(
(row, index) =>
row && (
<RowStat
minimized={props.minimized}
key={index}
name={`${row.title} • ${row.career ?? '--'}`}
value={row.current ?? '--'}
delta={row.delta}
percentile={row.percentile}
/>
),
)}
</div>
</div>
);
@@ -15,7 +15,7 @@ export function RowStat({
value,
delta,
percentile,
minimized,
minimized = false,
}: RowStatProps) {
return (
<div
@@ -55,6 +55,7 @@ export function RowStat({
color: theme.colors.textHighContrast,
fontWeight: 'bold',
fontSize: 16,
textAlign: 'center',
}}
>
{value}
@@ -62,7 +63,13 @@ export function RowStat({
</div>
{!minimized && (
<span style={{ color: theme.colors.textLowContrast, fontSize: 12 }}>
<span
style={{
color: theme.colors.textLowContrast,
textAlign: 'center',
fontSize: 12,
}}
>
{name}
</span>
)}
@@ -44,6 +44,7 @@ export function Card({ items, title }: CardProps) {
>
{items.map((item) => (
<div
key={item.label}
style={{
display: 'flex',
gap: 8,
+3 -5
View File
@@ -1,12 +1,10 @@
import getWN8Percentile from '../../core/blitz/getWN8Percentile';
import isNumber from '../../core/node/isNumber';
import { AllStats, SupplementaryStats } from '../../types/accountInfo';
import { AllStats, SupplementaryStats } from '../../core/blitz/getAccountInfo';
import isNumber from '../../core/math/isNumber';
import getWN8Percentile from '../../core/statistics/getWN8Percentile';
import { Card } from './components/Card';
import { Root } from './components/Root';
import { Row } from './components/Row';
// TODO: remove the .js endings
export interface GenericAllStatsProps {
stats: AllStats;
supplementaryStats?: SupplementaryStats;
@@ -15,9 +15,18 @@ export default function GenericStatsRow({ stat }: GenericStatsRowProps) {
alignItems: 'center',
}}
>
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
<span
style={{
color: theme.colors.textLowContrast,
fontSize: 16,
whiteSpace: 'nowrap',
maxWidth: 128,
overflow: 'hidden',
}}
>
{stat[0]}
</span>
<span
style={{
color: theme.colors.textHighContrast,
+9 -1
View File
@@ -19,7 +19,15 @@ export default function Navbar() {
{/* <Button>
<HamburgerMenuIcon />
</Button> */}
<Link href="/" style={{ color: 'inherit' }}>
<Link
href="/"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<BlitzkriegWormWide />
</Link>
{/* <Button>
+18 -5
View File
@@ -2,8 +2,7 @@ import {
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
TankType,
} from '../../../core/blitz/tankopedia';
} from '../../../core/blitzstars/tankopedia';
import { theme } from '../../../stitches.config';
export enum TreeTypeEnum {
@@ -12,11 +11,17 @@ export enum TreeTypeEnum {
Collector,
}
export type TreeTypeString = 'techtree' | 'premium' | 'collector';
export type TreeTypeString = 'tech-tree' | 'premium' | 'collector';
export const TREE_TYPE_NAMES: Record<TreeTypeString, string> = {
'tech-tree': 'Tech tree',
premium: 'Premium',
collector: 'Collector',
};
export interface ItemProps {
image?: string;
tankType?: TankType;
tankType?: string;
name: string;
treeType: TreeTypeEnum;
}
@@ -75,7 +80,15 @@ export function Item({ image, tankType, name, treeType }: ItemProps) {
</span>
</div>
{image && <img src={image} style={{ flex: 1 }} />}
{/* {image && (
// TODO: remove hardcoded dimensions when satori fixes non-width unloadable images
<img
src={image}
width={106}
height={32}
style={{ objectFit: 'cover' }}
/>
)} */}
</div>
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { TIER_ROMAN_NUMERALS, Tier } from '../core/blitz/tankopedia';
import { Tier } from '../core/blitzstars/tankopedia';
import { theme } from '../stitches.config';
export type TierWeightsRecord = Partial<Record<Tier, number>>;
@@ -26,7 +26,7 @@ export default function TierWeights({ weights }: TierWeightsProps) {
minWidth: 16,
}}
>
{TIER_ROMAN_NUMERALS[parseInt(tier) as Tier]}
{tier}
</div>
))}
</div>
+2 -22
View File
@@ -2,17 +2,11 @@ import { theme } from '../stitches.config';
export interface TitleBarProps {
name: string;
nameDiscriminator?: string;
image?: string;
description?: string;
}
export default function TitleBar({
name,
nameDiscriminator,
image,
description,
}: TitleBarProps) {
export default function TitleBar({ name, image, description }: TitleBarProps) {
return (
<div
style={{
@@ -33,9 +27,8 @@ export default function TitleBar({
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
justifyContent: 'center',
gap: 4,
}}
>
@@ -51,24 +44,11 @@ export default function TitleBar({
color: theme.colors.textHighContrast,
fontWeight: 900,
whiteSpace: 'nowrap',
maxWidth: nameDiscriminator === undefined ? Infinity : 240,
overflow: 'hidden',
}}
>
{name}
</span>
{nameDiscriminator && (
<span
style={{
fontSize: 32,
color: theme.colors.textLowContrast,
fontWeight: 900,
whiteSpace: 'nowrap',
}}
>
{nameDiscriminator}
</span>
)}
</div>
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
+3 -2
View File
@@ -4,15 +4,16 @@ import { theme } from '../stitches.config';
export interface WrapperProps {
children: ReactNode;
naked?: boolean;
fat?: boolean;
}
export default function Wrapper({ children, naked }: WrapperProps) {
export default function Wrapper({ children, naked, fat }: WrapperProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: 16,
padding: `16px ${fat ? 0 : 16}px`,
gap: 32,
width: 480,
color: theme.colors.textHighContrast,
-1
View File
@@ -1 +0,0 @@
export const CYCLIC_API = 'https://blitzkrieg-embeds.cyclic.app';
+23
View File
@@ -0,0 +1,23 @@
export type BlitzResponse<Data extends object> =
| {
status: 'error';
error: { field: string; message: string; code: 402; value: null };
}
| {
status: 'ok';
data: Data;
};
export default async function fetchBlitz<Data extends object>(url: string) {
const data = (await fetch(url).then((response) =>
response.json(),
)) as BlitzResponse<Data>;
if (data.status === 'ok') {
return data.data;
} else {
throw new Error(`Wargaming response error status:"${data.status}"`, {
cause: `Message: "${data.error.message}"\nURL: "${url}"`,
});
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Region } from '../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import fetchBlitz from './fetchWargaming';
import { normalizeIds } from './normalizeIds';
interface IndividualAccountAchievements {
achievements: {
[achievement: string]: number;
};
max_series: {
[achievement: string]: number;
};
}
export interface AccountAchievements {
[id: number]: IndividualAccountAchievements;
}
export async function getAccountAchievements<
Ids extends number | number[],
ReturnType = Ids extends number
? IndividualAccountAchievements
: IndividualAccountAchievements[],
>(region: Region, ids: Ids) {
const object = await fetchBlitz<AccountAchievements>(
`https://api.wotblitz.${region}/wotb/account/achievements/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds(
ids,
)}`,
);
if (typeof ids === 'number') {
return object[ids as number] as ReturnType;
} else {
return ids.map((id) => object[id]) as ReturnType;
}
}
+89
View File
@@ -0,0 +1,89 @@
import { Region } from '../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import fetchBlitz from './fetchWargaming';
import { normalizeIds } from './normalizeIds';
export interface AllStats {
spotted: number;
hits: number;
frags: number;
max_xp: number;
wins: number;
losses: number;
capture_points: number;
battles: number;
damage_dealt: number;
damage_received: number;
max_frags: number;
shots: number;
frags8p: number;
xp: number;
win_and_survived: number;
survived_battles: number;
dropped_capture_points: number;
}
export interface SupplementaryStats {
// if undefined, WN8 is not available for tanks without averages
WN8?: number;
tier?: number;
}
export interface ClanStats {
spotted: number;
max_frags_tank_id: number;
hits: number;
frags: number;
max_xp: number;
max_xp_tank_id: number;
wins: number;
losses: number;
capture_points: number;
battles: number;
damage_dealt: number;
damage_received: number;
max_frags: number;
shots: number;
frags8p: number;
xp: number;
win_and_survived: number;
survived_battles: number;
dropped_capture_points: number;
}
interface IndividualAccountInfo {
account_id: number;
created_at: number;
updated_at: number;
private: null;
last_battle_time: number;
nickname: string;
statistics: {
all: AllStats;
clan: ClanStats;
frags: null | number;
};
}
export interface AccountInfo {
[accountId: number]: IndividualAccountInfo;
}
export async function getAccountInfo<
Ids extends number | number[],
ReturnType = Ids extends number
? IndividualAccountInfo
: IndividualAccountInfo[],
>(region: Region, ids: Ids) {
const object = await fetchBlitz<AccountInfo>(
`https://api.wotblitz.${region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds(
ids,
)}`,
);
if (typeof ids === 'number') {
return object[ids as number] as ReturnType;
} else {
return ids.map((id) => object[id]) as ReturnType;
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Region } from '../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import fetchBlitz from './fetchWargaming';
import { normalizeExtra } from './normalizeExtra';
import { normalizeIds } from './normalizeIds';
export type IndividualClanAccountInfo = {
account_id: number;
joined_at: number;
clan_id: number;
role: string;
account_name: string;
clan?: {
members_count: number;
name: string;
created_at: number;
tag: string;
clan_id: number;
emblem_set_id: number;
};
} | null;
export interface ClanAccountInfo {
[accountId: number]: IndividualClanAccountInfo;
}
export async function getClanAccountInfo<
Ids extends number | number[],
ReturnType = Ids extends number
? IndividualClanAccountInfo
: IndividualClanAccountInfo[],
>(region: Region, ids: Ids, extra?: string[]) {
const object = await fetchBlitz<ClanAccountInfo>(
`https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds(
ids,
)}${normalizeExtra(extra)}`,
);
if (typeof ids === 'number') {
return object[ids as number] as ReturnType;
} else {
return ids.map((id) => object[id]) as ReturnType;
}
}
+54
View File
@@ -0,0 +1,54 @@
import { Region } from '../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import fetchBlitz from './fetchWargaming';
import { normalizeIds } from './normalizeIds';
export interface IndividualClanInfo {
recruiting_options: {
vehicles_level: number;
wins_ratio: number;
average_battles_per_day: number;
battles: number;
average_damage: number;
};
members_count: number;
name: string;
creator_name: string;
clan_id: number;
created_at: number;
updated_at: number;
leader_name: string;
members_ids: number[];
recruiting_policy: string;
tag: string;
is_clan_disbanded: boolean;
old_name: string;
emblem_set_id: number;
creator_id: number;
motto: string;
renamed_at: number;
old_tag: null;
leader_id: number;
description: string;
}
export interface ClanInfo {
[clanId: number]: IndividualClanInfo;
}
export async function getClanInfo<
Ids extends number | number[],
ReturnType = Ids extends number ? IndividualClanInfo : IndividualClanInfo[],
>(region: Region, ids: Ids) {
const object = await fetchBlitz<ClanInfo>(
`https://api.wotblitz.${region}/wotb/clans/info/?application_id=${WARGAMING_APPLICATION_ID}&clan_id=${normalizeIds(
ids,
)}`,
);
if (typeof ids === 'number') {
return object[ids as number] as ReturnType;
} else {
return ids.map((id) => object[id]) as ReturnType;
}
}
+8 -8
View File
@@ -1,19 +1,19 @@
import { REGION_NAMES, Region } from '../../constants/regions';
import { Region } from '../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import { TanksStats } from '../../types/tanksStats';
import throwError from '../node/throwError';
import getWargamingResponse from './getWargamingResponse';
import { UserError } from '../blitzkrieg/userError';
import fetchBlitz from './fetchWargaming';
export default async function getTankStats(region: Region, id: number) {
const tankStats = await getWargamingResponse<TanksStats>(
const tankStats = await fetchBlitz<TanksStats>(
`https://api.wotblitz.${region}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
if (tankStats[id] === null) {
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 ${REGION_NAMES[region]} server.`,
);
throw new UserError('No tank stats available', {
cause:
"This player doesn't have any stats for tanks available. This may not be the player you are looking for.",
});
}
return tankStats[id];
+1 -1
View File
@@ -1,5 +1,5 @@
import { TreeTypeEnum } from '../../components/Tanks';
import { tankopedia } from './tankopedia';
import { tankopedia } from '../blitzstars/tankopedia';
export default async function getTreeType(id: number) {
const entry = (await tankopedia)[id];
-28
View File
@@ -1,28 +0,0 @@
import throwError from '../node/throwError';
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,
) {
const data = (await fetch(url).then((response) =>
response.json(),
)) as WargamingResponse<Data>;
if (data.status === 'ok') {
return data.data;
} else {
throw throwError(
`Wargaming response error status:"${data.status}"`,
`Message: "${data.error.message}"\nURL: "${url}"`,
);
}
}
+3
View File
@@ -0,0 +1,3 @@
export function normalizeExtra(extra?: string[]) {
return extra ? `&extra=${extra.join(',')}` : '';
}
+3
View File
@@ -0,0 +1,3 @@
export function normalizeIds(ids: number | number[]): string {
return typeof ids === 'number' ? ids.toString() : ids.join(',');
}
+14 -13
View File
@@ -1,18 +1,20 @@
import { go } from 'fuzzysort';
import throwError from '../node/throwError';
import { TANKS, tankopedia } from './tankopedia';
import { UserError } from '../blitzkrieg/userError';
import { TANKS, tankopedia } from '../blitzstars/tankopedia';
export default async function resolveTankId(tank: string) {
const number = parseInt(tank);
export default async function resolveTankId(tank: string | number) {
const number = typeof tank === 'string' ? parseInt(tank) : tank;
if (Number.isNaN(number)) {
const searchResult = go(tank, await TANKS, { keys: ['name'], limit: 1 });
const searchResult = go(`${tank}`, await TANKS, {
keys: ['name'],
limit: 1,
});
if (searchResult.length === 0) {
throw throwError(
'Tank not found',
`Could not find tank by the name "${tank}".`,
);
throw new UserError('Tank not found', {
cause: `Could not find tank by the name "${tank}".`,
});
} else {
return searchResult[0].obj.tank_id;
}
@@ -20,10 +22,9 @@ export default async function resolveTankId(tank: string) {
if ((await tankopedia)[number]) {
return number;
} else {
throw throwError(
'Tank not found',
`Could not find tank by the ID "${number}".`,
);
throw new UserError('Tank not found', {
cause: `Could not find tank by the ID "${number}".`,
});
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { tankopedia } from './tankopedia';
import { tankopedia } from '../blitzstars/tankopedia';
export default async function resolveTankName(tankId: number) {
const tankopediaName = (await tankopedia)[tankId]?.name;
@@ -1,20 +1,23 @@
import { Region } from '../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import { Clan, ClanList } from '../../types/clanList';
import getWargamingResponse from './getWargamingResponse';
import fetchBlitz from './fetchWargaming';
export type ClanListWithRegion = (Clan & {
region: 'com' | 'eu' | 'asia';
})[];
export default async function listClans(search: string, limit = 9) {
export default async function searchClansAcrossRegions(
search: string,
limit = 9,
) {
const trimmed = search.trim();
if (trimmed.length < 3 && trimmed.length > 100) return [];
const normalizedLimit = Math.round(limit / 3);
return (
await Promise.all([
getWargamingResponse<ClanList>(
fetchBlitz<ClanList>(
`https://api.wotblitz.com/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`,
).then(
(value) =>
@@ -24,7 +27,7 @@ export default async function listClans(search: string, limit = 9) {
region: 'com' as Region,
})),
),
getWargamingResponse<ClanList>(
fetchBlitz<ClanList>(
`https://api.wotblitz.eu/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`,
).then(
(value) =>
@@ -34,7 +37,7 @@ export default async function listClans(search: string, limit = 9) {
region: 'eu' as Region,
})),
),
getWargamingResponse<ClanList>(
fetchBlitz<ClanList>(
`https://api.wotblitz.asia/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`,
).then(
(value) =>
@@ -1,6 +1,6 @@
import { Region } from '../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import getWargamingResponse from './getWargamingResponse';
import fetchBlitz from './fetchWargaming';
interface Account {
nickname: string;
@@ -14,7 +14,10 @@ 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 listPlayers(search: string, limit = 9) {
export default async function searchPlayersAcrossRegions(
search: string,
limit = 9,
) {
const trimmedSearch = search.trim();
const normalizedLimit = Math.round(limit / 3);
const encodedSearch = encodeURIComponent(trimmedSearch);
@@ -22,7 +25,7 @@ export default async function listPlayers(search: string, limit = 9) {
if (usernamePattern.test(trimmedSearch)) {
return (
await Promise.all([
getWargamingResponse<AccountList>(
fetchBlitz<AccountList>(
`https://api.wotblitz.com/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`,
).then(
(value) =>
@@ -32,7 +35,7 @@ export default async function listPlayers(search: string, limit = 9) {
region: 'com' as Region,
})),
),
getWargamingResponse<AccountList>(
fetchBlitz<AccountList>(
`https://api.wotblitz.eu/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`,
).then(
(value) =>
@@ -42,7 +45,7 @@ export default async function listPlayers(search: string, limit = 9) {
region: 'eu' as Region,
})),
),
getWargamingResponse<AccountList>(
fetchBlitz<AccountList>(
`https://api.wotblitz.asia/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`,
).then(
(value) =>
+23
View File
@@ -0,0 +1,23 @@
import { PrismaClient } from '@prisma/client';
import { Region } from '../../constants/regions';
export const discordBlitzDB = new PrismaClient();
export function linkBlitzAndDiscord(
discord: number,
region: Region,
blitz: number,
) {
return discordBlitzDB.discord_blitz.upsert({
where: { discord },
update: { region, blitz },
create: { discord, region, blitz },
});
}
export function getBlitzFromDiscord(discord: number) {
return discordBlitzDB.discord_blitz.findUnique({
where: { discord },
select: { region: true, blitz: true },
}) as Promise<null | { region: Region; blitz: number }>;
}
+3
View File
@@ -0,0 +1,3 @@
export function emblemIdToURL(emblemId: number) {
return `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${emblemId}.png`;
}
@@ -0,0 +1,27 @@
import { TREE_TYPE_NAMES } from '../../components/Tanks';
import resolveTankName from '../blitz/resolveTankName';
import {
TIER_ROMAN_NUMERALS,
Tier,
tankopediaInfo,
} from '../blitzstars/tankopedia';
import { StatFilters } from '../statistics/filterStats';
export async function filtersToDescription({
nation,
tier,
tankType,
treeType,
tank,
}: StatFilters) {
const awaitedTankopediaInfo = await tankopediaInfo;
const info: string[] = [];
if (tank) return await resolveTankName(tank);
if (nation) info.push(awaitedTankopediaInfo.vehicle_nations[nation]);
if (tier) info.push(`Tier ${TIER_ROMAN_NUMERALS[tier as Tier]}`);
if (tankType) info.push(awaitedTankopediaInfo.vehicle_types[tankType]);
if (treeType) info.push(TREE_TYPE_NAMES[treeType]);
return info.length === 0 ? 'No filters' : info.join(', ');
}
@@ -1,25 +1,44 @@
import { RatingsInfo } from '../../commands/ratings';
import { Region } from '../../constants/regions';
import { DATABASE_REPO } from '../database/getMidnightLeaderboard';
import { octokit } from '../github/octokit';
import throwError from '../node/throwError';
import { DATABASE_REPO } from './getMidnightLeaderboard';
import { octokit } from './octokit';
const ARCHIVED_RATINGS_CACHE: Record<
Region,
Record<
number,
RatingsInfo & {
detail: undefined;
}
>
> = {
com: {},
eu: {},
asia: {},
};
export default async function getArchivedRatingsInfo(
season: number,
region: Region,
) {
if (ARCHIVED_RATINGS_CACHE[region][season]) {
return ARCHIVED_RATINGS_CACHE[region][season];
}
const { data } = await octokit.repos.getContent({
...DATABASE_REPO,
path: `${region}/ratings/${season}/info.json`,
});
if (Array.isArray(data) || data.type !== 'file')
throw throwError('Archived ratings info is malformed');
throw new Error('Archived ratings info is malformed');
const content = Buffer.from(data.content, 'base64').toString();
const jsonContent = JSON.parse(content) as RatingsInfo & {
detail: undefined;
};
ARCHIVED_RATINGS_CACHE[region][season] = jsonContent;
return jsonContent;
}
File renamed without changes.
@@ -1,13 +1,26 @@
import { BlitzkriegRatingsLeaderboard } from '../../../scripts/buildRatingsLeaderboard';
import { Region } from '../../constants/regions';
import { octokit } from '../github/octokit';
import { octokit } from './octokit';
export const DATABASE_REPO = { owner: 'tresabhi', repo: 'blitzkrieg-db' };
const MIDNIGHT_LEADERBOARD_CACHE: Record<
Region,
Record<number, BlitzkriegRatingsLeaderboard>
> = {
com: {},
eu: {},
asia: {},
};
export default async function getMidnightLeaderboard(
region: Region,
season: number,
) {
if (MIDNIGHT_LEADERBOARD_CACHE[region][season]) {
return MIDNIGHT_LEADERBOARD_CACHE[region][season];
}
try {
const { data } = await octokit.repos.getContent({
...DATABASE_REPO,
@@ -16,8 +29,11 @@ export default async function getMidnightLeaderboard(
if (!Array.isArray(data) && data.type === 'file') {
const response = await fetch(data.download_url!);
const json = (await response.json()) as BlitzkriegRatingsLeaderboard;
return (await response.json()) as BlitzkriegRatingsLeaderboard;
MIDNIGHT_LEADERBOARD_CACHE[region][season] = json;
return json;
}
} catch (error) {
console.warn(
File renamed without changes.
@@ -3,5 +3,7 @@ import { PeriodSize } from '../discord/addPeriodSubCommands';
import getTimeDaysAgo from './getTimeDaysAgo';
export default function getPeriodStart(region: Region, period: PeriodSize) {
return period === 'career' ? 0 : getTimeDaysAgo(region, parseInt(period));
if (period === 'career') return 0;
if (period === 'today') return getTimeDaysAgo(region, 1);
return getTimeDaysAgo(region, parseInt(period));
}
@@ -6,7 +6,7 @@ import {
getHistoriesDefaultOptions,
} from '../../types/histories';
import getTankStats from '../blitz/getTankStats';
import { emptyAllStats } from './getDiffedTankStats';
import { emptyAllStats } from '../blitzstars/getStatsInPeriod';
export interface TankHistory extends History {
tank_id: number;
File renamed without changes.
File renamed without changes.
File renamed without changes.
+9
View File
@@ -0,0 +1,9 @@
import jsxToSvg from './jsxToSvg';
import { svgToPngThreaded } from './svgToPngThreaded';
export default async function jsxToPngThreaded(jsx: JSX.Element) {
const svg = await jsxToSvg(jsx);
const png = await svgToPngThreaded(svg);
return png;
}
@@ -12,7 +12,6 @@ let roboto: Buffer;
let robotoBold: Buffer;
let robotoBlack: Buffer;
console.log('Importing fonts...');
Promise.all(
FONT_FILES.map(
(file) =>
@@ -27,7 +26,6 @@ Promise.all(
),
).then((data) => {
[roboto, robotoBold, robotoBlack] = data;
console.log('Fonts imported');
});
export default async function jsxToSvg(jsx: JSX.Element) {
@@ -1,4 +1,4 @@
import { Octokit } from '@octokit/rest';
import { secrets } from '../node/secrets';
import { secrets } from './secrets';
export const octokit = new Octokit({ auth: secrets.GH_TOKEN });
+29
View File
@@ -0,0 +1,29 @@
import { client } from '../discord/client';
import { discordBlitzDB } from './discordBlitz';
const EXIT_EVENTS = [
'beforeExit',
'exit',
'SIGINT',
'SIGTERM',
'SIGUSR1',
'SIGUSR2',
];
async function cleanup() {
console.log('Cleaning up...');
await Promise.all([discordBlitzDB.$disconnect(), client.destroy()]);
console.log('Gracefully exiting...');
EXIT_EVENTS.forEach((event) => process.off(event, cleanup));
process.exit(0);
}
export function registerProcesses() {
process.on('uncaughtException', console.error);
client.on('error', console.error);
EXIT_EVENTS.forEach((event) => process.on(event, cleanup));
}
@@ -2,7 +2,11 @@ import { config } from 'dotenv';
config();
export const SECRET_KEYS = ['DISCORD_TOKEN', 'GH_TOKEN'] as const;
export const SECRET_KEYS = [
'DISCORD_TOKEN',
'GH_TOKEN',
'DISCORD_BLITZ_DB',
] as const;
export type SECRET = typeof SECRET_KEYS extends readonly (infer T)[]
? T
File renamed without changes.
+46
View File
@@ -0,0 +1,46 @@
import { Worker } from 'worker_threads';
const MAX_WORKERS = 2;
const workers = [...Array(MAX_WORKERS)].map(() => {
const worker = new Worker(`${__dirname}/workers/render.cjs`);
console.log(`🟡 Launching render worker ${worker.threadId}`);
return worker;
});
const queue: {
svg: string;
resolve: (png: Buffer) => void;
}[] = [];
function manageQueue() {
if (queue.length > 0 && workers.length > 0) {
const { svg, resolve } = queue.shift()!;
const worker = workers.shift()!;
function cleanup() {
workers.push(worker);
worker.off('message', handleMessage);
worker.off('error', handleError);
manageQueue();
}
function handleMessage(png: Uint8Array) {
cleanup();
resolve(Buffer.from(png));
}
function handleError(error: Error) {
cleanup();
throw error;
}
worker.postMessage(svg);
worker.on('message', handleMessage);
worker.on('error', handleError);
}
}
export function svgToPngThreaded(svg: string) {
return new Promise<Buffer>((resolve) => {
queue.push({ svg, resolve });
manageQueue();
});
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Discriminator for user caused errors to not report in logs
*/
export class UserError extends Error {}
@@ -0,0 +1,18 @@
import { Region } from '../../constants/regions';
import { getAccountInfo } from '../blitz/getAccountInfo';
import buttonLink from '../discord/buttonLink';
export async function getBlitzStarsLinkButton(
region: Region,
id: number,
tankId?: number,
) {
const { nickname } = await getAccountInfo(region, id);
return buttonLink(
`https://www.blitzstars.com/player/${region}/${nickname}${
tankId ? `/tank/${tankId!}` : ''
}`,
'BlitzStars',
);
}
+6 -34
View File
@@ -1,34 +1,13 @@
import { Region } from '../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import { AccountInfo, AllStats } from '../../types/accountInfo';
import {
GetHistoriesOptions,
Histories,
History,
getHistoriesDefaultOptions,
} from '../../types/histories';
import getWargamingResponse from '../blitz/getWargamingResponse';
import { emptyAllStats } from './getDiffedTankStats';
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: Region;
}
export type PlayerHistoriesRaw = PlayerHistoryRaw[];
import { getAccountInfo } from '../blitz/getAccountInfo';
import { PlayerStats } from './getPlayerStats';
import { emptyAllStats } from './getStatsInPeriod';
export default async function getPlayerHistories(
server: Region,
@@ -39,7 +18,7 @@ export default async function getPlayerHistories(
const response = await fetch(
`https://www.blitzstars.com/api/playerstats/${id}`,
);
const playerHistories = ((await response.json()) as PlayerHistoriesRaw).map(
const playerHistories = ((await response.json()) as PlayerStats).map(
(history) =>
({
all: history.statistics.all,
@@ -62,16 +41,9 @@ export default async function getPlayerHistories(
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];
const { last_battle_time, statistics } = await getAccountInfo(server, id);
latestNodes.push({ all, last_battle_time });
latestNodes.push({ all: statistics.all, last_battle_time });
}
const previousNodes: Histories = [];
+38
View File
@@ -0,0 +1,38 @@
import { Region } from '../../constants/regions';
import { AllStats } from '../blitz/getAccountInfo';
export interface IndividualPlayerStats {
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: Region;
}
export type PlayerStats = IndividualPlayerStats[];
export async function getPlayerStats<
Ids extends number | number[],
ReturnType = Ids extends number ? PlayerStats : PlayerStats[],
>(region: Region, ids: Ids) {
const stats = await Promise.all(
((typeof ids === 'number' ? [ids] : ids) as number[]).map((id) =>
fetch(`https://www.blitzstars.com/api/playerstats/${id}`).then(
(response) => response.json() as Promise<PlayerStats>,
),
),
);
if (typeof ids === 'number') return stats[0] as ReturnType;
return stats as ReturnType;
}
@@ -1,9 +1,9 @@
import { Region } from '../../constants/regions';
import { AllStats } from '../../types/accountInfo';
import { AllStats } from '../blitz/getAccountInfo';
import getTankHistories, {
TankHistories,
TankHistoryRaw,
} from './getTankHistories';
} from '../blitzkrieg/getTankHistories';
export const emptyAllStats: AllStats = {
battles: 0,
@@ -34,8 +34,13 @@ export const emptyTankHistoryNode: TankHistoryRaw = {
all: emptyAllStats,
};
export interface DiffedTankStats {
diff: Record<number, AllStats>;
order: number[];
}
// BIG TODO: merge this neatly with diffNormalizedTankStats
export default async function getDiffedTankStats(
export default async function getStatsInPeriod(
server: Region,
id: number,
start: number,
@@ -66,7 +71,7 @@ export default async function getDiffedTankStats(
const playedTanks: number[] = [];
// fetch history node right before "start" and right before "end"
const diffed: Record<number, AllStats> = {};
const diff: Record<number, AllStats> = {};
inRangeTanks.forEach((tankId) => {
const sortedHistory = tankSortedHistory[tankId];
const previousIndex =
@@ -77,30 +82,30 @@ export default async function getDiffedTankStats(
const latest =
sortedHistory[latestIndex] ?? sortedHistory[sortedHistory.length - 1];
function diff(get: (allStats: AllStats) => number) {
function d(get: (allStats: AllStats) => number) {
return get(latest.all) - get(previous.all);
}
// check if there was a change in battles as games in ratings do update last_battle_time
if (diff((a) => a.battles) > 0) {
diffed[tankId] = {
battles: diff((a) => a.battles),
capture_points: diff((a) => a.capture_points),
damage_dealt: diff((a) => a.damage_dealt),
damage_received: diff((a) => a.damage_received),
dropped_capture_points: diff((a) => a.dropped_capture_points),
frags: diff((a) => a.frags),
frags8p: diff((a) => a.frags8p),
hits: diff((a) => a.hits),
losses: diff((a) => a.losses),
if (d((a) => a.battles) > 0) {
diff[tankId] = {
battles: d((a) => a.battles),
capture_points: d((a) => a.capture_points),
damage_dealt: d((a) => a.damage_dealt),
damage_received: d((a) => a.damage_received),
dropped_capture_points: d((a) => a.dropped_capture_points),
frags: d((a) => a.frags),
frags8p: d((a) => a.frags8p),
hits: d((a) => a.hits),
losses: d((a) => a.losses),
max_frags: latest.all.max_frags,
max_xp: latest.all.max_xp,
shots: diff((a) => a.shots),
spotted: diff((a) => a.spotted),
survived_battles: diff((a) => a.survived_battles),
win_and_survived: diff((a) => a.win_and_survived),
wins: diff((a) => a.wins),
xp: diff((a) => a.xp),
shots: d((a) => a.shots),
spotted: d((a) => a.spotted),
survived_battles: d((a) => a.survived_battles),
win_and_survived: d((a) => a.win_and_survived),
wins: d((a) => a.wins),
xp: d((a) => a.xp),
};
playedTanks.push(tankId);
@@ -116,5 +121,5 @@ export default async function getDiffedTankStats(
return bLatest.last_battle_time - aLatest.last_battle_time;
});
return { diffed, order };
return { diff, order } satisfies DiffedTankStats;
}
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more