Compare commits

..
91 Commits
Author SHA1 Message Date
TrèsAbhi 90e012319a Merge pull request #30 from tresabhi/1.12.7 2023-09-08 21:35:46 -05:00
TresAbhi 18de9cbaab feat: upgraded dependencies 2023-09-09 02:35:14 +00:00
TresAbhi 42a76cbd38 feat: changelogs 2023-09-09 02:19:20 +00:00
TresAbhi eb95634801 fix: escaped markdown for clan event title 2023-09-09 02:11:32 +00:00
TresAbhi de8bfff47f feat: clearer statement about timezones 2023-09-09 02:10:54 +00:00
TresAbhi 07c18911df feat: clearer statement about public data 2023-09-09 02:06:09 +00:00
TresAbhi cc769cc330 fix: react is now defined 2023-09-09 02:04:36 +00:00
TrèsAbhi 9794f58936 Update cd-host.yml 2023-09-08 11:50:31 -05:00
TrèsAbhi a44fcff3b1 Update cd-host.yml 2023-09-08 11:48:41 -05:00
TrèsAbhi 7e4a3e88b9 Update cd-host.yml 2023-09-08 11:47:39 -05:00
TrèsAbhi 8fd45e2205 Merge pull request #29 from tresabhi/1.12.5 2023-09-08 11:45:15 -05:00
TresAbhi 7d235057e8 feat: changelogs 2023-09-08 16:41:06 +00:00
TresAbhi 63a8a67771 fix: better approximation message and players with 0 wins are omitted 2023-09-08 16:38:08 +00:00
TresAbhi bac39ca176 fix: only use players who have played within the last day 2023-09-08 16:30:44 +00:00
TrèsAbhi 626b1e8601 Merge branch '1.13.0' into main 2023-09-07 15:23:10 -05:00
TresAbhi 04086f31b9 fix: spelling 2023-09-07 03:25:07 +00:00
TresAbhi 7037aa9c3b feat: 1.12.5 2023-09-07 03:24:07 +00:00
TresAbhi f5bab8a698 faet: clan-event 2023-09-07 01:24:12 +00:00
TresAbhi ee44b1b1d8 feat: next js 2023-09-07 01:13:03 +00:00
TresAbhi d0f91df95b feat: split bot and server builds 2023-09-04 16:49:38 +00:00
TrèsAbhi dc1c49da6a Merge pull request #27 from tresabhi/main 2023-09-03 13:24:34 -05:00
TrèsAbhi 331040bbdf Merge pull request #26 from tresabhi/1.12.3 2023-09-03 13:21:00 -05:00
TresAbhi 70e1d4b30d feat: changelogs 2023-09-03 18:20:19 +00:00
TresAbhi 03dbd797fb feat: no ongoing season proof 2023-09-03 18:15:10 +00:00
TresAbhi eb4cb1fbfe feat: removed inDevelopment 2023-08-24 00:08:08 +00:00
TrèsAbhi 28172f7f8f Merge pull request #25 from tresabhi/1.12.2 2023-08-23 09:52:48 -05:00
TresAbhi cb5347cbb8 feat: changelog 2023-08-23 14:51:51 +00:00
TresAbhi 1ce0781d2d feat: use application directory 2023-08-23 00:34:37 +00:00
TresAbhi 1257aa76d7 feat: code split bot 2023-08-23 00:33:26 +00:00
TresAbhi 6ec3835896 feat: updated dependencies 2023-08-23 00:18:25 +00:00
TresAbhi 506fe7d9bd feat: removed unused dependencies 2023-08-23 00:17:25 +00:00
TresAbhi 14ef9f6295 feat: removed unused files 2023-08-23 00:17:03 +00:00
TresAbhi 0d76e1a29e feat: added knip 2023-08-23 00:16:39 +00:00
TresAbhi 9e42d7e8f5 feat: removed powered by footer 2023-08-23 00:15:45 +00:00
TresAbhi 364f97332b feat: better and unified tier selection 2023-08-23 00:14:41 +00:00
TresAbhi caa7bb8a65 fix: removed random dev console log 2023-08-22 23:49:34 +00:00
TresAbhi 3e956b34c1 fix: clan name and icon now show up with multi-tank 2023-08-22 23:49:00 +00:00
TresAbhi 038374e5aa fix: changed examples to guide in navbar 2023-08-22 23:47:55 +00:00
TresAbhi dafd54f830 feat: github link 2023-08-22 23:47:35 +00:00
TresAbhi cb65e6b0af fix: corrected order of changelog versions 2023-08-22 23:46:59 +00:00
TresAbhi 4ca8590c83 fix: guide link 2023-08-22 23:46:15 +00:00
TresAbhi c5ef02d7e6 feat: updated dependencies 2023-08-22 23:45:47 +00:00
TrèsAbhi 8175aa8715 Merge pull request #24 from tresabhi/1.12.1 2023-08-11 13:03:52 -05:00
TresAbhi b426634659 feat: changelog 2023-08-11 18:03:41 +00:00
TresAbhi e92da1b700 fix: revert username pattern infrence 2023-08-11 18:01:31 +00:00
TresAbhi 7e0a2e88ee fix: tank id/index mismatch 2023-08-11 17:57:44 +00:00
TresAbhi 16afa8c2d4 feat: url change 2023-08-10 19:48:27 +00:00
TresAbhi 907288455a changed to js 2023-08-10 19:28:53 +00:00
TrèsAbhi 64689e4e3b Update package.json 2023-08-10 14:24:47 -05:00
TresAbhi 294377846a empty commit 2023-08-10 19:23:09 +00:00
TresAbhi 70e4cbaba2 fix: argv undefined 2023-08-10 19:14:43 +00:00
TrèsAbhi 9e2f9526fb Update package.dist.json 2023-08-10 13:58:15 -05:00
TrèsAbhi 84bec20fa5 Update cd-host.yml 2023-08-10 13:55:56 -05:00
TrèsAbhi e9a492fde6 Merge pull request #23 from tresabhi/1.12.0 2023-08-10 13:52:10 -05:00
TresAbhi 76a69f08b4 feat: docs 2023-08-10 18:51:22 +00:00
TresAbhi 893110d3ef feat: added 1 more decimal of precision in evolution 2023-08-10 01:49:05 +00:00
TresAbhi 70575fa9cf feat: accent-tolerant tank search 2023-08-10 01:46:39 +00:00
TresAbhi bb2450eb3c feat: added the new /today embeds 2023-08-10 01:31:05 +00:00
TresAbhi ab56689326 feat: updated /today with figma 2023-08-10 01:03:53 +00:00
TresAbhi a9cf07480a feat: added arabic numerals to owned tanks options 2023-08-10 00:37:54 +00:00
TresAbhi c81de2d758 feat: removed interaction based search param inference 2023-08-09 18:02:15 +00:00
TresAbhi aa1c7639f2 feat: auto refresh rate changed to every 10 seconds 2023-08-09 18:00:23 +00:00
TresAbhi d448101b32 fix: swapped start and end min and max 2023-08-09 17:56:35 +00:00
TresAbhi e806f0ede2 fix: swapped min and max for start and end 2023-08-09 17:51:19 +00:00
TresAbhi 8253c27333 fix: search parameters are now passed correctly 2023-08-09 17:39:21 +00:00
TresAbhi 254392370f feat: only interpret correctly formatter usernames 2023-08-09 17:27:38 +00:00
TresAbhi d2319688a5 fix: removed multi-tank from evolution 2023-08-09 17:25:05 +00:00
TresAbhi ffebe6c696 fix: use regional season info instead of hard-coded NA 2023-08-09 17:20:03 +00:00
TresAbhi 9e4094f3a9 feat: added ratings neighbours 2023-08-09 17:13:26 +00:00
TresAbhi f7eadb08a9 feat: made /ratings data-driven 2023-08-09 00:47:15 +00:00
TresAbhi d511cfa4cc fix: fixed ratings reward alignment 2023-08-09 00:35:33 +00:00
TresAbhi 270a3051b7 feat: updated /owned-tanks to new width 2023-08-09 00:19:50 +00:00
TresAbhi 81bc9f9ba6 feat: updated /inactive 2023-08-07 22:54:55 +00:00
TresAbhi 338cec4f18 feat: updated full-stats to new subcommands 2023-08-07 22:51:04 +00:00
TresAbhi b455e3380f feat: added minimum character limit to clan autocomplete 2023-08-06 19:13:28 +00:00
TresAbhi 92761f2c55 feat: made footer center aligned when without footer text 2023-08-06 19:08:02 +00:00
TresAbhi 7823b8ad2e feat: updated /evolution to new width 2023-08-06 19:07:44 +00:00
TresAbhi cac82f3124 fix: renamed statsfull to fullstats in power by bar 2023-08-06 19:00:32 +00:00
TresAbhi 4b892d06e4 fix: import types from stats instead of fullstats 2023-08-06 19:00:00 +00:00
TresAbhi 92149b0f00 feat: matched titlebar style to figma 2023-08-06 18:53:57 +00:00
TresAbhi 399aa4c03c feat: removed region text in title bar 2023-08-06 18:41:59 +00:00
TresAbhi d5de715032 fix: title bar spacing is now 4px just like figma 2023-08-06 18:39:23 +00:00
TresAbhi e6fb179184 feat: removed self-advert from title bar 2023-08-06 18:38:49 +00:00
TresAbhi af54f12ec3 feat: set width to 480px 2023-08-06 18:38:17 +00:00
TresAbhi 909cabd517 feat: filter functionality 2023-08-04 19:49:40 +00:00
TresAbhi a734a71d2f fix: duplicate efficienty cards 2023-08-04 18:25:20 +00:00
TresAbhi 91dc45c880 feat: added more mature periods 2023-08-04 18:24:20 +00:00
TresAbhi 91f364b76a feat: used dahses for command readibility 2023-08-04 02:24:14 +00:00
TresAbhi 23dd088466 feat: better subcommand organiztion 2023-08-04 02:13:21 +00:00
TresAbhi 2cabcc57f4 feat: moved ratings out of preview 2023-07-31 01:32:31 +00:00
TrèsAbhi dd6092a5e7 fix: GH_TOKEN 2023-07-17 19:37:12 -05:00
100 changed files with 3513 additions and 1885 deletions

No files matched your search

