Compare commits

...
57 Commits
Author SHA1 Message Date
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
TrèsAbhi 3c2861f956 Merge pull request #22 from tresabhi/1.11.0 2023-07-17 19:30:50 -05:00
TresAbhi 894b841769 feat: changelogs 2023-07-18 00:29:49 +00:00
TresAbhi 62627c96a3 feat: added other regions 2023-07-18 00:23:59 +00:00
TresAbhi c173ece882 feat: completed /ratings league 2023-07-18 00:17:00 +00:00
TresAbhi eca7cbbbeb feat: added more mature secrets handling 2023-07-16 18:26:33 +00:00
TresAbhi c72bfa9f71 feat: added rewards 2023-07-16 18:15:34 +00:00
TresAbhi b4ae39a509 feat: added custom lighter ratings leaderboard 2023-07-16 17:56:37 +00:00
TresAbhi 631c051895 feat: ditch histories 2023-07-16 17:51:01 +00:00
TresAbhi 9a2e11a2a4 feat: added basic rendering 2023-07-16 17:45:29 +00:00
TresAbhi aa6011b18d feat: added basic infrastructure for ratings command 2023-07-15 18:44:20 +00:00
TresAbhi 9419aec116 fix: removed "Europe" from /verify example 2023-07-15 18:07:14 +00:00
TresAbhi 638402589f feat: switched scripts to typescript 2023-07-15 01:21:59 +00:00
TrèsAbhi 6d6a6bf738 fix: workflow region names 2023-07-14 12:41:45 -05:00
TrèsAbhi 31f6123a55 Merge pull request #21 from tresabhi/1.10.2 2023-07-14 12:37:51 -05:00
TresAbhi 18a3e3092c fix: gave all regions their own cron times 2023-07-14 17:35:51 +00:00
TresAbhi 5ded0f8744 feat: changelogs 2023-07-14 17:31:05 +00:00
TresAbhi 4e0f0536fa feat: added separate latest and leaderboards for all regions 2023-07-14 17:27:57 +00:00
TresAbhi 3f081a5577 fix: install dependencies 2023-07-14 17:16:51 +00:00
TresAbhi 2447d663fa fix: use na region 2023-07-14 17:14:53 +00:00
101 changed files with 2640 additions and 1596 deletions

No files matched your search

