Compare commits

...
79 Commits
Author SHA1 Message Date
TrèsAbhi 06ae024c2c Merge pull request #46 from tresabhi/1.14.4 2023-10-25 10:29:24 -05:00
TrèsAbhi ce8fd0f22f feat: package version 2023-10-25 10:28:07 -05:00
TrèsAbhi 7dc1aff1dc feat: changelogs 2023-10-25 10:27:56 -05:00
TrèsAbhi a60003127f feat: instructions for streamers 2023-10-25 10:26:35 -05:00
TrèsAbhi 7d1ba58806 feat: updated hero logo 2023-10-20 11:31:59 -05:00
TrèsAbhi 963d71e221 Merge pull request #38 from tresabhi/imgbot 2023-10-20 11:29:08 -05:00
TrèsAbhi 2a804fda1c Merge pull request #44 from tresabhi/dependabot/npm_and_yarn/babel/traverse-7.23.2 2023-10-20 11:28:46 -05:00
TrèsAbhi 597a1a04d2 Merge pull request #45 from tresabhi/1.14.3 2023-10-20 11:26:24 -05:00
TrèsAbhi aec6f49ea1 Update 1.14.2.md 2023-10-20 11:26:13 -05:00
TrèsAbhi e1ad710de5 fix: corrected version number 2023-10-20 11:26:03 -05:00
TrèsAbhi edc8447617 feat: changelogs 2023-10-20 11:24:19 -05:00
TrèsAbhi f726d4f8e8 feat: don't manage queue after task completion 2023-10-20 11:12:06 -05:00
TrèsAbhi 1f333f6b88 fix: manage queue after space available 2023-10-20 11:11:41 -05:00
TrèsAbhi 9b855fca47 fix: fetchBlitz return type 2023-10-20 11:08:22 -05:00
TrèsAbhi c5f433fdf4 feat: removed unnesesary type 2023-10-20 10:57:24 -05:00
TrèsAbhi fd61dae1c1 feat: added queues to fetchBlitz 2023-10-20 10:50:54 -05:00
TrèsAbhi 2c45f9759f fix: removed lie about username inference 2023-10-20 10:29:01 -05:00
TrèsAbhi 0d3261f748 feat: named asia region short name "ASIA" 2023-10-20 10:28:41 -05:00
TrèsAbhi d38e873800 feat: used short region names for username autocomplete 2023-10-20 10:28:20 -05:00
TrèsAbhi ca38914095 feat: chunked images in breakdown 2023-10-20 10:22:12 -05:00
TrèsAbhi ee5beeea45 fix: preserve image order 2023-10-20 10:12:01 -05:00
dependabot[bot] a9d40d9f49 build(deps): bump @babel/traverse from 7.22.20 to 7.23.2
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.22.20 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2023-10-18 13:18:13 +00:00
TrèsAbhi b8be916c18 Merge pull request #43 from tresabhi/1.14.2 2023-10-17 22:08:51 -05:00
TrèsAbhi 24d0313fd3 feat: changelogs 2023-10-17 22:08:39 -05:00
TrèsAbhi c3d2a81327 fix: generic stats name overflow 2023-10-17 22:06:50 -05:00
TrèsAbhi dfeb117663 fix: getClanInfo now uses clan_id 2023-10-17 22:04:27 -05:00
TrèsAbhi 495a830506 Merge pull request #42 from tresabhi/1.14.1 2023-10-17 21:14:13 -05:00
TrèsAbhi a730f67ca5 feat: changelogs 2023-10-17 21:14:30 -05:00
TrèsAbhi 9a3867770d feat: reduced render workers to 2 2023-10-17 21:01:26 -05:00
TrèsAbhi f8a66bb5e7 feat: simpler /link message 2023-10-17 20:59:33 -05:00
TrèsAbhi 4b00e1342e feat: user error discrimination 2023-10-17 20:58:36 -05:00
TrèsAbhi f9e0584f00 fix: memory leak in render threads 2023-10-17 20:45:45 -05:00
TrèsAbhi 900c290e75 Update cd-host.yml 2023-10-16 20:50:40 -05:00
TrèsAbhi 69e1577d18 fix: prisma copy over 2023-10-16 20:44:47 -05:00
TrèsAbhi a6d9044c3a Merge pull request #41 from tresabhi/1.14.0 2023-10-16 20:34:08 -05:00
TrèsAbhi 9e23d60ef1 fix: isolated session functions 2023-10-16 20:33:12 -05:00
TrèsAbhi f12449119c feat: changelog 2023-10-16 20:31:00 -05:00
TrèsAbhi 533bfc4458 fix: clan images 2023-10-16 20:16:23 -05:00
TrèsAbhi 1317c2879c feat: switch to 6 hour hostings 2023-10-16 20:11:19 -05:00
TrèsAbhi f9e51f894a feat: removed sharding as it's not needed for now 2023-10-16 20:10:35 -05:00
TrèsAbhi 6fdd4ba833 feat: render threading 2023-10-16 20:08:08 -05:00
TrèsAbhi 9495ae2336 feat: simpler logging 2023-10-15 15:52:35 -05:00
TrèsAbhi 43f634e198 feat: added sharding 2023-10-15 15:42:36 -05:00
TrèsAbhi ad51be7afc feat: added /breakdown 2023-10-15 15:24:07 -05:00
TrèsAbhi 15b8bfdf8f fix: use extra clan feilds 2023-10-14 19:16:07 -05:00
TrèsAbhi cb8ae47be6 feat: removed tank icons in owned tanks 2023-10-14 19:06:26 -05:00
TrèsAbhi db846c0680 fix: provide clan_id 2023-10-14 18:50:08 -05:00
TrèsAbhi 630a37025e feat: added reset in options 2023-10-14 12:04:11 -05:00
TrèsAbhi d3bbc7f2b0 feat: merge renderers 2023-10-14 11:40:16 -05:00
TrèsAbhi 4e0321ca5f fix: removed unused pallets 2023-10-14 11:38:07 -05:00
TrèsAbhi ec00bdf9de feat: core chunk 8 2023-10-14 11:36:48 -05:00
TrèsAbhi 01dd7ad9c6 feat: core chunk 7 2023-10-14 11:36:10 -05:00
TrèsAbhi d726005c38 feat: core chunk 6 2023-10-14 11:32:02 -05:00
TrèsAbhi 4f3e868367 feat: added mightnight leaderboard cache 2023-10-14 11:28:41 -05:00
TrèsAbhi ed8bab7df8 feat: core chunk 5 2023-10-14 11:25:42 -05:00
TrèsAbhi 9bdb14e13a feat: core chunk 4 2023-10-14 11:15:42 -05:00
TrèsAbhi b827f7f730 feat: swap core and _core 2023-10-14 11:06:03 -05:00
TrèsAbhi 07e3660388 feat: core chunk 3 2023-10-14 10:58:48 -05:00
TrèsAbhi 970ab9004e feat: cached archived ratings 2023-10-14 10:55:00 -05:00
TrèsAbhi 862445259e feat: core chunk 2 2023-10-14 10:52:05 -05:00
TrèsAbhi e96538bae0 feat: core chunk 1 2023-10-12 13:00:33 -05:00
TrèsAbhi 7447ccc487 fix: evolution refresh button 2023-10-12 12:07:45 -05:00
TrèsAbhi bcd3a00f91 feat: updated /evolution 2023-10-12 11:56:11 -05:00
TrèsAbhi 8c3675546f feat: sorted commands in /about commands 2023-10-12 11:45:43 -05:00
TrèsAbhi ec64dab720 fix: use webp images in layout 2023-10-11 21:26:33 -05:00
TrèsAbhi db976c4ae9 feat: added future features 👀 2023-10-11 20:18:51 -05:00
TrèsAbhi 0db96795b3 fix: a few type erros 2023-10-11 19:04:58 -05:00
TrèsAbhi 59bae96c1b feat: change tier weights to arabic numerals 2023-10-11 19:01:00 -05:00
TrèsAbhi d062a0da1b feat: updated full stats 2023-10-11 18:57:51 -05:00
TrèsAbhi d2668974f7 feat: code split a lot 2023-10-11 18:42:36 -05:00
TrèsAbhi 78e4c82f20 feat: simpler subcommands 2023-10-11 18:26:35 -05:00
TrèsAbhi f965452c5d feat: re-added manual username entry 2023-10-10 21:45:00 -05:00
TrèsAbhi be644bb3d9 feat: refined welcome 2023-10-10 21:37:17 -05:00
TrèsAbhi 97cc9509c0 feat: updated welcome message 2023-10-10 21:33:11 -05:00
TrèsAbhi 6dc7a10c2e feat: completed link 2023-10-10 21:20:01 -05:00
TrèsAbhi 19298b9ad6 feat: link command basic functionaility 2023-10-10 20:33:24 -05:00
TrèsAbhi 97d427a6a8 feat: better guild command handling 2023-10-08 18:53:21 -05:00
TrèsAbhi bff6eb0503 feat: simplified no ongoing ratings season 2023-10-08 17:53:24 -05:00
ImgBotApp e3864b86a4 [ImgBot] Optimize images
*Total -- 21,554.66kb -> 14,287.64kb (33.71%)