+4 -4
View File
@@ -22,11 +22,11 @@ jobs:
- name: Download release assets - name: Download release assets
run: | run: |
DOWNLOAD_URL=$(curl -s https://api.github.com/repos/tresabhi/blitzkrieg/releases/latest \ DOWNLOAD_URL=$(curl -s https://api.github.com/repos/tresabhi/blitzkrieg/releases/latest \
| grep "browser_download_url.*dist.zip" \ | grep "browser_download_url.*bot.zip" \
| cut -d : -f 2,3 \ | cut -d : -f 2,3 \
| tr -d \") | tr -d \")
curl -L -o dist.zip $DOWNLOAD_URL curl -L -o bot.zip $DOWNLOAD_URL
unzip dist.zip unzip bot.zip
- name: Run bot - name: Run bot
run: NODE_ENV=production DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} WARGAMING_APPLICATION_ID=${{ secrets.WARGAMING_APPLICATION_ID }} node --enable-source-maps --trace-warnings bot.cjs run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} WARGAMING_APPLICATION_ID=${{ secrets.WARGAMING_APPLICATION_ID }} node --enable-source-maps --trace-warnings bot/index.cjs
+2
View File
@@ -9,6 +9,8 @@ dist.*
yarn-error.log yarn-error.log
commands.txt commands.txt
/test.* /test.*
test.*
/docs/.vitepress/dist /docs/.vitepress/dist
/docs/.vitepress/cache /docs/.vitepress/cache
servers.csv servers.csv
.next/
+21 -8
View File
@@ -1,4 +1,7 @@
import { DefaultTheme, defineConfig } from 'vitepress'; import { compareVersions } from 'compare-versions';
import { readdirSync } from 'fs';
import { parse } from 'path';
import { defineConfig } from 'vitepress';
import { getSidebar } from 'vitepress-plugin-auto-sidebar'; import { getSidebar } from 'vitepress-plugin-auto-sidebar';
export default defineConfig({ export default defineConfig({
@@ -9,17 +12,27 @@ export default defineConfig({
themeConfig: { themeConfig: {
nav: [ nav: [
{ text: 'Home', link: '/' }, { text: 'Home', link: '/' },
{ text: 'Examples', link: '/markdown-examples' }, { text: 'Guide', link: '/guide/about' },
], ],
sidebar: getSidebar({ sidebar: [
collapsed: false, ...getSidebar({
contentDirs: ['guide', 'legal', 'changelogs'], collapsed: false,
contentRoot: '/docs/', contentDirs: ['guide', 'legal'],
}) as DefaultTheme.Sidebar, contentRoot: '/docs/',
}),
{
text: 'Changelogs',
items: readdirSync('./docs/changelogs')
.map((file) => parse(file).name)
.sort(compareVersions)
.map((name) => ({ text: name, link: `/changelogs/${name}.html` })),
},
],
socialLinks: [ socialLinks: [
{ icon: 'github', link: 'https://github.com/vuejs/vitepress' }, { icon: 'github', link: 'https://github.com/tresabhi/blitzkrieg' },
], ],
}, },
}); });
+1 -1
View File
@@ -12,7 +12,7 @@ I am going on vacation so I thought I'd publish this a bit prematurely without a
- Blitzkrieg now internally generates and keeps a permanent record of all leaderboards updated every `UTC+0` midnight - Blitzkrieg now internally generates and keeps a permanent record of all leaderboards updated every `UTC+0` midnight
- This approach will allow us to capture the absolute latest state of the leaderboard before it is wiped for the next season - This approach will allow us to capture the absolute latest state of the leaderboard before it is wiped for the next season
## Fixed ## Fixes
- Removed region "Europe" in `/verify` example - Removed region "Europe" in `/verify` example
+54
View File
@@ -0,0 +1,54 @@
# Blitzkrieg 1.12.0
_Houston, permission to return to Earth? A new update dropped._
## Features
- Added `multi-tank` filters to `/stats` and `/full-stats`
- This lets you pull up the stats by combining filters of `nation`, `tank-type`, `tier`, and `tree-type`
- Added `/ratings neighbours`
- Lets you see your rank and the neighbours around you
- Added a bunch more `/today` parameters (thank you for the suggestions, HisRoyalFatness!)
- `cutoff`: the max number of rows to show (excluding the total row)
- `maximized`: the max number of rows that show the full non-collapsed stats (default: `4`)
- `show-total`: show the total row or not (default: `true`)
- Tank search is now accent-tolerant
## Changes
- `/ratings` has been moved out of preview and is now accessible to all
- `/today` now has a more appealing look with more efficient space management
- `/stats`, `/full-stats`, and `/evolution` now have less convoluted commands
- All periods now fall under `period` and `custom`
- `period` has preset periods like `today`, `career`, `30`, `60`, `90`
- `custom` lets you set a start and end day
- Added dashes to command names, subcommands, options, etc. to make them more readable
- Titlebar
- Removed the Blitzkrieg logo
- Removed region
- Center aligned items
- Footers are now center alinged when they do no have footer text on the left
- Moved several title discriminators into the description
- `/ratings` is now data-driven
- Embeds now auto refresh every `30` seconds instead of `60`
- Added numbers besides roman numerals in tier choice
- I forgot to implement this in places other than `/owned-tanks`... oh well!
- Increased precision of winrate in `/evolution` to `2` decimals
## Fixes
- Removed duplicate "Efficiency" cards in `/full-stats`
- Titlebar gap is now `4px` just like Figma
- Renamed `/statsfull` to `/full-stats` in the `/stats` footer
- Rewards in `/ratings` are now properly aligned
- `/ratings` now uses the correct source of information per region instead of getting it from a cached North American source
- Search parameters are now passed onto the URL parser correctly
- Custom date ranges now work correctly once again as the start is now the maximum days ago from today and the end is the minimum
## Technical Changes
- All generated images are now `480px` wide instead of `640px` to combat the unnecessary amounts of negative space
- Changed all relevant typings to be sourced from `stats` instead of `full-stats`
- Added minimum character limit to clan search
- Username auto-completing now only takes in regex-accepted parts of the input
- Search parameters are now longer automatically generated by inferring command interactions
+8
View File
@@ -0,0 +1,8 @@
# Blitzkrieg 1.12.1
A duo of nasty bugs fixed.
## Fixes
- Fixed a tank index and id mismatch
- Reverted username pattern infrence in command username inputs
+29
View File
@@ -0,0 +1,29 @@
# Blitzkrieg 1.12.2
A much needed room service is here!
## Changes
- Tier selection is now easier
- Tier options across all commands have been unified to a single style
- Tiers are now sorted from `10` to `1`
- Tiers now show up in the format `Tier {tier_number} - {tier_roman_numeral}` (example: `Tier 8 - VIII`)
- Removed powered by footer as links are provided regardless
- Changed invite instructions to instead use application directory
## Fixes
- Guide link on the website is now fixed
- Versions now show up in the correct order in the navigation bar
- Added Blitzkrieg's GitHub link instead of the default Vitepress
- Changed examples to guide in the navigation bar
- Clan name and icon now show up with multi-tank filters
- Removed a random `console.log`
## Technical Changes
- Added `knip`
- Removed a lot of unused files and dependencies
- Updated all dependencies to their latest
- Code split `bot.ts`
- `client` and `octokit` now reside separately
+13
View File
@@ -0,0 +1,13 @@
# Blitzkrieg 1.12.3
3-day downtime fixed!
## Changes
- Split `/ratings` into `/ratings current` and `/ratings archive`
- `current` now properly handles when there is no ongoing ratings season
- `archive` displays older archived seasons
## Fixes
- The bot is now no ratings seasons tolerant
+7
View File
@@ -0,0 +1,7 @@
# Blitzkrieg 1.12.4
Added very badly-coded temporary `/clan-event` command (without linting)
## Changes
- Added `/clan-event` command
+12
View File
@@ -0,0 +1,12 @@
# Blitzkrieg 1.12.5
Some patch up work
## Changes
- `/clan-event` now is more legible and comes with an approximation warning
## Fixes
- `/clan-event` now uses a more optimized way of fetching large data
- This should fix the issue where too many people using the command broke it
+12
View File
@@ -0,0 +1,12 @@
# Blitzkrieg 1.12.6
Stops making the bot lie!
## Changes
- The approximation warning for `/clan-event` has been made more concise
- Players with `0` wins are now omitted
## Fixes
- Players who haven't player within the current day are now omitted
+13
View File
@@ -0,0 +1,13 @@
# Blitzkrieg 1.12.7
A few fixes and clarifications
## Changes
- `/clan-event`
- Made statement about the limited publicly available data
- Added a statement about reset times
## Fixes
- Commands with images now once again work
+1 -8
View File
@@ -1,10 +1,3 @@
# Inviting the Bot # Inviting the Bot
It's quite simple really. Just follow the steps below: Go to [application directory for Blitzkrieg](https://discord.com/application-directory/1097673957865443370) and click "Add to Server." Voila! You're ready to go.
1. Join a Discord server that already has Blitzkrieg installed or [the official Discord Server](https://discord.gg/nDt7AjGJQH).
2. Click on the bot's profile.
3. Click the "Add to Server" button under the bot's name and ID.
4. Select what server you want it in and see what permissions you seem fit for the bot.
Voila! You're ready to go.
+1 -1
View File
@@ -15,7 +15,7 @@ hero:
link: guide/install link: guide/install
- theme: alt - theme: alt
text: Guide text: Guide
link: /guide link: /guide/about
features: features:
- title: Clean and Efficient UI - title: Clean and Efficient UI
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
distDir: 'dist/website',
};
export default nextConfig;
+2 -1
View File
@@ -1,5 +1,6 @@
{ {
"main": "server.cjs", "main": "server.js",
"type": "module",
"scripts": { "scripts": {
"start": "node --enable-source-maps --trace-warnings server.cjs" "start": "node --enable-source-maps --trace-warnings server.cjs"
} }
+32 -24
View File
@@ -1,55 +1,63 @@
{ {
"name": "blitzkrieg", "name": "blitzkrieg",
"version": "1.10.2", "version": "1.12.7",
"description": "", "description": "🎉 All-in-one Discord bot for everything World of Tanks Blitz",
"main": "src/server.ts", "main": "src/server.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build:bot": "vite-node --script scripts/build --build=bot", "build": "node scripts/build && yarn build:website",
"build:server": "vite-node --script scripts/build --build=server", "build:bot": "node scripts/build --build=bot",
"build:server": "node scripts/build --build=server",
"build:website": "next build",
"build:docs": "vitepress build docs", "build:docs": "vitepress build docs",
"build:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard", "build:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard",
"build": "vite-node --script scripts/build --build=all", "dev:bot": "yarn build:bot && yarn start:bot",
"dev:bot": "yarn build:bot --dev && yarn start:bot", "dev:server": "yarn build:server && yarn start:server",
"dev:server": "yarn build:server --dev && yarn start:server",
"dev:docs": "vitepress dev docs", "dev:docs": "vitepress dev docs",
"start:bot": "node --enable-source-maps --trace-warnings dist/bot.cjs", "dev:website": "next dev",
"start:server": "node --enable-source-maps --trace-warnings dist/server.cjs", "start:bot": "node --enable-source-maps --trace-warnings dist/bot/index.cjs",
"start:server": "node --enable-source-maps --trace-warnings dist/server/index.cjs",
"start:website": "next start",
"host:docs": "vitepress preview docs", "host:docs": "vitepress preview docs",
"publish:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard --publish", "publish:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard --publish",
"zip": "zip -r dist.zip dist", "zip": "zip -r dist.zip dist",
"lint": "npx tsc", "lint": "npx tsc",
"format": "prettier --write \"**/*.@(json|js|jsx|css|md|ts|tsx|html|xml|yml|gltf)\"" "lint:website": "next lint",
"format": "prettier --write \"**/*.@(json|js|jsx|css|md|ts|tsx|html|xml|yml|gltf)\"",
"knip": "knip",
"update": "yarn upgrade --latest"
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@octokit/rest": "^20.0.1", "@octokit/rest": "^20.0.1",
"@radix-ui/colors": "^0.1.8", "@radix-ui/colors": "^2.1.0",
"@resvg/resvg-js": "^2.4.1", "@resvg/resvg-js": "^2.4.1",
"bepaint": "^0.1.0-beta.3", "bepaint": "^0.1.0-beta.3",
"discord.js": "^14.9.0", "discord.js": "^14.13.0",
"esbuild": "^0.18.10", "esbuild": "^0.19.2",
"escape-html": "^1.0.3",
"express": "^4.18.2", "express": "^4.18.2",
"false": "^0.0.4",
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"markdown-escape": "^2.0.0", "markdown-escape": "^2.0.0",
"next": "^13.4.19",
"react": "^18.2.0", "react": "^18.2.0",
"satori": "^0.9.1", "react-dom": "^18.2.0",
"vite-node": "^0.33.0", "satori": "^0.10.4",
"vite-node": "^0.34.4",
"vitepress-plugin-auto-sidebar": "^1.1.0" "vitepress-plugin-auto-sidebar": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {
"@total-typescript/ts-reset": "^0.4.2", "@total-typescript/ts-reset": "^0.5.1",
"@types/escape-html": "^1.0.2", "@types/escape-html": "^1.0.2",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/lodash": "^4.14.195", "@types/lodash": "^4.14.198",
"@types/markdown-escape": "^1.1.0", "@types/markdown-escape": "^1.1.0",
"@types/node": "^20.2.1", "@types/node": "^20.6.0",
"@types/react": "^18.2.7", "@types/react": "^18.2.21",
"prettier": "^3.0.0", "compare-versions": "^6.1.0",
"typescript": "^5.0.4", "knip": "^2.22.0",
"vitepress": "^1.0.0-beta.3" "prettier": "^3.0.3",
"typescript": "^5.2.2",
"vitepress": "^1.0.0-rc.10"
} }
} }
+33
View File
@@ -0,0 +1,33 @@
import { build } from 'esbuild';
import { argv } from 'process';
const targetFile = argv
.find((arg) => arg.startsWith('--build='))
?.split('=')[1];
const files = targetFile ? [targetFile] : ['bot', 'server'];
files.forEach(async (file) => {
console.log(`Building ${file}...`);
await build({
entryPoints: [`src/${file}.ts`],
outfile: `dist/${file}/index.cjs`,
platform: 'node',
loader: {
'.node': 'copy',
'.ttf': 'file',
},
format: 'cjs',
bundle: true,
sourcemap: true,
minifyIdentifiers: false, // cause goofy errors
minifySyntax: true,
minifyWhitespace: true,
logLevel: 'info',
});
console.log(`Built ${file}`);
});
-62
View File
@@ -1,62 +0,0 @@
import { BuildOptions, build } from 'esbuild';
import { copyFile, mkdir, rm } from 'fs/promises';
import { argv } from 'process';
const isProd = !argv.includes('--dev');
const buildAll = argv.includes('--build=all');
const buildBot = argv.includes('--build=bot') || buildAll;
const buildServer = argv.includes('--build=server') || buildAll;
if (isProd) {
// only remove in production to ensure no time is wasted in dev
console.log('Removing dist...');
await rm('dist', { recursive: true, force: true });
console.log('Dist removed');
console.log('Creating dist...');
await mkdir('dist');
console.log('Dist created');
console.log('Copying package.json...');
copyFile('package.dist.json', 'dist/package.json').then(() =>
console.log('package.json copied'),
);
}
const commonOptions: BuildOptions = {
platform: 'node',
loader: {
'.node': 'copy',
'.ttf': 'file',
},
bundle: true,
sourcemap: true,
minifyIdentifiers: false, // causes errors
minifySyntax: isProd,
minifyWhitespace: isProd,
logLevel: isProd ? 'info' : 'silent',
};
if (buildBot) {
console.log('Building bot...');
build({
...commonOptions,
entryPoints: ['src/bot.ts'],
outfile: 'dist/bot.cjs',
}).then(() => console.log('Bot built'));
}
if (buildServer) {
console.log('Building server...');
build({
...commonOptions,
entryPoints: ['src/server.ts'],
outfile: 'dist/server.cjs',
}).then(() => {
console.log('Server built');
});
}
+10 -3
View File
@@ -31,7 +31,11 @@ const PLAYERS = await fetch(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`, `https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
) )
.then((response) => response.json() as Promise<RatingsInfo>) .then((response) => response.json() as Promise<RatingsInfo>)
.then((data) => data.count); .then((data) => (data.detail === undefined ? data.count : undefined));
if (PLAYERS === undefined) {
throw new Error('No current ratings season running');
}
const players: Record<number, RatingsPlayer> = {}; const players: Record<number, RatingsPlayer> = {};
const leaderboard: BlitzkriegRatingsLeaderboard = []; const leaderboard: BlitzkriegRatingsLeaderboard = [];
@@ -60,7 +64,7 @@ async function branchFromPlayer(id: number) {
console.log( console.log(
`Registered ${registered} / ${PLAYERS} (${( `Registered ${registered} / ${PLAYERS} (${(
(registered / PLAYERS) * (registered / PLAYERS!) *
100 100
).toFixed(2)}%)`, ).toFixed(2)}%)`,
); );
@@ -109,7 +113,10 @@ if (publish) {
const info = await fetch( const info = await fetch(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`, `https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
).then((response) => response.json() as Promise<RatingsInfo>); ).then(
(response) =>
response.json() as Promise<RatingsInfo & { detail: undefined }>,
);
const normalizedServer = server === 'na' ? 'com' : server; const normalizedServer = server === 'na' ? 'com' : server;
const infoPath = `${normalizedServer}/ratings/${info.current_season}/info.json`; const infoPath = `${normalizedServer}/ratings/${info.current_season}/info.json`;
+31
View File
@@ -0,0 +1,31 @@
import { ReactNode } from 'react';
import Navbar from '../components/Navbar';
interface RootLayoutProps {
children: ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html>
<body
style={{
margin: 0,
}}
>
<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
}}
>
<Navbar />
{children}
</div>
</body>
</html>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function Page() {
return <h1>Hello, Next.js!</h1>;
}
+1 -14
View File
@@ -1,19 +1,6 @@
import { Octokit } from '@octokit/rest'; import { client } from './core/discord/client';
import { Client, GatewayIntentBits } from 'discord.js';
import { secrets } from './core/node/secrets'; import { secrets } from './core/node/secrets';
import { registerErrorHandlers } from './events/error'; import { registerErrorHandlers } from './events/error';
import guildMemberAdd from './events/guildMemberAdd';
import interactionCreate from './events/interactionCreate';
import ready from './events/ready';
export const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
})
.on('ready', ready)
.on('guildMemberAdd', guildMemberAdd)
.on('interactionCreate', interactionCreate);
export const octokit = new Octokit({ auth: secrets.GH_TOKEN });
registerErrorHandlers(); registerErrorHandlers();
client.login(secrets.DISCORD_TOKEN); client.login(secrets.DISCORD_TOKEN);
+97
View File
@@ -0,0 +1,97 @@
import { SlashCommandBuilder, escapeMarkdown } from 'discord.js';
import markdownEscape from 'markdown-escape';
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 { secrets } from '../core/node/secrets';
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: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName('clan-event')
.setDescription('Lists all inactive players')
.addStringOption(addClanChoices),
async handler(interaction) {
const { region, id } = await resolveClanFromCommand(interaction);
const clan = (
await getWargamingResponse<ClanInfo>(
`https://api.wotblitz.${region}/wotb/clans/info/?application_id=${secrets.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=${secrets.WARGAMING_APPLICATION_ID}&account_id=${memberIds}`,
),
getWargamingResponse<AccountAchievements>(
`https://api.wotblitz.${region}/wotb/account/achievements/?application_id=${secrets.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,
}));
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')}`,
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 `/help timezones` for more info)",
),
];
},
autocomplete: autocompleteClan,
};
+1 -2
View File
@@ -1,8 +1,8 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import packageJSON from '../../package.json' assert { type: 'json' }; import packageJSON from '../../package.json' assert { type: 'json' };
import { client } from '../bot';
import { tankopedia } from '../core/blitz/tankopedia'; import { tankopedia } from '../core/blitz/tankopedia';
import { tankAverages } from '../core/blitzstars/tankAverages'; import { tankAverages } from '../core/blitzstars/tankAverages';
import { client } from '../core/discord/client';
import embedInfo from '../core/discord/embedInfo'; import embedInfo from '../core/discord/embedInfo';
import markdownTable from '../core/discord/markdownTable'; import markdownTable from '../core/discord/markdownTable';
import getClientId from '../core/node/getClientId'; import getClientId from '../core/node/getClientId';
@@ -12,7 +12,6 @@ const executionStart = new Date().getTime();
export const debugCommand: CommandRegistry = { export const debugCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: true, inPublic: true,
command: new SlashCommandBuilder() command: new SlashCommandBuilder()
-1
View File
@@ -26,7 +26,6 @@ export const SKILLED_CLANS: Record<SkilledClan, string> = {
export const eligibleCommand: CommandRegistry = { export const eligibleCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: false, inPublic: false,
command: new SlashCommandBuilder() command: new SlashCommandBuilder()
+73 -67
View File
@@ -13,75 +13,81 @@ import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton'; import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { secrets } from '../core/node/secrets'; import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate'; import { CommandRegistryRaw } from '../events/interactionCreate';
import evolution from '../renderers/evolution'; import evolution from '../renderers/evolution';
import { StatType } from '../renderers/fullStats'; import { StatType } from '../renderers/stats';
import { AccountInfo } from '../types/accountInfo'; import { AccountInfo } from '../types/accountInfo';
export const evolutionCommand: CommandRegistry = { export const evolutionCommand = new Promise<CommandRegistryRaw>(
inProduction: true, async (resolve) => {
inDevelopment: false, const command = await addStatTypeSubCommandGroups(
inPublic: true, new SlashCommandBuilder()
.setName('evolution')
command: addStatTypeSubCommandGroups( .setDescription('Evolution of statistics'),
new SlashCommandBuilder() false,
.setName('evolution')
.setDescription('Evolution of statistics'),
),
async handler(interaction) {
const commandGroup = interaction.options.getSubcommandGroup(
true,
) as StatType;
const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(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=${secrets.WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
const path = interactionToURL(interaction, {
...player,
tankId,
start,
end,
});
return [
await evolution(commandGroup, period, player, tankId),
primaryButton(path, 'Refresh'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.region}/${nickname}${
commandGroup === 'tank' ? `/tank/${tankId!}` : ''
}`,
'BlitzStars',
),
];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const url = new URL(`${CYCLIC_API}/${interaction.customId}`);
const path = url.pathname.split('/').filter(Boolean);
const commandGroup = path[1] as StatType;
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction);
return await evolution(
commandGroup,
period,
player,
parseInt(url.searchParams.get('tankId')!),
); );
resolve({
inProduction: true,
inPublic: true,
command,
async handler(interaction) {
const commandGroup = interaction.options.getSubcommandGroup(
true,
) as StatType;
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=${secrets.WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
const path = interactionToURL(interaction, {
...player,
tankId,
start,
end,
});
return [
await evolution(commandGroup, period, player, tankId),
primaryButton(path, 'Refresh'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.region}/${nickname}${
commandGroup === 'tank' ? `/tank/${tankId!}` : ''
}`,
'BlitzStars',
),
];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const url = new URL(`${CYCLIC_API}/${interaction.customId}`);
const path = url.pathname.split('/').filter(Boolean);
const commandGroup = path[1] as StatType;
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction);
return await evolution(
commandGroup,
period,
player,
parseInt(url.searchParams.get('tankId')!),
);
},
});
}, },
}; );
+95 -65
View File
@@ -1,4 +1,5 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import { TreeTypeString } from '../components/Tanks';
import { CYCLIC_API } from '../constants/cyclic'; import { CYCLIC_API } from '../constants/cyclic';
import getWargamingResponse from '../core/blitz/getWargamingResponse'; import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankId from '../core/blitz/resolveTankId'; import resolveTankId from '../core/blitz/resolveTankId';
@@ -13,72 +14,101 @@ import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton'; import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { secrets } from '../core/node/secrets'; import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate'; import { CommandRegistryRaw } from '../events/interactionCreate';
import fullStats, { StatType } from '../renderers/fullStats'; import fullStats from '../renderers/fullStats';
import { MultiTankFilters, StatType } from '../renderers/stats';
import { AccountInfo } from '../types/accountInfo'; import { AccountInfo } from '../types/accountInfo';
export const fullStatsCommand: CommandRegistry = { export const fullStatsCommand = new Promise<CommandRegistryRaw>(
inProduction: true, async (resolve) => {
inDevelopment: false, const command = await addStatTypeSubCommandGroups(
inPublic: true, new SlashCommandBuilder()
.setName('full-stats')
command: addStatTypeSubCommandGroups( .setDescription('Full in-game statistics'),
new SlashCommandBuilder()
.setName('fullstats')
.setDescription('Full in-game statistics'),
),
async handler(interaction) {
const commandGroup = interaction.options.getSubcommandGroup(
true,
) as StatType;
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 path = interactionToURL(interaction, {
...player,
tankId,
start,
end,
});
const { nickname } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
return [
await fullStats(commandGroup, period, player, tankId),
primaryButton(path, 'Refresh'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.region}/${nickname}`,
'BlitzStars',
),
];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const url = new URL(`${CYCLIC_API}/${interaction.customId}`);
const path = url.pathname.split('/').filter(Boolean);
const commandGroup = path[1] as StatType;
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction);
return await fullStats(
commandGroup,
period,
player,
parseInt(url.searchParams.get('tankId')!),
); );
resolve({
inProduction: true,
inPublic: true,
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, {
...player,
tankId,
start,
end,
period,
});
const { nickname } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${secrets.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',
),
];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const url = new URL(`${CYCLIC_API}/${interaction.customId}`);
const path = url.pathname.split('/').filter(Boolean);
const commandGroup = path[1] as StatType;
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction);
return await fullStats(
commandGroup,
period,
player,
parseInt(url.searchParams.get('tankId')!),
);
},
});
}, },
}; );
+4 -4
View File
@@ -30,7 +30,6 @@ function addDocsSubcommands(option: SlashCommandSubcommandsOnlyBuilder) {
export const helpCommand: CommandRegistry = { export const helpCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: true, inPublic: true,
command: addDocsSubcommands( command: addDocsSubcommands(
@@ -44,13 +43,14 @@ export const helpCommand: CommandRegistry = {
), ),
), ),
handler(interaction) { async handler(interaction) {
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
if (subcommand === 'commands') { if (subcommand === 'commands') {
return `# Commands\n\nBlitzkrieg offers the following commands:\n\n${COMMANDS_RAW.filter( return `# Commands\n\nBlitzkrieg offers the following commands:\n\n${(
(registry) => registry.inPublic && registry.inProduction, await Promise.all(COMMANDS_RAW)
) )
.filter((registry) => registry.inPublic && registry.inProduction)
.map( .map(
(registry) => (registry) =>
`- \`/${registry.command.name}\`: ${registry.command.description}`, `- \`/${registry.command.name}\`: ${registry.command.description}`,
+5 -14
View File
@@ -1,14 +1,12 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import GenericStats from '../components/GenericStats'; import GenericStats from '../components/GenericStats';
import NoData, { NoDataType } from '../components/NoData'; import NoData, { NoDataType } from '../components/NoData';
import PoweredBy, { PoweredByType } from '../components/PoweredBy';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper'; import Wrapper from '../components/Wrapper';
import { REGION_NAMES } from '../constants/regions';
import getBlitzClan from '../core/blitz/getBlitzClan';
import getWargamingResponse from '../core/blitz/getWargamingResponse'; import getWargamingResponse from '../core/blitz/getWargamingResponse';
import addClanChoices from '../core/discord/addClanChoices'; import addClanChoices from '../core/discord/addClanChoices';
import autocompleteClan from '../core/discord/autocompleteClan'; import autocompleteClan from '../core/discord/autocompleteClan';
import resolveClanFromCommand from '../core/discord/resolveClanFromCommand';
import { secrets } from '../core/node/secrets'; import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate'; import { CommandRegistry } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo'; import { AccountInfo } from '../types/accountInfo';
@@ -18,7 +16,6 @@ const DEFAULT_THRESHOLD = 7;
export const inactiveCommand: CommandRegistry = { export const inactiveCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: true, inPublic: true,
command: new SlashCommandBuilder() command: new SlashCommandBuilder()
@@ -35,19 +32,18 @@ export const inactiveCommand: CommandRegistry = {
), ),
async handler(interaction) { async handler(interaction) {
const clanName = interaction.options.getString('clan')!; const { region, id } = await resolveClanFromCommand(interaction);
const { server, id } = await getBlitzClan(clanName);
const threshold = const threshold =
interaction.options.getNumber('threshold')! ?? DEFAULT_THRESHOLD; interaction.options.getNumber('threshold')! ?? DEFAULT_THRESHOLD;
const time = new Date().getTime() / 1000; const time = new Date().getTime() / 1000;
const clanData = ( const clanData = (
await getWargamingResponse<ClanInfo>( await getWargamingResponse<ClanInfo>(
`https://api.wotblitz.${server}/wotb/clans/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&clan_id=${id}`, `https://api.wotblitz.${region}/wotb/clans/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&clan_id=${id}`,
) )
)[id]; )[id];
const memberIds = clanData.members_ids; const memberIds = clanData.members_ids;
const accounts = await getWargamingResponse<AccountInfo>( const accounts = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${ `https://api.wotblitz.${region}/wotb/account/info/?application_id=${
secrets.WARGAMING_APPLICATION_ID secrets.WARGAMING_APPLICATION_ID
}&account_id=${memberIds.join(',')}`, }&account_id=${memberIds.join(',')}`,
); );
@@ -69,17 +65,12 @@ export const inactiveCommand: CommandRegistry = {
<Wrapper> <Wrapper>
<TitleBar <TitleBar
name={clanData.name} name={clanData.name}
nameDiscriminator="(Inactivity)"
image={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData.emblem_set_id}.png`} image={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData.emblem_set_id}.png`}
description={`${threshold}+ Days • ${new Date().toDateString()} • ${ description={`Inactive for ${threshold}+ Days • ${new Date().toDateString()}`}
REGION_NAMES[server]
}`}
/> />
{!hasInactiveMembers && <NoData type={NoDataType.PlayersInPeriod} />} {!hasInactiveMembers && <NoData type={NoDataType.PlayersInPeriod} />}
{hasInactiveMembers && <GenericStats stats={inactive} />} {hasInactiveMembers && <GenericStats stats={inactive} />}
<PoweredBy type={PoweredByType.Wargaming} />
</Wrapper> </Wrapper>
); );
}, },
+87 -64
View File
@@ -1,19 +1,20 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import NoData, { NoDataType } from '../components/NoData'; import NoData, { NoDataType } from '../components/NoData';
import PoweredBy, { PoweredByType } from '../components/PoweredBy';
import * as Tanks from '../components/Tanks'; import * as Tanks from '../components/Tanks';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper'; import Wrapper from '../components/Wrapper';
import { REGION_NAMES } from '../constants/regions';
import getTankStats from '../core/blitz/getTankStats'; import getTankStats from '../core/blitz/getTankStats';
import getTreeType from '../core/blitz/getTreeType'; import getTreeType from '../core/blitz/getTreeType';
import getWargamingResponse from '../core/blitz/getWargamingResponse'; import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankName from '../core/blitz/resolveTankName'; import resolveTankName from '../core/blitz/resolveTankName';
import { import {
TIER_ROMAN_NUMERALS, TIER_ROMAN_NUMERALS,
TankopediaEntry,
Tier, Tier,
tankopedia, tankopedia,
tankopediaInfo,
} from '../core/blitz/tankopedia'; } from '../core/blitz/tankopedia';
import addTierChoices from '../core/discord/addTierChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
@@ -45,30 +46,12 @@ const COMP_TANKS = [
export const ownedTanksCommand: CommandRegistry = { export const ownedTanksCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: true, inPublic: true,
command: new SlashCommandBuilder() command: new SlashCommandBuilder()
.setName('ownedtanks') .setName('owned-tanks')
.setDescription("Shows a player's owned tanks") .setDescription("Shows a player's owned tanks")
.addStringOption((option) => .addStringOption(addTierChoices)
option
.setName('tier')
.setDescription('The tier you want to see')
.setChoices(
{ name: 'Tier I', value: '1' },
{ name: 'Tier II', value: '2' },
{ name: 'Tier III', value: '3' },
{ name: 'Tier IV', value: '4' },
{ name: 'Tier V', value: '5' },
{ name: 'Tier VI', value: '6' },
{ name: 'Tier VII', value: '7' },
{ name: 'Tier VIII', value: '8' },
{ name: 'Tier IX', value: '9' },
{ name: 'Tier X', value: '10' },
)
.setRequired(true),
)
.addStringOption(addUsernameChoices), .addStringOption(addUsernameChoices),
async handler(interaction) { async handler(interaction) {
@@ -79,73 +62,113 @@ export const ownedTanksCommand: CommandRegistry = {
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`, `https://api.wotblitz.${server}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
); );
const tankStats = await getTankStats(server, id); const tankStats = await getTankStats(server, id);
const tanks = ( const filteredTanks = (
await Promise.all( await Promise.all(
tankStats.map(async (tankData) => ({ tankStats.map(async (tankData) => ({
...(await tankopedia)[tankData.tank_id], tankopedia: (await tankopedia)[tankData.tank_id]!,
tank_id: tankData.tank_id, tank_id: tankData.tank_id,
})), })),
) )
).filter((tank) => tank?.tier === tier); ).filter((tank) => tank.tankopedia?.tier === tier);
const clanData = await getWargamingResponse<PlayerClanData>( const clanData = await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
); );
const leftColumnSize = Math.ceil(tanks.length / 2); const groupedTanks: Record<string, TankopediaEntry[]> = {};
const leftColumn = tanks.slice(0, leftColumnSize); const nations: string[] = [];
const rightColumn = tanks.slice(leftColumnSize);
filteredTanks.forEach((tank) => {
if (groupedTanks[tank.tankopedia.nation] === undefined) {
groupedTanks[tank.tankopedia.nation] = [tank.tankopedia];
nations.push(tank.tankopedia.nation);
} else {
groupedTanks[tank.tankopedia.nation].push(tank.tankopedia);
}
});
nations.sort();
return ( return (
<Wrapper> <Wrapper>
<TitleBar <TitleBar
name={accountInfo[id].nickname} name={accountInfo[id].nickname}
nameDiscriminator={`(Tier ${TIER_ROMAN_NUMERALS[tier as Tier]})`} nameDiscriminator={
clanData[id]?.clan ? `[${clanData[id]?.clan?.tag}]` : undefined
}
image={ image={
clanData[id]?.clan clanData[id]?.clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png` ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png`
: undefined : undefined
} }
description={`Owned tanks • ${new Date().toDateString()} • ${ description={`Tier ${
REGION_NAMES[server] TIER_ROMAN_NUMERALS[tier as Tier]
}`} } tanks • ${new Date().toDateString()}`}
/> />
{tanks.length === 0 && <NoData type={NoDataType.TanksFound} />} {filteredTanks.length === 0 && <NoData type={NoDataType.TanksFound} />}
{tanks.length > 0 && ( {filteredTanks.length > 0 &&
<Tanks.Root> (await Promise.all(
<Tanks.Column> nations.map(async (nation) => {
{await Promise.all( const tanks = groupedTanks[nation];
leftColumn.map(async (tank) => ( const leftColumnSize = Math.ceil(tanks.length / 2);
<Tanks.Item const leftColumn = tanks.slice(0, leftColumnSize);
key={tank.tank_id} const rightColumn = tanks.slice(leftColumnSize);
name={await resolveTankName(tank.tank_id)}
tankType={tank.type}
image={tank.images?.normal}
treeType={await getTreeType(tank.tank_id)}
/>
)),
)}
</Tanks.Column>
<Tanks.Column>
{await Promise.all(
rightColumn.map(async (tank) => (
<Tanks.Item
key={tank.tank_id}
name={await resolveTankName(tank.tank_id)}
tankType={tank.type}
image={tank.images?.normal}
treeType={await getTreeType(tank.tank_id)}
/>
)),
)}
</Tanks.Column>
</Tanks.Root>
)}
<PoweredBy type={PoweredByType.Wargaming} /> return (
<Tanks.Root>
<Tanks.Title>
{(await tankopediaInfo).vehicle_nations[nation]}
</Tanks.Title>
<Tanks.Row>
<Tanks.Column>
{await Promise.all(
leftColumn.map(async (tank) => (
<Tanks.Item
key={tank.tank_id}
name={await resolveTankName(tank.tank_id)}
tankType={tank.type}
image={tank.images?.normal}
treeType={await getTreeType(tank.tank_id)}
/>
)),
)}
</Tanks.Column>
<Tanks.Column>
{await Promise.all(
rightColumn.map(async (tank) => (
<Tanks.Item
key={tank.tank_id}
name={await resolveTankName(tank.tank_id)}
tankType={tank.type}
image={tank.images?.normal}
treeType={await getTreeType(tank.tank_id)}
/>
)),
)}
</Tanks.Column>
</Tanks.Row>
</Tanks.Root>
);
}),
))}
</Wrapper> </Wrapper>
); );
}, },
autocomplete: autocompleteUsername, 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'));
+1 -2
View File
@@ -1,9 +1,8 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import { CommandRegistry } from '../events/interactionCreate'; import { CommandRegistry } from '../events/interactionCreate';
export const pingCommand: CommandRegistry<true> = { export const pingCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: true, inPublic: true,
handlesInteraction: true, handlesInteraction: true,
+1 -2
View File
@@ -16,11 +16,10 @@ type SortBy = 'name' | 'count';
export const playerAchievementsCommand: CommandRegistry = { export const playerAchievementsCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: true, inPublic: true,
command: new SlashCommandBuilder() command: new SlashCommandBuilder()
.setName('playerachievements') .setName('player-achievements')
.setDescription("All the player's achievements") .setDescription("All the player's achievements")
.addStringOption(addUsernameChoices) .addStringOption(addUsernameChoices)
.addStringOption((option) => .addStringOption((option) =>
+1 -2
View File
@@ -12,11 +12,10 @@ import { AccountInfo } from '../types/accountInfo';
export const playerInfoCommand: CommandRegistry = { export const playerInfoCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: true, inPublic: true,
command: new SlashCommandBuilder() command: new SlashCommandBuilder()
.setName('playerinfo') .setName('player-info')
.setDescription('Basic information about a player') .setDescription('Basic information about a player')
.addStringOption(addUsernameChoices), .addStringOption(addUsernameChoices),
+435 -153
View File
@@ -1,13 +1,31 @@
import { SlashCommandBuilder, SlashCommandSubcommandBuilder } from 'discord.js'; import {
APIApplicationCommandOptionChoice,
SlashCommandBuilder,
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from 'discord.js';
import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard'; import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard';
import { octokit } from '../bot';
import * as Leaderboard from '../components/Leaderboard'; import * as Leaderboard from '../components/Leaderboard';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper'; import Wrapper from '../components/Wrapper';
import { REGION_NAMES, Region } from '../constants/regions'; import { REGION_NAMES_SHORT, Region } from '../constants/regions';
import getArchivedRatingsInfo from '../core/blitz/getArchivedRatingsInfo';
import getRatingsInfo from '../core/blitz/getRatingsInfo';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import regionToRegionSubdomain from '../core/blitz/regionToRegionSubdomain'; import regionToRegionSubdomain from '../core/blitz/regionToRegionSubdomain';
import getMidnightLeaderboard, {
DATABASE_REPO,
} from '../core/database/getMidnightLeaderboard';
import addRegionChoices from '../core/discord/addRegionChoices'; import addRegionChoices from '../core/discord/addRegionChoices';
import { CommandRegistry } from '../events/interactionCreate'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { octokit } from '../core/express/octokit';
import { secrets } from '../core/node/secrets';
import throwError from '../core/node/throwError';
import { CommandRegistryRaw } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
import { PlayerClanData } from '../types/playerClanData';
export interface RatingsPlayer { export interface RatingsPlayer {
spa_id: number; spa_id: number;
@@ -75,188 +93,452 @@ export interface RatingsLeague {
percentile: number; percentile: number;
} }
export interface RatingsInfo { export type RatingsInfo =
title: string; | {
icon: string | null; detail: undefined;
start_at: string;
finish_at: string;
current_season: number;
updated_at: string;
count: number;
rewards: RatingsReward[]; title: string;
leagues: RatingsLeague[]; icon: string | null;
} start_at: string;
finish_at: string;
current_season: number;
updated_at: string;
count: number;
rewards: RatingsReward[];
leagues: RatingsLeague[];
}
| { detail: { error: string } };
export interface RatingsNeighbors { export interface RatingsNeighbors {
neighbors: RatingsPlayer[]; neighbors: RatingsPlayer[];
} }
console.log('Caching ratings info...'); const LEAGUES = [
const ratingsInfo = fetch( { name: 'Diamond', minScore: 5000 },
'https://na.wotblitz.com/en/api/rating-leaderboards/season/', { name: 'Platinum', minScore: 4000 },
) { name: 'Gold', minScore: 3000 },
.then((response) => response.json() as Promise<RatingsInfo>) { name: 'Silver', minScore: 2000 },
.then((info) => { { name: 'Bronze', minScore: 0 },
console.log('Cached ratings info'); ];
return info;
});
function optionalParameters(option: SlashCommandSubcommandBuilder) { function addSubcommands(
option: SlashCommandSubcommandGroupBuilder,
addOptions = (option: SlashCommandSubcommandBuilder) => option,
) {
return option return option
.addStringOption(addRegionChoices) .addSubcommand((option) =>
.addIntegerOption((option) => addOptions(
option option
.setName('limit') .setName('neighbours')
.setDescription('How many rows to display (default: 10)') .setDescription('Your position and neighbours'),
.setRequired(false) )
.setMinValue(5) .addStringOption(addUsernameChoices)
.setMaxValue(30), .addIntegerOption((option) =>
option
.setName('limit')
.setDescription('How many neighbours to display (default: 10)')
.setRequired(false)
.setMinValue(5)
.setMaxValue(30),
),
)
.addSubcommand((option) =>
addOptions(option.setName('league').setDescription('Top league players'))
.addStringOption((option) =>
option
.setName('league')
.setDescription('The league to display')
.addChoices(
...LEAGUES.map(
({ name }, value) =>
({
name,
value: `${value}`,
}) satisfies APIApplicationCommandOptionChoice<string>,
),
)
.setRequired(true),
)
.addStringOption(addRegionChoices)
.addIntegerOption((option) =>
option
.setName('limit')
.setDescription('How many rows to display (default: 10)')
.setRequired(false)
.setMinValue(5)
.setMaxValue(30),
),
); );
} }
const LEAGUE_INDEXES = ['diamond', 'platinum', 'gold', 'silver', 'bronze']; export const ratingsCommand = new Promise<CommandRegistryRaw>(
async (resolve) => {
const seasonChoices = await octokit.repos
.getContent({
...DATABASE_REPO,
path: 'com/ratings',
})
.then(({ data }) => {
if (!Array.isArray(data)) {
throw throwError('Archived ratings data is malformed');
}
return data.map(
(folder) =>
({
name: `Season ${folder.name}`,
value: folder.name,
}) satisfies APIApplicationCommandOptionChoice<string>,
);
});
export const DATABASE_REPO = { owner: 'tresabhi', repo: 'blitzkrieg-db' }; const command = new SlashCommandBuilder()
.setName('ratings')
export const ratingsCommand: CommandRegistry = { .setDescription('Ratings battles statistics')
inProduction: true, .addSubcommandGroup((option) =>
inDevelopment: true, addSubcommands(
inPublic: true, option.setName('archive').setDescription('Any previous season'),
inPreview: true, (option) =>
option.addStringOption((option) =>
command: new SlashCommandBuilder() option
.setName('ratings') .setName('season')
.setDescription('Ratings battles statistics') .setDescription('The season number')
.addSubcommandGroup((option) => .setChoices(...seasonChoices)
option .setRequired(true),
.setName('league') ),
.setDescription('Top league players')
.addSubcommand((option) =>
optionalParameters(
option
.setName('diamond')
.setDescription('Top Diamond League players'),
),
)
.addSubcommand((option) =>
optionalParameters(
option
.setName('platinum')
.setDescription('Top Platinum League players'),
),
)
.addSubcommand((option) =>
optionalParameters(
option.setName('gold').setDescription('Top Gold League players'),
),
)
.addSubcommand((option) =>
optionalParameters(
option
.setName('silver')
.setDescription('Top Silver League players'),
),
)
.addSubcommand((option) =>
optionalParameters(
option
.setName('bronze')
.setDescription('Top Bronze League players'),
),
), ),
),
async handler(interaction) {
const subcommandGroup = interaction.options.getSubcommandGroup(true);
if (subcommandGroup === 'league') {
const limit = interaction.options.getInteger('limit') ?? 10;
const subcommand = interaction.options.getSubcommand(true);
const leagueIndex = LEAGUE_INDEXES.indexOf(subcommand);
const region = interaction.options.getString('region') as Region;
const regionSubdomain = regionToRegionSubdomain(region);
const result = await fetch(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${leagueIndex}/top/`,
) )
.then((response) => response.json() as Promise<LeagueTop>) .addSubcommandGroup((option) =>
.then(({ result }) => result.slice(0, limit)); addSubcommands(
const awaitedInfo = await ratingsInfo; option
const leagueInfo = awaitedInfo.leagues[leagueIndex]; .setName('current')
const midnightLeaderboard = await octokit.repos .setDescription('The current running Ratings season'),
.getContent({ ),
...DATABASE_REPO, );
path: `${region}/ratings/${awaitedInfo.current_season}/midnight.json`,
})
.then(({ data }) => {
if (!Array.isArray(data) && data.type === 'file') {
const content = Buffer.from(data.content, 'base64').toString();
const jsonContent = JSON.parse(
content,
) as BlitzkriegRatingsLeaderboard;
return jsonContent; resolve({
} inProduction: true,
}) inPublic: true,
.catch(() => console.warn('No midnight leaderboard found'));
const items = await Promise.all( command,
result.map(async (player) => {
const reward = awaitedInfo.rewards.find( async handler(interaction) {
(reward) => interface SimplifiedPlayer {
player.number >= reward.from_position && id: number;
player.number <= reward.to_position, score: number;
position: number;
clan: string | undefined;
nickname: string;
}
const subcommandGroup = interaction.options.getSubcommandGroup(true) as
| 'archive'
| 'current';
const subcommand = interaction.options.getSubcommand(true) as
| 'league'
| 'neighbours';
const limit = interaction.options.getInteger('limit') ?? 10;
const seasonOption = interaction.options.getString('season');
const season = seasonOption ? parseInt(seasonOption) : undefined;
let titleName: string;
let titleImage: string | undefined;
let titleNameDiscriminator: string | undefined;
let titleDescription: string;
let playersBefore: number;
let playersAfter: number;
let players: SimplifiedPlayer[] | undefined;
let midnightLeaderboard: BlitzkriegRatingsLeaderboard | undefined;
let playerId: number | undefined;
let regionRatingsInfo: RatingsInfo;
if (subcommand === 'league') {
const leagueIndex = parseInt(
interaction.options.getString('league')!,
); );
const region = interaction.options.getString('region') as Region;
regionRatingsInfo = await (subcommandGroup === 'current'
? getRatingsInfo(region)
: getArchivedRatingsInfo(season!, region));
const regionSubdomain = regionToRegionSubdomain(region);
const result =
subcommandGroup === 'current'
? regionRatingsInfo.detail === undefined
? 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,
),
)
: undefined
: await octokit.repos
.getContent({
...DATABASE_REPO,
path: `${region}/ratings/${season}/latest.json`,
})
.then(async ({ data }) => {
if (Array.isArray(data) || data.type !== 'file') {
throw throwError('Archived ratings data is malformed');
}
const players = (await fetch(data.download_url!).then(
(response) => response.json(),
)) as BlitzkriegRatingsLeaderboard;
const firstPlayerIndex =
leagueIndex === 0
? 0
: players.findIndex(
(player) =>
player.score < LEAGUES[leagueIndex - 1].minScore,
);
const lastPlayerIndex = firstPlayerIndex + limit - 1;
const trimmed = players.slice(
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=${secrets.WARGAMING_APPLICATION_ID}&account_id=${ids}&extra=clan`,
);
return trimmed.map(
(player, index) =>
({
id: player.id,
score: player.score,
position: firstPlayerIndex + index + 1,
clan: clanData[player.id]!.clan?.tag,
nickname: clanData[player.id]!.account_name,
}) satisfies SimplifiedPlayer,
);
});
const leagueInfo =
regionRatingsInfo.detail === undefined
? regionRatingsInfo.leagues[leagueIndex]
: undefined;
midnightLeaderboard =
subcommandGroup === 'current'
? regionRatingsInfo.detail === undefined
? await getMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: undefined
: 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;
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;
titleDescription = `Top ${limit} players`;
} else {
const { region, id } = await resolvePlayerFromCommand(interaction);
regionRatingsInfo =
subcommandGroup === 'current'
? await getRatingsInfo(region)
: await octokit.repos
.getContent({
...DATABASE_REPO,
path: `${region}/ratings/${season}/info.json`,
})
.then(async ({ data }) => {
if (Array.isArray(data) || data.type !== 'file') {
throw throwError('Archived ratings info is malformed');
}
const response = fetch(data.download_url!);
return (await response.then((response) =>
response.json(),
)) as RatingsInfo;
});
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${region}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const clan = (
await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
)
)[id]?.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,
)}`,
)
.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,
path: `${region}/ratings/${season}/latest.json`,
})
.then(async ({ data }) => {
if (Array.isArray(data) || data.type !== 'file') {
throw throwError(
'Archived ratings latest data is malformed',
);
}
const response = fetch(data.download_url!);
const players = (await response.then((response) =>
response.json(),
)) as BlitzkriegRatingsLeaderboard;
const playerIndex = players.findIndex(
(player) => player.id === id,
);
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.',
);
}
const halfRange = Math.round(limit / 2);
const trimmed = players.slice(
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=${secrets.WARGAMING_APPLICATION_ID}&account_id=${ids}&extra=clan`,
);
return trimmed.map((player, index) => ({
id: player.id,
score: player.score,
position: playerIndex + index + 1,
clan: clanData[player.id]!.clan?.tag,
nickname: clanData[player.id]!.account_name,
}));
});
midnightLeaderboard =
subcommandGroup === 'current'
? regionRatingsInfo.detail === undefined
? await getMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: undefined
: 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
: undefined;
titleName = neighbors
? accountInfo[id].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;
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 midnightIndex = midnightLeaderboard?.findIndex( const midnightIndex = midnightLeaderboard?.findIndex(
(item) => item.id === player.spa_id, (item) => item.id === player.id,
); );
const midnightScore = const midnightScore =
midnightIndex === undefined midnightIndex === undefined
? player.score ? player.score
: midnightLeaderboard![midnightIndex].score; : midnightLeaderboard![midnightIndex].score;
const midnightPosition = const midnightPosition =
midnightIndex === undefined ? player.number : midnightIndex + 1; midnightIndex === undefined ? player.position : midnightIndex + 1;
return ( return (
<Leaderboard.Item <Leaderboard.Item
nickname={player.nickname} nickname={player.nickname}
score={player.score} score={player.score}
position={player.number} position={player.position}
clan={player.clan_tag} clan={player.clan}
reward={reward} reward={reward}
deltaScore={player.score - midnightScore} deltaScore={player.score - midnightScore}
deltaPosition={midnightPosition - player.number} deltaPosition={midnightPosition - player.position}
key={player.spa_id} key={player.id}
highlight={player.id === playerId}
/> />
); );
}), });
);
const playersBefore = result[0].number - 1; return (
const playersAfter = awaitedInfo.count - result[result.length - 1].number; <Wrapper>
<TitleBar
name={titleName}
image={titleImage}
nameDiscriminator={titleNameDiscriminator}
description={`${titleDescription} • ${new Date().toDateString()}`}
/>
return ( {items && (
<Wrapper> <Leaderboard.Root>
<TitleBar {playersBefore > 0 && (
name={leagueInfo.title} <Leaderboard.Gap number={playersBefore} />
image={ )}
leagueInfo.big_icon.startsWith('http') {items}
? leagueInfo.big_icon {playersAfter > 0 && <Leaderboard.Gap number={playersAfter} />}
: `https:${leagueInfo.big_icon}` </Leaderboard.Root>
} )}
description={`Top ${limit} players • ${new Date().toDateString()} • ${ </Wrapper>
REGION_NAMES[region] );
}`} },
/>
<Leaderboard.Root> autocomplete: autocompleteUsername,
{playersBefore > 0 && <Leaderboard.Gap number={playersBefore} />} });
{items}
{playersAfter > 0 && <Leaderboard.Gap number={playersAfter} />}
</Leaderboard.Root>
</Wrapper>
);
}
return [];
}, },
}; );
+1 -2
View File
@@ -11,11 +11,10 @@ import { ClanList } from '../types/clanList';
export const searchClansCommand: CommandRegistry = { export const searchClansCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: true, inPublic: true,
command: new SlashCommandBuilder() command: new SlashCommandBuilder()
.setName('searchclans') .setName('search-clans')
.setDescription('Search clans') .setDescription('Search clans')
.addStringOption(addRegionChoices) .addStringOption(addRegionChoices)
.addStringOption((option) => addClanChoices(option).setAutocomplete(false)) .addStringOption((option) => addClanChoices(option).setAutocomplete(false))
+1 -2
View File
@@ -12,11 +12,10 @@ import { AccountList } from '../types/accountList';
export const searchPlayersCommand: CommandRegistry = { export const searchPlayersCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: true, inPublic: true,
command: new SlashCommandBuilder() command: new SlashCommandBuilder()
.setName('searchplayers') .setName('search-players')
.setDescription('Search players in a Blitz server') .setDescription('Search players in a Blitz server')
.addStringOption(addRegionChoices) .addStringOption(addRegionChoices)
.addStringOption((option) => .addStringOption((option) =>
+6 -6
View File
@@ -1,18 +1,17 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import { go } from 'fuzzysort'; import { go } from 'fuzzysort';
import markdownEscape from 'markdown-escape'; import markdownEscape from 'markdown-escape';
import { TANK_NAMES } from '../core/blitz/tankopedia'; import { TANK_NAMES_DIACRITICS } from '../core/blitz/tankopedia';
import addTankChoices from '../core/discord/addTankChoices'; import addTankChoices from '../core/discord/addTankChoices';
import embedInfo from '../core/discord/embedInfo'; import embedInfo from '../core/discord/embedInfo';
import { CommandRegistry } from '../events/interactionCreate'; import { CommandRegistry } from '../events/interactionCreate';
export const searchTanksCommand: CommandRegistry = { export const searchTanksCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: true, inPublic: true,
command: new SlashCommandBuilder() command: new SlashCommandBuilder()
.setName('searchtanks') .setName('search-tanks')
.setDescription('Search tanks') .setDescription('Search tanks')
.addStringOption((option) => addTankChoices(option).setAutocomplete(false)) .addStringOption((option) => addTankChoices(option).setAutocomplete(false))
.addIntegerOption((option) => .addIntegerOption((option) =>
@@ -26,9 +25,10 @@ export const searchTanksCommand: CommandRegistry = {
async handler(interaction) { async handler(interaction) {
const tank = interaction.options.getString('tank')!; const tank = interaction.options.getString('tank')!;
const limit = interaction.options.getInteger('limit') ?? 25; const limit = interaction.options.getInteger('limit') ?? 25;
const results = go(tank, await TANK_NAMES, { limit }).map( const results = go(tank, await TANK_NAMES_DIACRITICS, {
(result) => result.target, limit,
); keys: ['combined'],
}).map((result) => result.obj.original);
return embedInfo( return embedInfo(
`Tank search for "${markdownEscape(tank)}"`, `Tank search for "${markdownEscape(tank)}"`,
+95 -60
View File
@@ -1,4 +1,5 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import { TreeTypeString } from '../components/Tanks';
import { CYCLIC_API } from '../constants/cyclic'; import { CYCLIC_API } from '../constants/cyclic';
import getWargamingResponse from '../core/blitz/getWargamingResponse'; import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankId from '../core/blitz/resolveTankId'; import resolveTankId from '../core/blitz/resolveTankId';
@@ -13,73 +14,107 @@ import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton'; import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { secrets } from '../core/node/secrets'; import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate'; import { CommandRegistryRaw } from '../events/interactionCreate';
import { StatType } from '../renderers/fullStats'; import stats, { MultiTankFilters, StatType } from '../renderers/stats';
import stats from '../renderers/stats';
import { AccountInfo } from '../types/accountInfo'; import { AccountInfo } from '../types/accountInfo';
export const statsCommand: CommandRegistry = { export const statsCommand = new Promise<CommandRegistryRaw>(async (resolve) => {
inProduction: true, const command = await addStatTypeSubCommandGroups(
inDevelopment: false,
inPublic: true,
command: addStatTypeSubCommandGroups(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('stats') .setName('stats')
.setDescription('Regular battles statistics'), .setDescription('Regular battles statistics'),
), );
async handler(interaction) { resolve({
const commandGroup = interaction.options.getSubcommandGroup( inProduction: true,
true, inPublic: true,
) as StatType;
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 path = interactionToURL(interaction, {
...player,
tankId,
start,
end,
});
const { nickname } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
return [ command,
await stats(commandGroup, period, player, tankId),
primaryButton(path, 'Refresh'),
linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.region}/${nickname}`,
'BlitzStars',
),
];
},
autocomplete: (interaction) => { async handler(interaction) {
autocompleteUsername(interaction); const commandGroup = interaction.options.getSubcommandGroup(
autocompleteTanks(interaction); 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, {
...player,
tankId,
start,
end,
period,
});
const { nickname } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
async button(interaction) { const tierOption = interaction.options.getString('tier');
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);
return await stats( return [
commandGroup, await stats(
period, commandGroup,
player, resolvedPeriod,
parseInt(url.searchParams.get('tankId')!), 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',
),
];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const url = new URL(`${CYCLIC_API}/${interaction.customId}`);
const path = url.pathname.split('/').filter(Boolean);
const commandGroup = path[1] as StatType;
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, 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,
);
},
});
});
+26 -5
View File
@@ -15,7 +15,6 @@ import { AccountInfo } from '../types/accountInfo';
export const todayCommand: CommandRegistry = { export const todayCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: true, inPublic: true,
command: new SlashCommandBuilder() command: new SlashCommandBuilder()
@@ -24,26 +23,48 @@ export const todayCommand: CommandRegistry = {
.addStringOption(addUsernameChoices) .addStringOption(addUsernameChoices)
.addIntegerOption((option) => .addIntegerOption((option) =>
option option
.setName('limit') .setName('cutoff')
.setDescription( .setDescription(
'The maximum number of tanks to display (default: Infinity)', 'The maximum number of tanks to display (default: Infinity)',
) )
.setRequired(false) .setRequired(false)
.setMinValue(1), .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),
), ),
async handler(interaction) { async handler(interaction) {
const player = await resolvePlayerFromCommand(interaction); const player = await resolvePlayerFromCommand(interaction);
const limit = interaction.options.getInteger('limit') ?? Infinity; const cutoff = interaction.options.getInteger('cutoff') ?? undefined;
const maximized = interaction.options.getInteger('maximized') ?? undefined;
const showTotal = interaction.options.getBoolean('show-total') ?? undefined;
const { nickname } = ( const { nickname } = (
await getWargamingResponse<AccountInfo>( await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${player.id}`, `https://api.wotblitz.${player.region}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
) )
)[player.id]; )[player.id];
const path = interactionToURL(interaction, player); const path = interactionToURL(interaction, {
...player,
cutoff,
maximized,
'show-total': showTotal,
});
return [ return [
await today(player, limit, false), await today(player, cutoff, maximized, showTotal, false),
primaryButton(path, 'Refresh'), primaryButton(path, 'Refresh'),
linkButton(`${CYCLIC_API}/${path}`, 'Embed'), linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton( linkButton(
-1
View File
@@ -14,7 +14,6 @@ import { PlayerClanData } from '../types/playerClanData';
export const verifyCommand: CommandRegistry = { export const verifyCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inDevelopment: false,
inPublic: true, inPublic: true,
command: new SlashCommandBuilder() command: new SlashCommandBuilder()
+76 -35
View File
@@ -1,6 +1,7 @@
import getWN8Percentile from '../../../core/blitz/getWN8Percentile'; import getWN8Percentile from '../../../core/blitz/getWN8Percentile';
import { TankType } from '../../../core/blitz/tankopedia';
import { theme } from '../../../stitches.config'; import { theme } from '../../../stitches.config';
import { RowDiscriminator } from './RowDiscriminator'; import { TREE_TYPE_ICONS, TreeTypeEnum } from '../../Tanks';
import { RowStat } from './RowStat'; import { RowStat } from './RowStat';
export interface RowProps { export interface RowProps {
@@ -13,14 +14,14 @@ export interface RowProps {
careerDamage: number; careerDamage: number;
battles: number; battles: number;
careerBattles: number; careerBattles: number;
icon?: string;
minimized: boolean; minimized: boolean;
isListing: boolean; isTank: boolean;
naked?: boolean; treeType?: TreeTypeEnum;
tankType?: TankType;
} }
export function Row({ export function Row({
isListing, isTank,
minimized, minimized,
name, name,
winrate, winrate,
@@ -31,45 +32,85 @@ export function Row({
careerDamage, careerDamage,
battles, battles,
careerBattles, careerBattles,
icon, treeType,
naked, tankType,
}: RowProps) { }: RowProps) {
return ( return (
<div <div
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
borderRadius: 4, borderRadius: 4,
backgroundColor: isListing backgroundColor: theme.colors.appBackground2,
? theme.colors.componentInteractive overflow: 'hidden',
: theme.colors.appBackground2,
padding: 8,
}} }}
> >
<RowDiscriminator minimized={minimized} name={name} icon={icon} /> <div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 8,
gap: 4,
backgroundColor: theme.colors.componentInteractive,
}}
>
{isTank && treeType !== undefined && tankType !== undefined && (
<img
src={TREE_TYPE_ICONS[treeType][tankType]}
style={{ width: 16, height: 16 }}
/>
)}
<span
style={{
color: isTank
? treeType === TreeTypeEnum.Collector
? theme.colors.textLowContrast_blue
: treeType === TreeTypeEnum.Premium
? theme.colors.textLowContrast_amber
: theme.colors.textHighContrast
: theme.colors.textLowContrast,
fontWeight: 900,
fontSize: 16,
}}
>
{name}
</span>
</div>
<RowStat <div
minimized={minimized} style={{
name={`Winrate • ${(careerWinrate * 100).toFixed(2)}%`} display: 'flex',
value={`${(winrate * 100).toFixed(2)}%`} paddingTop: 8,
delta={winrate - careerWinrate} paddingBottom: 8,
/> }}
<RowStat >
minimized={minimized} <RowStat
name={`WN8 • ${careerWN8 === undefined ? '--' : careerWN8.toFixed(0)}`} minimized={minimized}
value={WN8 === undefined ? '--' : WN8.toFixed(0)} name={`Battles • ${careerBattles.toFixed(0)}`}
percentile={WN8 === undefined ? undefined : getWN8Percentile(WN8)} value={battles.toFixed(0)}
/> />
<RowStat <RowStat
minimized={minimized} minimized={minimized}
name={`Damage • ${careerDamage.toFixed(0)}`} name={`Winrate • ${(careerWinrate * 100).toFixed(2)}%`}
value={damage.toFixed(0)} value={`${(winrate * 100).toFixed(2)}%`}
delta={damage - careerDamage} delta={winrate - careerWinrate}
/> />
<RowStat <RowStat
minimized={minimized} minimized={minimized}
name={`Battles • ${careerBattles.toFixed(0)}`} name={`WN8 • ${
value={battles.toFixed(0)} careerWN8 === undefined ? '--' : careerWN8.toFixed(0)
/> }`}
value={WN8 === undefined ? '--' : WN8.toFixed(0)}
percentile={WN8 === undefined ? undefined : getWN8Percentile(WN8)}
/>
<RowStat
minimized={minimized}
name={`Damage • ${careerDamage.toFixed(0)}`}
value={damage.toFixed(0)}
delta={damage - careerDamage}
/>
</div>
</div> </div>
); );
} }
@@ -1,51 +0,0 @@
import { theme } from '../../../stitches.config';
export interface RowDiscriminatorProps {
name: string;
icon?: string;
minimized: boolean;
}
export function RowDiscriminator({
name,
icon,
minimized,
}: RowDiscriminatorProps) {
return (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
height: '100%',
boxSizing: 'border-box',
width: 0,
alignItems: 'center',
justifyContent: 'center',
}}
>
{!minimized && icon && (
<img
src={icon}
height={0}
style={{ flex: 1, width: '100%', objectFit: 'contain' }}
/>
)}
<span
style={{
color: theme.colors.textLowContrast,
fontSize: 16,
textAlign: 'center',
display: 'block',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'clip',
maxWidth: '100%',
}}
>
{name}
</span>
</div>
);
}
@@ -53,6 +53,7 @@ export function RowStat({
<span <span
style={{ style={{
color: theme.colors.textHighContrast, color: theme.colors.textHighContrast,
fontWeight: 'bold',
fontSize: 16, fontSize: 16,
}} }}
> >
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './components/Root'; export * from './components/Root';
export * from './components/Row'; export * from './components/Row';
export * from './components/RowDiscriminator';
export * from './components/RowStat'; export * from './components/RowStat';
+1 -1
View File
@@ -96,7 +96,7 @@ export default function GenericAllStats({
]} ]}
/> />
<Card <Card
title="Efficiency" title="Miscellaneous"
items={[ items={[
{ {
label: 'Average tier', label: 'Average tier',
+16 -14
View File
@@ -3,7 +3,7 @@ import { ReactNode } from 'react';
import { theme } from '../../../stitches.config'; import { theme } from '../../../stitches.config';
import { Margin, MarginOrientation } from './Margin'; import { Margin, MarginOrientation } from './Margin';
export const GRAPH_WIDTH = 544; export const GRAPH_WIDTH = 384;
export const GRAPH_HEIGHT = 320; export const GRAPH_HEIGHT = 320;
export interface MarginInputProps { export interface MarginInputProps {
@@ -20,7 +20,7 @@ export interface RootProps {
separations?: number; separations?: number;
xMinLabel: string; xMinLabel: string;
xMaxLabel: string; xMaxLabel: string;
xTitle: string; xTitle?: string;
} }
export function Root({ export function Root({
@@ -91,18 +91,20 @@ export function Root({
/> />
)} )}
<div {xTitle !== undefined && (
style={{ <div
paddingLeft: 64, style={{
display: 'flex', paddingLeft: 64,
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
}} justifyContent: 'center',
> }}
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}> >
{xTitle} <span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
</span> {xTitle}
</div> </span>
</div>
)}
</div> </div>
); );
} }
+39 -10
View File
@@ -9,6 +9,7 @@ export interface ItemProps {
reward?: RatingsReward; reward?: RatingsReward;
nickname: string; nickname: string;
clan?: string; clan?: string;
highlight?: boolean;
} }
function normalizeImage(url: string) { function normalizeImage(url: string) {
@@ -23,13 +24,16 @@ export function Item({
reward, reward,
nickname, nickname,
clan, clan,
highlight,
}: ItemProps) { }: ItemProps) {
return ( return (
<div <div
style={{ style={{
display: 'flex', display: 'flex',
padding: 8, padding: 8,
backgroundColor: theme.colors.componentInteractive, backgroundColor: highlight
? theme.colors.componentInteractive_blue
: theme.colors.componentInteractive,
borderRadius: 4, borderRadius: 4,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -38,7 +42,9 @@ export function Item({
> >
<span <span
style={{ style={{
color: theme.colors.textLowContrast, color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16, fontSize: 16,
}} }}
> >
@@ -55,7 +61,9 @@ export function Item({
> >
<span <span
style={{ style={{
color: theme.colors.textHighContrast, color: highlight
? theme.colors.textHighContrast_blue
: theme.colors.textHighContrast,
fontSize: 16, fontSize: 16,
}} }}
> >
@@ -64,7 +72,9 @@ export function Item({
{clan && ( {clan && (
<span <span
style={{ style={{
color: theme.colors.textLowContrast, color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16, fontSize: 16,
}} }}
> >
@@ -95,7 +105,9 @@ export function Item({
/> />
<span <span
style={{ style={{
color: theme.colors.textLowContrast, color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16, fontSize: 16,
}} }}
> >
@@ -109,7 +121,7 @@ export function Item({
flex: 1, flex: 1,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'flex-end',
gap: 4, gap: 4,
}} }}
> >
@@ -126,7 +138,9 @@ export function Item({
{reward && reward.count > 1 && ( {reward && reward.count > 1 && (
<span <span
style={{ style={{
color: theme.colors.textLowContrast, color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16, fontSize: 16,
}} }}
> >
@@ -140,11 +154,19 @@ export function Item({
display: 'flex', display: 'flex',
gap: 4, gap: 4,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', width: 96,
justifyContent: 'flex-end',
}} }}
> >
{deltaScore !== 0 && deltaScore !== undefined && ( {deltaScore !== 0 && deltaScore !== undefined && (
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}> <span
style={{
color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{Math.abs(deltaScore).toLocaleString()} {Math.abs(deltaScore).toLocaleString()}
</span> </span>
)} )}
@@ -161,7 +183,14 @@ export function Item({
} }
/> />
)} )}
<span style={{ color: theme.colors.textHighContrast, fontSize: 16 }}> <span
style={{
color: highlight
? theme.colors.textHighContrast_blue
: theme.colors.textHighContrast,
fontSize: 16,
}}
>
{score.toLocaleString()} {score.toLocaleString()}
</span> </span>
</div> </div>
+5
View File
@@ -0,0 +1,5 @@
export default function Navbar() {
return (
<div style={{ display: 'flex', height: 100, backgroundColor: 'red' }}></div>
);
}
-38
View File
@@ -1,38 +0,0 @@
import { ReactNode } from 'react';
import { theme } from '../stitches.config';
export enum PoweredByType {
BlitzStars,
Wargaming,
}
export interface PoweredByProps {
type: PoweredByType;
footer?: ReactNode;
}
const POWERED_BY_IMAGES: Record<PoweredByType, string> = {
[PoweredByType.BlitzStars]:
'https://www.blitzstars.com/assets/images/4b4cb591.TankyMcPewpew.png',
[PoweredByType.Wargaming]: 'https://i.imgur.com/jADGWm0.png',
};
const POWERED_BY_NAMES: Record<PoweredByType, string> = {
[PoweredByType.BlitzStars]: 'BlitzStars',
[PoweredByType.Wargaming]: 'Wargaming',
};
export default function PoweredBy({ type, footer }: PoweredByProps) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ display: 'flex' }}>{footer}</div>
<div style={{ display: 'flex', gap: 4 }}>
<img src={POWERED_BY_IMAGES[type]} style={{ height: 16, width: 16 }} />
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
Powered by {POWERED_BY_NAMES[type]}
</span>
</div>
</div>
);
}
+13 -10
View File
@@ -2,32 +2,35 @@ import {
TANK_ICONS, TANK_ICONS,
TANK_ICONS_COLLECTOR, TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM, TANK_ICONS_PREMIUM,
TankType,
} from '../../../core/blitz/tankopedia'; } from '../../../core/blitz/tankopedia';
import { theme } from '../../../stitches.config'; import { theme } from '../../../stitches.config';
export enum TreeType { export enum TreeTypeEnum {
TechTree, TechTree,
Premium, Premium,
Collector, Collector,
} }
export type TreeTypeString = 'techtree' | 'premium' | 'collector';
export interface ItemProps { export interface ItemProps {
image?: string; image?: string;
tankType?: string; tankType?: TankType;
name: string; name: string;
treeType: TreeType; treeType: TreeTypeEnum;
} }
const TREE_TYPE_COLOR = { const TREE_TYPE_COLOR = {
[TreeType.TechTree]: '', [TreeTypeEnum.TechTree]: '',
[TreeType.Premium]: '_amber', [TreeTypeEnum.Premium]: '_amber',
[TreeType.Collector]: '_blue', [TreeTypeEnum.Collector]: '_blue',
} as const; } as const;
const TREE_TYPE_ICONS = { export const TREE_TYPE_ICONS = {
[TreeType.TechTree]: TANK_ICONS, [TreeTypeEnum.TechTree]: TANK_ICONS,
[TreeType.Premium]: TANK_ICONS_PREMIUM, [TreeTypeEnum.Premium]: TANK_ICONS_PREMIUM,
[TreeType.Collector]: TANK_ICONS_COLLECTOR, [TreeTypeEnum.Collector]: TANK_ICONS_COLLECTOR,
}; };
export function Item({ image, tankType, name, treeType }: ItemProps) { export function Item({ image, tankType, name, treeType }: ItemProps) {
+2 -1
View File
@@ -9,7 +9,8 @@ export function Root({ children }: RootProps) {
<div <div
style={{ style={{
display: 'flex', display: 'flex',
gap: 8, flexDirection: 'column',
gap: 16,
}} }}
> >
{children} {children}
+18
View File
@@ -0,0 +1,18 @@
import { ReactNode } from 'react';
export interface RowProps {
children: ReactNode;
}
export function Row({ children }: RowProps) {
return (
<div
style={{
display: 'flex',
gap: 8,
}}
>
{children}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { theme } from '../../../stitches.config';
export interface TitleProps {
children: string;
}
export function Title({ children }: TitleProps) {
return (
<span
style={{
fontWeight: 900,
color: theme.colors.textHighContrast,
fontSize: 24,
}}
>
{children}
</span>
);
}
+2
View File
@@ -1,3 +1,5 @@
export * from './components/Column'; export * from './components/Column';
export * from './components/Item'; export * from './components/Item';
export * from './components/Root'; export * from './components/Root';
export * from './components/Row';
export * from './components/Title';
+12 -15
View File
@@ -1,4 +1,3 @@
import isDev from '../core/node/isDev';
import { theme } from '../stitches.config'; import { theme } from '../stitches.config';
export interface TitleBarProps { export interface TitleBarProps {
@@ -19,10 +18,11 @@ export default function TitleBar({
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 16, justifyContent: 'center',
overflow: 'hidden',
}} }}
> >
<div style={{ display: 'flex', gap: 8, flex: 1 }}> <div style={{ display: 'flex', gap: 8 }}>
{image && ( {image && (
<img <img
style={{ width: 64, height: 64, objectFit: 'contain' }} style={{ width: 64, height: 64, objectFit: 'contain' }}
@@ -31,13 +31,18 @@ export default function TitleBar({
)} )}
<div <div
style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1 }} style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 4,
}}
> >
<div <div
style={{ style={{
display: 'flex', display: 'flex',
gap: 4, gap: 4,
overflow: 'hidden',
}} }}
> >
<span <span
@@ -46,6 +51,8 @@ export default function TitleBar({
color: theme.colors.textHighContrast, color: theme.colors.textHighContrast,
fontWeight: 900, fontWeight: 900,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
maxWidth: nameDiscriminator === undefined ? Infinity : 240,
overflow: 'hidden',
}} }}
> >
{name} {name}
@@ -53,7 +60,6 @@ export default function TitleBar({
{nameDiscriminator && ( {nameDiscriminator && (
<span <span
style={{ style={{
flex: 1,
fontSize: 32, fontSize: 32,
color: theme.colors.textLowContrast, color: theme.colors.textLowContrast,
fontWeight: 900, fontWeight: 900,
@@ -70,15 +76,6 @@ export default function TitleBar({
</span> </span>
</div> </div>
</div> </div>
<img
style={{ width: 64, height: 64 }}
src={
isDev()
? 'https://i.imgur.com/j6TItdy.png'
: 'https://i.imgur.com/pP6RN0x.png'
}
/>
</div> </div>
); );
} }
+1 -1
View File
@@ -14,7 +14,7 @@ export default function Wrapper({ children, naked }: WrapperProps) {
flexDirection: 'column', flexDirection: 'column',
padding: 16, padding: 16,
gap: 32, gap: 32,
width: 640, width: 480,
color: theme.colors.textHighContrast, color: theme.colors.textHighContrast,
...(!naked && { ...(!naked && {
+1 -1
View File
@@ -1 +1 @@
export const CYCLIC_API = 'https://blitzkrieg.cyclic.app'; export const CYCLIC_API = 'https://blitzkrieg-embeds.cyclic.app';
+7
View File
@@ -7,6 +7,13 @@ export const REGION_NAMES: Record<Region, string> = {
asia: 'Asia', asia: 'Asia',
}; };
// https://planetarynames.wr.usgs.gov/Abbreviations
export const REGION_NAMES_SHORT: Record<Region, string> = {
com: 'NA',
eu: 'EU',
asia: 'AS',
};
export const REGION_SUBDOMAIN_NAMES: Record<RegionSubdomain, string> = { export const REGION_SUBDOMAIN_NAMES: Record<RegionSubdomain, string> = {
na: 'North America', na: 'North America',
eu: 'Europe', eu: 'Europe',
-30
View File
@@ -1,30 +0,0 @@
import { AllStats } from '../../types/accountInfo';
export default function diffStats(stats1: AllStats, stats2: AllStats) {
function diff(value: (allStats: AllStats) => number) {
return value(stats2) - value(stats1);
}
function max(value: (allStats: AllStats) => number) {
return Math.max(value(stats1), value(stats2));
}
return {
battles: diff((a) => a.battles),
wins: diff((a) => a.wins),
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),
max_frags: max((a) => a.max_frags),
max_xp: max((a) => a.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),
xp: diff((a) => a.xp),
} satisfies AllStats;
}
+25
View File
@@ -0,0 +1,25 @@
import { RatingsInfo } from '../../commands/ratings';
import { Region } from '../../constants/regions';
import { DATABASE_REPO } from '../database/getMidnightLeaderboard';
import { octokit } from '../express/octokit';
import throwError from '../node/throwError';
export default async function getArchivedRatingsInfo(
season: number,
region: Region,
) {
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');
const content = Buffer.from(data.content, 'base64').toString();
const jsonContent = JSON.parse(content) as RatingsInfo & {
detail: undefined;
};
return jsonContent;
}
-22
View File
@@ -1,22 +0,0 @@
import { Region } from '../../constants/regions';
import { serverAndIdPattern } from '../discord/resolvePlayerFromCommand';
import throwError from '../node/throwError';
import listClans from './listClans';
export default async function getBlitzClan(clan: string) {
if (serverAndIdPattern.test(clan)) {
const [server, accountId] = clan.split('/');
return { server: server as Region, id: Number(accountId) };
} else {
const accounts = await listClans(clan);
if (accounts[0]) {
return { server: accounts[0].server, id: accounts[0].clan_id };
} else {
throw throwError(
'Could not find clan',
`I couldn't find clan \`${clan}\`. Try selecting a username from the search result.`,
);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { RatingsInfo } from '../../commands/ratings';
import { Region } from '../../constants/regions';
import regionToRegionSubdomain from './regionToRegionSubdomain';
export default async function getRatingsInfo(region: Region) {
const response = await fetch(
`https://${regionToRegionSubdomain(
region,
)}.wotblitz.com/en/api/rating-leaderboards/season/`,
);
return (await response.json()) as Promise<RatingsInfo>;
}
+4 -4
View File
@@ -1,11 +1,11 @@
import { TreeType } from '../../components/Tanks'; import { TreeTypeEnum } from '../../components/Tanks';
import { tankopedia } from './tankopedia'; import { tankopedia } from './tankopedia';
export default async function getTreeType(id: number) { export default async function getTreeType(id: number) {
const entry = (await tankopedia)[id]; const entry = (await tankopedia)[id];
if (entry?.is_collectible) return TreeType.Collector; if (entry?.is_collectible) return TreeTypeEnum.Collector;
if (entry?.is_premium) return TreeType.Premium; if (entry?.is_premium) return TreeTypeEnum.Premium;
return TreeType.TechTree; return TreeTypeEnum.TechTree;
} }
+5 -5
View File
@@ -3,8 +3,8 @@ import { Clan, ClanList } from '../../types/clanList';
import { secrets } from '../node/secrets'; import { secrets } from '../node/secrets';
import getWargamingResponse from './getWargamingResponse'; import getWargamingResponse from './getWargamingResponse';
export type ClanListWithServer = (Clan & { export type ClanListWithRegion = (Clan & {
server: 'com' | 'eu' | 'asia'; region: 'com' | 'eu' | 'asia';
})[]; })[];
export default async function listClans(search: string, limit = 9) { export default async function listClans(search: string, limit = 9) {
@@ -21,7 +21,7 @@ export default async function listClans(search: string, limit = 9) {
value && value &&
value.map((account) => ({ value.map((account) => ({
...account, ...account,
server: 'com' as Region, region: 'com' as Region,
})), })),
), ),
getWargamingResponse<ClanList>( getWargamingResponse<ClanList>(
@@ -31,7 +31,7 @@ export default async function listClans(search: string, limit = 9) {
value && value &&
value.map((account) => ({ value.map((account) => ({
...account, ...account,
server: 'eu' as Region, region: 'eu' as Region,
})), })),
), ),
getWargamingResponse<ClanList>( getWargamingResponse<ClanList>(
@@ -41,7 +41,7 @@ export default async function listClans(search: string, limit = 9) {
value && value &&
value.map((account) => ({ value.map((account) => ({
...account, ...account,
server: 'asia' as Region, region: 'asia' as Region,
})), })),
), ),
]) ])
+32
View File
@@ -0,0 +1,32 @@
/**
* Thanks, August!
* https://stackoverflow.com/a/41358305/12294756
*/
const ROMAN = {
M: 1000,
CM: 900,
D: 500,
CD: 400,
C: 100,
XC: 90,
L: 50,
XL: 40,
X: 10,
IX: 9,
V: 5,
IV: 4,
I: 1,
};
const ROMAN_KEYS = Object.keys(ROMAN) as (keyof typeof ROMAN)[];
export default function numberToRomanNumeral(value: number) {
let string = '';
ROMAN_KEYS.forEach((index) => {
const part = Math.floor(value / ROMAN[index]);
value -= part * ROMAN[index];
string += index.repeat(part);
});
return string;
}
+42 -4
View File
@@ -1,3 +1,9 @@
import { deburr } from 'lodash';
import { secrets } from '../node/secrets';
import getWargamingResponse from './getWargamingResponse';
export type TankType = 'AT-SPG' | 'lightTank' | 'mediumTank' | 'heavyTank';
export interface TankopediaEntry { export interface TankopediaEntry {
name: string; name: string;
nation: string; nation: string;
@@ -7,7 +13,7 @@ export interface TankopediaEntry {
cost: { price_credit: number; price_gold: number }; cost: { price_credit: number; price_gold: number };
images: { preview: string; normal: string }; images: { preview: string; normal: string };
tank_id: number; tank_id: number;
type: string; type: TankType;
description: string; description: string;
} }
@@ -31,22 +37,54 @@ export const TANKS = new Promise<TankopediaEntry[]>(async (resolve) => {
export const TANK_NAMES = new Promise<string[]>(async (resolve) => { export const TANK_NAMES = new Promise<string[]>(async (resolve) => {
resolve((await entries).map(({ name }) => name)); resolve((await entries).map(({ name }) => name));
}); });
export const TANK_NAMES_DIACRITICS = TANK_NAMES.then((tankNames) =>
Promise.all(
tankNames.map(async (tankName, index) => {
const id = (await TANKS)[index].tank_id;
const name = tankName ?? `Unknown Tank ${id}`;
const diacriticless = deburr(name);
export const TANK_ICONS: Record<string, string> = { return {
original: name,
diacriticless,
combined: `${name}${diacriticless}`,
id,
};
}),
),
);
export interface TankopediaInfo {
achievement_sections: Record<string, { name: string; order: number }>;
tanks_updated_at: number;
languages: Record<string, string>;
vehicle_types: Record<string, string>;
vehicle_nations: Record<string, string>;
game_version: string;
}
// this is blocking because info is needed for command creation
console.log('Caching tankopedia info...');
export const tankopediaInfo = getWargamingResponse<TankopediaInfo>(
`https://api.wotblitz.com/wotb/encyclopedia/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}`,
);
console.log('Cached tankopedia info');
export const TANK_ICONS: Record<TankType, string> = {
'AT-SPG': 'https://i.imgur.com/BIHSEH0.png', 'AT-SPG': 'https://i.imgur.com/BIHSEH0.png',
lightTank: 'https://i.imgur.com/CSNha5V.png', lightTank: 'https://i.imgur.com/CSNha5V.png',
mediumTank: 'https://i.imgur.com/wvf3ltm.png', mediumTank: 'https://i.imgur.com/wvf3ltm.png',
heavyTank: 'https://i.imgur.com/ECeqlZa.png', heavyTank: 'https://i.imgur.com/ECeqlZa.png',
}; };
export const TANK_ICONS_PREMIUM: Record<string, string> = { export const TANK_ICONS_PREMIUM: Record<TankType, string> = {
'AT-SPG': 'https://i.imgur.com/TCu3EdR.png', 'AT-SPG': 'https://i.imgur.com/TCu3EdR.png',
lightTank: 'https://i.imgur.com/zdkpTRb.png', lightTank: 'https://i.imgur.com/zdkpTRb.png',
mediumTank: 'https://i.imgur.com/3z7eHX6.png', mediumTank: 'https://i.imgur.com/3z7eHX6.png',
heavyTank: 'https://i.imgur.com/P3vbmyA.png', heavyTank: 'https://i.imgur.com/P3vbmyA.png',
}; };
export const TANK_ICONS_COLLECTOR: Record<string, string> = { export const TANK_ICONS_COLLECTOR: Record<TankType, string> = {
'AT-SPG': 'https://i.imgur.com/WTjeirB.png', 'AT-SPG': 'https://i.imgur.com/WTjeirB.png',
lightTank: 'https://i.imgur.com/EwhtKkU.png', lightTank: 'https://i.imgur.com/EwhtKkU.png',
mediumTank: 'https://i.imgur.com/u8YDMBh.png', mediumTank: 'https://i.imgur.com/u8YDMBh.png',
+3 -12
View File
@@ -1,16 +1,7 @@
import { Region } from '../../constants/regions'; import { Region } from '../../constants/regions';
import { Period } from '../discord/addPeriodSubCommands'; import { PeriodSize } from '../discord/addPeriodSubCommands';
import getTimeDaysAgo from './getTimeDaysAgo'; import getTimeDaysAgo from './getTimeDaysAgo';
export default function getPeriodStart( export default function getPeriodStart(region: Region, period: PeriodSize) {
server: Region, return period === 'career' ? 0 : getTimeDaysAgo(region, parseInt(period));
period: Exclude<Period, 'custom'>,
) {
if (period === 'career') {
return -Infinity;
} else if (period === 'today') {
return getTimeDaysAgo(server, 1);
} else {
return getTimeDaysAgo(server, parseInt(period));
}
} }
+2 -2
View File
@@ -6,13 +6,13 @@ export const TIME_ZONE_MAPPINGS: Record<Region, number> = {
asia: +7, // Central Asia Standard Time asia: +7, // Central Asia Standard Time
}; };
export default function getTimeDaysAgo(server: Region, daysAgo: number) { export default function getTimeDaysAgo(region: Region, daysAgo: number) {
const now = new Date(); const now = new Date();
if (daysAgo === 0) now.getTime() / 1000; if (daysAgo === 0) now.getTime() / 1000;
const time = new Date(); const time = new Date();
time.setUTCHours(-TIME_ZONE_MAPPINGS[server], 0, 0, 0); time.setUTCHours(-TIME_ZONE_MAPPINGS[region], 0, 0, 0);
if (time > now) time.setUTCDate(time.getUTCDate() - 1); if (time > now) time.setUTCDate(time.getUTCDate() - 1);
time.setUTCDate(time.getUTCDate() - daysAgo + 1); time.setUTCDate(time.getUTCDate() - daysAgo + 1);
-12
View File
@@ -1,12 +0,0 @@
import sumStats from '../blitz/sumStats';
import { emptyAllStats } from './getDiffedTankStats';
import { tankAverages } from './tankAverages';
console.log('Calculating player average...');
export const playerAverage = Object.entries(await tankAverages).reduce(
(accumulator, [, tankAverage]) => {
return sumStats([accumulator, tankAverage.all]);
},
emptyAllStats,
);
console.log('Calculated player average');
@@ -0,0 +1,27 @@
import { BlitzkriegRatingsLeaderboard } from '../../../scripts/buildRatingsLeaderboard';
import { Region } from '../../constants/regions';
import { octokit } from '../express/octokit';
export const DATABASE_REPO = { owner: 'tresabhi', repo: 'blitzkrieg-db' };
export default async function getMidnightLeaderboard(
region: Region,
season: number,
) {
try {
const { data } = await octokit.repos.getContent({
...DATABASE_REPO,
path: `${region}/ratings/${season}/midnight.json`,
});
if (!Array.isArray(data) && data.type === 'file') {
const response = await fetch(data.download_url!);
return (await response.json()) as BlitzkriegRatingsLeaderboard;
}
} catch (error) {
console.warn(
`No midnight leaderboard found for season ${season}, region ${region}`,
);
}
}
+24 -19
View File
@@ -2,9 +2,9 @@ import {
SlashCommandSubcommandBuilder, SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandGroupBuilder,
} from 'discord.js'; } from 'discord.js';
import { PERIOD_NAMES } from '../discord/resolvePeriodFromCommand';
export type Period = 'today' | '30' | '60' | '90' | 'career' | 'custom'; export type PeriodType = 'period' | 'custom';
export type PeriodSize = `${number}` | 'career';
export default function addPeriodSubCommands( export default function addPeriodSubCommands(
option: SlashCommandSubcommandGroupBuilder, option: SlashCommandSubcommandGroupBuilder,
@@ -14,36 +14,41 @@ export default function addPeriodSubCommands(
) { ) {
return option return option
.addSubcommand((option) => .addSubcommand((option) =>
extra(option.setName('today').setDescription(PERIOD_NAMES.today)), extra(
) option
.addSubcommand((option) => .setName('period' satisfies PeriodType)
extra(option.setName('30').setDescription(PERIOD_NAMES[30])), .setDescription("Preset period's statistics")
) .addStringOption((option) =>
.addSubcommand((option) => option
extra(option.setName('60').setDescription(PERIOD_NAMES[60])), .setName('period')
) .setDescription('A preset period')
.addSubcommand((option) => .setChoices(
extra(option.setName('90').setDescription(PERIOD_NAMES[90])), { name: 'Today', value: '1' satisfies PeriodSize },
) { name: '30 days', value: '30' satisfies PeriodSize },
.addSubcommand((option) => { name: '60 days', value: '60' satisfies PeriodSize },
extra(option.setName('career').setDescription(PERIOD_NAMES.career)), { name: '90 days', value: '90' satisfies PeriodSize },
{ name: 'Career', value: 'career' satisfies PeriodSize },
)
.setRequired(true),
),
),
) )
.addSubcommand((option) => .addSubcommand((option) =>
extra( extra(
option option
.setName('custom') .setName('custom' satisfies PeriodType)
.setDescription(PERIOD_NAMES.custom) .setDescription("Custom period's statistics")
.addIntegerOption((option) => .addIntegerOption((option) =>
option option
.setName('start') .setName('start')
.setDescription('Start of period in days ago from today') .setDescription('Days ago from today')
.setMinValue(0) .setMinValue(0)
.setRequired(true), .setRequired(true),
) )
.addIntegerOption((option) => .addIntegerOption((option) =>
option option
.setName('end') .setName('end')
.setDescription('End of period in days ago from today') .setDescription('Days ago from today')
.setMinValue(0) .setMinValue(0)
.setRequired(true), .setRequired(true),
), ),
@@ -1,18 +1,25 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import { TreeTypeString } from '../../components/Tanks';
import { StatType } from '../../renderers/stats';
import { tankopediaInfo } from '../blitz/tankopedia';
import addPeriodSubCommands from './addPeriodSubCommands'; import addPeriodSubCommands from './addPeriodSubCommands';
import addTankChoices from './addTankChoices'; import addTankChoices from './addTankChoices';
import addTierChoices from './addTierChoices';
import addUsernameChoices from './addUsernameChoices'; import addUsernameChoices from './addUsernameChoices';
export default function addStatTypeSubCommandGroups( export default async function addStatTypeSubCommandGroups(
option: SlashCommandBuilder, option: SlashCommandBuilder,
addMultiTank = true,
) { ) {
return option const awaitedTankopediaInfo = await tankopediaInfo;
option
.addSubcommandGroup((option) => .addSubcommandGroup((option) =>
addPeriodSubCommands(option, (option) => addPeriodSubCommands(option, (option) =>
option.addStringOption(addUsernameChoices), option.addStringOption(addUsernameChoices),
) )
.setName('player') .setName('player' satisfies StatType)
.setDescription("Player's statistics"), .setDescription('Player statistics'),
) )
.addSubcommandGroup((option) => .addSubcommandGroup((option) =>
addPeriodSubCommands(option, (option) => addPeriodSubCommands(option, (option) =>
@@ -20,7 +27,65 @@ export default function addStatTypeSubCommandGroups(
.addStringOption(addTankChoices) .addStringOption(addTankChoices)
.addStringOption(addUsernameChoices), .addStringOption(addUsernameChoices),
) )
.setName('tank') .setName('tank' satisfies StatType)
.setDescription("Tank's statistics"), .setDescription('Tank statistics'),
); );
if (addMultiTank)
option.addSubcommandGroup((option) =>
addPeriodSubCommands(option, (option) =>
option
.addStringOption(addUsernameChoices)
.addStringOption((option) => {
option
.setName('nation')
.setDescription('Nation')
.addChoices(
...Object.entries(awaitedTankopediaInfo.vehicle_nations).map(
([type, name]) => ({ name, value: type }),
),
);
return option;
})
.addStringOption((option) =>
option
.setName('tank-type')
.setDescription('Type')
.addChoices(
...Object.entries(awaitedTankopediaInfo.vehicle_types).map(
([type, name]) => ({
name: name.replace(' Tank', ''),
value: type,
}),
),
)
.setRequired(false),
)
.addStringOption((option) =>
addTierChoices(option).setRequired(false),
)
.addStringOption((option) =>
option
.setName('tree-type')
.setDescription('Tech tree type')
.addChoices(
{
name: 'Tech Tree',
value: 'techtree' satisfies TreeTypeString,
},
{ name: 'Premium', value: 'premium' satisfies TreeTypeString },
{
name: 'Collector',
value: 'collector' satisfies TreeTypeString,
},
)
.setRequired(false),
),
)
.setName('multi-tank' satisfies StatType)
.setDescription('Multiple tanks'),
);
return option;
} }
+16
View File
@@ -0,0 +1,16 @@
import { SlashCommandStringOption } from 'discord.js';
import { range } from 'lodash';
import numberToRomanNumeral from '../blitz/numberToRomanNumeral';
export default function addTierChoices(option: SlashCommandStringOption) {
return option
.setName('tier')
.setDescription('The tier you want to see')
.setChoices(
...range(10, 0).map((tier) => ({
name: `Tier ${tier} - ${numberToRomanNumeral(tier)}`,
value: `${tier}`,
})),
)
.setRequired(true);
}
+3 -2
View File
@@ -11,6 +11,7 @@ export default async function autocompleteClan(
) { ) {
const focusedOption = interaction.options.getFocused(true); const focusedOption = interaction.options.getFocused(true);
if (focusedOption.name !== 'clan') return; if (focusedOption.name !== 'clan') return;
if (focusedOption.value.length < 2) return interaction.respond([]);
const players = await listClans(focusedOption.value); const players = await listClans(focusedOption.value);
await interaction.respond( await interaction.respond(
@@ -18,8 +19,8 @@ export default async function autocompleteClan(
? players.map( ? players.map(
(player) => (player) =>
({ ({
name: `${player.name} (${REGION_NAMES[player.server]})`, name: `${player.name} (${REGION_NAMES[player.region]})`,
value: `${player.server}/${player.clan_id}`, value: `${player.region}/${player.clan_id}`,
}) satisfies ApplicationCommandOptionChoiceData<string>, }) satisfies ApplicationCommandOptionChoiceData<string>,
) )
: [], : [],
+5 -8
View File
@@ -4,8 +4,7 @@ import {
CacheType, CacheType,
} from 'discord.js'; } from 'discord.js';
import { go } from 'fuzzysort'; import { go } from 'fuzzysort';
import resolveTankName from '../blitz/resolveTankName'; import { TANK_NAMES_DIACRITICS } from '../blitz/tankopedia';
import { TANKS } from '../blitz/tankopedia';
export default async function autocompleteTanks( export default async function autocompleteTanks(
interaction: AutocompleteInteraction<CacheType>, interaction: AutocompleteInteraction<CacheType>,
@@ -16,16 +15,14 @@ export default async function autocompleteTanks(
await interaction.respond( await interaction.respond(
focusedOption.value focusedOption.value
? await Promise.all( ? await Promise.all(
go(focusedOption.value, await TANKS, { go(focusedOption.value, await TANK_NAMES_DIACRITICS, {
keys: ['name'], keys: ['combined'],
limit: 10, limit: 10,
}).map( }).map(
async (item) => async (item) =>
({ ({
name: item.obj.name name: item.obj.original,
? item.obj.name value: `${item.obj.id}`,
: await resolveTankName(item.obj.tank_id),
value: `${item.obj.tank_id}`,
}) satisfies ApplicationCommandOptionChoiceData<string>, }) satisfies ApplicationCommandOptionChoiceData<string>,
), ),
) )
+11
View File
@@ -0,0 +1,11 @@
import { Client, GatewayIntentBits } from 'discord.js';
import guildMemberAdd from '../../events/guildMemberAdd';
import interactionCreate from '../../events/interactionCreate';
import ready from '../../events/ready';
export const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
})
.on('ready', ready)
.on('guildMemberAdd', guildMemberAdd)
.on('interactionCreate', interactionCreate);
+1 -6
View File
@@ -19,12 +19,7 @@ export default function interactionToURL(
] ]
.filter(Boolean) .filter(Boolean)
.join('/'); .join('/');
const params = [ const params = (parameters ? Object.entries(parameters) : [])
...interaction.options.data.map(
(option) => [option.name, option.value] as const,
),
...Object.entries(parameters ?? {}),
]
.filter(([, value]) => value !== undefined && value !== null) .filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${key}=${value}`) .map(([key, value]) => `${key}=${value}`)
.join('&'); .join('&');
@@ -0,0 +1,27 @@
import { ChatInputCommandInteraction } from 'discord.js';
import { Region } from '../../constants/regions';
import listClans from '../blitz/listClans';
import throwError from '../node/throwError';
import { serverAndIdPattern } from './resolvePlayerFromCommand';
export default async function resolveClanFromCommand(
interaction: ChatInputCommandInteraction,
) {
const clan = interaction.options.getString('clan', true);
if (serverAndIdPattern.test(clan)) {
const [server, accountId] = clan.split('/');
return { region: server as Region, id: Number(accountId) };
} else {
const accounts = await listClans(clan);
if (accounts[0]) {
return { region: accounts[0].region, id: accounts[0].clan_id };
} else {
throw throwError(
'Could not find clan',
`I couldn't find clan \`${clan}\`. Try selecting a username from the search result.`,
);
}
}
}
+19 -32
View File
@@ -3,25 +3,11 @@ import { Region } from '../../constants/regions';
import getPeriodNow from '../blitzstars/getPeriodNow'; import getPeriodNow from '../blitzstars/getPeriodNow';
import getPeriodStart from '../blitzstars/getPeriodStart'; import getPeriodStart from '../blitzstars/getPeriodStart';
import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo'; import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo';
import { Period } from '../discord/addPeriodSubCommands'; import { PeriodSize, PeriodType } from '../discord/addPeriodSubCommands';
export const PERIOD_NAMES: Record<Period, string> = { export function getPeriodOptionName(period: PeriodSize) {
today: "Today's statistics", return period === 'career' ? 'Career' : `${period}-day`;
30: "30 days' statistics", }
60: "60 days' statistics",
90: "90 days' statistics",
career: 'Career statistics',
custom: 'Custom period',
};
export const EVOLUTION_PERIOD_NAMES: Record<Period, string> = {
today: "Today's evolution",
30: "30 days' evolution",
60: "60 days' evolution",
90: "90 days' evolution",
career: 'Career evolution',
custom: 'Custom period',
};
export interface ResolvedPeriod { export interface ResolvedPeriod {
statsName: string; statsName: string;
@@ -31,29 +17,30 @@ export interface ResolvedPeriod {
} }
export default function resolvePeriodFromCommand( export default function resolvePeriodFromCommand(
server: Region, region: Region,
interaction: ChatInputCommandInteraction<CacheType>, interaction: ChatInputCommandInteraction<CacheType>,
) { ) {
let statsName: string; let statsName: string;
let evolutionName: string; let evolutionName: string;
let start: number; let start: number;
let end: number; let end: number;
const period = interaction.options.getSubcommand() as Period; const periodSubcommand = interaction.options.getSubcommand() as PeriodType;
const periodOption = interaction.options.getString('period') as PeriodSize;
if (period === 'custom') { if (periodSubcommand === 'custom') {
const startRaw = interaction.options.getInteger('start')!; const startRaw = interaction.options.getInteger('start', true);
const endRaw = interaction.options.getInteger('end')!; const endRaw = interaction.options.getInteger('end', true);
const startMin = Math.min(startRaw, endRaw); const startDaysAgoMin = Math.min(startRaw, endRaw);
const endMax = Math.max(startRaw, endRaw); const endDaysAgoMax = Math.max(startRaw, endRaw);
statsName = `${startMin} to ${endMax} days' statistics`; statsName = `${startDaysAgoMin} to ${endDaysAgoMax} days' statistics`;
evolutionName = `${startMin} to ${endMax} days' evolution`; evolutionName = `${startDaysAgoMin} to ${endDaysAgoMax} days' evolution`;
start = getTimeDaysAgo(server, startMin); start = getTimeDaysAgo(region, endDaysAgoMax);
end = getTimeDaysAgo(server, endMax); end = getTimeDaysAgo(region, startDaysAgoMin);
} else { } else {
statsName = PERIOD_NAMES[period]; statsName = `${getPeriodOptionName(periodOption)} Statistics`;
evolutionName = EVOLUTION_PERIOD_NAMES[period]; evolutionName = `${getPeriodOptionName(periodOption)} Evolution`;
start = getPeriodStart(server, period); start = getPeriodStart(region, periodOption);
end = getPeriodNow(); end = getPeriodNow();
} }
-7
View File
@@ -1,7 +0,0 @@
import { ButtonBuilder, ButtonStyle } from 'discord.js';
// TODO: ADD THIS AGAIN IMMEDIATELY
export const supportBlitzStars = new ButtonBuilder()
.setLabel('Support BlitzStars')
.setURL('https://www.blitzstars.com/supporters')
.setStyle(ButtonStyle.Link);
+4
View File
@@ -0,0 +1,4 @@
import { Octokit } from '@octokit/rest';
import { secrets } from '../node/secrets';
export const octokit = new Octokit({ auth: secrets.GH_TOKEN });
+1 -1
View File
@@ -7,5 +7,5 @@ export default function resolvePeriodFromRequest(
server: Region, server: Region,
request: Request, request: Request,
) { ) {
return resolvePeriodFromURL(server, `${CYCLIC_API}${request.path}`); return resolvePeriodFromURL(server, `${CYCLIC_API}${request.url}`);
} }
+15 -20
View File
@@ -2,11 +2,10 @@ import { Region } from '../../constants/regions';
import getPeriodNow from '../blitzstars/getPeriodNow'; import getPeriodNow from '../blitzstars/getPeriodNow';
import getPeriodStart from '../blitzstars/getPeriodStart'; import getPeriodStart from '../blitzstars/getPeriodStart';
import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo'; import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo';
import { Period } from '../discord/addPeriodSubCommands'; import { PeriodSize, PeriodType } from '../discord/addPeriodSubCommands';
import { import {
EVOLUTION_PERIOD_NAMES,
PERIOD_NAMES,
ResolvedPeriod, ResolvedPeriod,
getPeriodOptionName,
} from '../discord/resolvePeriodFromCommand'; } from '../discord/resolvePeriodFromCommand';
export default function resolvePeriodFromURL( export default function resolvePeriodFromURL(
@@ -19,29 +18,25 @@ export default function resolvePeriodFromURL(
let end: number; let end: number;
const url = new URL(urlString); const url = new URL(urlString);
const path = url.pathname.split('/').filter(Boolean); const path = url.pathname.split('/').filter(Boolean);
const period = path[path.length - 1] as Period; const periodSubcommand = path[path.length - 1] as PeriodType;
const periodOption = url.searchParams.get('period') as PeriodSize;
if (period === 'custom') { if (periodSubcommand === 'custom') {
const startRaw = parseInt(url!.searchParams.get('start')!); const startRaw = parseInt(url!.searchParams.get('start')!);
const endRaw = parseInt(url!.searchParams.get('end')!); const endRaw = parseInt(url!.searchParams.get('end')!);
const startMin = Math.min(startRaw, endRaw); const startDaysAgoMin = Math.min(startRaw, endRaw);
const endMax = Math.max(startRaw, endRaw); const endDaysAgoMax = Math.max(startRaw, endRaw);
statsName = `${startMin} to ${endMax} days' statistics`; statsName = `${startDaysAgoMin} to ${endDaysAgoMax} days' statistics`;
evolutionName = `${startMin} to ${endMax} days' evolution`; evolutionName = `${startDaysAgoMin} to ${endDaysAgoMax} days' evolution`;
start = getTimeDaysAgo(server, startMin); start = getTimeDaysAgo(server, endDaysAgoMax);
end = getTimeDaysAgo(server, endMax); end = getTimeDaysAgo(server, startDaysAgoMin);
} else { } else {
statsName = PERIOD_NAMES[period]; statsName = `${getPeriodOptionName(periodOption)} Statistics`;
evolutionName = EVOLUTION_PERIOD_NAMES[period]; evolutionName = `${getPeriodOptionName(periodOption)} Evolution`;
start = getPeriodStart(server, period); start = getPeriodStart(server, periodOption);
end = getPeriodNow(); end = getPeriodNow();
} }
return { return { statsName, evolutionName, start, end } satisfies ResolvedPeriod;
statsName,
evolutionName,
start,
end,
} satisfies ResolvedPeriod;
} }
+1 -1
View File
@@ -32,7 +32,7 @@ Promise.all(
export default async function jsxToSvg(jsx: JSX.Element) { export default async function jsxToSvg(jsx: JSX.Element) {
return await satori(jsx, { return await satori(jsx, {
width: 640, width: 480,
fonts: [ fonts: [
{ data: roboto, name: FONT_NAME, weight: 400 }, { data: roboto, name: FONT_NAME, weight: 400 },
{ data: robotoBold, name: FONT_NAME, weight: 700 }, { data: robotoBold, name: FONT_NAME, weight: 700 },
+1 -1
View File
@@ -1,7 +1,7 @@
import { EmbedBuilder, TextChannel } from 'discord.js'; import { EmbedBuilder, TextChannel } from 'discord.js';
import discord from '../../discord.json' assert { type: 'json' }; import discord from '../../discord.json' assert { type: 'json' };
import { client } from '../bot';
import { NEGATIVE_COLOR } from '../constants/colors'; import { NEGATIVE_COLOR } from '../constants/colors';
import { client } from '../core/discord/client';
import isDev from '../core/node/isDev'; import isDev from '../core/node/isDev';
const PROCESS_ERROR_EVENTS = ['uncaughtException']; const PROCESS_ERROR_EVENTS = ['uncaughtException'];
@@ -1,10 +1,10 @@
import { AutocompleteInteraction, CacheType } from 'discord.js'; import { AutocompleteInteraction, CacheType } from 'discord.js';
import { commands } from '..'; import { commands } from '..';
export default function handleAutocomplete( export default async function handleAutocomplete(
interaction: AutocompleteInteraction<CacheType>, interaction: AutocompleteInteraction<CacheType>,
) { ) {
const command = commands[interaction.commandName]; const command = (await commands)[interaction.commandName];
if (!command) { if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`); console.error(`No command matching ${interaction.commandName} was found.`);
@@ -11,7 +11,7 @@ export default async function handleButton(
const url = new URL(`${CYCLIC_API}/${interaction.customId}`); const url = new URL(`${CYCLIC_API}/${interaction.customId}`);
const commandName = url.pathname.split('/')[1]; const commandName = url.pathname.split('/')[1];
const button = commands[commandName]?.button; const button = (await commands)[commandName]?.button;
if (!button) { if (!button) {
throwError(`Button handler not found for "${commandName}"`); throwError(`Button handler not found for "${commandName}"`);
@@ -16,7 +16,7 @@ import { handleError } from '../../error';
export default async function handleChatInputCommand( export default async function handleChatInputCommand(
interaction: ChatInputCommandInteraction<CacheType>, interaction: ChatInputCommandInteraction<CacheType>,
) { ) {
const registry = commands[interaction.commandName]; const registry = (await commands)[interaction.commandName];
console.log(interaction.toString()); console.log(interaction.toString());
await interaction.deferReply(); await interaction.deferReply();
+62 -46
View File
@@ -13,6 +13,7 @@ import {
SlashCommandSubcommandsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder,
} from 'discord.js'; } from 'discord.js';
import discord from '../../../discord.json' assert { type: 'json' }; import discord from '../../../discord.json' assert { type: 'json' };
import { clanEventCommand } from '../../commands/clanEvent';
import { debugCommand } from '../../commands/debug'; import { debugCommand } from '../../commands/debug';
import { eligibleCommand } from '../../commands/eligible'; import { eligibleCommand } from '../../commands/eligible';
import { evolutionCommand } from '../../commands/evolution'; import { evolutionCommand } from '../../commands/evolution';
@@ -50,26 +51,33 @@ export type InteractionReturnable =
| Promise<InteractionIterableReturnable>; | Promise<InteractionIterableReturnable>;
export interface Registry { export interface Registry {
inDevelopment: boolean;
inProduction: boolean; inProduction: boolean;
} }
export interface CommandRegistry<HandlesInteraction extends boolean = boolean> export type CommandRegistryRaw = Registry & {
extends Registry {
inPublic: boolean; inPublic: boolean;
inPreview?: boolean; inPreview?: boolean;
handlesInteraction?: HandlesInteraction;
command: command:
| SlashCommandBuilder | SlashCommandBuilder
| SlashCommandSubcommandsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder
| Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>; | Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
handler: (
interaction: ChatInputCommandInteraction<CacheType>,
) => HandlesInteraction extends true ? void : InteractionReturnable;
autocomplete?: (interaction: AutocompleteInteraction<CacheType>) => void; autocomplete?: (interaction: AutocompleteInteraction<CacheType>) => void;
button?: (interaction: ButtonInteraction<CacheType>) => InteractionReturnable; button?: (interaction: ButtonInteraction<CacheType>) => InteractionReturnable;
} } & (
| {
handlesInteraction: true;
handler: (interaction: ChatInputCommandInteraction<CacheType>) => void;
}
| {
handlesInteraction?: false;
handler: (
interaction: ChatInputCommandInteraction<CacheType>,
) => InteractionReturnable;
}
);
export type CommandRegistry = CommandRegistryRaw | Promise<CommandRegistryRaw>;
const rest = new REST().setToken(secrets.DISCORD_TOKEN); const rest = new REST().setToken(secrets.DISCORD_TOKEN);
@@ -91,13 +99,21 @@ export const COMMANDS_RAW: CommandRegistry[] = [
evolutionCommand, evolutionCommand,
statsCommand, statsCommand,
ratingsCommand, ratingsCommand,
clanEventCommand,
]; ];
export const commands: Record<string, CommandRegistry> = COMMANDS_RAW.reduce(
(accumulator, registry) => { export const commands = Promise.all(COMMANDS_RAW).then((rawCommands) =>
if (isDev()) registry.command.setDefaultMemberPermissions(0); rawCommands.reduce<Record<string, CommandRegistryRaw>>(
return { ...accumulator, [registry.command.name]: registry }; (accumulator, registry) => {
}, if (isDev()) registry.command.setDefaultMemberPermissions(0);
{},
return {
...accumulator,
[registry.command.name]: registry,
};
},
{},
),
); );
export const guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = export const guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] =
@@ -105,8 +121,8 @@ export const guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] =
export const publicCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = export const publicCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] =
[]; [];
Object.entries(commands).forEach(([, registry]) => { commands.then((awaitedCommands) => {
if (isDev() ? registry.inDevelopment : registry.inProduction) { Object.entries(awaitedCommands).forEach(([, registry]) => {
const json = registry.command.toJSON(); const json = registry.command.toJSON();
if (registry.inPublic) { if (registry.inPublic) {
@@ -114,39 +130,39 @@ Object.entries(commands).forEach(([, registry]) => {
} else { } else {
guildCommands.push(json); guildCommands.push(json);
} }
});
try {
console.log(`Refreshing ${publicCommands.length} public command(s).`);
rest
.put(Routes.applicationCommands(getClientId()), { body: publicCommands })
.then((publicData) => {
console.log(
`Successfully refreshed ${
(publicData as RESTPostAPIChatInputApplicationCommandsJSONBody[])
.length
} public command(s).`,
);
});
console.log(`Refreshing ${guildCommands.length} guild command(s).`);
rest
.put(
Routes.applicationGuildCommands(getClientId(), discord.tres_guild_id),
{ body: guildCommands },
)
.then((guildData) => {
console.log(
`Successfully refreshed ${
(guildData as unknown[]).length
} guild command(s).`,
);
});
} catch (error) {
console.error(error);
} }
}); });
try {
console.log(`Refreshing ${publicCommands.length} public command(s).`);
rest
.put(Routes.applicationCommands(getClientId()), { body: publicCommands })
.then((publicData) => {
console.log(
`Successfully refreshed ${
(publicData as RESTPostAPIChatInputApplicationCommandsJSONBody[])
.length
} public command(s).`,
);
});
console.log(`Refreshing ${guildCommands.length} guild command(s).`);
rest
.put(
Routes.applicationGuildCommands(getClientId(), discord.tres_guild_id),
{ body: guildCommands },
)
.then((guildData) => {
console.log(
`Successfully refreshed ${
(guildData as unknown[]).length
} guild command(s).`,
);
});
} catch (error) {
console.error(error);
}
export default async function interactionCreate( export default async function interactionCreate(
interaction: Interaction<CacheType>, interaction: Interaction<CacheType>,
) { ) {
+3 -10
View File
@@ -1,9 +1,7 @@
import * as Graph from '../components/Graph'; import * as Graph from '../components/Graph';
import NoData, { NoDataType } from '../components/NoData'; import NoData, { NoDataType } from '../components/NoData';
import PoweredBy, { PoweredByType } from '../components/PoweredBy';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper'; import Wrapper from '../components/Wrapper';
import { REGION_NAMES } from '../constants/regions';
import getWargamingResponse from '../core/blitz/getWargamingResponse'; import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankName from '../core/blitz/resolveTankName'; import resolveTankName from '../core/blitz/resolveTankName';
import { tankopedia } from '../core/blitz/tankopedia'; import { tankopedia } from '../core/blitz/tankopedia';
@@ -15,7 +13,7 @@ import { secrets } from '../core/node/secrets';
import { AccountInfo } from '../types/accountInfo'; import { AccountInfo } from '../types/accountInfo';
import { Histories } from '../types/histories'; import { Histories } from '../types/histories';
import { PlayerClanData } from '../types/playerClanData'; import { PlayerClanData } from '../types/playerClanData';
import { StatType } from './fullStats'; import { StatType } from './stats';
export default async function evolution<Type extends StatType>( export default async function evolution<Type extends StatType>(
type: Type, type: Type,
@@ -88,20 +86,17 @@ export default async function evolution<Type extends StatType>(
name={accountInfo[id].nickname} name={accountInfo[id].nickname}
nameDiscriminator={nameDiscriminator} nameDiscriminator={nameDiscriminator}
image={image} image={image}
description={`${evolutionName} • ${new Date().toDateString()} • ${ description={`${evolutionName} • ${new Date().toDateString()}`}
REGION_NAMES[server]
}`}
/> />
{/* goofy ahh bug forces me to call them as functions */} {/* goofy ahh bug forces me to call them as functions */}
{plot.length > 0 && ( {plot.length > 0 && (
<Graph.Root <Graph.Root
xTitle="Battles"
verticalMargin={{ verticalMargin={{
min: minY, min: minY,
max: maxY, max: maxY,
suffix: '%', suffix: '%',
precision: 1, precision: 2,
}} }}
horizontalMargin={{ horizontalMargin={{
min: minX, min: minX,
@@ -122,8 +117,6 @@ export default async function evolution<Type extends StatType>(
)} )}
{plot.length === 0 && <NoData type={NoDataType.BattlesInPeriod} />} {plot.length === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
<PoweredBy type={PoweredByType.BlitzStars} />
</Wrapper> </Wrapper>
); );
} }
+80 -76
View File
@@ -1,10 +1,8 @@
import GenericAllStats from '../components/GenericAllStats'; import GenericAllStats from '../components/GenericAllStats';
import NoData, { NoDataType } from '../components/NoData'; import NoData, { NoDataType } from '../components/NoData';
import PoweredBy, { PoweredByType } from '../components/PoweredBy';
import TierWeights, { TierWeightsRecord } from '../components/TierWeights'; import TierWeights, { TierWeightsRecord } from '../components/TierWeights';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper'; import Wrapper from '../components/Wrapper';
import { REGION_NAMES } from '../constants/regions';
import calculateWN8 from '../core/blitz/calculateWN8'; import calculateWN8 from '../core/blitz/calculateWN8';
import getWargamingResponse from '../core/blitz/getWargamingResponse'; import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankName from '../core/blitz/resolveTankName'; import resolveTankName from '../core/blitz/resolveTankName';
@@ -21,20 +19,22 @@ import {
SupplementaryStats, SupplementaryStats,
} from '../types/accountInfo'; } from '../types/accountInfo';
import { PlayerClanData } from '../types/playerClanData'; import { PlayerClanData } from '../types/playerClanData';
import { PossiblyPromise } from '../types/possiblyPromise'; import { MultiTankFilters, StatType } from './stats';
export type StatType = 'player' | 'tank';
export default async function fullStats<Type extends StatType>( export default async function fullStats<Type extends StatType>(
type: Type, type: Type,
{ start, end, statsName }: ResolvedPeriod, { start, end, statsName }: ResolvedPeriod,
{ region: server, id }: ResolvedPlayer, { region: server, id }: ResolvedPlayer,
tankId: Type extends 'tank' ? number : null, filters: Type extends 'tank'
? number
: Type extends 'multi-tank'
? Partial<MultiTankFilters>
: null,
) { ) {
let nameDiscriminator: string | undefined; let nameDiscriminator: string | undefined;
let image: string | undefined; let image: string | undefined;
if (type === 'player') { if (type === 'player' || type === 'multi-tank') {
const clan = ( const clan = (
await getWargamingResponse<PlayerClanData>( await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
@@ -46,89 +46,97 @@ export default async function fullStats<Type extends StatType>(
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png` ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
: undefined; : undefined;
} else if (type === 'tank') { } else if (type === 'tank') {
nameDiscriminator = `(${await resolveTankName(tankId!)})`; nameDiscriminator = `(${await resolveTankName(filters as number)})`;
image = (await tankopedia)[tankId!]?.images.normal; image = (await tankopedia)[filters as number]?.images.normal;
} }
const { diffed } = await getDiffedTankStats(server, id, start, end); const { diffed, order } = await getDiffedTankStats(server, id, start, end);
let stats: AllStats; const awaitedTankopedia = await tankopedia;
let stats: AllStats | undefined;
let supplementaryStats: SupplementaryStats; let supplementaryStats: SupplementaryStats;
let tierWeights: TierWeightsRecord; let tierWeights: TierWeightsRecord;
if (type === 'player') { if (type === 'player' || type === 'multi-tank') {
const entries = Object.entries(diffed); const typedFilters = filters as Partial<MultiTankFilters> | null;
stats = sumStats(entries.map(([, stats]) => stats)); const filteredOrder = order.filter(
const battlesOfTanksWithAverages = await entries.reduce< (id) =>
PossiblyPromise<number> typedFilters === null ||
>(async (accumulator, [tankIdString, stats]) => { ((typedFilters.nation === undefined ||
const tankId = parseInt(tankIdString); awaitedTankopedia[id]?.nation === typedFilters.nation) &&
const tankAverage = (await tankAverages)[tankId]; (typedFilters['tank-type'] === undefined ||
awaitedTankopedia[id]?.type === typedFilters['tank-type']) &&
return tankAverage ? (await accumulator) + stats.battles : accumulator; (typedFilters.tier === undefined ||
}, 0); awaitedTankopedia[id]?.tier === typedFilters.tier) &&
const battlesOfTanksWithTankopediaEntry = await entries.reduce< (typedFilters['tree-type'] === undefined ||
PossiblyPromise<number> (typedFilters['tree-type'] === 'collector' &&
>(async (accumulator, [tankIdString, stats]) => { awaitedTankopedia[id]?.is_collectible) ||
const tankId = parseInt(tankIdString); (typedFilters['tree-type'] === 'premium' &&
const tankopediaEntry = (await tankopedia)[tankId]; awaitedTankopedia[id]?.is_premium) ||
(typedFilters['tree-type'] === 'techtree' &&
return tankopediaEntry !awaitedTankopedia[id]?.is_collectible &&
? (await accumulator) + stats.battles !awaitedTankopedia[id]?.is_premium))),
: accumulator; );
}, 0); const awaitedTankAverages = await tankAverages;
stats = sumStats(filteredOrder.map((id) => diffed[id]));
const battlesOfTanksWithAverages = filteredOrder.reduce<number>(
(accumulator, id) =>
awaitedTankAverages[id]
? accumulator + diffed[id].battles
: accumulator,
0,
);
const battlesOfTanksWithTankopediaEntry = filteredOrder.reduce<number>(
(accumulator, id) =>
awaitedTankopedia[id] ? accumulator + diffed[id].battles : accumulator,
0,
);
supplementaryStats = { supplementaryStats = {
WN8: WN8:
(await entries.reduce<PossiblyPromise<number>>( filteredOrder.reduce<number>(
async (accumulator, [tankIdString, stats]) => { (accumulator, id) =>
const tankId = parseInt(tankIdString); awaitedTankAverages[id]
const tankAverage = (await tankAverages)[tankId]; ? accumulator +
calculateWN8(awaitedTankAverages[id].all, diffed[id]) *
return tankAverage diffed[id].battles
? (await accumulator) + : accumulator,
calculateWN8(tankAverage.all, stats) * stats.battles
: accumulator;
},
0, 0,
)) / battlesOfTanksWithAverages, ) / battlesOfTanksWithAverages,
tier: tier:
(await entries.reduce<PossiblyPromise<number>>( filteredOrder.reduce<number>(
async (accumulator, [tankIdString, stats]) => { (accumulator, id) =>
const tankId = parseInt(tankIdString); awaitedTankopedia[id]
const tankopediaEntry = (await tankopedia)[tankId]; ? accumulator + awaitedTankopedia[id]!.tier * diffed[id].battles
: accumulator,
return tankopediaEntry
? (await accumulator) + tankopediaEntry.tier * stats.battles
: accumulator;
},
0, 0,
)) / battlesOfTanksWithTankopediaEntry, ) / battlesOfTanksWithTankopediaEntry,
}; };
tierWeights = await entries.reduce<PossiblyPromise<TierWeightsRecord>>( tierWeights = filteredOrder.reduce<TierWeightsRecord>((accumulator, id) => {
async (accumulator, [tankIdString, stats]) => { const tankopediaEntry = awaitedTankopedia[id];
const tankId = parseInt(tankIdString);
const tankopediaEntry = (await tankopedia)[tankId];
if (!tankopediaEntry) return accumulator; if (!tankopediaEntry) return accumulator;
const tier = tankopediaEntry.tier as Tier; const tier = tankopediaEntry.tier as Tier;
if ((await accumulator)[tier]) { if (accumulator[tier]) {
(await accumulator)[tier]! += stats.battles; accumulator[tier]! += diffed[id].battles;
} else { } else {
(await accumulator)[tier] = stats.battles; accumulator[tier] = diffed[id].battles;
} }
return accumulator; return accumulator;
}, }, {});
{},
);
} else { } else {
stats = diffed[tankId!]; stats = diffed[(filters as number)!];
supplementaryStats = { supplementaryStats = {
WN8: calculateWN8((await tankAverages)[tankId!].all, diffed[tankId!]), WN8: stats
tier: (await tankopedia)[tankId!]?.tier, ? calculateWN8(
(await tankAverages)[(filters as number)!].all,
diffed[(filters as number)!],
)
: undefined,
tier: (await tankopedia)[(filters as number)!]?.tier,
}; };
} }
@@ -142,9 +150,7 @@ export default async function fullStats<Type extends StatType>(
name={accountInfo[id].nickname} name={accountInfo[id].nickname}
nameDiscriminator={nameDiscriminator} nameDiscriminator={nameDiscriminator}
image={image} image={image}
description={`${statsName} • ${new Date().toDateString()} • ${ description={`${statsName} • ${new Date().toDateString()}`}
REGION_NAMES[server]
}`}
/> />
{stats.battles === 0 && <NoData type={NoDataType.BattlesInPeriod} />} {stats.battles === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
@@ -157,8 +163,6 @@ export default async function fullStats<Type extends StatType>(
supplementaryStats={supplementaryStats} supplementaryStats={supplementaryStats}
/> />
)} )}
<PoweredBy type={PoweredByType.BlitzStars} />
</Wrapper> </Wrapper>
); );
} }
+78 -83
View File
@@ -1,15 +1,13 @@
import AllStatsOverview from '../components/AllStatsOverview'; import AllStatsOverview from '../components/AllStatsOverview';
import NoData, { NoDataType } from '../components/NoData'; import NoData, { NoDataType } from '../components/NoData';
import PoweredBy, { PoweredByType } from '../components/PoweredBy'; import { TreeTypeString } from '../components/Tanks';
import { TierWeightsRecord } from '../components/TierWeights';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper'; import Wrapper from '../components/Wrapper';
import { REGION_NAMES } from '../constants/regions';
import calculateWN8 from '../core/blitz/calculateWN8'; import calculateWN8 from '../core/blitz/calculateWN8';
import getWargamingResponse from '../core/blitz/getWargamingResponse'; import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankName from '../core/blitz/resolveTankName'; import resolveTankName from '../core/blitz/resolveTankName';
import sumStats from '../core/blitz/sumStats'; import sumStats from '../core/blitz/sumStats';
import { Tier, tankopedia } from '../core/blitz/tankopedia'; import { tankopedia } from '../core/blitz/tankopedia';
import getDiffedTankStats from '../core/blitzstars/getDiffedTankStats'; import getDiffedTankStats from '../core/blitzstars/getDiffedTankStats';
import { tankAverages } from '../core/blitzstars/tankAverages'; import { tankAverages } from '../core/blitzstars/tankAverages';
import { ResolvedPeriod } from '../core/discord/resolvePeriodFromCommand'; import { ResolvedPeriod } from '../core/discord/resolvePeriodFromCommand';
@@ -22,21 +20,31 @@ import {
SupplementaryStats, SupplementaryStats,
} from '../types/accountInfo'; } from '../types/accountInfo';
import { PlayerClanData } from '../types/playerClanData'; import { PlayerClanData } from '../types/playerClanData';
import { PossiblyPromise } from '../types/possiblyPromise';
export type StatType = 'player' | 'tank'; export type StatType = 'player' | 'tank' | 'multi-tank';
export interface MultiTankFilters {
nation: string;
'tank-type': string;
tier: number;
'tree-type': TreeTypeString;
}
export default async function stats<Type extends StatType>( export default async function stats<Type extends StatType>(
type: Type, type: Type,
{ start, end, statsName }: ResolvedPeriod, { start, end, statsName }: ResolvedPeriod,
{ region: server, id }: ResolvedPlayer, { region: server, id }: ResolvedPlayer,
tankId: Type extends 'tank' ? number : null, filters: Type extends 'tank'
? number
: Type extends 'multi-tank'
? Partial<MultiTankFilters>
: null,
naked = false, naked = false,
) { ) {
let nameDiscriminator: string | undefined; let nameDiscriminator: string | undefined;
let image: string | undefined; let image: string | undefined;
if (type === 'player') { if (type === 'player' || type === 'multi-tank') {
const clan = ( const clan = (
await getWargamingResponse<PlayerClanData>( await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
@@ -48,91 +56,82 @@ export default async function stats<Type extends StatType>(
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png` ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
: undefined; : undefined;
} else if (type === 'tank') { } else if (type === 'tank') {
nameDiscriminator = `(${await resolveTankName(tankId!)})`; nameDiscriminator = `(${await resolveTankName(filters as number)})`;
image = (await tankopedia)[tankId!]?.images.normal; image = (await tankopedia)[filters as number]?.images.normal;
} else {
} }
const { diffed } = await getDiffedTankStats(server, id, start, end); const { diffed, order } = await getDiffedTankStats(server, id, start, end);
const awaitedTankopedia = await tankopedia;
let stats: AllStats | undefined; let stats: AllStats | undefined;
let supplementaryStats: SupplementaryStats; let supplementaryStats: SupplementaryStats;
let tierWeights: TierWeightsRecord;
if (type === 'player') { if (type === 'player' || type === 'multi-tank') {
const entries = Object.entries(diffed); const typedFilters = filters as Partial<MultiTankFilters> | null;
stats = sumStats(entries.map(([, stats]) => stats)); const filteredOrder = order.filter(
const battlesOfTanksWithAverages = await entries.reduce< (id) =>
PossiblyPromise<number> typedFilters === null ||
>(async (accumulator, [tankIdString, stats]) => { ((typedFilters.nation === undefined ||
const tankId = parseInt(tankIdString); awaitedTankopedia[id]?.nation === typedFilters.nation) &&
const tankAverage = (await tankAverages)[tankId]; (typedFilters['tank-type'] === undefined ||
awaitedTankopedia[id]?.type === typedFilters['tank-type']) &&
return tankAverage ? (await accumulator) + stats.battles : accumulator; (typedFilters.tier === undefined ||
}, 0); awaitedTankopedia[id]?.tier === typedFilters.tier) &&
const battlesOfTanksWithTankopediaEntry = await entries.reduce< (typedFilters['tree-type'] === undefined ||
PossiblyPromise<number> (typedFilters['tree-type'] === 'collector' &&
>(async (accumulator, [tankIdString, stats]) => { awaitedTankopedia[id]?.is_collectible) ||
const tankId = parseInt(tankIdString); (typedFilters['tree-type'] === 'premium' &&
const tankopediaEntry = (await tankopedia)[tankId]; awaitedTankopedia[id]?.is_premium) ||
(typedFilters['tree-type'] === 'techtree' &&
return tankopediaEntry !awaitedTankopedia[id]?.is_collectible &&
? (await accumulator) + stats.battles !awaitedTankopedia[id]?.is_premium))),
: accumulator; );
}, 0); const awaitedTankAverages = await tankAverages;
stats = sumStats(filteredOrder.map((id) => diffed[id]));
const battlesOfTanksWithAverages = filteredOrder.reduce<number>(
(accumulator, id) =>
awaitedTankAverages[id]
? accumulator + diffed[id].battles
: accumulator,
0,
);
const battlesOfTanksWithTankopediaEntry = filteredOrder.reduce<number>(
(accumulator, id) =>
awaitedTankopedia[id] ? accumulator + diffed[id].battles : accumulator,
0,
);
supplementaryStats = { supplementaryStats = {
WN8: WN8:
(await entries.reduce<PossiblyPromise<number>>( filteredOrder.reduce<number>(
async (accumulator, [tankIdString, stats]) => { (accumulator, id) =>
const tankId = parseInt(tankIdString); awaitedTankAverages[id]
const tankAverage = (await tankAverages)[tankId]; ? accumulator +
calculateWN8(awaitedTankAverages[id].all, diffed[id]) *
return tankAverage diffed[id].battles
? (await accumulator) + : accumulator,
calculateWN8(tankAverage.all, stats) * stats.battles
: accumulator;
},
0, 0,
)) / battlesOfTanksWithAverages, ) / battlesOfTanksWithAverages,
tier: tier:
(await entries.reduce<PossiblyPromise<number>>( filteredOrder.reduce<number>(
async (accumulator, [tankIdString, stats]) => { (accumulator, id) =>
const tankId = parseInt(tankIdString); awaitedTankopedia[id]
const tankopediaEntry = (await tankopedia)[tankId]; ? accumulator + awaitedTankopedia[id]!.tier * diffed[id].battles
: accumulator,
return tankopediaEntry
? (await accumulator) + tankopediaEntry.tier * stats.battles
: accumulator;
},
0, 0,
)) / battlesOfTanksWithTankopediaEntry, ) / battlesOfTanksWithTankopediaEntry,
}; };
tierWeights = await entries.reduce<PossiblyPromise<TierWeightsRecord>>(
async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString);
const tankopediaEntry = (await tankopedia)[tankId];
if (!tankopediaEntry) return accumulator;
const tier = tankopediaEntry.tier as Tier;
if ((await accumulator)[tier]) {
(await accumulator)[tier]! += stats.battles;
} else {
(await accumulator)[tier] = stats.battles;
}
return accumulator;
},
{},
);
} else { } else {
stats = diffed[tankId!]; stats = diffed[(filters as number)!];
supplementaryStats = { supplementaryStats = {
WN8: stats WN8: stats
? calculateWN8((await tankAverages)[tankId!].all, diffed[tankId!]) ? calculateWN8(
(await tankAverages)[(filters as number)!].all,
diffed[(filters as number)!],
)
: undefined, : undefined,
tier: (await tankopedia)[tankId!]?.tier, tier: (await tankopedia)[(filters as number)!]?.tier,
}; };
} }
@@ -151,7 +150,7 @@ export default async function stats<Type extends StatType>(
gap: 4, gap: 4,
}} }}
> >
<u>/statsfull</u> for all statistics <u>/full-stats</u> for all statistics
</span> </span>
); );
@@ -161,17 +160,13 @@ export default async function stats<Type extends StatType>(
<Wrapper> <Wrapper>
<TitleBar <TitleBar
name={accountInfo[id].nickname} name={accountInfo[id].nickname}
nameDiscriminator={nameDiscriminator}
image={image} image={image}
description={`${statsName} • ${new Date().toDateString()} • ${ nameDiscriminator={nameDiscriminator}
REGION_NAMES[server] description={`${statsName} • ${new Date().toDateString()}`}
}`}
/> />
{!stats?.battles && <NoData type={NoDataType.BattlesInPeriod} />} {!stats?.battles && <NoData type={NoDataType.BattlesInPeriod} />}
{stats?.battles > 0 && overview} {stats?.battles > 0 && overview}
<PoweredBy type={PoweredByType.BlitzStars} footer={footer} />
</Wrapper> </Wrapper>
); );
} }
+57 -56
View File
@@ -1,11 +1,10 @@
import * as Breakdown from '../components/Breakdown'; import * as Breakdown from '../components/Breakdown';
import NoData, { NoDataType } from '../components/NoData'; import NoData, { NoDataType } from '../components/NoData';
import PoweredBy, { PoweredByType } from '../components/PoweredBy';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper'; import Wrapper from '../components/Wrapper';
import { REGION_NAMES } from '../constants/regions';
import calculateWN8 from '../core/blitz/calculateWN8'; import calculateWN8 from '../core/blitz/calculateWN8';
import getTankStats from '../core/blitz/getTankStats'; import getTankStats from '../core/blitz/getTankStats';
import getTreeType from '../core/blitz/getTreeType';
import getWargamingResponse from '../core/blitz/getWargamingResponse'; import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankName from '../core/blitz/resolveTankName'; import resolveTankName from '../core/blitz/resolveTankName';
import sumStats from '../core/blitz/sumStats'; import sumStats from '../core/blitz/sumStats';
@@ -22,7 +21,9 @@ import { PossiblyPromise } from '../types/possiblyPromise';
export default async function today( export default async function today(
{ region: server, id }: ResolvedPlayer, { region: server, id }: ResolvedPlayer,
limit = Infinity, cutoff = Infinity,
maximized = 4,
showTotal = true,
naked?: boolean, naked?: boolean,
) { ) {
const { diffed, order } = await getDiffedTankStats( const { diffed, order } = await getDiffedTankStats(
@@ -38,9 +39,11 @@ export default async function today(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
); );
const careerTankStatsRaw = await getTankStats(server, id); const careerTankStatsRaw = await getTankStats(server, id);
const careerStats: Record<number, AllStats> = { const careerStats: Record<number, AllStats> = showTotal
0: accountInfo[id].statistics.all, ? {
}; 0: accountInfo[id].statistics.all,
}
: {};
const allStatsToAccumulate: AllStats[] = []; const allStatsToAccumulate: AllStats[] = [];
Object.entries(diffed).forEach(([, tankStats]) => { Object.entries(diffed).forEach(([, tankStats]) => {
@@ -52,7 +55,7 @@ export default async function today(
const accumulatedStats = sumStats(allStatsToAccumulate); const accumulatedStats = sumStats(allStatsToAccumulate);
if (Object.keys(diffed).length > 0) { if (showTotal && Object.keys(diffed).length > 0) {
diffed[0] = accumulatedStats; diffed[0] = accumulatedStats;
} }
@@ -62,7 +65,7 @@ export default async function today(
>(async (accumulator, [tankIdString, tankStats]) => { >(async (accumulator, [tankIdString, tankStats]) => {
const tankId = parseInt(tankIdString); const tankId = parseInt(tankIdString);
return tankId === 0 || !(await tankAverages)[tankId] return (showTotal && tankId === 0) || !(await tankAverages)[tankId]
? accumulator ? accumulator
: { : {
...(await accumulator), ...(await accumulator),
@@ -73,7 +76,8 @@ export default async function today(
const careerWN8s = await careerTankStatsRaw.reduce< const careerWN8s = await careerTankStatsRaw.reduce<
PossiblyPromise<Record<number, number>> PossiblyPromise<Record<number, number>>
>(async (accumulator, { tank_id }) => { >(async (accumulator, { tank_id }) => {
return tank_id === 0 || (await tankAverages)[tank_id] === undefined return (showTotal && tank_id === 0) ||
(await tankAverages)[tank_id] === undefined
? accumulator ? accumulator
: { : {
...(await accumulator), ...(await accumulator),
@@ -86,60 +90,61 @@ export default async function today(
const todayWN8sEntries = Object.entries(todayWN8s); const todayWN8sEntries = Object.entries(todayWN8s);
const careerWN8sEntries = Object.entries(careerWN8s); const careerWN8sEntries = Object.entries(careerWN8s);
todayWN8s[0] = if (showTotal) {
todayWN8sEntries.reduce( todayWN8s[0] =
(accumulator, [tankIdString, wn8]) => todayWN8sEntries.reduce(
accumulator + wn8 * diffed[Number(tankIdString)].battles, (accumulator, [tankIdString, wn8]) =>
0, accumulator + wn8 * diffed[Number(tankIdString)].battles,
) / 0,
todayWN8sEntries.reduce( ) /
(accumulator, [tankIdString]) => todayWN8sEntries.reduce(
accumulator + diffed[Number(tankIdString)].battles, (accumulator, [tankIdString]) =>
0, accumulator + diffed[Number(tankIdString)].battles,
); 0,
careerWN8s[0] = );
careerWN8sEntries.reduce( careerWN8s[0] =
(accumulator, [tankIdString, WN8]) => careerWN8sEntries.reduce(
isNaN(WN8) (accumulator, [tankIdString, WN8]) =>
? accumulator isNaN(WN8)
: accumulator + WN8 * careerStats[Number(tankIdString)].battles, ? accumulator
0, : accumulator + WN8 * careerStats[Number(tankIdString)].battles,
) / 0,
careerWN8sEntries.reduce( ) /
(accumulator, [tankIdString, WN8]) => careerWN8sEntries.reduce(
isNaN(WN8) (accumulator, [tankIdString, WN8]) =>
? accumulator isNaN(WN8)
: accumulator + careerStats[Number(tankIdString)].battles, ? accumulator
0, : accumulator + careerStats[Number(tankIdString)].battles,
); 0,
);
}
const rows = await Promise.all( const rows = await Promise.all(
[ [
// this code unreadable on god will forget tomorrow no cap // this code unreadable on god will forget tomorrow no cap
...(order.length === 0 ? [] : [0]), ...(order.length === 0 || !showTotal ? [] : [0]),
...(isFinite(limit) ? order.slice(0, limit) : order), ...(isFinite(cutoff) ? order.slice(0, cutoff) : order),
].map(async (tankId, index) => { ].map(async (id, index) => {
const tankStats = diffed[tankId]; const tankStats = diffed[id];
const career = careerStats[tankId]; const career = careerStats[id];
const tankopediaEntry = (await tankopedia)[id];
return ( return (
<Breakdown.Row <Breakdown.Row
naked={naked} isTank={!showTotal || id !== 0}
isListing={tankId !== 0} tankType={tankopediaEntry?.type}
minimized={index > 4} treeType={tankopediaEntry ? await getTreeType(id) : undefined}
key={tankId} minimized={showTotal ? index > maximized : index + 1 > maximized}
name={tankId === 0 ? 'Total' : await resolveTankName(tankId)} key={id}
name={showTotal && id === 0 ? 'Total' : await resolveTankName(id)}
winrate={tankStats.wins / tankStats.battles} winrate={tankStats.wins / tankStats.battles}
careerWinrate={career.wins / career.battles} careerWinrate={career.wins / career.battles}
WN8={isNaN(todayWN8s[tankId]) ? undefined : todayWN8s[tankId]} WN8={isNaN(todayWN8s[id]) ? undefined : todayWN8s[id]}
careerWN8={isNaN(careerWN8s[tankId]) ? undefined : careerWN8s[tankId]} careerWN8={isNaN(careerWN8s[id]) ? undefined : careerWN8s[id]}
damage={tankStats.damage_dealt / tankStats.battles} damage={tankStats.damage_dealt / tankStats.battles}
careerDamage={career.damage_dealt / career.battles} careerDamage={career.damage_dealt / career.battles}
battles={tankStats.battles} battles={tankStats.battles}
careerBattles={career.battles} careerBattles={career.battles}
icon={
tankId === 0 ? undefined : (await tankopedia)[tankId]?.images.normal
}
/> />
); );
}), }),
@@ -147,7 +152,7 @@ export default async function today(
return naked ? ( return naked ? (
<Wrapper naked> <Wrapper naked>
<Breakdown.Root>{rows.slice(1)}</Breakdown.Root> <Breakdown.Root>{rows}</Breakdown.Root>
</Wrapper> </Wrapper>
) : ( ) : (
<Wrapper> <Wrapper>
@@ -161,15 +166,11 @@ export default async function today(
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png` ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png`
: undefined : undefined
} }
description={`Today's breakdown • ${new Date().toDateString()} • ${ description={`Today's breakdown • ${new Date().toDateString()}`}
REGION_NAMES[server]
}`}
/> />
{rows.length === 0 && <NoData type={NoDataType.BattlesInPeriod} />} {rows.length === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
{rows.length > 0 && <Breakdown.Root>{rows}</Breakdown.Root>} {rows.length > 0 && <Breakdown.Root>{rows}</Breakdown.Root>}
<PoweredBy type={PoweredByType.BlitzStars} />
</Wrapper> </Wrapper>
); );
} }
-1
View File
@@ -6,7 +6,6 @@ import { RouteRegistry } from '../server';
export const statsRoute: RouteRegistry = { export const statsRoute: RouteRegistry = {
route: '/stats/:type/:period', route: '/stats/:type/:period',
inProduction: true, inProduction: true,
inDevelopment: false,
async handler(req) { async handler(req) {
const type = req.params.type as StatType; const type = req.params.type as StatType;
+3 -2
View File
@@ -5,14 +5,15 @@ import { RouteRegistry } from '../server';
export const todayRoute: RouteRegistry = { export const todayRoute: RouteRegistry = {
route: '/today', route: '/today',
inProduction: true, inProduction: true,
inDevelopment: false,
async handler(req) { async handler(req) {
const player = resolvePlayerFromRequest(req); const player = resolvePlayerFromRequest(req);
return await today( return await today(
player, player,
req.query.limit ? parseInt(req.query.limit as string) : undefined, req.query.cutoff ? parseInt(req.query.cutoff as string) : undefined,
req.query.maximized ? parseInt(req.query.maximized as string) : undefined,
req.query['show-total'] ? req.query['show-total'] === 'true' : undefined,
true, true,
); );
}, },
+1 -4
View File
@@ -1,7 +1,6 @@
import express, { Request, Response } from 'express'; import express, { Request, Response } from 'express';
import normalizeRouteReturnable from './core/express/normalizeRouteReturnable'; import normalizeRouteReturnable from './core/express/normalizeRouteReturnable';
import withAutoRefresh from './core/express/withAutoRefresh'; import withAutoRefresh from './core/express/withAutoRefresh';
import isDev from './core/node/isDev';
import { Registry } from './events/interactionCreate'; import { Registry } from './events/interactionCreate';
import { statsRoute } from './routes/stats'; import { statsRoute } from './routes/stats';
import { todayRoute } from './routes/today'; import { todayRoute } from './routes/today';
@@ -22,8 +21,6 @@ export interface RouteRegistry<HandlesInteraction extends boolean = false>
const app = express(); const app = express();
([statsRoute, todayRoute] as RouteRegistry[]).forEach((registry) => { ([statsRoute, todayRoute] as RouteRegistry[]).forEach((registry) => {
if (!(isDev() ? registry.inDevelopment : registry.inProduction)) return;
app.get(registry.route, async (req, res) => { app.get(registry.route, async (req, res) => {
console.log(req.originalUrl); console.log(req.originalUrl);
@@ -31,7 +28,7 @@ const app = express();
if (registry.handlesInteraction) return; if (registry.handlesInteraction) return;
res.send( res.send(
withAutoRefresh(await normalizeRouteReturnable(returnable), 60 * 1000), withAutoRefresh(await normalizeRouteReturnable(returnable), 30 * 1000),
); );
}); });
}); });
+12 -2
View File
@@ -9,7 +9,17 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true, "noEmit": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"jsx": "react-jsx" "jsx": "react-jsx",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"incremental": true,
"isolatedModules": true,
"plugins": [
{
"name": "next"
}
]
}, },
"include": ["src/**/*.ts"] "include": ["src/**/*", ".next/types/**/*.ts", "dist/website/types/**/*.ts"],
"exclude": ["node_modules"]
} }
+1416 -651
View File
File diff suppressed because it is too large. Load diff