@@ -0,0 +1,34 @@
name: CD Asia Ratings Latest
on:
schedule:
- cron: "0 0 * * *"
jobs:
host:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use node
uses: actions/setup-node@v3
with:
node-version: 18
cache: "npm"
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: "**/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=asia --latest
@@ -0,0 +1,34 @@
name: CD Asia Ratings Leaderboards
on:
schedule:
- cron: "0 17 * * *"
jobs:
host:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use node
uses: actions/setup-node@v3
with:
node-version: 18
cache: "npm"
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: "**/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=asia
+2 -6
View File
@@ -16,18 +16,14 @@ jobs:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
strategy:
matrix:
node-version: [18.x]
steps:
- name: Get code
- name: Checkout
uses: actions/checkout@v3
- name: Use node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
node-version: 18
cache: 'npm'
- name: Install Yarn
@@ -0,0 +1,34 @@
name: CD Europe Ratings Latest
on:
schedule:
- cron: "0 0 * * *"
jobs:
host:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use node
uses: actions/setup-node@v3
with:
node-version: 18
cache: "npm"
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: "**/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=eu --latest
@@ -0,0 +1,34 @@
name: CD Europe Ratings Leaderboards
on:
schedule:
- cron: "0 23 * * *"
jobs:
host:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use node
uses: actions/setup-node@v3
with:
node-version: 18
cache: "npm"
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: "**/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=eu
+2 -5
View File
@@ -12,15 +12,12 @@ concurrency:
jobs:
host:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- name: Use node
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
node-version: 18
- name: Download release assets
run: |
@@ -32,4 +29,4 @@ jobs:
unzip dist.zip
- 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.js
@@ -0,0 +1,34 @@
name: CD North America Ratings Latest
on:
schedule:
- cron: '0 0 * * *'
jobs:
host:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use node
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=na --latest
@@ -0,0 +1,34 @@
name: CD North America Ratings Leaderboards
on:
schedule:
- cron: '0 5 * * *'
jobs:
host:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use node
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=na
@@ -1,21 +0,0 @@
name: CD Ratings Leaderboard North America
on:
schedule:
- cron: '0 5 * * *'
jobs:
host:
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v3
- name: Use node
uses: actions/setup-node@v3
with:
node-version: 18
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=asia
+1 -5
View File
@@ -11,10 +11,6 @@ jobs:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -22,7 +18,7 @@ jobs:
- name: Use node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
node-version: 18
cache: 'npm'
- name: Install Yarn
+2
View File
@@ -28,6 +28,7 @@
"statsfull",
"Stringifying",
"strvk",
"subnation",
"tankaverages",
"tankopedia",
"tankstats",
@@ -37,6 +38,7 @@
"vickers",
"vitepress",
"wargaming",
"webp",
"winrate"
],
"deno.enable": false,
+10
View File
@@ -0,0 +1,10 @@
# Blitzkrieg 1.10.2
The experiment in Blitzkrieg `1.10.1` was a bit of a failure so here are a few changes.
## Fixes
- North American leaderboards are now saved in the `na` directory instead of `asia`
- Dependencies are now installed before the script is ran
- Added leaderboards for all regions
- All regions now have their own separate cron timings
+22
View File
@@ -0,0 +1,22 @@
# Blitzkrieg 1.11.0
I am going on vacation so I thought I'd publish this a bit prematurely without all the planned features.
## Changes
- Added `/ratings`
- This command currently only has the `leagues` subcommand group
- Displays the top players in a league
- Also shows the changes in their positions and scores since last region midnight
- Use `/help timezones` to know when Blitzkrieg records the leaderboard
- 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
## Fixes
- Removed region "Europe" in `/verify` example
## Technical Changes
- Add scripts were upgraded to TypeScript
- Secrets now only throw errors if they're used when not provided
+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
+3 -2
View File
@@ -1,6 +1,7 @@
{
"main": "server.cjs",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node --enable-source-maps --trace-warnings server.cjs"
"start": "node --enable-source-maps --trace-warnings server.js"
}
}
+10 -8
View File
@@ -1,44 +1,46 @@
{
"name": "blitzkrieg",
"version": "1.10.0",
"version": "1.12.0",
"description": "",
"main": "src/server.ts",
"type": "module",
"scripts": {
"build:bot": "node scripts/build --build=bot",
"build:server": "node scripts/build --build=server",
"build:bot": "vite-node --script scripts/build --build=bot",
"build:server": "vite-node --script scripts/build --build=server",
"build:docs": "vitepress build docs",
"build:ratingsleaderboard": "node scripts/generateRatingsLeaderboard",
"build": "node scripts/build --build=all",
"build:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard",
"build": "vite-node --script scripts/build --build=all",
"dev:bot": "yarn build:bot --dev && yarn start:bot",
"dev:server": "yarn build:server --dev && yarn start:server",
"dev:docs": "vitepress dev docs",
"start:bot": "node --enable-source-maps --trace-warnings dist/bot.cjs",
"start:server": "node --enable-source-maps --trace-warnings dist/server.cjs",
"host:docs": "vitepress preview docs",
"publish:ratingsleaderboard": "node scripts/generateRatingsLeaderboard --publish",
"publish:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard --publish",
"zip": "zip -r dist.zip dist",
"lint": "npx tsc",
"format": "prettier --write \"**/*.@(json|js|jsx|css|md|ts|tsx|html|xml|yml|gltf)\""
},
"license": "MIT",
"dependencies": {
"@octokit/rest": "^20.0.1",
"@radix-ui/colors": "^0.1.8",
"@resvg/resvg-js": "^2.4.1",
"bepaint": "^0.1.0-beta.3",
"discord.js": "^14.9.0",
"discord.js": "^14.12.0",
"esbuild": "^0.18.10",
"escape-html": "^1.0.3",
"express": "^4.18.2",
"false": "^0.0.4",
"fuzzysort": "^2.0.4",
"lodash": "^4.17.21",
"markdown-escape": "^2.0.0",
"react": "^18.2.0",
"satori": "^0.9.1",
"vite-node": "^0.33.0",
"vitepress-plugin-auto-sidebar": "^1.1.0"
},
"devDependencies": {
"@octokit/rest": "^20.0.1",
"@total-typescript/ts-reset": "^0.4.2",
"@types/escape-html": "^1.0.2",
"@types/express": "^4.17.17",
+8 -9
View File
@@ -1,5 +1,5 @@
import { build } from 'esbuild';
import { copyFile, mkdir, rm } from 'fs/promises';
import { BuildOptions, build } from 'esbuild';
import { copyFile, mkdirSync, rmSync } from 'fs';
import { argv } from 'process';
const isProd = !argv.includes('--dev');
@@ -10,25 +10,26 @@ 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 });
rmSync('dist', { recursive: true, force: true });
console.log('Dist removed');
console.log('Creating dist...');
await mkdir('dist');
mkdirSync('dist');
console.log('Dist created');
console.log('Copying package.json...');
copyFile('package.dist.json', 'dist/package.json').then(() =>
copyFile('package.dist.json', 'dist/package.json', () =>
console.log('package.json copied'),
);
}
const commonOptions = {
const commonOptions: BuildOptions = {
platform: 'node',
loader: {
'.node': 'copy',
'.ttf': 'file',
},
format: 'cjs',
bundle: true,
sourcemap: true,
@@ -46,9 +47,7 @@ if (buildBot) {
entryPoints: ['src/bot.ts'],
outfile: 'dist/bot.cjs',
}).then(() => {
console.log('Bot built');
});
}).then(() => console.log('Bot built'));
}
if (buildServer) {
@@ -1,15 +1,27 @@
import { Octokit } from '@octokit/rest';
import { rm, writeFile } from 'fs/promises';
import { argv, env } from 'process';
import {
RatingsInfo,
RatingsNeighbors,
RatingsPlayer,
} from '../src/commands/ratings';
import commitMultipleFiles from './commitMultipleFiles.js';
/*
For Central North American Time (UTC-5), use 0 5 * * *.
For Central European Time (UTC+1), use 0 23 * * *.
For Central Asia Standard Time (UTC+7), use 0 17 * * *.
*/
* Central North American Time (UTC-5): use 0 5 * * *.
* Central European Time (UTC+1): use 0 23 * * *.
* Central Asia Standard Time (UTC+7): use 0 17 * * *.
*
* na/ratings/49/info.json: season info like rewards, start & end time, etc.
* na/ratings/49/latest.json: the absolute latest leaderboard recorded at UTC+0 midnight
* na/ratings/49/midnight.json: last midnight's leaderboard relative to the region
*/
export type BlitzkriegRatingsLeaderboard = { id: number; score: number }[];
const publish = argv.includes('--publish');
const latest = argv.includes('--latest');
const server = argv.find((arg) => arg.startsWith('--region='))?.split('=')[1];
if (!server) throw new Error('Region parameter not specified');
@@ -18,22 +30,22 @@ const NEIGHBORS = 1000;
const PLAYERS = await fetch(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
)
.then((response) => response.json())
.then((response) => response.json() as Promise<RatingsInfo>)
.then((data) => data.count);
const players = {};
const leaderboard = [];
const players: Record<number, RatingsPlayer> = {};
const leaderboard: BlitzkriegRatingsLeaderboard = [];
let registered = 0;
async function branchFromPlayer(id) {
async function branchFromPlayer(id: number) {
const { neighbors } = await fetch(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${NEIGHBORS}`,
).then((response) => response.json());
).then((response) => response.json() as Promise<RatingsNeighbors>);
const firstPlayer = neighbors[0];
const lastPlayer = neighbors[neighbors.length - 1];
const branches = [];
const branches: Promise<void>[] = [];
if (!players[firstPlayer.number])
branches.push(branchFromPlayer(firstPlayer.spa_id));
if (!players[lastPlayer.number])
@@ -56,10 +68,10 @@ async function branchFromPlayer(id) {
await Promise.all(branches);
}
async function branchFromLeague(id) {
async function branchFromLeague(id: number) {
const { result } = await fetch(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/league/${id}/top/`,
).then((response) => response.json());
).then((response) => response.json() as Promise<{ result: RatingsPlayer[] }>);
const firstPlayer = result[0];
const lastPlayer = result[result.length - 1];
@@ -70,7 +82,7 @@ async function branchFromLeague(id) {
]);
}
console.log('Branching form leagues...');
console.log('Branching from leagues...');
await Promise.all([
branchFromLeague(0),
branchFromLeague(1),
@@ -81,8 +93,13 @@ await Promise.all([
console.log('Converting to an array...');
for (let index = 0; index < PLAYERS; index++) {
leaderboard[index] = players[index + 1];
if (!leaderboard[index]) console.warn(`${index} had no player`);
const listing = players[index + 1];
if (!listing) console.warn(`Position ${index + 1} had no player`);
leaderboard[index] = {
id: listing.spa_id,
score: listing.score,
};
}
const leaderboardJSON = JSON.stringify(leaderboard);
@@ -92,12 +109,13 @@ if (publish) {
const info = await fetch(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
).then((response) => response.json());
const time = Math.round(Date.now() / 1000);
).then((response) => response.json() as Promise<RatingsInfo>);
const normalizedServer = server === 'na' ? 'com' : server;
const infoPath = `${normalizedServer}/ratings/seasons/${info.current_season}.json`;
const leaderboardPath = `${normalizedServer}/ratings/leaderboards/${info.current_season}/${time}.json`;
const infoPath = `${normalizedServer}/ratings/${info.current_season}/info.json`;
const leaderboardPath = `${normalizedServer}/ratings/${info.current_season}/${
latest ? 'latest' : 'midnight'
}.json`;
const infoJSON = JSON.stringify(info);
console.log(`Publishing to season ${info.current_season}...`);
@@ -106,7 +124,7 @@ if (publish) {
'tresabhi',
'blitzkrieg-db',
'main',
`${info.current_season}/${time}`,
new Date().toString(),
[
{ path: infoPath, content: infoJSON },
{ path: leaderboardPath, content: leaderboardJSON },
@@ -1,10 +1,12 @@
import { Octokit } from '@octokit/rest';
export default async function commitMultipleFiles(
octokit,
owner,
repo,
branch,
message,
changes,
octokit: Octokit,
owner: string,
repo: string,
branch: string,
message: string,
changes: { path: string; content: string }[],
) {
// Get the latest commit SHA on the specified branch
const { data: refData } = await octokit.git.getRef({
@@ -31,12 +33,13 @@ export default async function commitMultipleFiles(
content: change.content,
encoding: 'utf-8',
});
return {
sha: blobData.sha,
path: change.path,
mode: '100644',
type: 'blob',
};
} as const;
}),
);
+5 -2
View File
@@ -1,5 +1,6 @@
import { Octokit } from '@octokit/rest';
import { Client, GatewayIntentBits } from 'discord.js';
import { DISCORD_TOKEN } from './core/node/arguments';
import { secrets } from './core/node/secrets';
import { registerErrorHandlers } from './events/error';
import guildMemberAdd from './events/guildMemberAdd';
import interactionCreate from './events/interactionCreate';
@@ -12,5 +13,7 @@ export const client = new Client({
.on('guildMemberAdd', guildMemberAdd)
.on('interactionCreate', interactionCreate);
export const octokit = new Octokit({ auth: secrets.GH_TOKEN });
registerErrorHandlers();
client.login(DISCORD_TOKEN);
client.login(secrets.DISCORD_TOKEN);
+1 -1
View File
@@ -12,7 +12,7 @@ const executionStart = new Date().getTime();
export const debugCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
+4 -4
View File
@@ -12,7 +12,7 @@ import embedNegative from '../core/discord/embedNegative';
import embedPositive from '../core/discord/embedPositive';
import markdownTable, { TableInput } from '../core/discord/markdownTable';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
import { PossiblyPromise } from '../types/possiblyPromise';
@@ -26,7 +26,7 @@ export const SKILLED_CLANS: Record<SkilledClan, string> = {
export const eligibleCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: false,
command: new SlashCommandBuilder()
@@ -49,9 +49,9 @@ export const eligibleCommand: CommandRegistry = {
async handler(interaction) {
const clan = interaction.options.getString('clan') as SkilledClan;
const { id, server } = await resolvePlayerFromCommand(interaction);
const { id, region: server } = await resolvePlayerFromCommand(interaction);
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const problems: TableInput = [];
const title = `${accountInfo[id].nickname}'s eligibility for ${SKILLED_CLANS[clan]}`;
+75 -68
View File
@@ -12,76 +12,83 @@ import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { CommandRegistry } from '../events/interactionCreate';
import { secrets } from '../core/node/secrets';
import { CommandRegistryRaw } from '../events/interactionCreate';
import evolution from '../renderers/evolution';
import { StatType } from '../renderers/fullStats';
import { StatType } from '../renderers/stats';
import { AccountInfo } from '../types/accountInfo';
export const evolutionCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inPublic: true,
command: addStatTypeSubCommandGroups(
new SlashCommandBuilder()
.setName('evolution')
.setDescription('Evolution of statistics'),
),
async handler(interaction) {
const commandGroup = interaction.options.getSubcommandGroup(
true,
) as StatType;
const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(player.server, 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.server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
const path = interactionToURL(interaction, {
...player,
tankId,
start,
end,
});
return [
await evolution(commandGroup, period, player, tankId),
primaryButton(path, 'Refresh'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.server}/${nickname}${
commandGroup === 'tank' ? `/tank/${tankId!}` : ''
}`,
'BlitzStars',
),
];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const url = new URL(`${CYCLIC_API}/${interaction.customId}`);
const path = url.pathname.split('/').filter(Boolean);
const commandGroup = path[1] as StatType;
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.server, interaction);
return await evolution(
commandGroup,
period,
player,
parseInt(url.searchParams.get('tankId')!),
export const evolutionCommand = new Promise<CommandRegistryRaw>(
async (resolve) => {
const command = await addStatTypeSubCommandGroups(
new SlashCommandBuilder()
.setName('evolution')
.setDescription('Evolution of statistics'),
false,
);
resolve({
inProduction: true,
inDevelopment: 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 -66
View File
@@ -1,4 +1,5 @@
import { SlashCommandBuilder } from 'discord.js';
import { TreeTypeString } from '../components/Tanks';
import { CYCLIC_API } from '../constants/cyclic';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankId from '../core/blitz/resolveTankId';
@@ -12,73 +13,101 @@ import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { CommandRegistry } from '../events/interactionCreate';
import fullStats, { StatType } from '../renderers/fullStats';
import { secrets } from '../core/node/secrets';
import { CommandRegistryRaw } from '../events/interactionCreate';
import fullStats from '../renderers/fullStats';
import { MultiTankFilters, StatType } from '../renderers/stats';
import { AccountInfo } from '../types/accountInfo';
export const fullStatsCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inPublic: true,
command: addStatTypeSubCommandGroups(
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.server, 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.server}/wotb/account/info/?application_id=${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.server}/${nickname}`,
'BlitzStars',
),
];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const url = new URL(`${CYCLIC_API}/${interaction.customId}`);
const path = url.pathname.split('/').filter(Boolean);
const commandGroup = path[1] as StatType;
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.server, interaction);
return await fullStats(
commandGroup,
period,
player,
parseInt(url.searchParams.get('tankId')!),
export const fullStatsCommand = new Promise<CommandRegistryRaw>(
async (resolve) => {
const command = await addStatTypeSubCommandGroups(
new SlashCommandBuilder()
.setName('full-stats')
.setDescription('Full in-game statistics'),
);
resolve({
inProduction: true,
inDevelopment: 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];
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: interaction.options.getInteger('tier') ?? 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')!),
);
},
});
},
};
);
+5 -4
View File
@@ -30,7 +30,7 @@ function addDocsSubcommands(option: SlashCommandSubcommandsOnlyBuilder) {
export const helpCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
command: addDocsSubcommands(
@@ -44,13 +44,14 @@ export const helpCommand: CommandRegistry = {
),
),
handler(interaction) {
async handler(interaction) {
const subcommand = interaction.options.getSubcommand();
if (subcommand === 'commands') {
return `# Commands\n\nBlitzkrieg offers the following commands:\n\n${COMMANDS_RAW.filter(
(registry) => registry.inPublic && registry.inProduction,
return `# Commands\n\nBlitzkrieg offers the following commands:\n\n${(
await Promise.all(COMMANDS_RAW)
)
.filter((registry) => registry.inPublic && registry.inProduction)
.map(
(registry) =>
`- \`/${registry.command.name}\`: ${registry.command.description}`,
+7 -11
View File
@@ -4,12 +4,11 @@ import NoData, { NoDataType } from '../components/NoData';
import PoweredBy, { PoweredByType } from '../components/PoweredBy';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { REGION_DOMAIN_NAMES } from '../constants/regions';
import getBlitzClan from '../core/blitz/getBlitzClan';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import addClanChoices from '../core/discord/addClanChoices';
import autocompleteClan from '../core/discord/autocompleteClan';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
import { ClanInfo } from '../types/clanInfo';
@@ -18,7 +17,7 @@ const DEFAULT_THRESHOLD = 7;
export const inactiveCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
@@ -42,14 +41,14 @@ export const inactiveCommand: CommandRegistry = {
const time = new Date().getTime() / 1000;
const clanData = (
await getWargamingResponse<ClanInfo>(
`https://api.wotblitz.${server}/wotb/clans/info/?application_id=${WARGAMING_APPLICATION_ID}&clan_id=${id}`,
`https://api.wotblitz.${server}/wotb/clans/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&clan_id=${id}`,
)
)[id];
const memberIds = clanData.members_ids;
const accounts = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${memberIds.join(
',',
)}`,
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${
secrets.WARGAMING_APPLICATION_ID
}&account_id=${memberIds.join(',')}`,
);
const inactive = memberIds
.map((memberId) => {
@@ -69,11 +68,8 @@ export const inactiveCommand: CommandRegistry = {
<Wrapper>
<TitleBar
name={clanData.name}
nameDiscriminator="(Inactivity)"
image={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData.emblem_set_id}.png`}
description={`${threshold}+ Days • ${new Date().toDateString()} • ${
REGION_DOMAIN_NAMES[server]
}`}
description={`Inactive for ${threshold}+ Days • ${new Date().toDateString()}`}
/>
{!hasInactiveMembers && <NoData type={NoDataType.PlayersInPeriod} />}
+101 -58
View File
@@ -4,20 +4,21 @@ import PoweredBy, { PoweredByType } from '../components/PoweredBy';
import * as Tanks from '../components/Tanks';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { REGION_DOMAIN_NAMES } from '../constants/regions';
import getTankStats from '../core/blitz/getTankStats';
import getTreeType from '../core/blitz/getTreeType';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankName from '../core/blitz/resolveTankName';
import {
TIER_ROMAN_NUMERALS,
TankopediaEntry,
Tier,
tankopedia,
tankopediaInfo,
} from '../core/blitz/tankopedia';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
import { PlayerClanData } from '../types/playerClanData';
@@ -45,27 +46,27 @@ const COMP_TANKS = [
export const ownedTanksCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName('ownedtanks')
.setName('owned-tanks')
.setDescription("Shows a player's owned tanks")
.addStringOption((option) =>
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' },
{ name: 'Tier I (1)', value: '1' },
{ name: 'Tier II (2)', value: '2' },
{ name: 'Tier III (3)', value: '3' },
{ name: 'Tier IV (4)', value: '4' },
{ name: 'Tier V (5)', value: '5' },
{ name: 'Tier VI (6)', value: '6' },
{ name: 'Tier VII (7)', value: '7' },
{ name: 'Tier VIII (8)', value: '8' },
{ name: 'Tier IX (9)', value: '9' },
{ name: 'Tier X (10)', value: '10' },
)
.setRequired(true),
)
@@ -74,73 +75,101 @@ export const ownedTanksCommand: CommandRegistry = {
async handler(interaction) {
const tier = Number(interaction.options.getString('tier'));
const account = await resolvePlayerFromCommand(interaction);
const { id, server } = account;
const { id, region: server } = account;
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const tankStats = await getTankStats(server, id);
const tanks = (
const filteredTanks = (
await Promise.all(
tankStats.map(async (tankData) => ({
...(await tankopedia)[tankData.tank_id],
tankopedia: (await tankopedia)[tankData.tank_id]!,
tank_id: tankData.tank_id,
})),
)
).filter((tank) => tank?.tier === tier);
).filter((tank) => tank.tankopedia?.tier === tier);
const clanData = await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
`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 leftColumn = tanks.slice(0, leftColumnSize);
const rightColumn = tanks.slice(leftColumnSize);
const groupedTanks: Record<string, TankopediaEntry[]> = {};
const nations: string[] = [];
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 (
<Wrapper>
<TitleBar
name={accountInfo[id].nickname}
nameDiscriminator={`(Tier ${TIER_ROMAN_NUMERALS[tier as Tier]})`}
nameDiscriminator={
clanData[id]?.clan ? `[${clanData[id]?.clan?.tag}]` : undefined
}
image={
clanData[id]?.clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png`
: undefined
}
description={`Owned tanks • ${new Date().toDateString()} • ${
REGION_DOMAIN_NAMES[server]
}`}
description={`Tier ${
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 && (
<Tanks.Root>
<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.Root>
)}
{filteredTanks.length > 0 &&
(await Promise.all(
nations.map(async (nation) => {
const tanks = groupedTanks[nation];
const leftColumnSize = Math.ceil(tanks.length / 2);
const leftColumn = tanks.slice(0, leftColumnSize);
const rightColumn = tanks.slice(leftColumnSize);
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>
);
}),
))}
<PoweredBy type={PoweredByType.Wargaming} />
</Wrapper>
@@ -149,3 +178,17 @@ export const ownedTanksCommand: CommandRegistry = {
autocomplete: autocompleteUsername,
};
// TODO: make this work in the future
// console.log('Caching tank images...');
// TANKS.then((tanks) =>
// jsxToSvg(
// <div style={{ display: 'flex' }}>
// {tanks.map(({ images, tank_id }) => {
// return (
// images.normal && <img key={tank_id} src={images.normal} width={0} />
// );
// })}
// </div>,
// ),
// ).then(() => console.log('Cached tank images'));
+2 -2
View File
@@ -1,9 +1,9 @@
import { SlashCommandBuilder } from 'discord.js';
import { CommandRegistry } from '../events/interactionCreate';
export const pingCommand: CommandRegistry<true> = {
export const pingCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
handlesInteraction: true,
+6 -6
View File
@@ -7,7 +7,7 @@ import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedInfo from '../core/discord/embedInfo';
import markdownTable, { TableInputEntry } from '../core/discord/markdownTable';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountAchievements } from '../types/accountAchievements';
import { AccountInfo } from '../types/accountInfo';
@@ -16,11 +16,11 @@ type SortBy = 'name' | 'count';
export const playerAchievementsCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName('playerachievements')
.setName('player-achievements')
.setDescription("All the player's achievements")
.addStringOption(addUsernameChoices)
.addStringOption((option) =>
@@ -36,13 +36,13 @@ export const playerAchievementsCommand: CommandRegistry = {
async handler(interaction) {
const sortBy = (interaction.options.getString('sort') ?? 'name') as SortBy;
const account = await resolvePlayerFromCommand(interaction);
const { id, server } = account;
const { id, region: server } = account;
const accounts = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const accountsAchievements =
await getWargamingResponse<AccountAchievements>(
`https://api.wotblitz.${server}/wotb/account/achievements/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
`https://api.wotblitz.${server}/wotb/account/achievements/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const accountAchievements = accountsAchievements[id];
const compound = {
+5 -5
View File
@@ -6,25 +6,25 @@ import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedInfo from '../core/discord/embedInfo';
import markdownTable from '../core/discord/markdownTable';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
export const playerInfoCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName('playerinfo')
.setName('player-info')
.setDescription('Basic information about a player')
.addStringOption(addUsernameChoices),
async handler(interaction) {
const account = await resolvePlayerFromCommand(interaction);
const { id, server } = account;
const { id, region: server } = account;
const accounts = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const accountInfo = accounts[id];
+299
View File
@@ -0,0 +1,299 @@
import {
APIApplicationCommandOptionChoice,
SlashCommandBuilder,
} from 'discord.js';
import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard';
import * as Leaderboard from '../components/Leaderboard';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { REGION_NAMES_SHORT, Region } from '../constants/regions';
import getRatingsInfo from '../core/blitz/getRatingsInfo';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import regionToRegionSubdomain from '../core/blitz/regionToRegionSubdomain';
import getMidnightLeaderboard from '../core/database/getMidnightLeaderboard';
import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { secrets } from '../core/node/secrets';
import { CommandRegistryRaw } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
import { PlayerClanData } from '../types/playerClanData';
export interface RatingsPlayer {
spa_id: number;
mmr: number;
season_number: number;
calibrationBattlesLeft: number;
number: number;
percentile: number;
skip: boolean;
updated_at: string;
score: number;
nickname: string;
clan_tag: string;
}
export interface LeagueTop {
result: RatingsPlayer[];
}
export type RatingsReward = {
from_position: number;
to_position: number;
count: number;
} & (
| {
type: 'vehicle';
vehicle: {
id: number;
name: string;
nation: string;
subnation: string;
use_subnation_flag: boolean;
type_slug: string;
level: number;
roman_level: string;
user_string: string;
image_url: string;
preview_url: string;
is_premium: boolean;
is_collectible: boolean;
};
}
| {
type: 'stuff';
stuff: {
name: string;
count: number;
title: string;
image_url: string;
type: string;
sizes: {};
};
}
);
export interface RatingsLeague {
title: string;
small_icon: string;
big_icon: string;
background: string;
index: number;
percentile: number;
}
export interface RatingsInfo {
title: string;
icon: string | null;
start_at: string;
finish_at: string;
current_season: number;
updated_at: string;
count: number;
rewards: RatingsReward[];
leagues: RatingsLeague[];
}
export interface RatingsNeighbors {
neighbors: RatingsPlayer[];
}
console.log('Caching ratings info...');
const ratingsInfo = getRatingsInfo('com').then((info) => {
console.log('Cached ratings info');
return info;
});
export const ratingsCommand = new Promise<CommandRegistryRaw>(
async (resolve) => {
const awaitedRatingsInfo = await ratingsInfo;
const command = new SlashCommandBuilder()
.setName('ratings')
.setDescription('Ratings battles statistics')
.addSubcommand((option) =>
option
.setName('league')
.setDescription('Top league players')
.addStringOption((option) =>
option
.setName('league')
.setDescription('The league to display')
.addChoices(
...awaitedRatingsInfo.leagues.map(
(league) =>
({
name: league.title.split(' ')[0].toLowerCase(),
value: `${league.index}`,
}) 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),
),
)
.addSubcommand((option) =>
option
.setName('neighbours')
.setDescription('Your position and neighbours')
.addStringOption(addUsernameChoices)
.addIntegerOption((option) =>
option
.setName('limit')
.setDescription('How many neighbours to display (default: 10)')
.setRequired(false)
.setMinValue(5)
.setMaxValue(30),
),
);
resolve({
inProduction: true,
inDevelopment: true,
inPublic: true,
command,
async handler(interaction) {
const subcommand = interaction.options.getSubcommand(true);
const limit = interaction.options.getInteger('limit') ?? 10;
let titleName: string;
let titleImage: string | undefined;
let titleNameDiscriminator: string | undefined;
let titleDescription: string;
let playersBefore: number;
let playersAfter: number;
let players: RatingsPlayer[];
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 getRatingsInfo(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>)
.then(({ result }) => result.slice(0, limit));
const leagueInfo = regionRatingsInfo.leagues[leagueIndex];
midnightLeaderboard = await getMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
);
players = result;
playersBefore = result[0].number - 1;
playersAfter =
regionRatingsInfo.count - result[result.length - 1].number;
titleName = `${leagueInfo.title} - ${REGION_NAMES_SHORT[region]}`;
titleImage = leagueInfo.big_icon.startsWith('http')
? leagueInfo.big_icon
: `https:${leagueInfo.big_icon}`;
titleDescription = `Top ${limit} players`;
} else {
const { region, id } = await resolvePlayerFromCommand(interaction);
regionRatingsInfo = await getRatingsInfo(region);
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 } = await fetch(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${Math.round(
limit / 2,
)}`,
).then((response) => response.json() as Promise<RatingsNeighbors>);
midnightLeaderboard = await getMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
);
if (clan) titleNameDiscriminator = `[${clan.tag}]`;
players = neighbors;
titleImage = clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
: undefined;
titleName = accountInfo[id].nickname;
titleDescription = 'Ratings neighbours';
playersBefore = neighbors[0].number - 1;
playersAfter =
regionRatingsInfo.count - neighbors[neighbors.length - 1].number;
playerId = id;
}
const items = players.map((player) => {
const reward = regionRatingsInfo.rewards.find(
(reward) =>
player.number >= reward.from_position &&
player.number <= reward.to_position,
);
const midnightIndex = midnightLeaderboard?.findIndex(
(item) => item.id === player.spa_id,
);
const midnightScore =
midnightIndex === undefined
? player.score
: midnightLeaderboard![midnightIndex].score;
const midnightPosition =
midnightIndex === undefined ? player.number : midnightIndex + 1;
return (
<Leaderboard.Item
nickname={player.nickname}
score={player.score}
position={player.number}
clan={player.clan_tag}
reward={reward}
deltaScore={player.score - midnightScore}
deltaPosition={midnightPosition - player.number}
key={player.spa_id}
highlight={player.spa_id === playerId}
/>
);
});
return (
<Wrapper>
<TitleBar
name={titleName}
image={titleImage}
nameDiscriminator={titleNameDiscriminator}
description={`${titleDescription} • ${new Date().toDateString()}`}
/>
<Leaderboard.Root>
{playersBefore > 0 && <Leaderboard.Gap number={playersBefore} />}
{items}
{playersAfter > 0 && <Leaderboard.Gap number={playersAfter} />}
</Leaderboard.Root>
</Wrapper>
);
},
autocomplete: autocompleteUsername,
});
},
);
+9 -11
View File
@@ -1,23 +1,23 @@
import { SlashCommandBuilder } from 'discord.js';
import markdownEscape from 'markdown-escape';
import { REGION_DOMAIN_NAMES, RegionDomain } from '../constants/regions';
import { REGION_NAMES, Region } from '../constants/regions';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import addClanChoices from '../core/discord/addClanChoices';
import addServerChoices from '../core/discord/addServerChoices';
import addRegionChoices from '../core/discord/addRegionChoices';
import embedInfo from '../core/discord/embedInfo';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate';
import { ClanList } from '../types/clanList';
export const searchClansCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName('searchclans')
.setName('search-clans')
.setDescription('Search clans')
.addStringOption(addServerChoices)
.addStringOption(addRegionChoices)
.addStringOption((option) => addClanChoices(option).setAutocomplete(false))
.addIntegerOption((option) =>
option
@@ -28,17 +28,15 @@ export const searchClansCommand: CommandRegistry = {
),
async handler(interaction) {
const server = interaction.options.getString('server') as RegionDomain;
const server = interaction.options.getString('region') as Region;
const clan = interaction.options.getString('clan')!;
const limit = interaction.options.getInteger('limit') ?? 25;
const clanList = await getWargamingResponse<ClanList>(
`https://api.wotblitz.${server}/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${clan}&limit=${limit}`,
`https://api.wotblitz.${server}/wotb/clans/list/?application_id=${secrets.WARGAMING_APPLICATION_ID}&search=${clan}&limit=${limit}`,
);
return embedInfo(
`Clan search for "${markdownEscape(clan)}" in ${
REGION_DOMAIN_NAMES[server]
}`,
`Clan search for "${markdownEscape(clan)}" in ${REGION_NAMES[server]}`,
clanList.length === 0
? 'No clans found.'
: `\`\`\`\n${clanList
+11 -13
View File
@@ -1,24 +1,24 @@
import { SlashCommandBuilder } from 'discord.js';
import markdownEscape from 'markdown-escape';
import { REGION_DOMAIN_NAMES, RegionDomain } from '../constants/regions';
import { REGION_NAMES, Region } from '../constants/regions';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import { usernamePattern } from '../core/blitz/listPlayers';
import addServerChoices from '../core/discord/addServerChoices';
import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import embedInfo from '../core/discord/embedInfo';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountList } from '../types/accountList';
export const searchPlayersCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName('searchplayers')
.setName('search-players')
.setDescription('Search players in a Blitz server')
.addStringOption(addServerChoices)
.addStringOption(addRegionChoices)
.addStringOption((option) =>
addUsernameChoices(option).setAutocomplete(false).setRequired(true),
)
@@ -31,22 +31,20 @@ export const searchPlayersCommand: CommandRegistry = {
),
async handler(interaction) {
const server = interaction.options.getString('server') as RegionDomain;
const server = interaction.options.getString('region') as Region;
const name = interaction.options.getString('username')!;
const limit = interaction.options.getInteger('limit') ?? 25;
const trimmedSearch = name.trim();
const players = usernamePattern.test(trimmedSearch)
? await getWargamingResponse<AccountList>(
`https://api.wotblitz.${server}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodeURIComponent(
trimmedSearch,
)}&limit=${limit}`,
`https://api.wotblitz.${server}/wotb/account/list/?application_id=${
secrets.WARGAMING_APPLICATION_ID
}&search=${encodeURIComponent(trimmedSearch)}&limit=${limit}`,
)
: [];
return embedInfo(
`Player search for "${markdownEscape(name)}" in ${
REGION_DOMAIN_NAMES[server]
}`,
`Player search for "${markdownEscape(name)}" in ${REGION_NAMES[server]}`,
`\`\`\`${
players.length === 0
? 'No players found.'
+7 -6
View File
@@ -1,18 +1,18 @@
import { SlashCommandBuilder } from 'discord.js';
import { go } from 'fuzzysort';
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 embedInfo from '../core/discord/embedInfo';
import { CommandRegistry } from '../events/interactionCreate';
export const searchTanksCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName('searchtanks')
.setName('search-tanks')
.setDescription('Search tanks')
.addStringOption((option) => addTankChoices(option).setAutocomplete(false))
.addIntegerOption((option) =>
@@ -26,9 +26,10 @@ export const searchTanksCommand: CommandRegistry = {
async handler(interaction) {
const tank = interaction.options.getString('tank')!;
const limit = interaction.options.getInteger('limit') ?? 25;
const results = go(tank, await TANK_NAMES, { limit }).map(
(result) => result.target,
);
const results = go(tank, await TANK_NAMES_DIACRITICS, {
limit,
keys: ['combined'],
}).map((result) => result.obj.original);
return embedInfo(
`Tank search for "${markdownEscape(tank)}"`,
+96 -62
View File
@@ -1,4 +1,5 @@
import { SlashCommandBuilder } from 'discord.js';
import { TreeTypeString } from '../components/Tanks';
import { CYCLIC_API } from '../constants/cyclic';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankId from '../core/blitz/resolveTankId';
@@ -12,74 +13,107 @@ import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { CommandRegistry } from '../events/interactionCreate';
import { StatType } from '../renderers/fullStats';
import stats from '../renderers/stats';
import { secrets } from '../core/node/secrets';
import { CommandRegistryRaw } from '../events/interactionCreate';
import stats, { MultiTankFilters, StatType } from '../renderers/stats';
import { AccountInfo } from '../types/accountInfo';
export const statsCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inPublic: true,
command: addStatTypeSubCommandGroups(
export const statsCommand = new Promise<CommandRegistryRaw>(async (resolve) => {
const command = await addStatTypeSubCommandGroups(
new SlashCommandBuilder()
.setName('stats')
.setDescription('In-game statistics'),
),
.setDescription('Regular battles statistics'),
);
async handler(interaction) {
const commandGroup = interaction.options.getSubcommandGroup(
true,
) as StatType;
const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(player.server, 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.server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${player.id}`,
)
)[player.id];
resolve({
inProduction: true,
inDevelopment: true,
inPublic: true,
return [
await stats(commandGroup, period, player, tankId),
primaryButton(path, 'Refresh'),
linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.server}/${nickname}`,
'BlitzStars',
),
];
},
command,
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
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];
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.server, interaction);
return [
await stats(
commandGroup,
resolvedPeriod,
player,
commandGroup === 'tank'
? tankId
: commandGroup === 'multi-tank'
? ({
'tank-type':
interaction.options.getString('tank-type') ?? undefined,
nation: interaction.options.getString('nation') ?? undefined,
tier: interaction.options.getInteger('tier') ?? undefined,
'tree-type': (interaction.options.getString('tree-type') ??
undefined) as TreeTypeString | undefined,
} satisfies Partial<MultiTankFilters>)
: null,
),
primaryButton(path, 'Refresh'),
linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.region}/${nickname}`,
'BlitzStars',
),
];
},
return await stats(
commandGroup,
period,
player,
parseInt(url.searchParams.get('tankId')!),
);
},
};
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,
);
},
});
});
+30 -8
View File
@@ -8,14 +8,14 @@ import linkButton from '../core/discord/linkButton';
import primaryButton from '../core/discord/primaryButton';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate';
import today from '../renderers/today';
import { AccountInfo } from '../types/accountInfo';
export const todayCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
@@ -24,30 +24,52 @@ export const todayCommand: CommandRegistry = {
.addStringOption(addUsernameChoices)
.addIntegerOption((option) =>
option
.setName('limit')
.setName('cutoff')
.setDescription(
'The maximum number of tanks to display (default: Infinity)',
)
.setRequired(false)
.setMinValue(1),
)
.addIntegerOption((option) =>
option
.setName('maximized')
.setDescription(
'The number of rows with full stats after which all are collapsed (default: 4)',
)
.setMinValue(0)
.setRequired(false),
)
.addBooleanOption((option) =>
option
.setName('show-total')
.setDescription('Show the total stats at the top (default: true)')
.setRequired(false),
),
async handler(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 } = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${player.server}/wotb/account/info/?application_id=${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];
const path = interactionToURL(interaction, player);
const path = interactionToURL(interaction, {
...player,
cutoff,
maximized,
'show-total': showTotal,
});
return [
await today(player, limit, false),
await today(player, cutoff, maximized, showTotal, false),
primaryButton(path, 'Refresh'),
linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.server}/${nickname}`,
`https://www.blitzstars.com/player/${player.region}/${nickname}`,
'BlitzStars',
),
];
+5 -5
View File
@@ -7,14 +7,14 @@ import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedNegative from '../core/discord/embedNegative';
import embedPositive from '../core/discord/embedPositive';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
import { PlayerClanData } from '../types/playerClanData';
export const verifyCommand: CommandRegistry = {
inProduction: true,
inDevelopment: false,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
@@ -24,12 +24,12 @@ export const verifyCommand: CommandRegistry = {
async handler(interaction) {
const blitzAccount = await resolvePlayerFromCommand(interaction);
const { id, server } = blitzAccount;
const { id, region: server } = blitzAccount;
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const clanData = await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
);
const clan = clanData[id]?.clan;
const clanTag = clan ? ` [${clan!.tag}]` : '';
+76 -35
View File
@@ -1,6 +1,7 @@
import getWN8Percentile from '../../../core/blitz/getWN8Percentile';
import { TankType } from '../../../core/blitz/tankopedia';
import { theme } from '../../../stitches.config';
import { RowDiscriminator } from './RowDiscriminator';
import { TREE_TYPE_ICONS, TreeTypeEnum } from '../../Tanks';
import { RowStat } from './RowStat';
export interface RowProps {
@@ -13,14 +14,14 @@ export interface RowProps {
careerDamage: number;
battles: number;
careerBattles: number;
icon?: string;
minimized: boolean;
isListing: boolean;
naked?: boolean;
isTank: boolean;
treeType?: TreeTypeEnum;
tankType?: TankType;
}
export function Row({
isListing,
isTank,
minimized,
name,
winrate,
@@ -31,45 +32,85 @@ export function Row({
careerDamage,
battles,
careerBattles,
icon,
naked,
treeType,
tankType,
}: RowProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
borderRadius: 4,
backgroundColor: isListing
? theme.colors.componentInteractive
: theme.colors.appBackground2,
padding: 8,
backgroundColor: theme.colors.appBackground2,
overflow: 'hidden',
}}
>
<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
minimized={minimized}
name={`Winrate • ${(careerWinrate * 100).toFixed(2)}%`}
value={`${(winrate * 100).toFixed(2)}%`}
delta={winrate - careerWinrate}
/>
<RowStat
minimized={minimized}
name={`WN8 • ${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}
/>
<RowStat
minimized={minimized}
name={`Battles • ${careerBattles.toFixed(0)}`}
value={battles.toFixed(0)}
/>
<div
style={{
display: 'flex',
paddingTop: 8,
paddingBottom: 8,
}}
>
<RowStat
minimized={minimized}
name={`Battles • ${careerBattles.toFixed(0)}`}
value={battles.toFixed(0)}
/>
<RowStat
minimized={minimized}
name={`Winrate • ${(careerWinrate * 100).toFixed(2)}%`}
value={`${(winrate * 100).toFixed(2)}%`}
delta={winrate - careerWinrate}
/>
<RowStat
minimized={minimized}
name={`WN8 • ${
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>
);
}
@@ -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
style={{
color: theme.colors.textHighContrast,
fontWeight: 'bold',
fontSize: 16,
}}
>
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './components/Root';
export * from './components/Row';
export * from './components/RowDiscriminator';
export * from './components/RowStat';
+1 -1
View File
@@ -96,7 +96,7 @@ export default function GenericAllStats({
]}
/>
<Card
title="Efficiency"
title="Miscellaneous"
items={[
{
label: 'Average tier',
+16 -14
View File
@@ -3,7 +3,7 @@ import { ReactNode } from 'react';
import { theme } from '../../../stitches.config';
import { Margin, MarginOrientation } from './Margin';
export const GRAPH_WIDTH = 544;
export const GRAPH_WIDTH = 384;
export const GRAPH_HEIGHT = 320;
export interface MarginInputProps {
@@ -20,7 +20,7 @@ export interface RootProps {
separations?: number;
xMinLabel: string;
xMaxLabel: string;
xTitle: string;
xTitle?: string;
}
export function Root({
@@ -91,18 +91,20 @@ export function Root({
/>
)}
<div
style={{
paddingLeft: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
{xTitle}
</span>
</div>
{xTitle !== undefined && (
<div
style={{
paddingLeft: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
{xTitle}
</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,29 @@
import { theme } from '../../../stitches.config';
export interface GapProps {
number: number;
}
export function Gap({ number }: GapProps) {
return (
<div
style={{
display: 'flex',
padding: 8,
backgroundColor: theme.colors.componentNonInteractive,
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
}}
>
<span
style={{
color: theme.colors.textLowContrast,
fontSize: 16,
}}
>
+ {number.toLocaleString()} more
</span>
</div>
);
}
@@ -0,0 +1,199 @@
import { RatingsReward } from '../../../commands/ratings';
import { theme } from '../../../stitches.config';
export interface ItemProps {
position: number;
deltaPosition?: number;
score: number;
deltaScore?: number;
reward?: RatingsReward;
nickname: string;
clan?: string;
highlight?: boolean;
}
function normalizeImage(url: string) {
return url.endsWith('.webp') ? 'https://i.imgur.com/ZniCzbO.png' : url;
}
export function Item({
position,
deltaPosition,
score,
deltaScore,
reward,
nickname,
clan,
highlight,
}: ItemProps) {
return (
<div
style={{
display: 'flex',
padding: 8,
backgroundColor: highlight
? theme.colors.componentInteractive_blue
: theme.colors.componentInteractive,
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}}
>
<span
style={{
color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{position.toLocaleString()}.
</span>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
}}
>
<span
style={{
color: highlight
? theme.colors.textHighContrast_blue
: theme.colors.textHighContrast,
fontSize: 16,
}}
>
{nickname}
</span>
{clan && (
<span
style={{
color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16,
}}
>
[{clan}]
</span>
)}
</div>
{deltaPosition !== undefined && deltaPosition !== 0 && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
}}
>
<img
style={{
width: 8,
height: 8,
}}
src={
deltaPosition > 0
? 'https://i.imgur.com/qbjiXa1.png'
: 'https://i.imgur.com/3uyNhun.png'
}
/>
<span
style={{
color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{Math.abs(deltaPosition).toLocaleString()}
</span>
</div>
)}
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 4,
}}
>
{reward && (
<img
style={{ width: 16, height: 16, objectFit: 'cover' }}
src={normalizeImage(
reward.type === 'vehicle'
? reward.vehicle.image_url
: reward.stuff.image_url,
)}
/>
)}
{reward && reward.count > 1 && (
<span
style={{
color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16,
}}
>
x {reward.count.toLocaleString()}
</span>
)}
</div>
<div
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
width: 96,
justifyContent: 'flex-end',
}}
>
{deltaScore !== 0 && deltaScore !== undefined && (
<span
style={{
color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{Math.abs(deltaScore).toLocaleString()}
</span>
)}
{deltaScore !== 0 && deltaScore !== undefined && (
<img
style={{
width: 8,
height: 8,
}}
src={
deltaScore > 0
? 'https://i.imgur.com/qbjiXa1.png'
: 'https://i.imgur.com/3uyNhun.png'
}
/>
)}
<span
style={{
color: highlight
? theme.colors.textHighContrast_blue
: theme.colors.textHighContrast,
fontSize: 16,
}}
>
{score.toLocaleString()}
</span>
</div>
</div>
);
}
@@ -0,0 +1,13 @@
import { ReactNode } from 'react';
export interface RootProps {
children: ReactNode;
}
export function Root({ children }: RootProps) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{children}
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export * from './components/Gap';
export * from './components/Item';
export * from './components/Root';
+7 -2
View File
@@ -24,8 +24,13 @@ const POWERED_BY_NAMES: Record<PoweredByType, string> = {
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',
justifyContent: footer === undefined ? 'space-around' : 'space-between',
}}
>
{footer !== undefined && <div style={{ display: 'flex' }}>{footer}</div>}
<div style={{ display: 'flex', gap: 4 }}>
<img src={POWERED_BY_IMAGES[type]} style={{ height: 16, width: 16 }} />
+13 -10
View File
@@ -2,32 +2,35 @@ import {
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
TankType,
} from '../../../core/blitz/tankopedia';
import { theme } from '../../../stitches.config';
export enum TreeType {
export enum TreeTypeEnum {
TechTree,
Premium,
Collector,
}
export type TreeTypeString = 'techtree' | 'premium' | 'collector';
export interface ItemProps {
image?: string;
tankType?: string;
tankType?: TankType;
name: string;
treeType: TreeType;
treeType: TreeTypeEnum;
}
const TREE_TYPE_COLOR = {
[TreeType.TechTree]: '',
[TreeType.Premium]: '_amber',
[TreeType.Collector]: '_blue',
[TreeTypeEnum.TechTree]: '',
[TreeTypeEnum.Premium]: '_amber',
[TreeTypeEnum.Collector]: '_blue',
} as const;
const TREE_TYPE_ICONS = {
[TreeType.TechTree]: TANK_ICONS,
[TreeType.Premium]: TANK_ICONS_PREMIUM,
[TreeType.Collector]: TANK_ICONS_COLLECTOR,
export const TREE_TYPE_ICONS = {
[TreeTypeEnum.TechTree]: TANK_ICONS,
[TreeTypeEnum.Premium]: TANK_ICONS_PREMIUM,
[TreeTypeEnum.Collector]: TANK_ICONS_COLLECTOR,
};
export function Item({ image, tankType, name, treeType }: ItemProps) {
+2 -1
View File
@@ -9,7 +9,8 @@ export function Root({ children }: RootProps) {
<div
style={{
display: 'flex',
gap: 8,
flexDirection: 'column',
gap: 16,
}}
>
{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/Item';
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';
export interface TitleBarProps {
@@ -19,10 +18,11 @@ export default function TitleBar({
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
justifyContent: 'center',
overflow: 'hidden',
}}
>
<div style={{ display: 'flex', gap: 8, flex: 1 }}>
<div style={{ display: 'flex', gap: 8 }}>
{image && (
<img
style={{ width: 64, height: 64, objectFit: 'contain' }}
@@ -31,13 +31,18 @@ export default function TitleBar({
)}
<div
style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1 }}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 4,
}}
>
<div
style={{
display: 'flex',
gap: 4,
overflow: 'hidden',
}}
>
<span
@@ -46,6 +51,8 @@ export default function TitleBar({
color: theme.colors.textHighContrast,
fontWeight: 900,
whiteSpace: 'nowrap',
maxWidth: nameDiscriminator === undefined ? Infinity : 240,
overflow: 'hidden',
}}
>
{name}
@@ -53,7 +60,6 @@ export default function TitleBar({
{nameDiscriminator && (
<span
style={{
flex: 1,
fontSize: 32,
color: theme.colors.textLowContrast,
fontWeight: 900,
@@ -70,15 +76,6 @@ export default function TitleBar({
</span>
</div>
</div>
<img
style={{ width: 64, height: 64 }}
src={
isDev()
? 'https://i.imgur.com/j6TItdy.png'
: 'https://i.imgur.com/pP6RN0x.png'
}
/>
</div>
);
}
+1 -1
View File
@@ -14,7 +14,7 @@ export default function Wrapper({ children, naked }: WrapperProps) {
flexDirection: 'column',
padding: 16,
gap: 32,
width: 640,
width: 480,
color: theme.colors.textHighContrast,
...(!naked && {
+9 -2
View File
@@ -1,12 +1,19 @@
export type RegionDomain = 'com' | 'eu' | 'asia';
export type Region = 'com' | 'eu' | 'asia';
export type RegionSubdomain = 'na' | 'eu' | 'asia';
export const REGION_DOMAIN_NAMES: Record<RegionDomain, string> = {
export const REGION_NAMES: Record<Region, string> = {
com: 'North America',
eu: 'Europe',
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> = {
na: 'North America',
eu: 'Europe',
+2 -2
View File
@@ -1,4 +1,4 @@
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
import { serverAndIdPattern } from '../discord/resolvePlayerFromCommand';
import throwError from '../node/throwError';
import listClans from './listClans';
@@ -6,7 +6,7 @@ import listClans from './listClans';
export default async function getBlitzClan(clan: string) {
if (serverAndIdPattern.test(clan)) {
const [server, accountId] = clan.split('/');
return { server: server as RegionDomain, id: Number(accountId) };
return { server: server as Region, id: Number(accountId) };
} else {
const accounts = await listClans(clan);
+12
View File
@@ -0,0 +1,12 @@
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>;
}
+5 -5
View File
@@ -1,18 +1,18 @@
import { REGION_DOMAIN_NAMES, RegionDomain } from '../../constants/regions';
import { REGION_NAMES, Region } from '../../constants/regions';
import { TanksStats } from '../../types/tanksStats';
import { WARGAMING_APPLICATION_ID } from '../node/arguments';
import { secrets } from '../node/secrets';
import throwError from '../node/throwError';
import getWargamingResponse from './getWargamingResponse';
export default async function getTankStats(server: RegionDomain, id: number) {
export default async function getTankStats(server: Region, id: number) {
const tankStats = await getWargamingResponse<TanksStats>(
`https://api.wotblitz.${server}/wotb/tanks/stats/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
`https://api.wotblitz.${server}/wotb/tanks/stats/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
if (tankStats[id] === null) {
throw throwError(
'No tank stats available',
`Wargaming says there is no tank stats for this account. This account may not have any battles/tanks or exist in the ${REGION_DOMAIN_NAMES[server]} server.`,
`Wargaming says there is no tank stats for this account. This account may not have any battles/tanks or exist in the ${REGION_NAMES[server]} server.`,
);
}
+4 -4
View File
@@ -1,11 +1,11 @@
import { TreeType } from '../../components/Tanks';
import { TreeTypeEnum } from '../../components/Tanks';
import { tankopedia } from './tankopedia';
export default async function getTreeType(id: number) {
const entry = (await tankopedia)[id];
if (entry?.is_collectible) return TreeType.Collector;
if (entry?.is_premium) return TreeType.Premium;
if (entry?.is_collectible) return TreeTypeEnum.Collector;
if (entry?.is_premium) return TreeTypeEnum.Premium;
return TreeType.TechTree;
return TreeTypeEnum.TechTree;
}
+8 -8
View File
@@ -1,6 +1,6 @@
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
import { Clan, ClanList } from '../../types/clanList';
import { WARGAMING_APPLICATION_ID } from '../node/arguments';
import { secrets } from '../node/secrets';
import getWargamingResponse from './getWargamingResponse';
export type ClanListWithServer = (Clan & {
@@ -15,33 +15,33 @@ export default async function listClans(search: string, limit = 9) {
return (
await Promise.all([
getWargamingResponse<ClanList>(
`https://api.wotblitz.com/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`,
`https://api.wotblitz.com/wotb/clans/list/?application_id=${secrets.WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`,
).then(
(value) =>
value &&
value.map((account) => ({
...account,
server: 'com' as RegionDomain,
server: 'com' as Region,
})),
),
getWargamingResponse<ClanList>(
`https://api.wotblitz.eu/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`,
`https://api.wotblitz.eu/wotb/clans/list/?application_id=${secrets.WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`,
).then(
(value) =>
value &&
value.map((account) => ({
...account,
server: 'eu' as RegionDomain,
server: 'eu' as Region,
})),
),
getWargamingResponse<ClanList>(
`https://api.wotblitz.asia/wotb/clans/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`,
`https://api.wotblitz.asia/wotb/clans/list/?application_id=${secrets.WARGAMING_APPLICATION_ID}&search=${search}&limit=${normalizedLimit}`,
).then(
(value) =>
value &&
value.map((account) => ({
...account,
server: 'asia' as RegionDomain,
server: 'asia' as Region,
})),
),
])
+8 -8
View File
@@ -1,6 +1,6 @@
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
import { Account, AccountList } from '../../types/accountList';
import { WARGAMING_APPLICATION_ID } from '../node/arguments';
import { secrets } from '../node/secrets';
import getWargamingResponse from './getWargamingResponse';
export type AccountListWithServer = (Account & {
@@ -19,33 +19,33 @@ export default async function listPlayers(search: string, limit = 9) {
return (
await Promise.all([
getWargamingResponse<AccountList>(
`https://api.wotblitz.com/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`,
`https://api.wotblitz.com/wotb/account/list/?application_id=${secrets.WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`,
).then(
(value) =>
value &&
value.map((account) => ({
...account,
server: 'com' as RegionDomain,
server: 'com' as Region,
})),
),
getWargamingResponse<AccountList>(
`https://api.wotblitz.eu/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`,
`https://api.wotblitz.eu/wotb/account/list/?application_id=${secrets.WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`,
).then(
(value) =>
value &&
value.map((account) => ({
...account,
server: 'eu' as RegionDomain,
server: 'eu' as Region,
})),
),
getWargamingResponse<AccountList>(
`https://api.wotblitz.asia/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`,
`https://api.wotblitz.asia/wotb/account/list/?application_id=${secrets.WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=${normalizedLimit}`,
).then(
(value) =>
value &&
value.map((account) => ({
...account,
server: 'asia' as RegionDomain,
server: 'asia' as Region,
})),
),
])
@@ -0,0 +1,7 @@
import { Region, RegionSubdomain } from '../../constants/regions';
export default function regionToRegionSubdomain(
regionDomain: Region,
): RegionSubdomain {
return regionDomain === 'com' ? 'na' : regionDomain;
}
+40 -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 {
name: string;
nation: string;
@@ -7,7 +13,7 @@ export interface TankopediaEntry {
cost: { price_credit: number; price_gold: number };
images: { preview: string; normal: string };
tank_id: number;
type: string;
type: TankType;
description: string;
}
@@ -31,22 +37,52 @@ export const TANKS = new Promise<TankopediaEntry[]>(async (resolve) => {
export const TANK_NAMES = new Promise<string[]>(async (resolve) => {
resolve((await entries).map(({ name }) => name));
});
export const TANK_NAMES_DIACRITICS = TANK_NAMES.then((tankNames) =>
Promise.all(
tankNames.map(async (tankName, index) => {
const name = tankName ?? `Unknown Tank ${(await TANKS)[index].tank_id}`;
const diacriticless = deburr(name);
export const TANK_ICONS: Record<string, string> = {
return {
original: name,
diacriticless,
combined: `${name}${diacriticless}`,
};
}),
),
);
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',
lightTank: 'https://i.imgur.com/CSNha5V.png',
mediumTank: 'https://i.imgur.com/wvf3ltm.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',
lightTank: 'https://i.imgur.com/zdkpTRb.png',
mediumTank: 'https://i.imgur.com/3z7eHX6.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',
lightTank: 'https://i.imgur.com/EwhtKkU.png',
mediumTank: 'https://i.imgur.com/u8YDMBh.png',
+2 -2
View File
@@ -1,4 +1,4 @@
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
import { AllStats } from '../../types/accountInfo';
import getTankHistories, {
TankHistories,
@@ -35,7 +35,7 @@ export const emptyTankHistoryNode: TankHistoryRaw = {
};
export default async function getDiffedTankStats(
server: RegionDomain,
server: Region,
id: number,
start: number,
end: number,
+4 -13
View File
@@ -1,16 +1,7 @@
import { RegionDomain } from '../../constants/regions';
import { Period } from '../discord/addPeriodSubCommands';
import { Region } from '../../constants/regions';
import { PeriodSize } from '../discord/addPeriodSubCommands';
import getTimeDaysAgo from './getTimeDaysAgo';
export default function getPeriodStart(
server: RegionDomain,
period: Exclude<Period, 'custom'>,
) {
if (period === 'career') {
return -Infinity;
} else if (period === 'today') {
return getTimeDaysAgo(server, 1);
} else {
return getTimeDaysAgo(server, parseInt(period));
}
export default function getPeriodStart(region: Region, period: PeriodSize) {
return period === 'career' ? 0 : getTimeDaysAgo(region, parseInt(period));
}
+5 -5
View File
@@ -1,4 +1,4 @@
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
import { AccountInfo, AllStats } from '../../types/accountInfo';
import {
GetHistoriesOptions,
@@ -7,7 +7,7 @@ import {
getHistoriesDefaultOptions,
} from '../../types/histories';
import getWargamingResponse from '../blitz/getWargamingResponse';
import { WARGAMING_APPLICATION_ID } from '../node/arguments';
import { secrets } from '../node/secrets';
import { emptyAllStats } from './getDiffedTankStats';
export interface PlayerHistoryRaw {
@@ -25,13 +25,13 @@ export interface PlayerHistoryRaw {
account_id: number;
[key: string]: number;
};
region: RegionDomain;
region: Region;
}
export type PlayerHistoriesRaw = PlayerHistoryRaw[];
export default async function getPlayerHistories(
server: RegionDomain,
server: Region,
id: number,
options?: Partial<GetHistoriesOptions>,
) {
@@ -67,7 +67,7 @@ export default async function getPlayerHistories(
statistics: { all },
} = (
await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
)
)[id];
+2 -2
View File
@@ -1,4 +1,4 @@
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
import {
GetHistoriesOptions,
Histories,
@@ -32,7 +32,7 @@ export const getTankHistoriesDefaultOptions: GetTankHistoriesOptions = {
};
export default async function getTankHistories(
server: RegionDomain,
server: Region,
id: number,
options?: Partial<GetTankHistoriesOptions>,
) {
+4 -4
View File
@@ -1,18 +1,18 @@
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
export const TIME_ZONE_MAPPINGS: Record<RegionDomain, number> = {
export const TIME_ZONE_MAPPINGS: Record<Region, number> = {
com: -5, // Central North American Time
eu: +1, // Central European Time
asia: +7, // Central Asia Standard Time
};
export default function getTimeDaysAgo(server: RegionDomain, daysAgo: number) {
export default function getTimeDaysAgo(region: Region, daysAgo: number) {
const now = new Date();
if (daysAgo === 0) now.getTime() / 1000;
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);
time.setUTCDate(time.getUTCDate() - daysAgo + 1);
@@ -0,0 +1,26 @@
import { BlitzkriegRatingsLeaderboard } from '../../../scripts/buildRatingsLeaderboard';
import { octokit } from '../../bot';
import { Region } from '../../constants/regions';
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 content = Buffer.from(data.content, 'base64').toString();
const jsonContent = JSON.parse(content) as BlitzkriegRatingsLeaderboard;
return jsonContent;
}
} catch (error) {
console.warn('No midnight leaderboard found');
}
}
+24 -19
View File
@@ -2,9 +2,9 @@ import {
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} 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(
option: SlashCommandSubcommandGroupBuilder,
@@ -14,36 +14,41 @@ export default function addPeriodSubCommands(
) {
return option
.addSubcommand((option) =>
extra(option.setName('today').setDescription(PERIOD_NAMES.today)),
)
.addSubcommand((option) =>
extra(option.setName('30').setDescription(PERIOD_NAMES[30])),
)
.addSubcommand((option) =>
extra(option.setName('60').setDescription(PERIOD_NAMES[60])),
)
.addSubcommand((option) =>
extra(option.setName('90').setDescription(PERIOD_NAMES[90])),
)
.addSubcommand((option) =>
extra(option.setName('career').setDescription(PERIOD_NAMES.career)),
extra(
option
.setName('period' satisfies PeriodType)
.setDescription("Preset period's statistics")
.addStringOption((option) =>
option
.setName('period')
.setDescription('A preset period')
.setChoices(
{ name: 'Today', value: '1' satisfies PeriodSize },
{ name: '30 days', value: '30' satisfies PeriodSize },
{ name: '60 days', value: '60' satisfies PeriodSize },
{ name: '90 days', value: '90' satisfies PeriodSize },
{ name: 'Career', value: 'career' satisfies PeriodSize },
)
.setRequired(true),
),
),
)
.addSubcommand((option) =>
extra(
option
.setName('custom')
.setDescription(PERIOD_NAMES.custom)
.setName('custom' satisfies PeriodType)
.setDescription("Custom period's statistics")
.addIntegerOption((option) =>
option
.setName('start')
.setDescription('Start of period in days ago from today')
.setDescription('Days ago from today')
.setMinValue(0)
.setRequired(true),
)
.addIntegerOption((option) =>
option
.setName('end')
.setDescription('End of period in days ago from today')
.setDescription('Days ago from today')
.setMinValue(0)
.setRequired(true),
),
+14
View File
@@ -0,0 +1,14 @@
import { SlashCommandStringOption } from 'discord.js';
import { REGION_NAMES } from '../../constants/regions';
export default function addRegionChoices(option: SlashCommandStringOption) {
return option
.setName('region')
.setDescription('The Blitz region you are in')
.addChoices(
{ name: REGION_NAMES.com, value: 'com' },
{ name: REGION_NAMES.eu, value: 'eu' },
{ name: REGION_NAMES.asia, value: 'asia' },
)
.setRequired(true);
}
-14
View File
@@ -1,14 +0,0 @@
import { SlashCommandStringOption } from 'discord.js';
import { REGION_DOMAIN_NAMES } from '../../constants/regions';
export default function addServerChoices(option: SlashCommandStringOption) {
return option
.setName('server')
.setDescription('The Blitz server you are in')
.addChoices(
{ name: REGION_DOMAIN_NAMES.com, value: 'com' },
{ name: REGION_DOMAIN_NAMES.eu, value: 'eu' },
{ name: REGION_DOMAIN_NAMES.asia, value: 'asia' },
)
.setRequired(true);
}
@@ -1,18 +1,24 @@
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 addTankChoices from './addTankChoices';
import addUsernameChoices from './addUsernameChoices';
export default function addStatTypeSubCommandGroups(
export default async function addStatTypeSubCommandGroups(
option: SlashCommandBuilder,
addMultiTank = true,
) {
return option
const awaitedTankopediaInfo = await tankopediaInfo;
option
.addSubcommandGroup((option) =>
addPeriodSubCommands(option, (option) =>
option.addStringOption(addUsernameChoices),
)
.setName('player')
.setDescription("Player's statistics"),
.setName('player' satisfies StatType)
.setDescription('Player statistics'),
)
.addSubcommandGroup((option) =>
addPeriodSubCommands(option, (option) =>
@@ -20,7 +26,70 @@ export default function addStatTypeSubCommandGroups(
.addStringOption(addTankChoices)
.addStringOption(addUsernameChoices),
)
.setName('tank')
.setDescription("Tank's statistics"),
.setName('tank' satisfies StatType)
.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),
)
.addIntegerOption((option) =>
option
.setName('tier')
.setDescription('Tier')
.setMinValue(1)
.setMaxValue(10)
.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;
}
+3 -2
View File
@@ -3,7 +3,7 @@ import {
AutocompleteInteraction,
CacheType,
} from 'discord.js';
import { REGION_DOMAIN_NAMES } from '../../constants/regions';
import { REGION_NAMES } from '../../constants/regions';
import listClans from '../blitz/listClans';
export default async function autocompleteClan(
@@ -11,6 +11,7 @@ export default async function autocompleteClan(
) {
const focusedOption = interaction.options.getFocused(true);
if (focusedOption.name !== 'clan') return;
if (focusedOption.value.length < 2) return interaction.respond([]);
const players = await listClans(focusedOption.value);
await interaction.respond(
@@ -18,7 +19,7 @@ export default async function autocompleteClan(
? players.map(
(player) =>
({
name: `${player.name} (${REGION_DOMAIN_NAMES[player.server]})`,
name: `${player.name} (${REGION_NAMES[player.server]})`,
value: `${player.server}/${player.clan_id}`,
}) satisfies ApplicationCommandOptionChoiceData<string>,
)
+6 -9
View File
@@ -4,8 +4,7 @@ import {
CacheType,
} from 'discord.js';
import { go } from 'fuzzysort';
import resolveTankName from '../blitz/resolveTankName';
import { TANKS } from '../blitz/tankopedia';
import { TANKS, TANK_NAMES_DIACRITICS } from '../blitz/tankopedia';
export default async function autocompleteTanks(
interaction: AutocompleteInteraction<CacheType>,
@@ -16,16 +15,14 @@ export default async function autocompleteTanks(
await interaction.respond(
focusedOption.value
? await Promise.all(
go(focusedOption.value, await TANKS, {
keys: ['name'],
go(focusedOption.value, await TANK_NAMES_DIACRITICS, {
keys: ['combined'],
limit: 10,
}).map(
async (item) =>
async (item, index) =>
({
name: item.obj.name
? item.obj.name
: await resolveTankName(item.obj.tank_id),
value: `${item.obj.tank_id}`,
name: item.obj.original,
value: `${(await TANKS)[index].tank_id}`,
}) satisfies ApplicationCommandOptionChoiceData<string>,
),
)
+2 -4
View File
@@ -3,7 +3,7 @@ import {
AutocompleteInteraction,
CacheType,
} from 'discord.js';
import { REGION_DOMAIN_NAMES } from '../../constants/regions';
import { REGION_NAMES } from '../../constants/regions';
import listPlayers from '../blitz/listPlayers';
export default async function autocompleteUsername(
@@ -19,9 +19,7 @@ export default async function autocompleteUsername(
? players.map(
(player) =>
({
name: `${player.nickname} (${
REGION_DOMAIN_NAMES[player.server]
})`,
name: `${player.nickname} (${REGION_NAMES[player.server]})`,
value: `${player.server}/${player.account_id}`,
}) satisfies ApplicationCommandOptionChoiceData<string>,
)
+1 -6
View File
@@ -19,12 +19,7 @@ export default function interactionToURL(
]
.filter(Boolean)
.join('/');
const params = [
...interaction.options.data.map(
(option) => [option.name, option.value] as const,
),
...Object.entries(parameters ?? {}),
]
const params = (parameters ? Object.entries(parameters) : [])
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${key}=${value}`)
.join('&');
+2 -2
View File
@@ -1,10 +1,10 @@
import { ButtonInteraction, CacheType } from 'discord.js';
import { CYCLIC_API } from '../../constants/cyclic';
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
import resolvePeriodFromURL from '../express/resolvePeriodFromURL';
export default function resolvePeriodFromButton(
server: RegionDomain,
server: Region,
interaction: ButtonInteraction<CacheType>,
) {
return resolvePeriodFromURL(server, `${CYCLIC_API}/${interaction.customId}`);
+20 -33
View File
@@ -1,27 +1,13 @@
import { CacheType, ChatInputCommandInteraction } from 'discord.js';
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
import getPeriodNow from '../blitzstars/getPeriodNow';
import getPeriodStart from '../blitzstars/getPeriodStart';
import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo';
import { Period } from '../discord/addPeriodSubCommands';
import { PeriodSize, PeriodType } from '../discord/addPeriodSubCommands';
export const PERIOD_NAMES: Record<Period, string> = {
today: "Today's statistics",
30: "30 days' statistics",
60: "60 days' statistics",
90: "90 days' statistics",
career: 'Career statistics',
custom: 'Custom period',
};
export const EVOLUTION_PERIOD_NAMES: Record<Period, string> = {
today: "Today's evolution",
30: "30 days' evolution",
60: "60 days' evolution",
90: "90 days' evolution",
career: 'Career evolution',
custom: 'Custom period',
};
export function getPeriodOptionName(period: PeriodSize) {
return period === 'career' ? 'Career' : `${period}-day`;
}
export interface ResolvedPeriod {
statsName: string;
@@ -31,29 +17,30 @@ export interface ResolvedPeriod {
}
export default function resolvePeriodFromCommand(
server: RegionDomain,
region: Region,
interaction: ChatInputCommandInteraction<CacheType>,
) {
let statsName: string;
let evolutionName: string;
let start: 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') {
const startRaw = interaction.options.getInteger('start')!;
const endRaw = interaction.options.getInteger('end')!;
const startMin = Math.min(startRaw, endRaw);
const endMax = Math.max(startRaw, endRaw);
if (periodSubcommand === 'custom') {
const startRaw = interaction.options.getInteger('start', true);
const endRaw = interaction.options.getInteger('end', true);
const startDaysAgoMin = Math.min(startRaw, endRaw);
const endDaysAgoMax = Math.max(startRaw, endRaw);
statsName = `${startMin} to ${endMax} days' statistics`;
evolutionName = `${startMin} to ${endMax} days' evolution`;
start = getTimeDaysAgo(server, startMin);
end = getTimeDaysAgo(server, endMax);
statsName = `${startDaysAgoMin} to ${endDaysAgoMax} days' statistics`;
evolutionName = `${startDaysAgoMin} to ${endDaysAgoMax} days' evolution`;
start = getTimeDaysAgo(region, endDaysAgoMax);
end = getTimeDaysAgo(region, startDaysAgoMin);
} else {
statsName = PERIOD_NAMES[period];
evolutionName = EVOLUTION_PERIOD_NAMES[period];
start = getPeriodStart(server, period);
statsName = `${getPeriodOptionName(periodOption)} Statistics`;
evolutionName = `${getPeriodOptionName(periodOption)} Evolution`;
start = getPeriodStart(region, periodOption);
end = getPeriodNow();
}
+6 -5
View File
@@ -1,6 +1,7 @@
import { CacheType, ChatInputCommandInteraction } from 'discord.js';
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
import listPlayers, {
usernamePattern,
usernamePatternWithoutPosition,
} from '../blitz/listPlayers';
import throwError from '../node/throwError';
@@ -8,7 +9,7 @@ import throwError from '../node/throwError';
export const serverAndIdPattern = /(com|eu|asia)\/[0-9]+/;
export interface ResolvedPlayer {
server: RegionDomain;
region: Region;
id: number;
}
@@ -19,7 +20,7 @@ export default async function resolvePlayerFromCommand(
const displayName = interaction.guild?.members.cache
.get(interaction.user.id)
?.displayName.match(usernamePatternWithoutPosition)?.[0];
const username = commandUsername ?? displayName;
const username = commandUsername?.match(usernamePattern)?.[0] ?? displayName;
if (username === undefined) {
throw throwError(
@@ -31,7 +32,7 @@ export default async function resolvePlayerFromCommand(
if (serverAndIdPattern.test(username)) {
const [server, accountId] = username.split('/');
return {
server: server as RegionDomain,
region: server as Region,
id: Number(accountId),
} satisfies ResolvedPlayer;
} else {
@@ -39,7 +40,7 @@ export default async function resolvePlayerFromCommand(
if (accounts[0]) {
return {
server: accounts[0].server,
region: accounts[0].server,
id: accounts[0].account_id,
} satisfies ResolvedPlayer;
} else {
+3 -3
View File
@@ -1,11 +1,11 @@
import { Request } from 'express';
import { CYCLIC_API } from '../../constants/cyclic';
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
import resolvePeriodFromURL from './resolvePeriodFromURL';
export default function resolvePeriodFromRequest(
server: RegionDomain,
server: Region,
request: Request,
) {
return resolvePeriodFromURL(server, `${CYCLIC_API}${request.path}`);
return resolvePeriodFromURL(server, `${CYCLIC_API}${request.url}`);
}
+17 -22
View File
@@ -1,16 +1,15 @@
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
import getPeriodNow from '../blitzstars/getPeriodNow';
import getPeriodStart from '../blitzstars/getPeriodStart';
import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo';
import { Period } from '../discord/addPeriodSubCommands';
import { PeriodSize, PeriodType } from '../discord/addPeriodSubCommands';
import {
EVOLUTION_PERIOD_NAMES,
PERIOD_NAMES,
ResolvedPeriod,
getPeriodOptionName,
} from '../discord/resolvePeriodFromCommand';
export default function resolvePeriodFromURL(
server: RegionDomain,
server: Region,
urlString: string,
) {
let statsName: string;
@@ -19,29 +18,25 @@ export default function resolvePeriodFromURL(
let end: number;
const url = new URL(urlString);
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 endRaw = parseInt(url!.searchParams.get('end')!);
const startMin = Math.min(startRaw, endRaw);
const endMax = Math.max(startRaw, endRaw);
const startDaysAgoMin = Math.min(startRaw, endRaw);
const endDaysAgoMax = Math.max(startRaw, endRaw);
statsName = `${startMin} to ${endMax} days' statistics`;
evolutionName = `${startMin} to ${endMax} days' evolution`;
start = getTimeDaysAgo(server, startMin);
end = getTimeDaysAgo(server, endMax);
statsName = `${startDaysAgoMin} to ${endDaysAgoMax} days' statistics`;
evolutionName = `${startDaysAgoMin} to ${endDaysAgoMax} days' evolution`;
start = getTimeDaysAgo(server, endDaysAgoMax);
end = getTimeDaysAgo(server, startDaysAgoMin);
} else {
statsName = PERIOD_NAMES[period];
evolutionName = EVOLUTION_PERIOD_NAMES[period];
start = getPeriodStart(server, period);
statsName = `${getPeriodOptionName(periodOption)} Statistics`;
evolutionName = `${getPeriodOptionName(periodOption)} Evolution`;
start = getPeriodStart(server, periodOption);
end = getPeriodNow();
}
return {
statsName,
evolutionName,
start,
end,
} satisfies ResolvedPeriod;
return { statsName, evolutionName, start, end } satisfies ResolvedPeriod;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { RegionDomain } from '../../constants/regions';
import { Region } from '../../constants/regions';
import { ResolvedPlayer } from '../discord/resolvePlayerFromCommand';
export default function resolvePlayerFromURL(urlString: string) {
@@ -6,6 +6,6 @@ export default function resolvePlayerFromURL(urlString: string) {
return {
id: parseInt(url.searchParams.get('id')!),
server: url.searchParams.get('server') as RegionDomain,
region: url.searchParams.get('region') as Region,
} satisfies ResolvedPlayer;
}
-9
View File
@@ -1,9 +0,0 @@
export const DISCORD_TOKEN = process.env.DISCORD_TOKEN!;
export const WARGAMING_APPLICATION_ID = process.env.WARGAMING_APPLICATION_ID!;
if (!DISCORD_TOKEN) {
throw new Error('No Discord token provided');
}
if (!WARGAMING_APPLICATION_ID) {
throw new Error('No Wargaming application id provided}');
}
+1 -1
View File
@@ -32,7 +32,7 @@ Promise.all(
export default async function jsxToSvg(jsx: JSX.Element) {
return await satori(jsx, {
width: 640,
width: 480,
fonts: [
{ data: roboto, name: FONT_NAME, weight: 400 },
{ data: robotoBold, name: FONT_NAME, weight: 700 },
+21
View File
@@ -0,0 +1,21 @@
export const SECRET_KEYS = [
'DISCORD_TOKEN',
'WARGAMING_APPLICATION_ID',
'GH_TOKEN',
] as const;
export type SECRET = typeof SECRET_KEYS extends readonly (infer T)[]
? T
: never;
export const secrets = SECRET_KEYS.reduce<Partial<Record<SECRET, string>>>(
(accumulator, key) => ({
...accumulator,
get [key](): string {
if (!(key in process.env)) throw new Error(`Key ${key} not provided`);
return process.env[key]!;
},
}),
{},
) as Record<SECRET, string>;
+1 -1
View File
@@ -15,7 +15,7 @@ export default async function (member: GuildMember) {
.setColor(ACCENT_COLOR)
.setTitle(`Welcome ${member.user.username}`)
.setDescription(
`Welcome to the Skilled server, **${member.user.username}**! To continue, please use the \`/verify\` command.\n\nExample: \`/verify Europe Sabina244\``,
`Welcome to the Skilled server, **${member.user.username}**! To continue, please use the \`/verify\` command.\n\nExample: \`/verify Sabina244\``,
),
],
});
@@ -1,10 +1,10 @@
import { AutocompleteInteraction, CacheType } from 'discord.js';
import { commands } from '..';
export default function handleAutocomplete(
export default async function handleAutocomplete(
interaction: AutocompleteInteraction<CacheType>,
) {
const command = commands[interaction.commandName];
const command = (await commands)[interaction.commandName];
if (!command) {
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 commandName = url.pathname.split('/')[1];
const button = commands[commandName]?.button;
const button = (await commands)[commandName]?.button;
if (!button) {
throwError(`Button handler not found for "${commandName}"`);
@@ -6,6 +6,7 @@ import {
ChatInputCommandInteraction,
} from 'discord.js';
import { commands } from '..';
import discord from '../../../../discord.json' assert { type: 'json' };
import embedNegative from '../../../core/discord/embedNegative';
import embedWarning from '../../../core/discord/embedWarning';
import normalizeInteractionReturnable from '../../../core/discord/normalizeInteractionReturnable';
@@ -15,12 +16,25 @@ import { handleError } from '../../error';
export default async function handleChatInputCommand(
interaction: ChatInputCommandInteraction<CacheType>,
) {
const registry = commands[interaction.commandName];
const registry = (await commands)[interaction.commandName];
console.log(interaction.toString());
await interaction.deferReply();
try {
if (registry.inPreview && interaction.guildId !== discord.tres_guild_id) {
interaction.editReply({
embeds: [
embedWarning(
`\`/${registry.command.name}\` is in Public Preview`,
'[Join the official Discord server](https://discord.gg/nDt7AjGJQH) to gain early access to this command before it is released to the public.',
),
],
});
return;
}
const returnable = await registry.handler(interaction);
if (registry.handlesInteraction) return;
+72 -52
View File
@@ -23,15 +23,16 @@ import { ownedTanksCommand } from '../../commands/ownedTanks';
import { pingCommand } from '../../commands/ping';
import { playerAchievementsCommand } from '../../commands/playerAchievements';
import { playerInfoCommand } from '../../commands/playerInfo';
import { ratingsCommand } from '../../commands/ratings';
import { searchClansCommand } from '../../commands/searchClans';
import { searchPlayersCommand } from '../../commands/searchPlayers';
import { searchTanksCommand } from '../../commands/searchTanks';
import { statsCommand } from '../../commands/stats';
import { todayCommand } from '../../commands/today';
import { verifyCommand } from '../../commands/verify';
import { DISCORD_TOKEN } from '../../core/node/arguments';
import getClientId from '../../core/node/getClientId';
import isDev from '../../core/node/isDev';
import { secrets } from '../../core/node/secrets';
import handleAutocomplete from './handlers/autocomplete';
import handleButton from './handlers/button';
import handleChatInputCommand from './handlers/chatInputCommand';
@@ -53,23 +54,32 @@ export interface Registry {
inProduction: boolean;
}
export interface CommandRegistry<HandlesInteraction extends boolean = boolean>
extends Registry {
export type CommandRegistryRaw = Registry & {
inPublic: boolean;
handlesInteraction?: HandlesInteraction;
inPreview?: boolean;
command:
| SlashCommandBuilder
| SlashCommandSubcommandsOnlyBuilder
| Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
handler: (
interaction: ChatInputCommandInteraction<CacheType>,
) => HandlesInteraction extends true ? void : InteractionReturnable;
autocomplete?: (interaction: AutocompleteInteraction<CacheType>) => void;
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(DISCORD_TOKEN);
const rest = new REST().setToken(secrets.DISCORD_TOKEN);
export const COMMANDS_RAW: CommandRegistry[] = [
debugCommand,
@@ -88,13 +98,21 @@ export const COMMANDS_RAW: CommandRegistry[] = [
pingCommand,
evolutionCommand,
statsCommand,
ratingsCommand,
];
export const commands: Record<string, CommandRegistry> = COMMANDS_RAW.reduce(
(accumulator, registry) => {
if (isDev()) registry.command.setDefaultMemberPermissions(0);
return { ...accumulator, [registry.command.name]: registry };
},
{},
export const commands = Promise.all(COMMANDS_RAW).then((rawCommands) =>
rawCommands.reduce<Record<string, CommandRegistryRaw>>(
(accumulator, registry) => {
if (isDev()) registry.command.setDefaultMemberPermissions(0);
return {
...accumulator,
[registry.command.name]: registry,
};
},
{},
),
);
export const guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] =
@@ -102,48 +120,50 @@ export const guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] =
export const publicCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] =
[];
Object.entries(commands).forEach(([, registry]) => {
if (isDev() ? registry.inDevelopment : registry.inProduction) {
const json = registry.command.toJSON();
commands.then((awaitedCommands) => {
Object.entries(awaitedCommands).forEach(([, registry]) => {
if (isDev() ? registry.inDevelopment : registry.inProduction) {
const json = registry.command.toJSON();
if (registry.inPublic) {
publicCommands.push(json);
} else {
guildCommands.push(json);
if (registry.inPublic) {
publicCommands.push(json);
} else {
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(
interaction: Interaction<CacheType>,
) {
+7 -11
View File
@@ -3,7 +3,6 @@ import NoData, { NoDataType } from '../components/NoData';
import PoweredBy, { PoweredByType } from '../components/PoweredBy';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { REGION_DOMAIN_NAMES } from '../constants/regions';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankName from '../core/blitz/resolveTankName';
import { tankopedia } from '../core/blitz/tankopedia';
@@ -11,16 +10,16 @@ import getPlayerHistories from '../core/blitzstars/getPlayerHistories';
import getTankHistories from '../core/blitzstars/getTankHistories';
import { ResolvedPeriod } from '../core/discord/resolvePeriodFromCommand';
import { ResolvedPlayer } from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import { AccountInfo } from '../types/accountInfo';
import { Histories } from '../types/histories';
import { PlayerClanData } from '../types/playerClanData';
import { StatType } from './fullStats';
import { StatType } from './stats';
export default async function evolution<Type extends StatType>(
type: Type,
{ start, end, evolutionName }: ResolvedPeriod,
{ server, id }: ResolvedPlayer,
{ region: server, id }: ResolvedPlayer,
tankId: Type extends 'tank' ? number : null,
) {
let nameDiscriminator: string | undefined;
@@ -29,7 +28,7 @@ export default async function evolution<Type extends StatType>(
if (type === 'player') {
const clan = (
await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${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`,
)
)[id]?.clan;
@@ -79,7 +78,7 @@ export default async function evolution<Type extends StatType>(
const minX = Math.min(...xs);
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
return (
@@ -88,20 +87,17 @@ export default async function evolution<Type extends StatType>(
name={accountInfo[id].nickname}
nameDiscriminator={nameDiscriminator}
image={image}
description={`${evolutionName} • ${new Date().toDateString()} • ${
REGION_DOMAIN_NAMES[server]
}`}
description={`${evolutionName} • ${new Date().toDateString()}`}
/>
{/* goofy ahh bug forces me to call them as functions */}
{plot.length > 0 && (
<Graph.Root
xTitle="Battles"
verticalMargin={{
min: minY,
max: maxY,
suffix: '%',
precision: 1,
precision: 2,
}}
horizontalMargin={{
min: minX,
+84 -76
View File
@@ -4,7 +4,6 @@ import PoweredBy, { PoweredByType } from '../components/PoweredBy';
import TierWeights, { TierWeightsRecord } from '../components/TierWeights';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { REGION_DOMAIN_NAMES } from '../constants/regions';
import calculateWN8 from '../core/blitz/calculateWN8';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankName from '../core/blitz/resolveTankName';
@@ -14,22 +13,24 @@ import getDiffedTankStats from '../core/blitzstars/getDiffedTankStats';
import { tankAverages } from '../core/blitzstars/tankAverages';
import { ResolvedPeriod } from '../core/discord/resolvePeriodFromCommand';
import { ResolvedPlayer } from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import {
AccountInfo,
AllStats,
SupplementaryStats,
} from '../types/accountInfo';
import { PlayerClanData } from '../types/playerClanData';
import { PossiblyPromise } from '../types/possiblyPromise';
export type StatType = 'player' | 'tank';
import { MultiTankFilters, StatType } from './stats';
export default async function fullStats<Type extends StatType>(
type: Type,
{ start, end, statsName }: ResolvedPeriod,
{ server, id }: ResolvedPlayer,
tankId: Type extends 'tank' ? number : null,
{ region: server, id }: ResolvedPlayer,
filters: Type extends 'tank'
? number
: Type extends 'multi-tank'
? Partial<MultiTankFilters>
: null,
) {
let nameDiscriminator: string | undefined;
let image: string | undefined;
@@ -37,7 +38,7 @@ export default async function fullStats<Type extends StatType>(
if (type === 'player') {
const clan = (
await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${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`,
)
)[id]?.clan;
@@ -46,94 +47,103 @@ export default async function fullStats<Type extends StatType>(
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
: undefined;
} else if (type === 'tank') {
nameDiscriminator = `(${await resolveTankName(tankId!)})`;
image = (await tankopedia)[tankId!]?.images.normal;
nameDiscriminator = `(${await resolveTankName(filters as number)})`;
image = (await tankopedia)[filters as number]?.images.normal;
} else {
}
const { diffed } = await getDiffedTankStats(server, id, start, end);
let stats: AllStats;
const { diffed, order } = await getDiffedTankStats(server, id, start, end);
const awaitedTankopedia = await tankopedia;
let stats: AllStats | undefined;
let supplementaryStats: SupplementaryStats;
let tierWeights: TierWeightsRecord;
if (type === 'player') {
const entries = Object.entries(diffed);
stats = sumStats(entries.map(([, stats]) => stats));
const battlesOfTanksWithAverages = await entries.reduce<
PossiblyPromise<number>
>(async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString);
const tankAverage = (await tankAverages)[tankId];
return tankAverage ? (await accumulator) + stats.battles : accumulator;
}, 0);
const battlesOfTanksWithTankopediaEntry = await entries.reduce<
PossiblyPromise<number>
>(async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString);
const tankopediaEntry = (await tankopedia)[tankId];
return tankopediaEntry
? (await accumulator) + stats.battles
: accumulator;
}, 0);
if (type === 'player' || type === 'multi-tank') {
const typedFilters = filters as Partial<MultiTankFilters> | null;
const filteredOrder = order.filter(
(id) =>
typedFilters === null ||
((typedFilters.nation === undefined ||
awaitedTankopedia[id]?.nation === typedFilters.nation) &&
(typedFilters['tank-type'] === undefined ||
awaitedTankopedia[id]?.type === typedFilters['tank-type']) &&
(typedFilters.tier === undefined ||
awaitedTankopedia[id]?.tier === typedFilters.tier) &&
(typedFilters['tree-type'] === undefined ||
(typedFilters['tree-type'] === 'collector' &&
awaitedTankopedia[id]?.is_collectible) ||
(typedFilters['tree-type'] === 'premium' &&
awaitedTankopedia[id]?.is_premium) ||
(typedFilters['tree-type'] === 'techtree' &&
!awaitedTankopedia[id]?.is_collectible &&
!awaitedTankopedia[id]?.is_premium))),
);
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 = {
WN8:
(await entries.reduce<PossiblyPromise<number>>(
async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString);
const tankAverage = (await tankAverages)[tankId];
return tankAverage
? (await accumulator) +
calculateWN8(tankAverage.all, stats) * stats.battles
: accumulator;
},
filteredOrder.reduce<number>(
(accumulator, id) =>
awaitedTankAverages[id]
? accumulator +
calculateWN8(awaitedTankAverages[id].all, diffed[id]) *
diffed[id].battles
: accumulator,
0,
)) / battlesOfTanksWithAverages,
) / battlesOfTanksWithAverages,
tier:
(await entries.reduce<PossiblyPromise<number>>(
async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString);
const tankopediaEntry = (await tankopedia)[tankId];
return tankopediaEntry
? (await accumulator) + tankopediaEntry.tier * stats.battles
: accumulator;
},
filteredOrder.reduce<number>(
(accumulator, id) =>
awaitedTankopedia[id]
? accumulator + awaitedTankopedia[id]!.tier * diffed[id].battles
: accumulator,
0,
)) / battlesOfTanksWithTankopediaEntry,
) / battlesOfTanksWithTankopediaEntry,
};
tierWeights = await entries.reduce<PossiblyPromise<TierWeightsRecord>>(
async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString);
const tankopediaEntry = (await tankopedia)[tankId];
tierWeights = filteredOrder.reduce<TierWeightsRecord>((accumulator, id) => {
const tankopediaEntry = awaitedTankopedia[id];
if (!tankopediaEntry) return accumulator;
if (!tankopediaEntry) return accumulator;
const tier = tankopediaEntry.tier as Tier;
const tier = tankopediaEntry.tier as Tier;
if ((await accumulator)[tier]) {
(await accumulator)[tier]! += stats.battles;
} else {
(await accumulator)[tier] = stats.battles;
}
if (accumulator[tier]) {
accumulator[tier]! += diffed[id].battles;
} else {
accumulator[tier] = diffed[id].battles;
}
return accumulator;
},
{},
);
return accumulator;
}, {});
} else {
stats = diffed[tankId!];
stats = diffed[(filters as number)!];
supplementaryStats = {
WN8: calculateWN8((await tankAverages)[tankId!].all, diffed[tankId!]),
tier: (await tankopedia)[tankId!]?.tier,
WN8: stats
? calculateWN8(
(await tankAverages)[(filters as number)!].all,
diffed[(filters as number)!],
)
: undefined,
tier: (await tankopedia)[(filters as number)!]?.tier,
};
}
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
return (
@@ -142,9 +152,7 @@ export default async function fullStats<Type extends StatType>(
name={accountInfo[id].nickname}
nameDiscriminator={nameDiscriminator}
image={image}
description={`${statsName} • ${new Date().toDateString()} • ${
REGION_DOMAIN_NAMES[server]
}`}
description={`${statsName} • ${new Date().toDateString()}`}
/>
{stats.battles === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
+81 -83
View File
@@ -1,20 +1,19 @@
import AllStatsOverview from '../components/AllStatsOverview';
import NoData, { NoDataType } from '../components/NoData';
import PoweredBy, { PoweredByType } from '../components/PoweredBy';
import { TierWeightsRecord } from '../components/TierWeights';
import { TreeTypeString } from '../components/Tanks';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { REGION_DOMAIN_NAMES } from '../constants/regions';
import calculateWN8 from '../core/blitz/calculateWN8';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankName from '../core/blitz/resolveTankName';
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 { tankAverages } from '../core/blitzstars/tankAverages';
import { ResolvedPeriod } from '../core/discord/resolvePeriodFromCommand';
import { ResolvedPlayer } from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import { theme } from '../stitches.config';
import {
AccountInfo,
@@ -22,15 +21,25 @@ import {
SupplementaryStats,
} from '../types/accountInfo';
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>(
type: Type,
{ start, end, statsName }: ResolvedPeriod,
{ server, id }: ResolvedPlayer,
tankId: Type extends 'tank' ? number : null,
{ region: server, id }: ResolvedPlayer,
filters: Type extends 'tank'
? number
: Type extends 'multi-tank'
? Partial<MultiTankFilters>
: null,
naked = false,
) {
let nameDiscriminator: string | undefined;
@@ -39,7 +48,7 @@ export default async function stats<Type extends StatType>(
if (type === 'player') {
const clan = (
await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${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`,
)
)[id]?.clan;
@@ -48,96 +57,87 @@ export default async function stats<Type extends StatType>(
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
: undefined;
} else if (type === 'tank') {
nameDiscriminator = `(${await resolveTankName(tankId!)})`;
image = (await tankopedia)[tankId!]?.images.normal;
nameDiscriminator = `(${await resolveTankName(filters as number)})`;
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 supplementaryStats: SupplementaryStats;
let tierWeights: TierWeightsRecord;
if (type === 'player') {
const entries = Object.entries(diffed);
stats = sumStats(entries.map(([, stats]) => stats));
const battlesOfTanksWithAverages = await entries.reduce<
PossiblyPromise<number>
>(async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString);
const tankAverage = (await tankAverages)[tankId];
return tankAverage ? (await accumulator) + stats.battles : accumulator;
}, 0);
const battlesOfTanksWithTankopediaEntry = await entries.reduce<
PossiblyPromise<number>
>(async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString);
const tankopediaEntry = (await tankopedia)[tankId];
return tankopediaEntry
? (await accumulator) + stats.battles
: accumulator;
}, 0);
if (type === 'player' || type === 'multi-tank') {
const typedFilters = filters as Partial<MultiTankFilters> | null;
const filteredOrder = order.filter(
(id) =>
typedFilters === null ||
((typedFilters.nation === undefined ||
awaitedTankopedia[id]?.nation === typedFilters.nation) &&
(typedFilters['tank-type'] === undefined ||
awaitedTankopedia[id]?.type === typedFilters['tank-type']) &&
(typedFilters.tier === undefined ||
awaitedTankopedia[id]?.tier === typedFilters.tier) &&
(typedFilters['tree-type'] === undefined ||
(typedFilters['tree-type'] === 'collector' &&
awaitedTankopedia[id]?.is_collectible) ||
(typedFilters['tree-type'] === 'premium' &&
awaitedTankopedia[id]?.is_premium) ||
(typedFilters['tree-type'] === 'techtree' &&
!awaitedTankopedia[id]?.is_collectible &&
!awaitedTankopedia[id]?.is_premium))),
);
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 = {
WN8:
(await entries.reduce<PossiblyPromise<number>>(
async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString);
const tankAverage = (await tankAverages)[tankId];
return tankAverage
? (await accumulator) +
calculateWN8(tankAverage.all, stats) * stats.battles
: accumulator;
},
filteredOrder.reduce<number>(
(accumulator, id) =>
awaitedTankAverages[id]
? accumulator +
calculateWN8(awaitedTankAverages[id].all, diffed[id]) *
diffed[id].battles
: accumulator,
0,
)) / battlesOfTanksWithAverages,
) / battlesOfTanksWithAverages,
tier:
(await entries.reduce<PossiblyPromise<number>>(
async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString);
const tankopediaEntry = (await tankopedia)[tankId];
return tankopediaEntry
? (await accumulator) + tankopediaEntry.tier * stats.battles
: accumulator;
},
filteredOrder.reduce<number>(
(accumulator, id) =>
awaitedTankopedia[id]
? accumulator + awaitedTankopedia[id]!.tier * diffed[id].battles
: accumulator,
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 {
stats = diffed[tankId!];
stats = diffed[(filters as number)!];
supplementaryStats = {
WN8: stats
? calculateWN8((await tankAverages)[tankId!].all, diffed[tankId!])
? calculateWN8(
(await tankAverages)[(filters as number)!].all,
diffed[(filters as number)!],
)
: undefined,
tier: (await tankopedia)[tankId!]?.tier,
tier: (await tankopedia)[(filters as number)!]?.tier,
};
}
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const overview = (
<AllStatsOverview stats={stats} supplementaryStats={supplementaryStats} />
@@ -151,7 +151,7 @@ export default async function stats<Type extends StatType>(
gap: 4,
}}
>
<u>/statsfull</u> for all statistics
<u>/full-stats</u> for all statistics
</span>
);
@@ -161,11 +161,9 @@ export default async function stats<Type extends StatType>(
<Wrapper>
<TitleBar
name={accountInfo[id].nickname}
nameDiscriminator={nameDiscriminator}
image={image}
description={`${statsName} • ${new Date().toDateString()} • ${
REGION_DOMAIN_NAMES[server]
}`}
nameDiscriminator={nameDiscriminator}
description={`${statsName} • ${new Date().toDateString()}`}
/>
{!stats?.battles && <NoData type={NoDataType.BattlesInPeriod} />}
+63 -57
View File
@@ -3,9 +3,9 @@ import NoData, { NoDataType } from '../components/NoData';
import PoweredBy, { PoweredByType } from '../components/PoweredBy';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { REGION_DOMAIN_NAMES } from '../constants/regions';
import calculateWN8 from '../core/blitz/calculateWN8';
import getTankStats from '../core/blitz/getTankStats';
import getTreeType from '../core/blitz/getTreeType';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import resolveTankName from '../core/blitz/resolveTankName';
import sumStats from '../core/blitz/sumStats';
@@ -15,16 +15,20 @@ import getPeriodNow from '../core/blitzstars/getPeriodNow';
import getTimeDaysAgo from '../core/blitzstars/getTimeDaysAgo';
import { tankAverages } from '../core/blitzstars/tankAverages';
import { ResolvedPlayer } from '../core/discord/resolvePlayerFromCommand';
import { WARGAMING_APPLICATION_ID } from '../core/node/arguments';
import { secrets } from '../core/node/secrets';
import { AccountInfo, AllStats } from '../types/accountInfo';
import { PlayerClanData } from '../types/playerClanData';
import { PossiblyPromise } from '../types/possiblyPromise';
export default async function today(
{ server, id }: ResolvedPlayer,
limit = Infinity,
{ region: server, id }: ResolvedPlayer,
cutoff = Infinity,
maximized = 4,
showTotal = true,
naked?: boolean,
) {
console.log(maximized);
const { diffed, order } = await getDiffedTankStats(
server,
id,
@@ -32,15 +36,17 @@ export default async function today(
getPeriodNow(),
);
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`,
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
const clanData = await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`,
`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 careerStats: Record<number, AllStats> = {
0: accountInfo[id].statistics.all,
};
const careerStats: Record<number, AllStats> = showTotal
? {
0: accountInfo[id].statistics.all,
}
: {};
const allStatsToAccumulate: AllStats[] = [];
Object.entries(diffed).forEach(([, tankStats]) => {
@@ -52,7 +58,7 @@ export default async function today(
const accumulatedStats = sumStats(allStatsToAccumulate);
if (Object.keys(diffed).length > 0) {
if (showTotal && Object.keys(diffed).length > 0) {
diffed[0] = accumulatedStats;
}
@@ -62,7 +68,7 @@ export default async function today(
>(async (accumulator, [tankIdString, tankStats]) => {
const tankId = parseInt(tankIdString);
return tankId === 0 || !(await tankAverages)[tankId]
return (showTotal && tankId === 0) || !(await tankAverages)[tankId]
? accumulator
: {
...(await accumulator),
@@ -73,7 +79,8 @@ export default async function today(
const careerWN8s = await careerTankStatsRaw.reduce<
PossiblyPromise<Record<number, number>>
>(async (accumulator, { tank_id }) => {
return tank_id === 0 || (await tankAverages)[tank_id] === undefined
return (showTotal && tank_id === 0) ||
(await tankAverages)[tank_id] === undefined
? accumulator
: {
...(await accumulator),
@@ -86,60 +93,61 @@ export default async function today(
const todayWN8sEntries = Object.entries(todayWN8s);
const careerWN8sEntries = Object.entries(careerWN8s);
todayWN8s[0] =
todayWN8sEntries.reduce(
(accumulator, [tankIdString, wn8]) =>
accumulator + wn8 * diffed[Number(tankIdString)].battles,
0,
) /
todayWN8sEntries.reduce(
(accumulator, [tankIdString]) =>
accumulator + diffed[Number(tankIdString)].battles,
0,
);
careerWN8s[0] =
careerWN8sEntries.reduce(
(accumulator, [tankIdString, WN8]) =>
isNaN(WN8)
? accumulator
: accumulator + WN8 * careerStats[Number(tankIdString)].battles,
0,
) /
careerWN8sEntries.reduce(
(accumulator, [tankIdString, WN8]) =>
isNaN(WN8)
? accumulator
: accumulator + careerStats[Number(tankIdString)].battles,
0,
);
if (showTotal) {
todayWN8s[0] =
todayWN8sEntries.reduce(
(accumulator, [tankIdString, wn8]) =>
accumulator + wn8 * diffed[Number(tankIdString)].battles,
0,
) /
todayWN8sEntries.reduce(
(accumulator, [tankIdString]) =>
accumulator + diffed[Number(tankIdString)].battles,
0,
);
careerWN8s[0] =
careerWN8sEntries.reduce(
(accumulator, [tankIdString, WN8]) =>
isNaN(WN8)
? accumulator
: accumulator + WN8 * careerStats[Number(tankIdString)].battles,
0,
) /
careerWN8sEntries.reduce(
(accumulator, [tankIdString, WN8]) =>
isNaN(WN8)
? accumulator
: accumulator + careerStats[Number(tankIdString)].battles,
0,
);
}
const rows = await Promise.all(
[
// this code unreadable on god will forget tomorrow no cap
...(order.length === 0 ? [] : [0]),
...(isFinite(limit) ? order.slice(0, limit) : order),
].map(async (tankId, index) => {
const tankStats = diffed[tankId];
const career = careerStats[tankId];
...(order.length === 0 || !showTotal ? [] : [0]),
...(isFinite(cutoff) ? order.slice(0, cutoff) : order),
].map(async (id, index) => {
const tankStats = diffed[id];
const career = careerStats[id];
const tankopediaEntry = (await tankopedia)[id];
return (
<Breakdown.Row
naked={naked}
isListing={tankId !== 0}
minimized={index > 4}
key={tankId}
name={tankId === 0 ? 'Total' : await resolveTankName(tankId)}
isTank={!showTotal || id !== 0}
tankType={tankopediaEntry?.type}
treeType={tankopediaEntry ? await getTreeType(id) : undefined}
minimized={showTotal ? index > maximized : index + 1 > maximized}
key={id}
name={showTotal && id === 0 ? 'Total' : await resolveTankName(id)}
winrate={tankStats.wins / tankStats.battles}
careerWinrate={career.wins / career.battles}
WN8={isNaN(todayWN8s[tankId]) ? undefined : todayWN8s[tankId]}
careerWN8={isNaN(careerWN8s[tankId]) ? undefined : careerWN8s[tankId]}
WN8={isNaN(todayWN8s[id]) ? undefined : todayWN8s[id]}
careerWN8={isNaN(careerWN8s[id]) ? undefined : careerWN8s[id]}
damage={tankStats.damage_dealt / tankStats.battles}
careerDamage={career.damage_dealt / career.battles}
battles={tankStats.battles}
careerBattles={career.battles}
icon={
tankId === 0 ? undefined : (await tankopedia)[tankId]?.images.normal
}
/>
);
}),
@@ -147,7 +155,7 @@ export default async function today(
return naked ? (
<Wrapper naked>
<Breakdown.Root>{rows.slice(1)}</Breakdown.Root>
<Breakdown.Root>{rows}</Breakdown.Root>
</Wrapper>
) : (
<Wrapper>
@@ -161,9 +169,7 @@ export default async function today(
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData[id]?.clan?.emblem_set_id}.png`
: undefined
}
description={`Today's breakdown • ${new Date().toDateString()} • ${
REGION_DOMAIN_NAMES[server]
}`}
description={`Today's breakdown • ${new Date().toDateString()}`}
/>
{rows.length === 0 && <NoData type={NoDataType.BattlesInPeriod} />}
+2 -2
View File
@@ -6,12 +6,12 @@ import { RouteRegistry } from '../server';
export const statsRoute: RouteRegistry = {
route: '/stats/:type/:period',
inProduction: true,
inDevelopment: false,
inDevelopment: true,
async handler(req) {
const type = req.params.type as StatType;
const player = resolvePlayerFromRequest(req);
const period = resolvePeriodFromRequest(player.server, req);
const period = resolvePeriodFromRequest(player.region, req);
return await stats(
type,
+4 -2
View File
@@ -5,14 +5,16 @@ import { RouteRegistry } from '../server';
export const todayRoute: RouteRegistry = {
route: '/today',
inProduction: true,
inDevelopment: false,
inDevelopment: true,
async handler(req) {
const player = resolvePlayerFromRequest(req);
return await today(
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,
);
},
+1 -1
View File
@@ -31,7 +31,7 @@ const app = express();
if (registry.handlesInteraction) return;
res.send(
withAutoRefresh(await normalizeRouteReturnable(returnable), 60 * 1000),
withAutoRefresh(await normalizeRouteReturnable(returnable), 30 * 1000),
);
});
});
+1 -4
View File
@@ -11,8 +11,5 @@
"resolveJsonModule": true,
"jsx": "react-jsx"
},
"include": [
"src/**/*.ts",
"src/components/AllStatsOverview/components/WN8SurroundingStat.tsx"
]
"include": ["src/**/*.ts", "src/components/Tanks/components/Row.tsx"]
}
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more