/docs/public/assets/images/hero.png -- 8.27kb -> 4.13kb (50.07%)
/public/assets/banners/tankopedia.png -- 2,422.26kb -> 1,285.27kb (46.94%)
/public/assets/banners/profile.png -- 2,004.61kb -> 1,143.78kb (42.94%)
/public/assets/banners/inactive.png -- 2,675.88kb -> 1,700.96kb (36.43%)
/public/assets/banners/ratings.png -- 3,878.81kb -> 2,495.66kb (35.66%)
/public/assets/banners/more.png -- 8,407.57kb -> 5,532.24kb (34.2%)
/public/assets/banners/session.png -- 2,157.27kb -> 2,125.60kb (1.47%)

Signed-off-by: ImgBotApp <[email protected]>
2023-10-01 22:45:47 +00:00
156 changed files with 2656 additions and 2199 deletions

No files matched your search

+2 -2
View File
@@ -2,7 +2,7 @@ name: CD Host
on:
schedule:
- cron: '0 */5 * * *'
- cron: '0 */6 * * *'
workflow_dispatch:
concurrency:
@@ -29,4 +29,4 @@ jobs:
unzip bot.zip
- name: Run bot
run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} node --enable-source-maps --trace-warnings index.cjs
run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} node --enable-source-maps index.cjs
+4
View File
@@ -5,6 +5,7 @@
"bepaint",
"blitzstars",
"cleandist",
"cockroackdb",
"customperiod",
"deburr",
"diacriticless",
@@ -14,6 +15,8 @@
"kranvagn",
"Leaderboard",
"leaderboards",
"Oopsy",
"outdir",
"outfile",
"ownedtanks",
"playerachievements",
@@ -42,6 +45,7 @@
"wargaming",
"webp",
"winrate",
"wotblitz",
"zustand"
],
"deno.enable": false,
+3 -1
View File
@@ -8,5 +8,7 @@
"sklld_peasant_role": "896878529512370228",
"tres_guild_id": "734786591205359697",
"tres_log_channel": "1109262265850544148"
"tres_log_channel": "1109262265850544148",
"test_guild_id": "1124729294829142116"
}
+45
View File
@@ -0,0 +1,45 @@
# Blitzkrieg 1.14.0
Elegance has never been met like this before
## Features
- Added `/link`
- Unlike `/verify`, `/link` links you Discord and Blitz accounts across all servers
- This command does not change your nickname
- `/link` now simply displays a deprecation message
- Usernames can still be inputted per-command to override your linked account
- `/stats` and `/full-stats` are now way simpler
- `/stats <type> <period>` has been replaced with `/stats <30|60|90|...>` (likewise, `/full-stats`)
- These commands now have filters without having to opt into `multi-tank`
- Added `/breakdown`
- Unlike `/today`, `/breakdown` lets you select any period of time
- `/breakdown` now simply displays a deprecation message
- `/about commands` are now sorted alphabetically
- Added a `/reset` button to the options dropdown and embed context-menu
## Changes
- Simplified no ongoing ratings season edge case
- Updated welcome message for Skilled server to use `/link` and other up-to-date information
- Tier weights are now reported in Arabic numerals instead of Roman
- Removed tank images from `/owned-tanks`
- Wargaming messed up their API and almost no tanks have any images anymore
## Fixes
- Banners now use a more economical `.webp` format just like the home page
- Removed unused color palettes
## Technical changes
- Guild commands are now handled more efficiently
- Removed nickname-based Blitz username inference
- Code split a lot of common functionalities
- Re-arranged `core` folder
- Added midnight and latest ratings leaderboard caches
- Merged renderers into their primary user files
- Added sharding
- Disabled for now but good for future proofing
- Switched to a way cleaner logging schema
- Hosting is now done in 6 hour periods instead of 5
+17
View File
@@ -0,0 +1,17 @@
# Blitzkrieg 1.14.1
Oopsy daisy, a memory leak!
## Changes
- Simplified message for users to use `/link`
## Fixes
- Fixed a memory leak in the render thread manager
## Technical changes
- Added `UserError` extending `Error`
- This lets the bot avoid spamming the debug console for mistakes users make
- Reduced render threads to 2
+8
View File
@@ -0,0 +1,8 @@
# Blitzkrieg 1.14.2
A late night hot fix for my homies
## Fixes
- Clan info is now fetched using `clan_id` instead of `account_id`
- Generic stats row names are now bounded by a max width
+24
View File
@@ -0,0 +1,24 @@
# Blitzkrieg 1.14.3
Pull your brush out and start polishing.
## Changes
![HisRoyalFatness' career breakdown](https://i.imgur.com/GhgMTnS.png)
- Rows in `/breakdown` are now chunked into `8` per image
- This is true unless the number of pages exceeds `10` in which case the rows are even distributed across all `10` pages
- `10` is the maximum number of images that Discord allows
- Short region names are used in username autocomplete instead of their full names
- Example: "North America" is "NA" now
## Fixes
- Removed reference to username inference in the username option description
- Removed an unnecessary type `asd` that did absolutely nothing used nowhere
## Technical changes
- Image orders are now preserved even if processed in parallel and all completed out of order
- `fetchBlitz` is now throttled to `10` requests per second
- Request overflows are managed by queueing
+7
View File
@@ -0,0 +1,7 @@
# Blitzkrieg 1.14.4
Streamers get some knowledge today
## Changes
- Added a video in the embed button prompt
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 183 KiB

+1 -1
View File
@@ -2,6 +2,6 @@
"main": "server.js",
"type": "module",
"scripts": {
"start": "node --enable-source-maps --trace-warnings server.cjs"
"start": "node --enable-source-maps server.cjs"
}
}
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "blitzkrieg",
"version": "1.13.3",
"version": "1.14.4",
"description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts",
"type": "module",
@@ -12,7 +12,7 @@
"dev:bot": "yarn build:bot && yarn start:bot",
"dev:docs": "vitepress dev docs",
"dev:website": "next dev",
"start:bot": "node --enable-source-maps --trace-warnings dist/bot/index.cjs",
"start:bot": "node --enable-source-maps dist/bot/index.cjs",
"start:website": "next start",
"host:docs": "vitepress preview docs",
"publish:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard --publish",
@@ -26,6 +26,7 @@
"license": "MIT",
"dependencies": {
"@octokit/rest": "^20.0.1",
"@prisma/client": "^5.4.1",
"@radix-ui/colors": "^3.0.0-rc.4",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/themes": "^1.1.2",
@@ -62,6 +63,7 @@
"compare-versions": "^6.1.0",
"knip": "^2.27.1",
"prettier": "^3.0.3",
"prisma": "^5.4.1",
"typescript": "^5.2.2"
}
}
@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "discord_blitz" (
"discord" INT8 NOT NULL,
"region" STRING NOT NULL,
"blitz" INT4 NOT NULL,
CONSTRAINT "discord_blitz_pkey" PRIMARY KEY ("discord")
);
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "cockroachdb"
+14
View File
@@ -0,0 +1,14 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "cockroachdb"
url = env("DISCORD_BLITZ_DB")
}
model discord_blitz {
discord BigInt @id
region String
blitz Int
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 MiB

After

Width:  |  Height:  |  Size: 5.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

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