Compare commits

...
106 Commits
Author SHA1 Message Date
TrèsAbhi d8a5f57787 feat: changelogs 2024-02-06 18:26:15 -06:00
TrèsAbhi 13651650ab fix: delta by time 2024-02-06 18:24:29 -06:00
TrèsAbhi 9644692084 Merge pull request #65 from tresabhi/1.15.12 2024-02-05 09:35:56 -06:00
TrèsAbhi b52c3c6c68 feat: updates 2024-02-05 09:36:03 -06:00
TrèsAbhi c1bd91a7f9 Merge pull request #63 from tresabhi/1.15.11 2024-01-21 18:48:12 -06:00
TrèsAbhi 1d65ff3e0c fix: type 2024-01-21 18:46:38 -06:00
TrèsAbhi f7fac5ad26 feat: chagelogs 2024-01-21 18:44:58 -06:00
TrèsAbhi e45f055493 feat: permissions command 2024-01-21 18:43:07 -06:00
TrèsAbhi 8946f115c0 feat: no embed errors 2024-01-21 18:29:32 -06:00
TrèsAbhi 6094875ffb discord tokens... for some reason? 2024-01-19 21:52:50 -06:00
TrèsAbhi 0644c9a67f Merge pull request #62 from tresabhi/1.15.10 2024-01-19 21:48:33 -06:00
TrèsAbhi c91ab29a23 feat: disabled tankopedia 2024-01-19 21:46:36 -06:00
TrèsAbhi 8f4e33fd5a feat: changelogs 2024-01-19 21:45:23 -06:00
TrèsAbhi 8cf5012602 feat: full region names in /link 2024-01-19 21:35:46 -06:00
TrèsAbhi 23a23e36bb feat: /today alias for /breakdown today 2024-01-19 21:32:46 -06:00
TrèsAbhi 0480445bae feat: easier /link command 2024-01-19 20:53:27 -06:00
TrèsAbhi ae07b8de4c feat: center align text when no image 2024-01-19 20:47:46 -06:00
TrèsAbhi 4c746cbb86 feat: improved error messages 2024-01-19 16:07:15 -06:00
TrèsAbhi 890a4d142b feat: simplified /ratings command 2024-01-19 16:02:03 -06:00
TrèsAbhi ff9bda29cf fix: archive league neighbors 2024-01-19 15:28:02 -06:00
TrèsAbhi 5ca9e29f3d fix: percentile indicator contrast 2024-01-19 15:22:53 -06:00
TrèsAbhi c804a3d564 feat: update version number 2024-01-19 09:53:42 -06:00
TrèsAbhi daa549e650 Merge pull request #61 from tresabhi/1.15.9 2024-01-01 14:26:13 -06:00
TrèsAbhi fe562915cb feat: docs 2024-01-01 14:24:45 -06:00
TrèsAbhi fff42b61e1 fix: deleted ratings players 2024-01-01 14:22:39 -06:00
TrèsAbhi 90ee71c9ca fix: archived season number 2024-01-01 14:19:13 -06:00
TrèsAbhi 3f264d6a49 feat: auto restart every 5 hours 2023-12-23 18:31:45 -06:00
TrèsAbhi 61eab0bca8 feat: update docs image 2023-12-15 22:00:20 -06:00
TrèsAbhi 0a19157dbb Merge branch '1.16.0' into main 2023-12-13 12:08:55 -06:00
TrèsAbhi 634bd2ea07 feat: shell icons and modes 2023-12-12 23:35:08 -06:00
TrèsAbhi f8f88ff8f2 fix: types 2023-12-12 18:11:55 -06:00
TrèsAbhi 8a2dc24f7e feat: more ui 2023-12-12 16:40:22 -06:00
TrèsAbhi dcc3ab6e4e feat: module ui 2023-12-12 15:12:09 -06:00
TrèsAbhi dabeab8612 feat: gun defs 2023-12-12 14:14:26 -06:00
TrèsAbhi 66dbc5b2ed feat: more defs 2023-12-12 13:40:58 -06:00
TrèsAbhi 2e2ab9b2d9 fix: added binary start padding 2023-12-12 12:01:50 -06:00
TrèsAbhi 662e0bb9ce feat: optimizations 2023-12-11 11:21:40 -06:00
TrèsAbhi 3f40e94235 feat: cleanup x2 2023-12-10 17:22:40 -06:00
TrèsAbhi 483b625f2d feat: cleanup 2023-12-10 17:14:05 -06:00
TrèsAbhi d18cf625d0 feat: final compatibility 2023-12-10 17:03:01 -06:00
TrèsAbhi dc01eff812 feat: ka 0xff02 handling 2023-12-10 11:57:21 -06:00
TrèsAbhi 5471c7fa32 feat: unconfirmed types 2023-12-10 11:51:54 -06:00
TrèsAbhi 83e28b4dac feat: move versions x2 2023-12-09 19:05:46 -06:00
TrèsAbhi af95897bde feat: move versions 2023-12-09 18:38:24 -06:00
TrèsAbhi 4c2f6bea25 feat: uhh 2023-12-09 15:56:25 -06:00
TrèsAbhi fdf77a43cb feat: sc2 complete 2023-12-08 19:11:40 -06:00
TrèsAbhi f1855a569a feat: removed sub values 2023-12-08 18:45:57 -06:00
TrèsAbhi a0434b6ed2 feat: more sc2 x2 2023-12-08 18:32:11 -06:00
TrèsAbhi 365c714213 feat: more sc2 2023-12-08 18:14:33 -06:00
TrèsAbhi 875abf4d32 feat: sc2 start 2023-12-08 11:36:54 -06:00
TrèsAbhi 6b9bf76da2 feat: model reconstruction 2023-12-08 11:06:56 -06:00
TrèsAbhi 610c75d274 feat: float 2023-12-07 23:36:46 -06:00
TrèsAbhi 81826d0549 feat: more data driven 2023-12-07 22:41:49 -06:00
TrèsAbhi 8caf4069dc feat: more things 2023-12-07 22:13:54 -06:00
TrèsAbhi 6c496e616d feat: scg tests 2023-12-07 12:58:54 -06:00
TrèsAbhi 3d5dd54ec0 feat: start of tank page 2023-12-06 17:58:26 -06:00
TrèsAbhi e7bfe81940 feat: bounded filters 2023-12-06 16:43:36 -06:00
TrèsAbhi fb9c04c779 feat: even cleaner filters 2023-12-06 12:48:19 -06:00
TrèsAbhi 84a1af8100 feat: cleaner filters 2023-12-06 12:40:43 -06:00
TrèsAbhi b804e60bac fix: jumping navbar 2023-12-06 11:33:23 -06:00
TrèsAbhi 604e751bf4 feat: test tank filters 2023-12-06 10:34:43 -06:00
TrèsAbhi 2ce083222e fix: tighter all targets integration 2023-12-06 10:19:13 -06:00
TrèsAbhi 9a44b1fcfb feat: re-added rest of the asset gens 2023-12-06 10:17:42 -06:00
TrèsAbhi 9df47777d0 feat: build assets 2023-12-05 19:14:53 -06:00
TrèsAbhi aa78bc4045 feat: modernized 2023-12-05 18:05:14 -06:00
TrèsAbhi 4bfe34ba53 feat: read dvpl 2023-12-05 17:59:58 -06:00
TrèsAbhi ad0fc69596 feat: move tankopedia to tankDefinitions 2023-12-05 17:36:18 -06:00
TrèsAbhi cfe71e15a4 feat: frosty navbar 2023-12-04 19:46:53 -06:00
TrèsAbhi bf7a319770 feat: better navbar 2023-12-04 19:27:23 -06:00
TrèsAbhi cadc84b32f feat: nation filters 2023-12-04 19:10:26 -06:00
TrèsAbhi 7bde92c7f1 feat: update to new asset format 2023-12-04 18:48:52 -06:00
TrèsAbhi 965d36cdc5 feat: remove more to... array funcs 2023-12-04 18:45:38 -06:00
TrèsAbhi f8ef0063e4 fix: removed toSorted 2023-12-04 18:40:57 -06:00
TrèsAbhi fb4e00afc4 feat: tier display 2023-12-04 18:40:24 -06:00
TrèsAbhi 6ed9a191b9 feat: generalized page turner 2023-12-04 12:39:14 -06:00
TrèsAbhi b503f4fa8f feat: more filters 2023-12-04 12:35:27 -06:00
TrèsAbhi 7020e5641f feat: tier filters 2023-12-04 11:47:33 -06:00
TrèsAbhi 5b4601b671 feat: persistent sorting 2023-12-04 10:25:54 -06:00
TrèsAbhi 3e952faad3 feat: correct flags 2023-12-04 10:13:14 -06:00
TrèsAbhi c51abc1c64 feat: :) 2023-12-03 22:11:37 -06:00
TrèsAbhi c7e2780d17 feat: i am having too much fun 2023-12-03 22:09:08 -06:00
TrèsAbhi 28e2139683 feat: even coooooooooler animations 2023-12-03 21:44:58 -06:00
TrèsAbhi 20c839394a fix: page readjusting on search 2023-12-03 19:51:55 -06:00
TrèsAbhi c5e1a7d6ab feat: page turner 2023-12-03 19:45:43 -06:00
TrèsAbhi 579d7570af feat: better sizing 2023-12-03 18:26:48 -06:00
TrèsAbhi 1d1d751d43 fix: missing tank types 2023-12-03 18:16:59 -06:00
TrèsAbhi 86664b5491 feat: updated dependencies 2023-12-03 18:16:49 -06:00
TrèsAbhi b7e9898163 Merge pull request #57 from tresabhi/main 2023-12-03 10:53:29 -06:00
TrèsAbhi d1fb5da002 feat: flags path 2023-12-03 09:56:08 -06:00
TrèsAbhi 17935fbe89 feat: better contrast 2023-12-03 09:55:57 -06:00
TrèsAbhi 48578407db feat: icons 2023-12-01 12:36:31 -06:00
TrèsAbhi bda0c4ef1b feat: animations on hove 2023-12-01 12:23:17 -06:00
TrèsAbhi 7ea5e444b3 feat: better icons 2023-11-30 18:00:48 -06:00
TrèsAbhi bef362935a feat: flags 2023-11-30 12:59:37 -06:00
TrèsAbhi f3144be382 feat: tankopedia website 2023-11-30 12:21:20 -06:00
TrèsAbhi 818ee1d447 feat: use userstring 2023-11-30 12:21:15 -06:00
TrèsAbhi 67e45ada85 feat: use short tank name 2023-11-29 12:20:59 -06:00
TrèsAbhi bdc7132337 feat: use new tankopedia 2023-11-29 12:17:21 -06:00
TrèsAbhi c5214f8d89 Merge pull request #55 from tresabhi/main 2023-11-28 10:19:47 -06:00
TrèsAbhi 6910f3c95e feat: better multiple file handling 2023-11-27 17:50:02 -06:00
TrèsAbhi e23f898c5f feat: assets writing 2023-11-27 16:52:38 -06:00
TrèsAbhi 3cf81afc4a feat: tree type 2023-11-27 12:26:51 -06:00
TrèsAbhi ca463a0f0e fix: removed dist package 2023-11-27 12:19:20 -06:00
TrèsAbhi a76572ebe3 Merge pull request #53 from tresabhi/main 2023-11-27 12:17:35 -06:00
TrèsAbhi 5bf83da726 feat: ratings leaderboard efficiency plot script 2023-11-20 13:16:44 -06:00
TrèsAbhi 393da234e4 feat: tankopedia images 2023-11-16 12:57:46 -06:00
101 changed files with 6592 additions and 2285 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@ name: CD Host
on: on:
schedule: schedule:
- cron: '0 */6 * * *' - cron: '0 */5 * * *'
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
+1 -1
View File
@@ -35,4 +35,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=asia --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} yarn publish:ratingsleaderboard --region=asia --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
+1 -1
View File
@@ -35,4 +35,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=eu --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} yarn publish:ratingsleaderboard --region=eu --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
+1 -1
View File
@@ -35,4 +35,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} yarn publish:ratingsleaderboard --region=na --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} yarn publish:ratingsleaderboard --region=na --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
+24
View File
@@ -1,40 +1,61 @@
{ {
"cSpell.words": [ "cSpell.words": [
"AABBOX",
"Abhi", "Abhi",
"accountinfo", "accountinfo",
"alphatest",
"apcr",
"atgm",
"bepaint", "bepaint",
"BINORMAL",
"blitzstars", "blitzstars",
"cleandist", "cleandist",
"cockroackdb", "cockroackdb",
"corsproxy", "corsproxy",
"CUBETEXCOORD",
"customperiod", "customperiod",
"deburr", "deburr",
"decalmask",
"diacriticless", "diacriticless",
"dvpl",
"DVPLCRC",
"Everytime", "Everytime",
"FASTNAME",
"fullstats", "fullstats",
"fuzzysort", "fuzzysort",
"gamemode",
"haha",
"immer", "immer",
"JOINTINDEX",
"JOINTWEIGHT",
"kranvagn", "kranvagn",
"krieg", "krieg",
"Leaderboard", "Leaderboard",
"leaderboards", "leaderboards",
"MURICA", "MURICA",
"normalmap",
"Oopsy", "Oopsy",
"outdir", "outdir",
"outfile", "outfile",
"ownedtanks", "ownedtanks",
"playerachievements", "playerachievements",
"playerinfo", "playerinfo",
"Promisable",
"ratingsleaderboard", "ratingsleaderboard",
"ratingsplot",
"researchable",
"Resvg", "Resvg",
"roboto", "roboto",
"SCPG",
"searchclans", "searchclans",
"searchplayers", "searchplayers",
"searchtanks", "searchtanks",
"SKLLD", "SKLLD",
"Smri", "Smri",
"Specularity",
"startcase", "startcase",
"statsfull", "statsfull",
"steamapps",
"Stringifying", "Stringifying",
"strvk", "strvk",
"subnation", "subnation",
@@ -42,6 +63,7 @@
"tankopedia", "tankopedia",
"tankopedias", "tankopedias",
"tankstats", "tankstats",
"TEXCOORD",
"tresabhi", "tresabhi",
"Unicum", "Unicum",
"Unregistering", "Unregistering",
@@ -52,6 +74,8 @@
"webp", "webp",
"winrate", "winrate",
"wotblitz", "wotblitz",
"XMLDVPL",
"YAMLDVPL",
"zustand" "zustand"
], ],
"deno.enable": false, "deno.enable": false,
+1 -1
View File
@@ -20,4 +20,4 @@ Go to [application directory for Blitzkrieg](https://discord.com/application-dir
### https://tresabhi.github.io/blitzkrieg/ ### https://tresabhi.github.io/blitzkrieg/
![Blitzkrieg documentation website](https://i.imgur.com/mCZCZEI.png) ![Blitzkrieg documentation website](https://i.imgur.com/TOt5fMx.png)
+23
View File
@@ -0,0 +1,23 @@
# Blitzkrieg 1.15.10
The 10th patch for the `1.15` update is here, breaking the record of the most patches for a single update!
## Features
- Added `/today`
- This is a direct alias for `/breakdown today`
- Drastically simplified ratings command
- `/ratings current` and `/ratings archive` have been now merged
- Where the data is fetched from is now decided directly from the chosen season number
- Simplified many error messages
- Title bar description text is now centered when there is no image
- It always felt awkward when there was no image on the left but the left still aligned to the right
- Removed `/link`'s requirement to select a username explicitly from the search results
- Full names for regions are now used in the `/link` command
- So "NA" turns into "North America"
## Fixes
- Fixed contrast issues with WN8 percentile colors behind white text
- Archived ratings neighbors now functions once again
- Disabled tankopedia; this feature isn't finished yet and the changes were accidentally leaked
+11
View File
@@ -0,0 +1,11 @@
# Blitzkrieg 1.15.11
There's an easter egg somewhere in this update 🤔
## Features
- Added `/permissions` which lists all permissions required to run the bot and checks if the bot has them or not
## Changes
- Error messages now do no use embeds, instead utilize simple headings and paragraphs
+8
View File
@@ -0,0 +1,8 @@
# Blitzkrieg 1.15.12
Sometimes embeds need love too.
## Changes
- Session embeds are now translucent by default
- Session embed titles are now the username of the player by default
+7
View File
@@ -0,0 +1,7 @@
# Blitzkrieg 1.15.13
I have run out of things to say here. This patch fixes like 1 thing haha.
# Technical Fixes
- Tank deltas are now filtered by last battle time
+13
View File
@@ -0,0 +1,13 @@
# Blitzkrieg 1.15.9
~~Curiosity killed the cat~~ Ratings killed the Blitzkrieg
## Fixes
- Deleted ratings players are now handled properly
## Technical Fixes
- Latest archived season number
- Is now cached properly
- Is now fetched in a chaining fashion correctly
-7
View File
@@ -1,7 +0,0 @@
# Permissions
The following Discord permissions are required by the bot to ensure full functionality:
- Manage nicknames
- Read messages / view channels
- Attach files
+1 -1
View File
@@ -1,6 +1,6 @@
import { createVanillaExtractPlugin } from '@vanilla-extract/next-plugin'; import { createVanillaExtractPlugin } from '@vanilla-extract/next-plugin';
const withVanillaExtract = createVanillaExtractPlugin(); const withVanillaExtract = createVanillaExtractPlugin({ tests: /\.css$/ });
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
-7
View File
@@ -1,7 +0,0 @@
{
"main": "server.js",
"type": "module",
"scripts": {
"start": "node --enable-source-maps server.cjs"
}
}
+19 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "blitzkrieg", "name": "blitzkrieg",
"version": "1.15.8", "version": "1.15.13",
"description": "🎉 Tools for everything World of Tanks Blitz", "description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts", "main": "src/server.ts",
"type": "module", "type": "module",
@@ -9,7 +9,9 @@
"build:website": "next build", "build:website": "next build",
"build:docs": "vitepress build docs", "build:docs": "vitepress build docs",
"build:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard", "build:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard",
"build:tankopedia": "vite-node --script scripts/buildTankopedia", "build:assets": "vite-node --script scripts/buildAssets",
"build:test": "vite-node --script scripts/buildTest",
"build:ratingsplot": "vite-node --script scripts/buildRatingsPlot",
"dev:bot": "yarn build:bot && yarn start:bot", "dev:bot": "yarn build:bot && yarn start:bot",
"dev:docs": "vitepress dev docs", "dev:docs": "vitepress dev docs",
"dev:website": "next dev", "dev:website": "next dev",
@@ -27,11 +29,14 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@octokit/plugin-throttling": "^8.1.3",
"@octokit/rest": "^20.0.1", "@octokit/rest": "^20.0.1",
"@prisma/client": "^5.4.1", "@prisma/client": "^5.4.1",
"@radix-ui/colors": "^3.0.0-rc.4", "@radix-ui/colors": "^3.0.0-rc.4",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/themes": "^1.1.2", "@radix-ui/themes": "^2.0.1",
"@react-three/drei": "^9.90.1",
"@react-three/fiber": "^8.15.12",
"@resvg/resvg-js": "^2.4.1", "@resvg/resvg-js": "^2.4.1",
"@vanilla-extract/css": "^1.13.0", "@vanilla-extract/css": "^1.13.0",
"bepaint": "^0.1.0-beta.3", "bepaint": "^0.1.0-beta.3",
@@ -40,20 +45,23 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"esbuild": "^0.19.3", "esbuild": "^0.19.3",
"express": "^4.18.2", "express": "^4.18.2",
"false": "^0.0.4",
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",
"immer": "^10.0.2", "immer": "^10.0.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"markdown-escape": "^2.0.0", "markdown-escape": "^2.0.0",
"next": "^13.5.2", "next": "^14.0.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-fuzzysort": "^1.0.1", "react-fuzzysort": "^1.0.1",
"satori": "^0.10.8", "satori": "^0.10.8",
"sharp": "^0.33.0",
"three": "^0.159.0",
"vite-node": "^0.34.5", "vite-node": "^0.34.5",
"vitepress": "^1.0.0-rc.20", "vitepress": "^1.0.0-rc.20",
"vitepress-plugin-auto-sidebar": "^1.1.0", "vitepress-plugin-auto-sidebar": "^1.1.0",
"xml-js": "^1.6.11", "yaml": "^2.3.4",
"zustand": "^4.4.1" "zustand": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
@@ -63,12 +71,17 @@
"@types/express": "^4.17.18", "@types/express": "^4.17.18",
"@types/js-yaml": "^4.0.8", "@types/js-yaml": "^4.0.8",
"@types/lodash": "^4.14.199", "@types/lodash": "^4.14.199",
"@types/lz4": "^0.6.4",
"@types/markdown-escape": "^1.1.0", "@types/markdown-escape": "^1.1.0",
"@types/node": "^20.6.5", "@types/node": "^20.6.5",
"@types/react": "^18.2.22", "@types/react": "^18.2.22",
"@types/three": "^0.159.0",
"@vanilla-extract/next-plugin": "^2.3.1", "@vanilla-extract/next-plugin": "^2.3.1",
"compare-versions": "^6.1.0", "compare-versions": "^6.1.0",
"knip": "^2.27.1", "crc": "^4.3.2",
"fast-xml-parser": "^4.3.2",
"knip": "^3.4.0",
"lz4": "^0.6.5",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"prisma": "^5.4.1", "prisma": "^5.4.1",
"typescript": "^5.2.2" "typescript": "^5.2.2"
+544
View File
@@ -0,0 +1,544 @@
import { config } from 'dotenv';
import { existsSync } from 'fs';
import { mkdir, readdir, rm } from 'fs/promises';
import { argv } from 'process';
import sharp from 'sharp';
import { TankType } from '../src/components/Tanks';
import { NATION_IDS } from '../src/constants/nations';
import { SCPGStream } from '../src/core/blitz/SCPGStream';
import { readBase64DVPL } from '../src/core/blitz/readBase64DVPL';
import { readDVPLFile } from '../src/core/blitz/readDVPLFile';
import { readStringDVPL } from '../src/core/blitz/readStringDVPL';
import { readXMLDVPL } from '../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../src/core/blitz/readYAMLDVPL';
import { toUniqueId } from '../src/core/blitz/toUniqueId';
import commitMultipleFiles, {
FileChange,
} from '../src/core/blitzkrieg/commitMultipleFiles';
import { BlitzkriegGunDefinitions } from '../src/core/blitzkrieg/definitions/guns';
import {
BlitzkriegShellDefinitions,
ShellType,
} from '../src/core/blitzkrieg/definitions/shells';
import {
BlitzkriegTankDefinitions,
Tier,
} from '../src/core/blitzkrieg/definitions/tanks';
import { BlitzkriegTurretDefinitions } from '../src/core/blitzkrieg/definitions/turrets';
config();
// WARNING! MOST OF THESE TYPES ARE NOT EXHAUSTIVE!
interface VehicleDefinitionList {
[key: string]: {
id: number;
userString: string;
shortUserString?: string;
description: string;
price: number | { gold: ''; '#text': number };
sellPrice?: { gold: ''; '#text': number };
enrichmentPermanentCost: number;
notInShop?: boolean;
tags: string;
level: number;
combatRole?: Record<string, string>;
configurationModes: string;
};
}
interface TurretDefinitionsList {
nextAvailableId: number;
ids: Record<string, number>;
}
interface GunDefinitionsList {
nextAvailableId: number;
ids: Record<string, number>;
shared: {
[key: string]: {
userString: string;
tags: string;
level: number;
pitchLimits: string;
burst?: { count: number; rate: number };
clip?: { count: number; rate: number };
shots: {
[key: string]: {
speed: number;
piercingPower: string;
};
};
};
};
}
type ShellKind =
| 'ARMOR_PIERCING'
| 'ARMOR_PIERCING_CR'
| 'HIGH_EXPLOSIVE'
| 'HOLLOW_CHARGE';
type ShellDefinitionsList = Record<
string,
{
id: number;
userString: string;
icon: string;
kind: ShellKind;
caliber: number;
damage: { armor: number; devices: number };
normalizationAngle: number;
ricochetAngle: number;
}
> & {
icons: Record<string, string>;
};
interface VehicleDefinitions {
turrets0: {
[key: string]: {
userString: number;
level: number;
yawLimits: string | string[];
guns: {
[key: string]: {
maxAmmo: number;
pitchLimits?: string | string[];
};
};
};
};
}
interface Strings {
[key: string]: string;
}
type BlitzTankType = 'AT-SPG' | 'lightTank' | 'mediumTank' | 'heavyTank';
interface TankParameters {
resourcesPath: {
smallIconPath: string;
bigIconPath: string;
blitzModelPath: string;
};
}
const blitzTankTypeToBlitzkrieg: Record<BlitzTankType, TankType> = {
'AT-SPG': 'tank_destroyer',
lightTank: 'light',
mediumTank: 'medium',
heavyTank: 'heavy',
};
const blitzShellKindToBLitzkrieg: Record<ShellKind, ShellType> = {
ARMOR_PIERCING: 'ap',
ARMOR_PIERCING_CR: 'ap_cr',
HIGH_EXPLOSIVE: 'he',
HOLLOW_CHARGE: 'hc',
};
const DATA =
'C:/Program Files (x86)/Steam/steamapps/common/World of Tanks Blitz/Data';
const LANGUAGE = 'en';
const DOI = {
vehicleDefinitions: 'XML/item_defs/vehicles',
strings: 'Strings',
tankParameters: '3d/Tanks/Parameters',
smallIcons: 'Gfx/UI/BattleScreenHUD/SmallTankIcons',
bigIcons: 'Gfx/UI/BigTankIcons',
flags: 'Gfx/Lobby/flags',
'3d': '3d',
bigShellIcons: 'Gfx/Shared/tank-supply/ammunition/big',
};
const allTargets = argv.includes('--all-targets');
const targets = argv
.find((argument) => argument.startsWith('--target'))
?.split('=')[1]
.split(',');
if (!targets && !allTargets) throw new Error('No target(s) specified');
if (allTargets || targets?.includes('definitions')) {
console.log('Building tank definitions...');
const tankDefinitions: BlitzkriegTankDefinitions = {};
const turretDefinitions: BlitzkriegTurretDefinitions = {};
const gunDefinitions: BlitzkriegGunDefinitions = {};
const shellDefinitions: BlitzkriegShellDefinitions = {};
const nations = await readdir(`${DATA}/${DOI.vehicleDefinitions}`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
const strings = await readYAMLDVPL<Strings>(
`${DATA}/${DOI.strings}/${LANGUAGE}.yaml.dvpl`,
);
await Promise.all(
nations.map(async (nation) => {
const tankList = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/list.xml.dvpl`,
);
const turretList = await readXMLDVPL<{
root: TurretDefinitionsList;
}>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/components/turrets.xml.dvpl`,
);
const gunList = await readXMLDVPL<{
root: GunDefinitionsList;
}>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/components/guns.xml.dvpl`,
);
const shellList = await readXMLDVPL<{
root: ShellDefinitionsList;
}>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/components/shells.xml.dvpl`,
);
for (const tankKey in tankList.root) {
const tank = tankList.root[tankKey];
const tankDefinition = await readXMLDVPL<{ root: VehicleDefinitions }>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/${tankKey}.xml.dvpl`,
);
const tankId = toUniqueId(nation, tank.id);
const tankTags = tank.tags.split(' ');
const tankTurrets = Object.keys(tankDefinition.root.turrets0).map(
(turretKey) => {
const turret = tankDefinition.root.turrets0[turretKey];
const turretId = toUniqueId(nation, turretList.root.ids[turretKey]);
const turretGuns = Object.keys(turret.guns).map((gunKey) => {
const turretGunEntry = turret.guns[gunKey];
const gunId = toUniqueId(nation, gunList.root.ids[gunKey]);
const gun = gunList.root.shared[gunKey];
const pitchLimitsRaw =
turretGunEntry.pitchLimits ?? gun.pitchLimits;
const gunShells = Object.keys(gun.shots).map((shellKey) => {
const turretShellEntry = gun.shots[shellKey];
const shell = shellList.root[shellKey];
const shellId = toUniqueId(nation, shell.id);
shellDefinitions[shellId] = {
id: shellId,
name:
strings[shell.userString] ?? shellKey.replaceAll('_', ' '),
speed: turretShellEntry.speed,
damage: {
armor: shell.damage.armor,
devices: shell.damage.devices,
},
caliber: shell.caliber,
normalization: shell.normalizationAngle,
ricochet: shell.ricochetAngle,
type: blitzShellKindToBLitzkrieg[shell.kind],
icon: shell.icon,
};
return shellId;
});
gunDefinitions[gunId] = {
id: gunId,
pitch: (typeof pitchLimitsRaw === 'string'
? pitchLimitsRaw
: pitchLimitsRaw.at(-1)!
)
.split(' ')
.map(Number) as [number, number],
name: strings[gun.userString] ?? gunKey.replaceAll('_', ' '),
tier: gun.level as Tier,
shells: gunShells,
};
return gunId;
});
turretDefinitions[turretId] = {
id: turretId,
name:
strings[turret.userString] ??
turretKey
.replaceAll('_', ' ')
.replace(/^(Turret ([0-9] )?)+/, ''),
tier: turret.level as Tier,
yaw: (typeof turret.yawLimits === 'string'
? turret.yawLimits
: turret.yawLimits.at(-1)!
)
.split(' ')
.map(Number) as [number, number],
guns: turretGuns,
};
return turretId;
},
);
tankDefinitions[tankId] = {
id: tankId,
name:
(tank.shortUserString
? strings[tank.shortUserString]
: undefined) ??
strings[tank.userString] ??
tankKey.replaceAll('_', ' '),
nation,
tree_type: (tank.sellPrice ? 'gold' in tank.sellPrice : false)
? 'collector'
: (typeof tank.price === 'number' ? false : 'gold' in tank.price)
? 'premium'
: 'researchable',
tier: tank.level as Tier,
type: blitzTankTypeToBlitzkrieg[tankTags[0] as BlitzTankType],
testing: tankTags.includes('testTank') ? true : undefined,
turrets: tankTurrets,
};
}
}),
);
console.log('Committing tank definitions...');
await commitMultipleFiles(
'tresabhi',
'blitzkrieg-assets',
'main',
'definitions',
[
{
content: JSON.stringify(tankDefinitions),
encoding: 'utf-8',
path: 'definitions/tanks.json',
},
{
content: JSON.stringify(turretDefinitions),
encoding: 'utf-8',
path: 'definitions/turrets.json',
},
{
content: JSON.stringify(gunDefinitions),
encoding: 'utf-8',
path: 'definitions/guns.json',
},
{
content: JSON.stringify(shellDefinitions),
encoding: 'utf-8',
path: 'definitions/shells.json',
},
],
true,
);
}
if (
allTargets ||
targets?.includes('bigTankIcons') ||
targets?.includes('smallTankIcons')
) {
console.log('Building tank icons...');
const changes: FileChange[] = [];
const nations = await readdir(`${DATA}/${DOI.vehicleDefinitions}`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
await Promise.all(
nations.map(async (nation) => {
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/list.xml.dvpl`,
);
for (const tankIndex in tanks.root) {
const tank = tanks.root[tankIndex];
const nationVehicleId = tank.id;
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/${DOI.tankParameters}/${nation}/${tankIndex}.yaml.dvpl`,
);
const small = `${DATA}/${parameters.resourcesPath.smallIconPath
.replace(/~res:\//, '')
.replace(/\..+/, '')}.packed.webp.dvpl`;
const big = `${DATA}/${parameters.resourcesPath.bigIconPath
.replace(/~res:\//, '')
.replace(/\..+/, '')}.packed.webp.dvpl`;
if (allTargets || targets?.includes('bigTankIcons')) {
changes.push({
content: await readBase64DVPL(big),
encoding: 'base64',
path: `icons/big/${id}.webp`,
});
}
if (allTargets || targets?.includes('smallTankIcons')) {
changes.push({
content: await readBase64DVPL(small),
encoding: 'base64',
path: `icons/small/${id}.webp`,
});
}
}
}),
);
console.log('Committing tank icons...');
await commitMultipleFiles(
'tresabhi',
'blitzkrieg-assets',
'main',
'tank icons',
changes,
true,
);
}
if (allTargets || targets?.includes('scratchedFlags')) {
console.log('Building scratched flags...');
const flags = await readdir(`${DATA}/${DOI.flags}`);
console.log('Committing scratched flags...');
commitMultipleFiles(
'tresabhi',
'blitzkrieg-assets',
'main',
'scratched flags',
await Promise.all(
flags
.filter(
(flag) =>
flag.startsWith('flag_tutor-tank_') &&
!flag.endsWith('@2x.packed.webp.dvpl'),
)
.map(async (flag) => {
const content = await readBase64DVPL(`${DATA}/${DOI.flags}/${flag}`);
const name = flag.match(/flag_tutor-tank_(.+)\.packed\.webp/)![1];
return {
content,
encoding: 'base64',
path: `flags/scratched/${name}.webp`,
} satisfies FileChange;
}),
),
true,
);
}
if (allTargets || targets?.includes('circleFlags')) {
const flags = await readdir(`${DATA}/${DOI.flags}`);
commitMultipleFiles(
'tresabhi',
'blitzkrieg-assets',
'main',
'circle flags',
await Promise.all(
flags
.filter(
(flag) =>
flag.startsWith('flag_profile-stat_') &&
!flag.endsWith('@2x.packed.webp.dvpl'),
)
.map(async (flag) => {
const content = await readBase64DVPL(`${DATA}/${DOI.flags}/${flag}`);
const name = flag.match(/flag_profile-stat_(.+)\.packed\.webp/)![1];
return {
content,
encoding: 'base64',
path: `flags/circle/${name}.webp`,
} satisfies FileChange;
}),
),
true,
);
}
if (allTargets || targets?.includes('tankModels')) {
console.log('Building tank models...');
if (existsSync('dist/assets/models')) {
await rm('dist/assets/models', { recursive: true });
}
await mkdir('dist/assets/models', { recursive: true });
const changes: FileChange[] = [];
const nations = await readdir(`${DATA}/${DOI.vehicleDefinitions}`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
await Promise.all(
nations.map(async (nation) => {
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/${DOI.vehicleDefinitions}/${nation}/list.xml.dvpl`,
);
for (const tankIndex in tanks.root) {
const tank = tanks.root[tankIndex];
const nationVehicleId = tank.id;
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
// if (id !== 897) continue;
console.log(`Building model ${id} @ ${nation}/${tankIndex}`);
const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/${DOI.tankParameters}/${nation}/${tankIndex}.yaml.dvpl`,
);
const sc2Path = parameters.resourcesPath.blitzModelPath;
const scgPath = parameters.resourcesPath.blitzModelPath.replace(
/\.sc2$/,
'.scg',
);
const sc2Stream = await SCPGStream.fromDVPLFile(
`${DATA}/${DOI['3d']}/${sc2Path}.dvpl`,
);
const scgStream = await SCPGStream.fromDVPLFile(
`${DATA}/${DOI['3d']}/${scgPath}.dvpl`,
);
const sc2 = sc2Stream.consumeSC2();
const scg = scgStream.consumeSCG();
}
}),
);
}
if (allTargets || targets?.includes('shellIcons')) {
console.log('Building shell icons...');
const image = sharp(
await readDVPLFile(
`${DATA}/${DOI.bigShellIcons}/texture0.packed.webp.dvpl`,
),
);
const changes = await Promise.all(
(await readdir(`${DATA}/${DOI.bigShellIcons}`))
.filter((file) => file.endsWith('_l.txt.dvpl'))
.map(async (file) => {
const name = file.match(/(.+)_l\.txt\.dvpl/)![1];
const sizes = (
await readStringDVPL(`${DATA}/${DOI.bigShellIcons}/${file}`)
)
.split('\n')[4]
.split(' ')
.map(Number);
return {
content: (
await image
.clone()
.extract({
left: sizes[0],
top: sizes[1],
width: sizes[2],
height: sizes[3],
})
.toBuffer()
).toString('base64'),
encoding: 'base64',
path: `shells/${name}.webp`,
} satisfies FileChange;
}),
);
console.log('Committing shell icons...');
commitMultipleFiles(
'tresabhi',
'blitzkrieg-assets',
'main',
'shell icons',
changes,
true,
);
}
+3 -4
View File
@@ -6,8 +6,8 @@ import {
RatingsNeighbors, RatingsNeighbors,
RatingsPlayer, RatingsPlayer,
} from '../src/commands/ratings'; } from '../src/commands/ratings';
import commitMultipleFiles from '../src/core/blitzkrieg/commitMultipleFiles.js';
import { patientFetchJSON } from '../src/core/blitzkrieg/patientFetchJSON'; import { patientFetchJSON } from '../src/core/blitzkrieg/patientFetchJSON';
import commitMultipleFiles from './commitMultipleFiles.js';
/* /*
* Central North American Time (UTC-5): use 0 5 * * *. * Central North American Time (UTC-5): use 0 5 * * *.
@@ -126,14 +126,13 @@ if (publish) {
console.log(`Publishing to season ${info.current_season}...`); console.log(`Publishing to season ${info.current_season}...`);
commitMultipleFiles( commitMultipleFiles(
octokit,
'tresabhi', 'tresabhi',
'blitzkrieg-assets', 'blitzkrieg-assets',
'main', 'main',
new Date().toString(), new Date().toString(),
[ [
{ path: infoPath, content: infoJSON }, { path: infoPath, content: infoJSON, encoding: 'utf-8' },
{ path: leaderboardPath, content: leaderboardJSON }, { path: leaderboardPath, content: leaderboardJSON, encoding: 'utf-8' },
], ],
); );
} else { } else {
+46
View File
@@ -0,0 +1,46 @@
import { writeFile } from 'fs/promises';
import { Region } from '../src/constants/regions';
import { getRatingsNeighbors } from '../src/core/blitz/getRatingsNeighbors';
const REGION: Region = 'com';
const PLAYER = 1041988373;
const ITERATIONS = 2 ** 4;
const MAX_NEIGHBORS = 2 ** 7;
const data: [number, number][] = [];
let finished = 0;
for (let index = 0; index < ITERATIONS; index++) {
const neighbors = Math.round((Math.random() * MAX_NEIGHBORS) / 2) * 2;
const start = Date.now();
console.log(`🟡 Iteration ${index} with ${neighbors} neighbors`);
try {
await getRatingsNeighbors(REGION, PLAYER, neighbors);
finished++;
console.log(
`🟢 (${Math.round(
100 * (finished / ITERATIONS),
)}%) Iteration ${index} with ${neighbors} neighbors`,
);
const end = Date.now();
const time = end - start;
data.push([neighbors, time]);
} catch (error) {
console.log(
`🔴 Iteration ${index} with ${neighbors} neighbors\n\n${error}`,
);
}
}
console.log('🟢 Done, sorting');
data.sort(([neighborsA], [neighborsB]) => neighborsA - neighborsB);
console.log('🟢 Writing');
writeFile(
'dist/ratingsPlot.csv',
data.map(([neighbors, time]) => `${neighbors},${time}`).join('\n'),
);
-86
View File
@@ -1,86 +0,0 @@
import decompress from 'decompress';
import { readFile, readdir, rm, writeFile } from 'fs/promises';
import { load } from 'js-yaml';
import { ElementCompact, xml2js } from 'xml-js';
import { NATION_IDS } from '../src/constants/nations';
const REWRITE = false;
const VEHICLE_DEFS = 'assets/Data/XML/item_defs/vehicles';
const APK_FILE = 'temp/blitz.apk';
const STRINGS = 'assets/Data/Strings';
const LANGUAGE = 'en';
const DIRECTORIES_OF_INTEREST = [VEHICLE_DEFS, STRINGS];
if (REWRITE) {
await rm('temp/blitz', { recursive: true, force: true });
decompress(APK_FILE, 'temp/blitz', {
filter: (file) =>
DIRECTORIES_OF_INTEREST.some((dir) => file.path.startsWith(dir)),
});
}
interface BlitzkriegTankopedia {
id: number;
nation: string;
name: string;
name_short?: string;
is_premium: boolean;
is_collector: boolean;
tier: number;
type: string;
}
const strings = await readFile(
`temp/blitz/${STRINGS}/${LANGUAGE}.yaml`,
'utf-8',
).then((data) => load(data) as Record<string, string>);
const tankopedia: Record<number, BlitzkriegTankopedia> = {};
const nations = await readdir(`temp/blitz/${VEHICLE_DEFS}`);
await Promise.all(
nations
.filter((nation) => nation !== 'common')
.map(async (nation) => {
const listXML = await readFile(
`temp/blitz/${VEHICLE_DEFS}/${nation}/list.xml`,
'utf-8',
);
const list = xml2js(listXML, { compact: true }) as ElementCompact;
await Promise.all(
Object.entries<ElementCompact>(list.root).map(
async ([vehicle, vehicleData]) => {
// const vehicleXML = await readFile(
// `temp/blitz/${VEHICLE_DEFS}/${nation}/${vehicle}.xml`,
// 'utf-8',
// ).then((xml) => xml2js(xml, { compact: true }) as ElementCompact);
const nationVehicleId = parseInt(list.root[vehicle].id._text);
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
// TODO: use userString property instead
const name = strings[`#${nation}_vehicles:${vehicle}`];
const name_short = strings[`#${nation}_vehicles:${vehicle}_short`];
const is_premium = 'gold' in vehicleData.price;
const is_collector = vehicleData.sellPrice
? 'gold' in vehicleData.sellPrice
: false;
const tier = parseInt(vehicleData.level._text);
const tags = vehicleData.tags._text.split(' ');
const type = tags[0];
tankopedia[id] = {
id,
name,
name_short,
nation,
is_premium,
is_collector,
tier,
type,
};
},
),
);
}),
);
writeFile('dist/tankopedia.json', JSON.stringify(tankopedia, null, 2));
-70
View File
@@ -1,70 +0,0 @@
import { Octokit } from '@octokit/rest';
export default async function commitMultipleFiles(
octokit: Octokit,
owner: string,
repo: string,
branch: string,
message: string,
changes: { path: string; content: string }[],
) {
// Get the latest commit SHA on the specified branch
const { data: refData } = await octokit.git.getRef({
owner,
repo,
ref: `heads/${branch}`,
});
const latestCommitSha = refData.object.sha;
// Get the tree SHA for the latest commit
const { data: commitData } = await octokit.git.getCommit({
owner,
repo,
commit_sha: latestCommitSha,
});
const treeSha = commitData.tree.sha;
// Create blobs for each file
const blobs = await Promise.all(
changes.map(async (change) => {
const { data: blobData } = await octokit.git.createBlob({
owner,
repo,
content: change.content,
encoding: 'utf-8',
});
return {
sha: blobData.sha,
path: change.path,
mode: '100644',
type: 'blob',
} as const;
}),
);
// Create a new tree with the new blobs
const { data: treeData } = await octokit.git.createTree({
owner,
repo,
base_tree: treeSha,
tree: blobs,
});
// Create a new commit with the new tree
const { data: newCommitData } = await octokit.git.createCommit({
owner,
repo,
message,
tree: treeData.sha,
parents: [latestCommitSha],
});
// Update the branch reference to point to the new commit
await octokit.git.updateRef({
owner,
repo,
ref: `heads/${branch}`,
sha: newCommitData.sha,
});
}
-6
View File
@@ -1,6 +0,0 @@
import { NextResponse } from 'next/server';
import { tankopedia } from '../../../core/blitzstars/tankopedia';
export async function GET() {
return NextResponse.json(await tankopedia);
}
+113 -91
View File
@@ -6,22 +6,29 @@ import * as Breakdown from '../../../components/Breakdown';
import { TreeTypeEnum } from '../../../components/Tanks'; import { TreeTypeEnum } from '../../../components/Tanks';
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID'; import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
import fetchBlitz from '../../../core/blitz/fetchBlitz'; import fetchBlitz from '../../../core/blitz/fetchBlitz';
import { tankAverages } from '../../../core/blitzstars/tankAverages';
import { import {
TankopediaEntry, BlitzkriegTankDefinition,
tankopedia, tankDefinitions,
} from '../../../core/blitzstars/tankopedia'; } from '../../../core/blitzkrieg/definitions/tanks';
import { tankAverages } from '../../../core/blitzstars/tankAverages';
import calculateWN8 from '../../../core/statistics/calculateWN8'; import calculateWN8 from '../../../core/statistics/calculateWN8';
import { deltaTankStats } from '../../../core/statistics/deltaTankStats'; import { deltaTankStats } from '../../../core/statistics/deltaTankStats';
import getWN8Percentile from '../../../core/statistics/getWN8Percentile'; import getWN8Percentile from '../../../core/statistics/getWN8Percentile';
import { resetSession, useSession } from '../../../stores/session'; import mutateSession, {
resetSession,
useSession,
} from '../../../stores/session';
import { IndividualTankStats, TanksStats } from '../../../types/tanksStats'; import { IndividualTankStats, TanksStats } from '../../../types/tanksStats';
import { CustomColumnDisplay } from '../../tools/session/components/CustomColumn'; import { CustomColumnDisplay } from '../../tools/session/components/CustomColumn';
import { Menu } from '../../tools/session/components/Menu'; import { Menu } from '../../tools/session/components/Menu';
const REFRESH_RATE = 1 / 5; const REFRESH_RATE = 1 / 5;
export default function SessionPage() { interface SessionPageProps {
naked?: boolean;
}
export default function SessionPage({ naked = true }: SessionPageProps) {
const session = useSession(); const session = useSession();
const since = session.isTracking ? new Date(session.time) : undefined; const since = session.isTracking ? new Date(session.time) : undefined;
const [diff, setDiff] = useState< const [diff, setDiff] = useState<
@@ -30,7 +37,7 @@ export default function SessionPage() {
list: { list: {
stats: IndividualTankStats; stats: IndividualTankStats;
tankopedia?: TankopediaEntry; tankDefinitions?: BlitzkriegTankDefinition;
career: IndividualTankStats; career: IndividualTankStats;
currentWN8?: number; currentWN8?: number;
careerWN8?: number; careerWN8?: number;
@@ -72,6 +79,14 @@ export default function SessionPage() {
0, 0,
) / careerBattles; ) / careerBattles;
useEffect(() => {
if (session.title === 'Untitled session') {
mutateSession((draft) => {
if (draft.isTracking) draft.title = draft.nickname;
});
}
}, [session.title]);
useEffect(() => { useEffect(() => {
async function recalculateDiff() { async function recalculateDiff() {
if (session.isTracking) { if (session.isTracking) {
@@ -88,7 +103,7 @@ export default function SessionPage() {
}), }),
{}, {},
); );
const awaitedTankopedia = await tankopedia; const awaitedTankDefinitions = await tankDefinitions;
const awaitedTankAverages = await tankAverages; const awaitedTankAverages = await tankAverages;
const careerWN8 = const careerWN8 =
careerRaw[id].reduce( careerRaw[id].reduce(
@@ -114,12 +129,12 @@ export default function SessionPage() {
list: ( list: (
Object.values( Object.values(
deltaTankStats(session.tankStats, career), deltaTankStats(session.tankStats, career, session.time / 1000),
) as IndividualTankStats[] ) as IndividualTankStats[]
).map((stats) => { ).map((stats) => {
return { return {
stats, stats,
tankopedia: awaitedTankopedia[stats.tank_id], tankDefinitions: awaitedTankDefinitions[stats.tank_id],
career: career[stats.tank_id], career: career[stats.tank_id],
currentWN8: awaitedTankAverages[stats.tank_id] currentWN8: awaitedTankAverages[stats.tank_id]
? calculateWN8( ? calculateWN8(
@@ -207,6 +222,7 @@ export default function SessionPage() {
since !== undefined && since !== undefined &&
diff !== undefined && ( diff !== undefined && (
<Breakdown.Row <Breakdown.Row
naked={naked}
color={session.color} color={session.color}
minimized={!session.showCareer} minimized={!session.showCareer}
title={session.title} title={session.title}
@@ -231,90 +247,96 @@ export default function SessionPage() {
.sort( .sort(
(a, b) => b.stats.last_battle_time - a.stats.last_battle_time, (a, b) => b.stats.last_battle_time - a.stats.last_battle_time,
) )
.map(({ stats, tankopedia, career, careerWN8, currentWN8 }) => { .map(
const rowStats: Record< ({ stats, tankDefinitions, career, careerWN8, currentWN8 }) => {
CustomColumnDisplay, const rowStats: Record<
Breakdown.RowStatItem | undefined CustomColumnDisplay,
> = { Breakdown.RowStatItem | undefined
battles: { > = {
title: 'Battles', battles: {
current: stats.all.battles.toLocaleString(), title: 'Battles',
career: career.all.battles.toLocaleString(), current: stats.all.battles.toLocaleString(),
}, career: career.all.battles.toLocaleString(),
winrate: { },
title: 'Winrate', winrate: {
current: `${( title: 'Winrate',
100 * current: `${(
(stats.all.wins / stats.all.battles) 100 *
).toFixed(2)}%`, (stats.all.wins / stats.all.battles)
career: `${( ).toFixed(2)}%`,
100 * career: `${(
(career.all.wins / career.all.battles) 100 *
).toFixed(2)}%`, (career.all.wins / career.all.battles)
delta: ).toFixed(2)}%`,
stats.all.wins / stats.all.battles - delta:
career.all.wins / career.all.battles, stats.all.wins / stats.all.battles -
}, career.all.wins / career.all.battles,
wn8: { },
title: 'WN8', wn8: {
current: title: 'WN8',
currentWN8 === undefined current:
? undefined currentWN8 === undefined
: Math.round(currentWN8).toLocaleString(), ? undefined
career: : Math.round(currentWN8).toLocaleString(),
careerWN8 === undefined career:
? undefined careerWN8 === undefined
: Math.round(careerWN8).toLocaleString(), ? undefined
percentile: : Math.round(careerWN8).toLocaleString(),
currentWN8 === undefined percentile:
? undefined currentWN8 === undefined
: getWN8Percentile(currentWN8), ? undefined
}, : getWN8Percentile(currentWN8),
damage: { },
title: 'Damage', damage: {
current: Math.round( title: 'Damage',
stats.all.damage_dealt / stats.all.battles, current: Math.round(
).toLocaleString(), stats.all.damage_dealt / stats.all.battles,
career: Math.round( ).toLocaleString(),
career.all.damage_dealt / career.all.battles, career: Math.round(
).toLocaleString(), career.all.damage_dealt / career.all.battles,
delta: ).toLocaleString(),
stats.all.damage_dealt / stats.all.battles - delta:
career.all.damage_dealt / career.all.battles, stats.all.damage_dealt / stats.all.battles -
}, career.all.damage_dealt / career.all.battles,
none: undefined, },
}; none: undefined,
};
return ( return (
<Breakdown.Row <Breakdown.Row
color={session.color} naked={naked}
key={stats.tank_id} color={session.color}
minimized={!session.showCareer} key={stats.tank_id}
title={tankopedia?.name ?? `Unknown tank ${stats.tank_id}`} minimized={!session.showCareer}
type="tank" title={
tankType={tankopedia?.type} tankDefinitions?.name ?? `Unknown tank ${stats.tank_id}`
treeType={(() => { }
if (tankopedia?.is_collectible) type="tank"
return TreeTypeEnum.Collector; tankType={tankDefinitions?.type}
if (tankopedia?.is_premium) return TreeTypeEnum.Premium; treeType={(() => {
})()} if (tankDefinitions?.tree_type === 'collector')
stats={session.customColumns.map((customColumn) => { return TreeTypeEnum.Collector;
const rowStat = rowStats[customColumn.display]; if (tankDefinitions?.tree_type === 'premium')
return TreeTypeEnum.Premium;
})()}
stats={session.customColumns.map((customColumn) => {
const rowStat = rowStats[customColumn.display];
if (!rowStat) return undefined; if (!rowStat) return undefined;
return { return {
...rowStat, ...rowStat,
delta: delta:
customColumn.showDelta && customColumn.showDelta &&
rowStat.percentile === undefined rowStat.percentile === undefined
? rowStat.delta ? rowStat.delta
: undefined, : undefined,
}; };
})} })}
/> />
); );
})} },
)}
</Breakdown.Root> </Breakdown.Root>
</div> </div>
</ContextMenu.Trigger> </ContextMenu.Trigger>
+8 -1
View File
@@ -36,12 +36,19 @@ export default function RootLayout({ children }: RootLayoutProps) {
<body <body
style={{ style={{
margin: 0, margin: 0,
paddingTop: isEmbed ? 0 : '3.25rem',
background: isEmbed background: isEmbed
? 'transparent' ? 'transparent'
: 'url(https://i.imgur.com/PhS06NJ.png)', : 'url(https://i.imgur.com/PhS06NJ.png)',
}} }}
> >
<Theme appearance="dark" panelBackground="translucent" radius="full"> <Theme
appearance="dark"
panelBackground="translucent"
radius="full"
suppressHydrationWarning
suppressContentEditableWarning
>
{!isEmbed && <Navbar />} {!isEmbed && <Navbar />}
<Flex direction="column">{children}</Flex> <Flex direction="column">{children}</Flex>
+1 -1
View File
@@ -11,7 +11,7 @@ interface ToolsLayoutProps {
export default function ToolsLayout({ children }: ToolsLayoutProps) { export default function ToolsLayout({ children }: ToolsLayoutProps) {
const pathname = usePathname(); const pathname = usePathname();
const toolId = pathname.split('/').at(-1); const toolId = pathname.split('/').at(2);
const tool = TOOLS.find((tool) => tool.id === toolId); const tool = TOOLS.find((tool) => tool.id === toolId);
return ( return (
@@ -59,6 +59,8 @@ export function PageTurner({
type="number" type="number"
ref={pageInput} ref={pageInput}
style={{ width: 64, textAlign: 'center' }} style={{ width: 64, textAlign: 'center' }}
min={1}
max={pages}
onBlur={(event) => { onBlur={(event) => {
onPageChange( onPageChange(
Math.max( Math.max(
+1 -1
View File
@@ -248,7 +248,7 @@ export default function Page() {
</div> </div>
</div> </div>
<SessionPage /> <SessionPage naked={false} />
</PageWrapper> </PageWrapper>
); );
} }
+250
View File
@@ -0,0 +1,250 @@
'use client';
import { ArrowLeftIcon, CaretRightIcon } from '@radix-ui/react-icons';
import {
Button,
Card,
Checkbox,
Dialog,
Flex,
Heading,
Slider,
Tabs,
Text,
Theme,
Tooltip,
} from '@radix-ui/themes';
import { OrbitControls } from '@react-three/drei';
import { Canvas } from '@react-three/fiber';
import Link from 'next/link';
import { use, useEffect, useState } from 'react';
import { Flag } from '../../../../components/Flag';
import PageWrapper from '../../../../components/PageWrapper';
import { asset } from '../../../../core/blitzkrieg/asset';
import { gunDefinitions } from '../../../../core/blitzkrieg/definitions/guns';
import { shellDefinitions } from '../../../../core/blitzkrieg/definitions/shells';
import {
TIER_ROMAN_NUMERALS,
tankDefinitions,
tanksDefinitionsArray,
} from '../../../../core/blitzkrieg/definitions/tanks';
import { turretDefinitions } from '../../../../core/blitzkrieg/definitions/turrets';
import * as styles from '../page.css';
type Mode = 'model' | 'armor';
export default function Page({ params }: { params: { id: string } }) {
const id = parseInt(params.id);
const awaitedTankDefinitions = use(tankDefinitions);
const awaitedTanksDefinitionsArray = use(tanksDefinitionsArray);
const awaitedTurretDefinitions = use(turretDefinitions);
const awaitedGunDefinitions = use(gunDefinitions);
const awaitedShellDefinitions = use(shellDefinitions);
const tank = awaitedTankDefinitions[id];
const [turret, setTurret] = useState(
awaitedTurretDefinitions[tank.turrets[0]],
);
const [gun, setGun] = useState(awaitedGunDefinitions[turret.guns[0]]);
const [crew, setCrew] = useState(100);
const [versusTank, setVersusTank] = useState(
awaitedTanksDefinitionsArray[0].id,
);
const [mode, setMode] = useState<Mode>('model');
console.log(versusTank);
useEffect(() => setGun(awaitedGunDefinitions[turret.guns[0]]), [turret]);
return (
<PageWrapper>
<Flex gap="8" direction="column">
<Flex gap="4" direction="column">
<Flex justify="between" align="center">
<Link
href="/tools/tankopedia"
style={{ color: 'unset', textDecoration: 'none' }}
>
<Flex gap="1" align="center">
<ArrowLeftIcon />
<Text>Back to tankopedia</Text>
</Flex>
</Link>
<Flex gap="2" align="center">
<Flag nation={tank.nation} />
<Heading>{tank.name}</Heading>
</Flex>
</Flex>
<Card style={{ position: 'relative' }}>
<Flex direction="column" gap="2">
<Tabs.Root
value={mode}
onValueChange={(value) => setMode(value as Mode)}
>
<Tabs.List>
<Tabs.Trigger value="model">Model</Tabs.Trigger>
<Tabs.Trigger value="armor">Armor</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<div style={{ height: '50vh', maxHeight: 576 }}>
<Canvas onPointerDown={(event) => event.preventDefault()}>
<OrbitControls />
<ambientLight />
<directionalLight />
<mesh>
<torusKnotGeometry args={[undefined, undefined, 128, 16]} />
<meshStandardMaterial color="cyan" />
</mesh>
</Canvas>
</div>
{mode === 'armor' && (
<Flex gap="2" align="center" className={styles.enhancedArmor}>
<Checkbox defaultChecked />
<Text>Enhanced armor</Text>
</Flex>
)}
</Flex>
</Card>
{mode === 'armor' && (
<Card>
<Flex align="center" justify="between" gap="2">
<Flex align="center" gap="4">
<Text>Versus</Text>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="ghost">
{awaitedTankDefinitions[versusTank].name}
<CaretRightIcon />
</Button>
</Dialog.Trigger>
<Dialog.Content>Do something lol</Dialog.Content>
</Dialog.Root>
</Flex>
<Flex gap="2" align="center">
<Checkbox defaultChecked />
<Text>Calibrated shells</Text>
</Flex>
<Flex gap="1">
<Button variant="solid" radius="small">
<img src={asset('shells/ap.webp')} width={24} height={24} />
</Button>
<Button variant="soft" radius="small" color="gray">
<img
src={asset('shells/hc_premium.webp')}
width={24}
height={24}
/>
</Button>
<Button variant="soft" radius="small" color="gray">
<img src={asset('shells/he.webp')} width={24} height={24} />
</Button>
</Flex>
</Flex>
</Card>
)}
</Flex>
<Flex gap="6" direction="column">
<Heading>Build</Heading>
<Flex gap="2" direction="column">
<Heading size="5" weight="regular">
Modules
</Heading>
<Theme radius="small">
<Flex gap="4">
<Flex gap="1">
{tank.turrets.map((turretId) => {
const thisTurret = awaitedTurretDefinitions[turretId];
return (
<Tooltip content={thisTurret.name}>
<Button
onClick={() => setTurret(thisTurret)}
variant={
turret.id === thisTurret.id ? 'solid' : 'soft'
}
>
<img
src="/images/modules/turret.png"
width={32}
height={32}
/>
{TIER_ROMAN_NUMERALS[thisTurret.tier]}
</Button>
</Tooltip>
);
})}
</Flex>
<Flex gap="1">
{turret.guns.map((gunId, index) => {
const thisGun = awaitedGunDefinitions[gunId];
return (
<Tooltip content={thisGun.name}>
<Button
onClick={() => setGun(thisGun)}
variant={gun.id === thisGun.id ? 'solid' : 'soft'}
>
<img
src="/images/modules/gun.png"
width={32}
height={32}
/>
{TIER_ROMAN_NUMERALS[thisGun.tier]}
</Button>
</Tooltip>
);
})}
</Flex>
</Flex>
</Theme>
</Flex>
<Flex gap="2" direction="column">
<Heading size="5" weight="regular">
Crew
</Heading>
<Flex gap="4" align="center">
<Text>{crew}%</Text>
<Slider
style={{ flex: 1 }}
value={[crew]}
min={50}
max={100}
onValueChange={([crew]) => setCrew(crew)}
/>
</Flex>
</Flex>
</Flex>
<Flex gap="2" direction="column">
<Heading size="5">Shells</Heading>
{gun.shells.map((shellId) => {
const shell = awaitedShellDefinitions[shellId];
return (
<Text>
{shell.name} ({shell.type}): {shell.damage.armor}
</Text>
);
})}
</Flex>
</Flex>
</PageWrapper>
);
}
@@ -0,0 +1,17 @@
import { style } from '@vanilla-extract/css';
export const splitFilters = style({
'@media': {
'screen and (min-width: 513px)': {
display: 'none',
},
},
});
export const oneLineFilters = style({
'@media': {
'screen and (max-width: 512px)': {
display: 'none',
},
},
});
@@ -0,0 +1,388 @@
'use client';
import { CaretDownIcon } from '@radix-ui/react-icons';
import { Button, DropdownMenu, Flex } from '@radix-ui/themes';
import { use } from 'react';
import { Flag } from '../../../../../components/Flag';
import {
TANK_TYPES,
TREE_TYPES,
TREE_TYPE_ICONS,
TREE_TYPE_IMAGES,
TreeTypeEnum,
} from '../../../../../components/Tanks';
import {
NATIONS,
TIERS,
TIER_ROMAN_NUMERALS,
} from '../../../../../core/blitzkrieg/definitions/tanks';
import mutateTankopedia, {
TankopediaTestTankDisplay,
useTankopedia,
} from '../../../../../stores/tankopedia';
import * as styles from './index.css';
export function Options() {
const nations = use(NATIONS);
const tankopediaState = useTankopedia();
return (
<Flex justify="center" direction="column" gap="2">
<Flex gap="2" style={{ flex: 1 }} wrap="wrap">
<Button
color="red"
onClick={() =>
useTankopedia.setState({
filters: {
nations: [],
tiers: [],
treeTypes: [],
types: [],
test: 'include',
},
})
}
>
Clear
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft">
Test tanks
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup
value={tankopediaState.filters.test}
onValueChange={(value) =>
mutateTankopedia((draft) => {
draft.filters.test = value as TankopediaTestTankDisplay;
})
}
>
<DropdownMenu.RadioItem value="include">
Include
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="exclude">
Exclude
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="only">Only</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Flex style={{ flex: 1 }}>
{TREE_TYPES.map((type, index) => (
<Button
key={type}
variant={
tankopediaState.filters.treeTypes.includes(type)
? 'solid'
: 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius:
index === TREE_TYPES.length - 1 ? undefined : 0,
borderBottomRightRadius:
index === TREE_TYPES.length - 1 ? undefined : 0,
}}
onClick={() =>
mutateTankopedia((draft) => {
if (draft.filters.treeTypes.includes(type)) {
draft.filters.treeTypes = draft.filters.treeTypes.filter(
(preexistingType) => preexistingType !== type,
);
} else {
draft.filters.treeTypes.push(type);
}
})
}
>
<img
src={TREE_TYPE_IMAGES[type]}
style={{ width: '1em', height: '1em' }}
/>
</Button>
))}
</Flex>
<Flex style={{ flex: 1 }}>
{[...TANK_TYPES].reverse().map((type, index) => (
<Button
key={type}
variant={
tankopediaState.filters.types.includes(type) ? 'solid' : 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius:
index === TANK_TYPES.length - 1 ? undefined : 0,
borderBottomRightRadius:
index === TANK_TYPES.length - 1 ? undefined : 0,
}}
onClick={() =>
mutateTankopedia((draft) => {
if (draft.filters.types.includes(type)) {
draft.filters.types = draft.filters.types.filter(
(preexistingType) => preexistingType !== type,
);
} else {
draft.filters.types.push(type);
}
})
}
>
<img
src={TREE_TYPE_ICONS[TreeTypeEnum.TechTree][type]}
style={{ width: '1em', height: '1em' }}
/>
</Button>
))}
</Flex>
<Flex
direction="column"
className={styles.splitFilters}
style={{ width: '100%' }}
>
<Flex>
{[...TIERS]
.reverse()
.slice(0, 5)
.map((tier, index) => (
<Button
key={tier}
variant={
tankopediaState.filters.tiers.includes(tier)
? 'solid'
: 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: 0,
borderTopRightRadius:
index === TIERS.length - Math.round(TIERS.length / 2) - 1
? undefined
: 0,
borderBottomRightRadius: 0,
}}
onClick={() =>
mutateTankopedia((draft) => {
if (draft.filters.tiers.includes(tier)) {
draft.filters.tiers = draft.filters.tiers.filter(
(preexistingTier) => preexistingTier !== tier,
);
} else {
draft.filters.tiers.push(tier);
}
})
}
>
{TIER_ROMAN_NUMERALS[tier]}
</Button>
))}
</Flex>
<Flex>
{[...TIERS]
.reverse()
.slice(5, 10)
.map((tier, index) => (
<Button
key={tier}
variant={
tankopediaState.filters.tiers.includes(tier)
? 'solid'
: 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius: 0,
borderBottomRightRadius:
index === TIERS.length - Math.round(TIERS.length / 2) - 1
? undefined
: 0,
}}
onClick={() =>
mutateTankopedia((draft) => {
if (draft.filters.tiers.includes(tier)) {
draft.filters.tiers = draft.filters.tiers.filter(
(preexistingTier) => preexistingTier !== tier,
);
} else {
draft.filters.tiers.push(tier);
}
})
}
>
{TIER_ROMAN_NUMERALS[tier]}
</Button>
))}
</Flex>
</Flex>
<Flex
direction="column"
className={styles.splitFilters}
style={{ width: '100%' }}
>
<Flex>
{nations.slice(0, 5).map((nation, index) => (
<Button
key={nation}
variant={
tankopediaState.filters.nations.includes(nation)
? 'solid'
: 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: 0,
borderTopRightRadius:
index === nations.length - Math.round(nations.length / 2)
? undefined
: 0,
borderBottomRightRadius:
index === nations.length - 1 ? undefined : 0,
}}
onClick={() =>
mutateTankopedia((draft) => {
if (draft.filters.nations.includes(nation)) {
draft.filters.nations = draft.filters.nations.filter(
(preexistingType) => preexistingType !== nation,
);
} else {
draft.filters.nations.push(nation);
}
})
}
>
<Flag nation={nation} />
</Button>
))}
</Flex>
<Flex>
{nations.slice(5, 10).map((nation, index) => (
<Button
key={nation}
variant={
tankopediaState.filters.nations.includes(nation)
? 'solid'
: 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius:
index === nations.length - 1 ? undefined : 0,
borderBottomRightRadius:
index ===
nations.length - Math.round(nations.length / 2) - 1
? undefined
: 0,
}}
onClick={() =>
mutateTankopedia((draft) => {
if (draft.filters.nations.includes(nation)) {
draft.filters.nations = draft.filters.nations.filter(
(preexistingType) => preexistingType !== nation,
);
} else {
draft.filters.nations.push(nation);
}
})
}
>
<Flag nation={nation} />
</Button>
))}
</Flex>
</Flex>
</Flex>
<Flex gap="2" style={{ flex: 1 }} wrap="wrap">
<Flex className={styles.oneLineFilters} style={{ flex: 1 }}>
{[...TIERS].reverse().map((tier, index) => (
<Button
key={tier}
variant={
tankopediaState.filters.tiers.includes(tier) ? 'solid' : 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius:
index === TIERS.length - 1 ? undefined : 0,
borderBottomRightRadius:
index === TIERS.length - 1 ? undefined : 0,
}}
onClick={() =>
mutateTankopedia((draft) => {
if (draft.filters.tiers.includes(tier)) {
draft.filters.tiers = draft.filters.tiers.filter(
(preexistingTier) => preexistingTier !== tier,
);
} else {
draft.filters.tiers.push(tier);
}
})
}
>
{TIER_ROMAN_NUMERALS[tier]}
</Button>
))}
</Flex>
<Flex className={styles.oneLineFilters} style={{ flex: 1 }}>
{nations.map((nation, index) => (
<Button
key={nation}
variant={
tankopediaState.filters.nations.includes(nation)
? 'solid'
: 'soft'
}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius:
index === nations.length - 1 ? undefined : 0,
borderBottomRightRadius:
index === nations.length - 1 ? undefined : 0,
}}
onClick={() =>
mutateTankopedia((draft) => {
if (draft.filters.nations.includes(nation)) {
draft.filters.nations = draft.filters.nations.filter(
(preexistingType) => preexistingType !== nation,
);
} else {
draft.filters.nations.push(nation);
}
})
}
>
<Flag nation={nation} />
</Button>
))}
</Flex>
</Flex>
</Flex>
);
}
@@ -0,0 +1,77 @@
import { CaretLeftIcon, CaretRightIcon } from '@radix-ui/react-icons';
import { Button, Flex, TextField } from '@radix-ui/themes';
import { useEffect, useRef } from 'react';
import { BlitzkriegTankDefinition } from '../../../../core/blitzkrieg/definitions/tanks';
interface PageTurnerProps {
page: number;
setPage: (page: number) => void;
searchedList: BlitzkriegTankDefinition[];
tanksPerPage: number;
}
export function PageTurner({
page,
setPage,
searchedList,
tanksPerPage,
}: PageTurnerProps) {
const pageInput = useRef<HTMLInputElement>(null);
useEffect(() => {
if (pageInput.current) pageInput.current.value = `${page + 1}`;
}, [page]);
return (
<Flex align="center" justify="center" gap="2">
<Button
variant="soft"
disabled={page === 0}
onClick={() => setPage(Math.max(0, page - 1))}
>
<CaretLeftIcon />
</Button>
<TextField.Root>
<TextField.Slot>Page</TextField.Slot>
<TextField.Input
defaultValue={1}
type="number"
ref={pageInput}
min={1}
max={Math.floor(searchedList.length / tanksPerPage) + 1}
style={{ width: 64, textAlign: 'center' }}
onBlur={(event) => {
setPage(
Math.max(
0,
Math.min(
Math.floor(searchedList.length / tanksPerPage),
event.target.valueAsNumber - 1,
),
),
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
/>
<TextField.Slot>
out of {Math.floor(searchedList.length / tanksPerPage) + 1}
</TextField.Slot>
</TextField.Root>
<Button
variant="soft"
disabled={Math.floor(searchedList.length / tanksPerPage) === page}
onClick={() =>
setPage(
Math.min(Math.floor(searchedList.length / tanksPerPage), page + 1),
)
}
>
<CaretRightIcon />
</Button>
</Flex>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { style } from '@vanilla-extract/css';
import { theme } from '../../../stitches.config';
export const listing = style({});
export const flag = style({
transitionDuration: theme.durations.regular,
left: '0',
top: '0',
[`.${listing}:hover &, .${listing}:active &`]: {
transform: 'translateX(-25%)',
filter: 'blur(3rem)',
},
});
export const listingShadow = style({
boxShadow: `inset 128px -128px 128px -128px ${theme.colors.appBackground1}`,
[`.${listing}:hover &, .${listing}:active &`]: {
boxShadow: `inset 0 0 0 -128px ${theme.colors.appBackground1}`,
},
});
export const listingLabel = style({
transition: `transform ${theme.durations.regular}`,
transform: 'translateY(0)',
[`.${listing}:hover &, .${listing}:active &`]: {
transform: 'translateY(200%)',
},
});
export const listingImage = style({
transition: `transform ${theme.durations.regular}`,
position: 'absolute',
left: '50%',
[`.${listing}:hover &, .${listing}:active &`]: {
transform: 'translate(-25%, 10%)',
},
});
export const enhancedArmor = style({
'@media': {
'screen and (min-width: 400px)': {
position: 'absolute',
right: 16,
top: 16,
},
'screen and (max-width: 399px)': {
// left: 16,
// bottom: 16,
},
},
});
+329
View File
@@ -0,0 +1,329 @@
'use client';
import { slateDark } from '@radix-ui/colors';
import {
CaretDownIcon,
CaretUpIcon,
MagnifyingGlassIcon,
} from '@radix-ui/react-icons';
import {
Button,
Card,
DropdownMenu,
Flex,
Inset,
Text,
TextField,
} from '@radix-ui/themes';
import { go } from 'fuzzysort';
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import { asset } from '../../../core/blitzkrieg/asset';
import {
BlitzkriegTankDefinition,
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
TIER_ROMAN_NUMERALS,
tanksDefinitionsArray,
} from '../../../core/blitzkrieg/definitions/tanks';
import { tankIcon } from '../../../core/blitzkrieg/tankIcon';
import { theme } from '../../../stitches.config';
import mutateTankopedia, {
TankopediaSortBy,
TankopediaSortDirection,
useTankopedia,
} from '../../../stores/tankopedia';
import { Options } from './components/Options';
import { PageTurner } from './components/PageTurner';
import * as styles from './page.css';
const TANKS_PER_PAGE = 3 * 2 ** 3;
export default function Page() {
const tankopediaState = useTankopedia();
const awaitedTanks = use(tanksDefinitionsArray);
const input = useRef<HTMLInputElement>(null);
const searchableTanks = useMemo(
() =>
awaitedTanks
.filter(
(tank) =>
(tankopediaState.filters.tiers.length === 0
? true
: tankopediaState.filters.tiers.includes(tank.tier)) &&
(tankopediaState.filters.types.length === 0
? true
: tankopediaState.filters.types.includes(tank.type)) &&
(tankopediaState.filters.treeTypes.length === 0
? true
: tankopediaState.filters.treeTypes.includes(tank.tree_type)) &&
(tankopediaState.filters.nations.length === 0
? true
: tankopediaState.filters.nations.includes(tank.nation)) &&
(tankopediaState.filters.test === 'include'
? true
: tankopediaState.filters.test === 'exclude'
? !tank.testing
: tank.testing),
)
.sort((a, b) => {
let diff = 0;
if (tankopediaState.sort.by === 'tier') {
diff = a.tier - b.tier;
}
if (tankopediaState.sort.by === 'name') {
diff = a.name.localeCompare(b.name);
}
return tankopediaState.sort.direction === 'ascending' ? diff : -diff;
}),
[tankopediaState.filters, tankopediaState.sort],
);
const [searchedList, setSearchedList] = useState(searchableTanks);
const [page, setPage] = useState(0);
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
setSearchedList(searchableTanks);
if (input.current) input.current.value = '';
}, [searchableTanks]);
useEffect(() => {
setPage(
Math.min(
Math.max(0, page),
Math.ceil(searchedList.length / TANKS_PER_PAGE) - 1,
),
);
}, [searchedList]);
return (
<PageWrapper size="wide" color="purple">
<Suspense fallback={<Text>Loading...</Text>}>
<Flex direction="column" gap="4">
<Flex gap="2">
<TextField.Root style={{ flex: 1 }}>
<TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input
ref={input}
placeholder="Search tanks..."
onChange={(event) => {
if (event.target.value.length === 0) {
setSearchedList(searchableTanks);
} else {
setSearchedList(
go(event.target.value, searchableTanks, {
keys: [
'name',
'id',
] satisfies (keyof BlitzkriegTankDefinition)[],
}).map(({ obj }) => obj),
);
}
}}
/>
</TextField.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft">
Sort
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>By</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={tankopediaState.sort.by}
onValueChange={(value) =>
mutateTankopedia((draft) => {
draft.sort.by = value as TankopediaSortBy;
})
}
>
<DropdownMenu.RadioItem value="tier">
Tier
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="name">
Name
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator />
<DropdownMenu.Label>Direction</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={tankopediaState.sort.direction}
onValueChange={(value) =>
mutateTankopedia((draft) => {
draft.sort.direction = value as TankopediaSortDirection;
})
}
>
<DropdownMenu.RadioItem value="ascending">
Ascending
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="descending">
Descending
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button
variant="soft"
onClick={() => setShowFilters((state) => !state)}
>
Filters {showFilters ? <CaretUpIcon /> : <CaretDownIcon />}
</Button>
</Flex>
{showFilters && (
<Card>
<Suspense fallback={<Text>Loading...</Text>}>
<Options />
</Suspense>
</Card>
)}
</Flex>
<PageTurner
page={page}
setPage={setPage}
tanksPerPage={TANKS_PER_PAGE}
searchedList={searchedList}
/>
<Flex wrap="wrap" gap="3" justify="center">
{searchedList
.map((tank) => (
<a
key={tank.id}
href={`/tools/tankopedia/${tank.id}`}
style={{
flex: 1,
textDecoration: 'none',
color: 'unset',
}}
>
<Card className={styles.listing}>
<Inset
key={tank.id}
style={{
minWidth: 256,
minHeight: 128,
position: 'relative',
display: 'flex',
}}
>
<img
className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
}}
/>
<div
style={{
width: 256,
height: 128,
}}
className={styles.listingImage}
>
<img
src={tankIcon(tank.id)}
style={{
objectFit: 'contain',
objectPosition: 'left center',
}}
/>
</div>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<Flex
align="center"
justify="between"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
padding: '0 12px',
width: '100%',
boxSizing: 'border-box',
}}
>
<Flex align="center" justify="center" gap="1">
<img
src={
(tank.tree_type === 'collector'
? TANK_ICONS_COLLECTOR
: tank.tree_type === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.type]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.tree_type === 'collector'
? 'blue'
: tank.tree_type === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{
color:
tank.tree_type === 'researchable'
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{tank.name}
</Text>
</Flex>
<Text size="4" color="gray" highContrast={false}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
</Flex>
</Inset>
</Card>
</a>
))
.slice(page * TANKS_PER_PAGE, (page + 1) * TANKS_PER_PAGE)}
</Flex>
<PageTurner
page={page}
setPage={setPage}
tanksPerPage={TANKS_PER_PAGE}
searchedList={searchedList}
/>
</Suspense>
</PageWrapper>
);
}
-2
View File
@@ -6,7 +6,6 @@ import { COMMANDS_RAW, CommandRegistry } from '../events/interactionCreate';
const RAW_PATH = `https://raw.githubusercontent.com/tresabhi/blitzkrieg/main/docs/`; const RAW_PATH = `https://raw.githubusercontent.com/tresabhi/blitzkrieg/main/docs/`;
const DOCS = { const DOCS = {
permissions: 'guide/permissions',
embeds: 'guide/embeds', embeds: 'guide/embeds',
introduction: 'guide/introduction', introduction: 'guide/introduction',
invite: 'guide/invite', invite: 'guide/invite',
@@ -14,7 +13,6 @@ const DOCS = {
creators: 'guide/creators', creators: 'guide/creators',
}; };
const DOC_DESCRIPTIONS: Record<keyof typeof DOCS, string> = { const DOC_DESCRIPTIONS: Record<keyof typeof DOCS, string> = {
permissions: 'The permissions needed for the bot to function',
embeds: 'How embeds work', embeds: 'How embeds work',
introduction: 'About the bot', introduction: 'About the bot',
invite: 'How to invite the bot', invite: 'How to invite the bot',
+13 -14
View File
@@ -8,13 +8,12 @@ import { AllStats, getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getTankStats from '../core/blitz/getTankStats'; import getTankStats from '../core/blitz/getTankStats';
import getTreeType from '../core/blitz/getTreeType'; import getTreeType from '../core/blitz/getTreeType';
import resolveTankName from '../core/blitz/resolveTankName'; import { tankDefinitions } from '../core/blitzkrieg/definitions/tanks';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import { tankAverages } from '../core/blitzstars/tankAverages'; import { tankAverages } from '../core/blitzstars/tankAverages';
import { tankopedia } from '../core/blitzstars/tankopedia'; import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
import addFilterOptions from '../core/discord/addFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteTanks from '../core/discord/autocompleteTanks';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
@@ -34,17 +33,17 @@ import resolvePlayerFromCommand, {
import calculateWN8 from '../core/statistics/calculateWN8'; import calculateWN8 from '../core/statistics/calculateWN8';
import { StatFilters, filterStats } from '../core/statistics/filterStats'; import { StatFilters, filterStats } from '../core/statistics/filterStats';
import getWN8Percentile from '../core/statistics/getWN8Percentile'; import getWN8Percentile from '../core/statistics/getWN8Percentile';
import { CommandRegistryRaw } from '../events/interactionCreate'; import { CommandRegistryPromisable } from '../events/interactionCreate';
const ROWS_PER_PAGE = 8; const ROWS_PER_PAGE = 8;
const MAX_PAGES = 10; const MAX_PAGES = 10;
async function render( export async function renderBreakdown(
{ region, id }: ResolvedPlayer, { region, id }: ResolvedPlayer,
{ start, end, name }: ResolvedPeriod, { start, end, name }: ResolvedPeriod,
filters: StatFilters, filters: StatFilters,
) { ) {
const awaitedTankopedia = await tankopedia; const awaitedTankDefinitions = await tankDefinitions;
const awaitedTankAverages = await tankAverages; const awaitedTankAverages = await tankAverages;
const statsInPeriod = await getStatsInPeriod(region, id, start, end); const statsInPeriod = await getStatsInPeriod(region, id, start, end);
const { filteredOrder } = await filterStats(statsInPeriod, filters); const { filteredOrder } = await filterStats(statsInPeriod, filters);
@@ -183,7 +182,7 @@ async function render(
await Promise.all( await Promise.all(
filteredOrder.map(async (id, index) => { filteredOrder.map(async (id, index) => {
const tankopediaEntry = awaitedTankopedia[id]; const tankDefinition = awaitedTankDefinitions[id];
const current = orderedCurrentStats[index]; const current = orderedCurrentStats[index];
const career = orderedCareerStats[index]; const career = orderedCareerStats[index];
const currentWN8 = orderedCurrentWN8[index]; const currentWN8 = orderedCurrentWN8[index];
@@ -193,9 +192,9 @@ async function render(
<Breakdown.Row <Breakdown.Row
key={id} key={id}
type="tank" type="tank"
tankType={tankopediaEntry?.type} tankType={tankDefinition?.type}
treeType={tankopediaEntry ? await getTreeType(id) : undefined} treeType={tankDefinition ? await getTreeType(id) : undefined}
title={await resolveTankName(id)} title={awaitedTankDefinitions[id].name}
stats={[ stats={[
{ {
title: 'Battles', title: 'Battles',
@@ -291,9 +290,9 @@ async function render(
} }
} }
export const breakdownCommand = new Promise<CommandRegistryRaw>( export const breakdownCommand = new Promise<CommandRegistryPromisable>(
async (resolve) => { async (resolve) => {
const command = await addFilterOptions( const command = await addPeriodicFilterOptions(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('breakdown') .setName('breakdown')
.setDescription("A period's breakdown by tanks played"), .setDescription("A period's breakdown by tanks played"),
@@ -317,7 +316,7 @@ export const breakdownCommand = new Promise<CommandRegistryRaw>(
}); });
return [ return [
...(await render(player, period, filters)), ...(await renderBreakdown(player, period, filters)),
buttonPrimary(path, 'Refresh'), buttonPrimary(path, 'Refresh'),
await getBlitzStarsLinkButton(player.region, player.id), await getBlitzStarsLinkButton(player.region, player.id),
]; ];
@@ -333,7 +332,7 @@ export const breakdownCommand = new Promise<CommandRegistryRaw>(
const period = resolvePeriodFromButton(player.region, interaction); const period = resolvePeriodFromButton(player.region, interaction);
const filters = getFiltersFromButton(interaction); const filters = getFiltersFromButton(interaction);
return await render(player, period, filters); return await renderBreakdown(player, period, filters);
}, },
}); });
}, },
+1 -1
View File
@@ -13,7 +13,7 @@ export const debugCommand: CommandRegistry = {
.setName('debug') .setName('debug')
.setDescription('Debug information about the bot'), .setDescription('Debug information about the bot'),
async handler(interaction) { async handler() {
const currentTime = new Date().getTime(); const currentTime = new Date().getTime();
const uptime = currentTime - executionStart; const uptime = currentTime - executionStart;
const list = [ const list = [
+8 -8
View File
@@ -1,10 +1,10 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { tankDefinitions } from '../core/blitzkrieg/definitions/tanks';
import getPeriodNow from '../core/blitzkrieg/getPeriodNow'; import getPeriodNow from '../core/blitzkrieg/getPeriodNow';
import getTimeDaysAgo from '../core/blitzkrieg/getTimeDaysAgo'; import getTimeDaysAgo from '../core/blitzkrieg/getTimeDaysAgo';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import { tankAverages } from '../core/blitzstars/tankAverages'; import { tankAverages } from '../core/blitzstars/tankAverages';
import { tankopedia } from '../core/blitzstars/tankopedia';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import embedNegative from '../core/discord/embedNegative'; import embedNegative from '../core/discord/embedNegative';
import embedPositive from '../core/discord/embedPositive'; import embedPositive from '../core/discord/embedPositive';
@@ -66,11 +66,11 @@ export const eligibleCommand: CommandRegistry = {
(await entries.reduce<PossiblyPromise<number>>( (await entries.reduce<PossiblyPromise<number>>(
async (accumulator, [tankIdString, stats]) => { async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString); const tankId = parseInt(tankIdString);
const tankopediaEntry = (await tankopedia)[tankId]; const tankDefinition = (await tankDefinitions)[tankId];
if (!tankopediaEntry) return accumulator; if (!tankDefinition) return accumulator;
const tankTier = tankopediaEntry.tier; const tankTier = tankDefinition.tier;
return (await accumulator) + tankTier * stats.battles; return (await accumulator) + tankTier * stats.battles;
}, },
@@ -79,9 +79,9 @@ export const eligibleCommand: CommandRegistry = {
(await entries.reduce<PossiblyPromise<number>>( (await entries.reduce<PossiblyPromise<number>>(
async (accumulator, [tankIdString, stats]) => { async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString); const tankId = parseInt(tankIdString);
const tankopediaEntry = (await tankopedia)[tankId]; const tankDefinition = (await tankDefinitions)[tankId];
if (!tankopediaEntry) return accumulator; if (!tankDefinition) return accumulator;
return stats.battles; return stats.battles;
}, },
@@ -110,7 +110,7 @@ export const eligibleCommand: CommandRegistry = {
async (accumulator, [tankIdString, stats]) => { async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString); const tankId = parseInt(tankIdString);
if ((await tankopedia)[tankId]?.tier === 10) { if ((await tankDefinitions)[tankId]?.tier === 10) {
const tankStats = diffed[tankId]; const tankStats = diffed[tankId];
return (await accumulator) + tankStats.damage_dealt; return (await accumulator) + tankStats.damage_dealt;
} else return accumulator; } else return accumulator;
@@ -121,7 +121,7 @@ export const eligibleCommand: CommandRegistry = {
async (accumulator, [tankIdString, stats]) => { async (accumulator, [tankIdString, stats]) => {
const tankId = parseInt(tankIdString); const tankId = parseInt(tankIdString);
if ((await tankopedia)[tankId]?.tier === 10) { if ((await tankDefinitions)[tankId]?.tier === 10) {
const tankStats = diffed[tankId]; const tankStats = diffed[tankId];
return (await accumulator) + tankStats.battles; return (await accumulator) + tankStats.battles;
} else return accumulator; } else return accumulator;
+2 -2
View File
@@ -29,7 +29,7 @@ import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand, { import resolvePlayerFromCommand, {
ResolvedPlayer, ResolvedPlayer,
} from '../core/discord/resolvePlayerFromCommand'; } from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistryRaw } from '../events/interactionCreate'; import { CommandRegistryPromisable } from '../events/interactionCreate';
type EvolutionStatType = 'player' | 'tank'; type EvolutionStatType = 'player' | 'tank';
@@ -107,7 +107,7 @@ async function render(
); );
} }
export const evolutionCommand = new Promise<CommandRegistryRaw>( export const evolutionCommand = new Promise<CommandRegistryPromisable>(
async (resolve) => { async (resolve) => {
let playerGroup: SlashCommandSubcommandGroupBuilder; let playerGroup: SlashCommandSubcommandGroupBuilder;
let tankGroup: SlashCommandSubcommandGroupBuilder; let tankGroup: SlashCommandSubcommandGroupBuilder;
+4 -4
View File
@@ -10,7 +10,7 @@ import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import addFilterOptions from '../core/discord/addFilterOptions'; import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteTanks from '../core/discord/autocompleteTanks';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
@@ -29,7 +29,7 @@ import resolvePlayerFromCommand, {
} from '../core/discord/resolvePlayerFromCommand'; } from '../core/discord/resolvePlayerFromCommand';
import { StatFilters, filterStats } from '../core/statistics/filterStats'; import { StatFilters, filterStats } from '../core/statistics/filterStats';
import { getTierWeights } from '../core/statistics/getTierWeights'; import { getTierWeights } from '../core/statistics/getTierWeights';
import { CommandRegistryRaw } from '../events/interactionCreate'; import { CommandRegistryPromisable } from '../events/interactionCreate';
async function render( async function render(
{ region, id }: ResolvedPlayer, { region, id }: ResolvedPlayer,
@@ -64,9 +64,9 @@ async function render(
); );
} }
export const fullStatsCommand = new Promise<CommandRegistryRaw>( export const fullStatsCommand = new Promise<CommandRegistryPromisable>(
async (resolve) => { async (resolve) => {
const command = await addFilterOptions( const command = await addPeriodicFilterOptions(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('full-stats') .setName('full-stats')
.setDescription('Full in-game statistics'), .setDescription('Full in-game statistics'),
+140 -154
View File
@@ -1,25 +1,21 @@
import { teal } from '@radix-ui/colors'; import { teal } from '@radix-ui/colors';
import { import { GuildMemberRoleManager, SlashCommandBuilder } from 'discord.js';
AttachmentBuilder,
GuildMemberRoleManager,
SlashCommandBuilder,
} from 'discord.js';
import markdownEscape from 'markdown-escape'; import markdownEscape from 'markdown-escape';
import discord from '../../discord.json' assert { type: 'json' }; import discord from '../../discord.json' assert { type: 'json' };
import { Glow } from '../components/AllStatsOverview/components/WN8Display/components/Glow'; import { Glow } from '../components/AllStatsOverview/components/WN8Display/components/Glow';
import Wrapper from '../components/Wrapper'; import Wrapper from '../components/Wrapper';
import { REGION_NAMES_SHORT, Region } from '../constants/regions'; import { REGION_NAMES } from '../constants/regions';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz'; import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedInfo from '../core/discord/embedInfo';
import embedNegative from '../core/discord/embedNegative'; import embedNegative from '../core/discord/embedNegative';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistry } from '../events/interactionCreate'; import { CommandRegistry } from '../events/interactionCreate';
import { theme } from '../stitches.config'; import { theme } from '../stitches.config';
const serverAndIdPattern = /(com|eu|asia)\/[0-9]+/;
export const verifyCommand: CommandRegistry = { export const verifyCommand: CommandRegistry = {
inProduction: true, inProduction: true,
inPublic: true, inPublic: true,
@@ -30,193 +26,183 @@ export const verifyCommand: CommandRegistry = {
.addStringOption((option) => addUsernameChoices(option).setRequired(true)), .addStringOption((option) => addUsernameChoices(option).setRequired(true)),
async handler(interaction) { async handler(interaction) {
const username = interaction.options.getString('username', true); const { id, region } = await resolvePlayerFromCommand(interaction);
const discordId = parseInt(interaction.user.id);
const accountInfo = await getAccountInfo(region, id);
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
if (serverAndIdPattern.test(username)) { await linkBlitzAndDiscord(discordId, region, id);
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')) {
if (interaction.guildId === discord.sklld_guild_id) { return embedNegative(
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) { `${markdownEscape(interaction.user.username)} failed to verify`,
return embedNegative( "I don't have the permission to change your manage roles.",
`${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 ( await (interaction.member!.roles as GuildMemberRoleManager).remove(
<Wrapper fat> 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 <div
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'center',
gap: 16,
}} }}
> >
<Glow color={teal.teal9} rotation={90} /> <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 <div
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 16, gap: 4,
color: theme.colors.textLowContrast_teal,
}} }}
> >
<div <img
src="https://i.imgur.com/jIcRgog.png"
style={{ style={{
display: 'flex', width: 16,
gap: 8, height: 16,
alignItems: 'center',
justifyContent: 'center',
}} }}
> />
<span style={{ fontSize: 16 }}>Accounts linked</span>
</div>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
justifyContent: 'center',
}}
>
{clanAccountInfo?.clan ? (
<img <img
src={
interaction.user.avatarURL({ extension: 'png' }) ??
'https://i.stack.imgur.com/l60Hf.png'
}
style={{ style={{
width: 64, width: 64,
height: 64, height: 64,
borderRadius: 32, borderRadius: 32,
objectFit: 'cover', 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',
}}
>
{interaction.user.displayName}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
@{interaction.user.username}
</span>
</div>
</div>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', flexDirection: 'column',
justifyContent: 'center',
gap: 4, gap: 4,
color: theme.colors.textLowContrast_teal,
}} }}
> >
<img <span
src="https://i.imgur.com/jIcRgog.png"
style={{ style={{
width: 16, fontSize: 32,
height: 16, color: theme.colors.textHighContrast,
}} fontWeight: 900,
/> maxWidth: 240,
<span style={{ fontSize: 16 }}>Accounts linked</span> overflow: 'hidden',
</div> textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
<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 {accountInfo.nickname}
style={{ </span>
fontSize: 32, <span
color: theme.colors.textHighContrast, style={{
fontWeight: 900, fontSize: 16,
maxWidth: 240, color: theme.colors.textLowContrast,
overflow: 'hidden', }}
textOverflow: 'ellipsis', >
whiteSpace: 'nowrap', {clanAccountInfo?.clan
}} ? `[${clanAccountInfo?.clan?.tag}] - `
> : ''}
{accountInfo.nickname} {REGION_NAMES[region]}
</span> </span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
{clanAccountInfo?.clan
? `[${clanAccountInfo?.clan?.tag}] - `
: ''}
{REGION_NAMES_SHORT[region as Region]}
</span>
</div>
</div> </div>
</div> </div>
<Glow color={teal.teal9} rotation={-90} />
</div> </div>
</Wrapper>
); <Glow color={teal.teal9} rotation={-90} />
} else { </div>
return [ </Wrapper>,
embedNegative(
'Please select from the options', embedInfo(
'Follow the procedure like the video above. The search results may take a while to show up.', 'Incorrect account?',
), 'Use the command again and select a username from the search results.',
new AttachmentBuilder('https://i.imgur.com/2p6GFgC.gif'), ),
]; ];
}
}, },
autocomplete: autocompleteUsername, autocomplete: autocompleteUsername,
+23 -23
View File
@@ -3,19 +3,19 @@ import NoData, { NoDataType } from '../components/NoData';
import * as Tanks from '../components/Tanks'; import * as Tanks from '../components/Tanks';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper'; import Wrapper from '../components/Wrapper';
import { encyclopediaInfo } from '../core/blitz/encyclopediaInfo';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getTankStats from '../core/blitz/getTankStats'; import getTankStats from '../core/blitz/getTankStats';
import getTreeType from '../core/blitz/getTreeType'; import getTreeType from '../core/blitz/getTreeType';
import resolveTankName from '../core/blitz/resolveTankName';
import { tankopediaInfo } from '../core/blitz/tankopediaInfo';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
import { import {
BlitzkriegTankDefinition,
TIER_ROMAN_NUMERALS, TIER_ROMAN_NUMERALS,
TankopediaEntry,
Tier, Tier,
tankopedia, tankDefinitions,
} from '../core/blitzstars/tankopedia'; } from '../core/blitzkrieg/definitions/tanks';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
import { tankIcon } from '../core/blitzkrieg/tankIcon';
import addTierChoices from '../core/discord/addTierChoices'; import addTierChoices from '../core/discord/addTierChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
@@ -41,21 +41,21 @@ export const ownedTanksCommand: CommandRegistry = {
const filteredTanks = ( const filteredTanks = (
await Promise.all( await Promise.all(
tankStats.map(async (tankData) => ({ tankStats.map(async (tankData) => ({
tankopedia: (await tankopedia)[tankData.tank_id]!, tankDefinitions: (await tankDefinitions)[tankData.tank_id]!,
tank_id: tankData.tank_id, id: tankData.tank_id,
})), })),
) )
).filter((tank) => tank.tankopedia?.tier === tier); ).filter((tank) => tank.tankDefinitions?.tier === tier);
const clanAccountInfo = await getClanAccountInfo(server, id, ['clan']); const clanAccountInfo = await getClanAccountInfo(server, id, ['clan']);
const groupedTanks: Record<string, TankopediaEntry[]> = {}; const groupedTanks: Record<string, BlitzkriegTankDefinition[]> = {};
const nations: string[] = []; const nations: string[] = [];
filteredTanks.forEach((tank) => { filteredTanks.forEach((tank) => {
if (groupedTanks[tank.tankopedia.nation] === undefined) { if (groupedTanks[tank.tankDefinitions.nation] === undefined) {
groupedTanks[tank.tankopedia.nation] = [tank.tankopedia]; groupedTanks[tank.tankDefinitions.nation] = [tank.tankDefinitions];
nations.push(tank.tankopedia.nation); nations.push(tank.tankDefinitions.nation);
} else { } else {
groupedTanks[tank.tankopedia.nation].push(tank.tankopedia); groupedTanks[tank.tankDefinitions.nation].push(tank.tankDefinitions);
} }
}); });
@@ -86,7 +86,7 @@ export const ownedTanksCommand: CommandRegistry = {
return ( return (
<Tanks.Root> <Tanks.Root>
<Tanks.Title> <Tanks.Title>
{(await tankopediaInfo).vehicle_nations[nation]} {(await encyclopediaInfo).vehicle_nations[nation]}
</Tanks.Title> </Tanks.Title>
<Tanks.Row> <Tanks.Row>
@@ -94,11 +94,11 @@ export const ownedTanksCommand: CommandRegistry = {
{await Promise.all( {await Promise.all(
leftColumn.map(async (tank) => ( leftColumn.map(async (tank) => (
<Tanks.Item <Tanks.Item
key={tank.tank_id} key={tank.id}
name={await resolveTankName(tank.tank_id)} name={tank.name}
tankType={tank.type} tankType={tank.type}
image={tank.images?.normal} image={tankIcon(tank.id)}
treeType={await getTreeType(tank.tank_id)} treeType={await getTreeType(tank.id)}
/> />
)), )),
)} )}
@@ -107,11 +107,11 @@ export const ownedTanksCommand: CommandRegistry = {
{await Promise.all( {await Promise.all(
rightColumn.map(async (tank) => ( rightColumn.map(async (tank) => (
<Tanks.Item <Tanks.Item
key={tank.tank_id} key={tank.id}
name={await resolveTankName(tank.tank_id)} name={tank.name}
tankType={tank.type} tankType={tank.type}
image={tank.images?.normal} image={tankIcon(tank.id)}
treeType={await getTreeType(tank.tank_id)} treeType={await getTreeType(tank.id)}
/> />
)), )),
)} )}
+42
View File
@@ -0,0 +1,42 @@
import { SlashCommandBuilder } from 'discord.js';
import { CommandRegistry } from '../events/interactionCreate';
export const permissionsCommand: CommandRegistry = {
inProduction: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName('permissions')
.setDescription('Checks wether the bot has the required permissions'),
async handler(interaction) {
// friday the 13th permissions easter egg
const isFridayThe13th =
new Date().getDay() === 5 && new Date().getDate() === 13;
const permissions = [
[
interaction.appPermissions?.has('ViewChannel'),
'View channels: needed for the refresh button to work',
],
[
interaction.appPermissions?.has('ReadMessageHistory'),
'Read message history: also needed for the refresh button to work',
],
[
interaction.appPermissions?.has('AttachFiles'),
'Attach files: needed for image-based commands to work',
],
];
const body = permissions
.map(([hasPermission, description]) => {
if (hasPermission) {
return `${isFridayThe13th ? '😇' : '✅'} ${description}`;
} else {
return `${isFridayThe13th ? '💀' : '❌'} ${description}`;
}
})
.join('\n');
return `# Permissions\n${body}`;
},
};
+164 -192
View File
@@ -2,9 +2,9 @@ import {
APIApplicationCommandOptionChoice, APIApplicationCommandOptionChoice,
SlashCommandBuilder, SlashCommandBuilder,
SlashCommandSubcommandBuilder, SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from 'discord.js'; } from 'discord.js';
import { range } from 'lodash'; import { range } from 'lodash';
import markdownEscape from 'markdown-escape';
import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard'; import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard';
import * as Leaderboard from '../components/Leaderboard'; import * as Leaderboard from '../components/Leaderboard';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
@@ -17,17 +17,17 @@ import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getRatingsInfo from '../core/blitz/getRatingsInfo'; import getRatingsInfo from '../core/blitz/getRatingsInfo';
import { getRatingsLeague } from '../core/blitz/getRatingsLeague'; import { getRatingsLeague } from '../core/blitz/getRatingsLeague';
import { getRatingsNeighbors } from '../core/blitz/getRatingsNeighbors'; import { getRatingsNeighbors } from '../core/blitz/getRatingsNeighbors';
import { isOnGoingRatingsSeason } from '../core/blitz/isOnGoingRatingsSeason';
import { getArchivedLatestSeasonNumber } from '../core/blitzkrieg/getArchivedLatestSeasonNumber'; import { getArchivedLatestSeasonNumber } from '../core/blitzkrieg/getArchivedLatestSeasonNumber';
import getArchivedRatingsInfo from '../core/blitzkrieg/getArchivedRatingsInfo'; import getArchivedRatingsInfo from '../core/blitzkrieg/getArchivedRatingsInfo';
import { getArchivedRatingsLeaderboard } from '../core/blitzkrieg/getArchivedRatingsLeaderboard'; import { getArchivedRatingsLeaderboard } from '../core/blitzkrieg/getArchivedRatingsLeaderboard';
import getArchivedRatingsMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingsMidnightLeaderboard'; import getArchivedRatingsMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingsMidnightLeaderboard';
import { UserError } from '../core/blitzkrieg/userError'; import { UserError } from '../core/blitzkrieg/userError';
import addRegionChoices from '../core/discord/addRegionChoices'; import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
import embedNegative from '../core/discord/embedNegative'; import embedNegative from '../core/discord/embedNegative';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistryRaw } from '../events/interactionCreate'; import { CommandRegistryPromisable } from '../events/interactionCreate';
export interface RatingsPlayer { export interface RatingsPlayer {
spa_id: number; spa_id: number;
@@ -116,18 +116,22 @@ export interface RatingsNeighbors {
neighbors: RatingsPlayer[]; neighbors: RatingsPlayer[];
} }
function addSubcommands( const noOngoingSeason = embedNegative(
option: SlashCommandSubcommandGroupBuilder, 'No ongoing season',
addOptions = (option: SlashCommandSubcommandBuilder) => option, "Wargaming didn't provide any data for this season.",
) { );
return option
.addSubcommand((option) => export const ratingsCommand = new Promise<CommandRegistryPromisable>(
addOptions( async (resolve) => {
option const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
.setName('neighbors') const onGoingSeason = await isOnGoingRatingsSeason();
.setDescription('Your position and neighbors'), const seasonNumbers = range(
) FIRST_ARCHIVED_RATINGS_SEASON,
.addStringOption(addUsernameChoices) latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1,
);
function addSubcommands(option: SlashCommandSubcommandBuilder) {
return option
.addIntegerOption((option) => .addIntegerOption((option) =>
option option
.setName('limit') .setName('limit')
@@ -135,77 +139,57 @@ function addSubcommands(
.setRequired(false) .setRequired(false)
.setMinValue(5) .setMinValue(5)
.setMaxValue(30), .setMaxValue(30),
), )
)
.addSubcommand((option) =>
addOptions(option.setName('league').setDescription('Top league players'))
.addStringOption((option) => .addStringOption((option) =>
option option
.setName('league') .setName('season')
.setDescription('The league to display') .setDescription('Season number')
.addChoices(
...LEAGUES.map(
({ name }, value) =>
({
name,
value: `${value}`,
}) satisfies APIApplicationCommandOptionChoice<string>,
),
)
.setRequired(true),
)
.addStringOption(addRegionChoices)
.addIntegerOption((option) =>
option
.setName('limit')
.setDescription('How many rows to display (default: 10)')
.setRequired(false) .setRequired(false)
.setMinValue(5) .addChoices(
.setMaxValue(30), ...seasonNumbers
), .map(
); (number, index) =>
} ({
name: `${number}${index === seasonNumbers.length - 1 && onGoingSeason ? ' (current)' : ''}`,
const noOngoingSeason = embedNegative( value: `${number}`,
'No ongoing season', }) satisfies APIApplicationCommandOptionChoice<string>,
"Wargaming didn't provide any data for this season.", )
); .reverse(),
),
export const ratingsCommand = new Promise<CommandRegistryRaw>( );
async (resolve) => { }
const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
const seasonChoices = range(
FIRST_ARCHIVED_RATINGS_SEASON,
latestArchivedSeasonNumber + 1,
).map(
(number) =>
({
name: `Season ${number}`,
value: `${number}`,
}) satisfies APIApplicationCommandOptionChoice<string>,
);
const command = new SlashCommandBuilder() const command = new SlashCommandBuilder()
.setName('ratings') .setName('ratings')
.setDescription('Ratings battles statistics') .setDescription('Ratings battles statistics')
.addSubcommandGroup((option) => .addSubcommand((option) =>
addSubcommands(
option.setName('archive').setDescription('Any previous season'),
(option) =>
option.addStringOption((option) =>
option
.setName('season')
.setDescription('The season number')
.setChoices(...seasonChoices)
.setRequired(true),
),
),
)
.addSubcommandGroup((option) =>
addSubcommands( addSubcommands(
option option
.setName('current') .setName('neighbors')
.setDescription('The current running Ratings season'), .setDescription('Shows neighboring players on the leaderboard'),
),
)
.addSubcommand((option) =>
addSubcommands(
option
.setName('league')
.setDescription('Shows top players from a league')
.addStringOption(addRegionChoices)
.addStringOption((option) =>
option
.setName('league')
.setDescription('The league to display')
.addChoices(
...LEAGUES.map(
({ name }, value) =>
({
name,
value: `${value}`,
}) satisfies APIApplicationCommandOptionChoice<string>,
),
)
.setRequired(true),
),
), ),
); );
@@ -224,18 +208,19 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
nickname: string; nickname: string;
} }
const subcommandGroup = interaction.options.getSubcommandGroup(true) as
| 'archive'
| 'current';
const subcommand = interaction.options.getSubcommand(true) as const subcommand = interaction.options.getSubcommand(true) as
| 'league' | 'league'
| 'neighbors'; | 'neighbors';
const limit = interaction.options.getInteger('limit') ?? 10; const limit = interaction.options.getInteger('limit') ?? 10;
const seasonOption = interaction.options.getString('season'); const seasonOption = interaction.options.getString('season');
const season = seasonOption ? parseInt(seasonOption) : undefined; const season = seasonOption
? parseInt(seasonOption)
: onGoingSeason
? latestArchivedSeasonNumber + 1
: latestArchivedSeasonNumber;
const isCurrentSeason = season === latestArchivedSeasonNumber + 1;
let titleName: string; let titleName: string;
let titleImage: string | undefined; let titleImage: string | undefined;
let titleNameDiscriminator: string | undefined;
let titleDescription: string; let titleDescription: string;
let playersBefore: number; let playersBefore: number;
let playersAfter: number; let playersAfter: number;
@@ -249,69 +234,66 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
interaction.options.getString('league')!, interaction.options.getString('league')!,
); );
const region = interaction.options.getString('region') as Region; const region = interaction.options.getString('region') as Region;
regionRatingsInfo = await (subcommandGroup === 'current' regionRatingsInfo = await (isCurrentSeason
? getRatingsInfo(region) ? getRatingsInfo(region)
: getArchivedRatingsInfo(region, season!)); : getArchivedRatingsInfo(region, season!));
if (regionRatingsInfo.detail !== undefined) return noOngoingSeason; if (regionRatingsInfo.detail !== undefined) return noOngoingSeason;
const result = const result = isCurrentSeason
subcommandGroup === 'current' ? await getRatingsLeague(region, leagueIndex).then(async (result) =>
? await getRatingsLeague(region, leagueIndex).then( result.slice(0, limit).map(
async (result) => (player) =>
result.slice(0, limit).map( ({
(player) => id: player.spa_id,
({ score: player.score,
id: player.spa_id, position: player.number,
score: player.score, clan: player.clan_tag,
position: player.number, nickname: player.nickname,
clan: player.clan_tag, }) satisfies SimplifiedPlayer,
nickname: player.nickname, ),
}) satisfies SimplifiedPlayer, )
), : await getArchivedRatingsLeaderboard(region, season!).then(
) async (players) => {
: await getArchivedRatingsLeaderboard(region, season!).then( const firstPlayerIndex =
async (players) => { leagueIndex === 0
const firstPlayerIndex = ? 0
leagueIndex === 0 : players.findIndex(
? 0 (player) =>
: players.findIndex( player.score < LEAGUES[leagueIndex - 1].minScore,
(player) => );
player.score < LEAGUES[leagueIndex - 1].minScore, const lastPlayerIndex = firstPlayerIndex + limit - 1;
); const trimmed = players.slice(
const lastPlayerIndex = firstPlayerIndex + limit - 1; firstPlayerIndex,
const trimmed = players.slice( lastPlayerIndex + 1,
firstPlayerIndex, );
lastPlayerIndex + 1, const clanData = await getClanAccountInfo(
); region,
const clanData = await getClanAccountInfo( trimmed.map(({ id }) => id),
region, ['clan'],
trimmed.map(({ id }) => id), );
['clan'],
);
return trimmed.map( return trimmed.map(
(player, index) => (player, index) =>
({ ({
id: player.id, id: player.id,
score: player.score, score: player.score,
position: firstPlayerIndex + index + 1, position: firstPlayerIndex + index + 1,
clan: clanData[player.id]?.clan?.tag, clan: clanData[player.id]?.clan?.tag,
nickname: nickname:
clanData[player.id]?.account_name ?? clanData[index]?.account_name ??
`Unknown Player ${player.id}`, `Deleted ${player.id}`,
}) satisfies SimplifiedPlayer, }) satisfies SimplifiedPlayer,
); );
}, },
); );
const leagueInfo = regionRatingsInfo.leagues[leagueIndex]; const leagueInfo = regionRatingsInfo.leagues[leagueIndex];
midnightLeaderboard = midnightLeaderboard = isCurrentSeason
subcommandGroup === 'current' ? await getArchivedRatingsMidnightLeaderboard(
? await getArchivedRatingsMidnightLeaderboard( region,
region, regionRatingsInfo.current_season,
regionRatingsInfo.current_season, )
) : await getArchivedRatingsMidnightLeaderboard(region, season!);
: await getArchivedRatingsMidnightLeaderboard(region, season!);
players = result; players = result;
playersBefore = result ? result[0].position - 1 : 0; playersBefore = result ? result[0].position - 1 : 0;
@@ -326,22 +308,17 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
titleDescription = `Top ${limit} players`; titleDescription = `Top ${limit} players`;
} else { } else {
const { region, id } = await resolvePlayerFromCommand(interaction); const { region, id } = await resolvePlayerFromCommand(interaction);
regionRatingsInfo = regionRatingsInfo = isCurrentSeason
subcommandGroup === 'current' ? await getRatingsInfo(region)
? await getRatingsInfo(region) : await getArchivedRatingsInfo(region, season!);
: await getArchivedRatingsInfo(region, season!);
if (regionRatingsInfo.detail !== undefined) return noOngoingSeason; if (regionRatingsInfo.detail !== undefined) return noOngoingSeason;
const accountInfo = await getAccountInfo(region, id); const accountInfo = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const neighbors = const neighbors = isCurrentSeason
subcommandGroup === 'current' ? await getRatingsNeighbors(region, id, Math.round(limit / 2)).then(
? await getRatingsNeighbors( (players) =>
region,
id,
Math.round(limit / 2),
).then((players) =>
players.neighbors.map( players.neighbors.map(
(player) => (player) =>
({ ({
@@ -352,52 +329,47 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
nickname: player.nickname, nickname: player.nickname,
}) satisfies SimplifiedPlayer, }) satisfies SimplifiedPlayer,
), ),
) )
: await getArchivedRatingsLeaderboard(region, season!).then( : await getArchivedRatingsLeaderboard(region, season!).then(
async (players) => { async (players) => {
const playerIndex = players.findIndex( const playerIndex = players.findIndex(
(player) => player.id === id, (player) => player.id === id,
);
if (playerIndex === -1) {
throw new UserError(
`# ${markdownEscape(accountInfo.nickname)} didn't play ratings in season ${season}\nThis player did not participate in this season or did not get past calibration.`,
); );
}
if (playerIndex === -1) { const halfRange = Math.round(limit / 2);
throw new UserError( const trimmed = players.slice(
`${accountInfo.nickname} didn't player ratings in season ${season}`, playerIndex - halfRange,
{ playerIndex + halfRange + 1,
cause: );
'This player did not participate in this season or did not get past calibration.', const clanData = await getClanAccountInfo(
}, region,
); trimmed.map((player) => player.id),
} ['clan'],
);
const halfRange = Math.round(limit / 2); return trimmed.map((player, index) => ({
const trimmed = players.slice( id: player.id,
playerIndex - halfRange, score: player.score,
playerIndex + halfRange + 1, position: playerIndex + index + 1,
); clan: clanData[index]?.clan?.tag,
const clanData = await getClanAccountInfo( nickname: clanData[index]
region, ? clanData[index]!.account_name
trimmed.map((player) => player.id), : `Deleted ${player.id}`,
['clan'], }));
); },
);
return trimmed.map((player, index) => ({ midnightLeaderboard = isCurrentSeason
id: player.id, ? await getArchivedRatingsMidnightLeaderboard(
score: player.score, region,
position: playerIndex + index + 1, regionRatingsInfo.current_season,
clan: clanData[player.id]!.clan?.tag, )
nickname: clanData[player.id]!.account_name, : await getArchivedRatingsMidnightLeaderboard(region, season!);
}));
},
);
midnightLeaderboard =
subcommandGroup === 'current'
? await getArchivedRatingsMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: await getArchivedRatingsMidnightLeaderboard(region, season!);
if (clan && neighbors) titleNameDiscriminator = `[${clan.tag}]`;
players = neighbors; players = neighbors;
titleImage = clan titleImage = clan
+2 -2
View File
@@ -1,7 +1,7 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import { go } from 'fuzzysort'; import { go } from 'fuzzysort';
import markdownEscape from 'markdown-escape'; import markdownEscape from 'markdown-escape';
import { TANK_NAMES_DIACRITICS } from '../core/blitzstars/tankopedia'; import { tankNamesDiacritics } from '../core/blitzkrieg/definitions/tanks';
import addTankChoices from '../core/discord/addTankChoices'; import addTankChoices from '../core/discord/addTankChoices';
import embedInfo from '../core/discord/embedInfo'; import embedInfo from '../core/discord/embedInfo';
import { CommandRegistry } from '../events/interactionCreate'; import { CommandRegistry } from '../events/interactionCreate';
@@ -25,7 +25,7 @@ export const searchTanksCommand: CommandRegistry = {
async handler(interaction) { async handler(interaction) {
const tank = interaction.options.getString('tank')!; const tank = interaction.options.getString('tank')!;
const limit = interaction.options.getInteger('limit') ?? 25; const limit = interaction.options.getInteger('limit') ?? 25;
const results = go(tank, await TANK_NAMES_DIACRITICS, { const results = go(tank, await tankNamesDiacritics, {
limit, limit,
keys: ['combined'], keys: ['combined'],
}).map((result) => result.obj.original); }).map((result) => result.obj.original);
+42 -40
View File
@@ -9,7 +9,7 @@ import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import addFilterOptions from '../core/discord/addFilterOptions'; import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteTanks from '../core/discord/autocompleteTanks';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
@@ -27,7 +27,7 @@ import resolvePlayerFromCommand, {
ResolvedPlayer, ResolvedPlayer,
} from '../core/discord/resolvePlayerFromCommand'; } from '../core/discord/resolvePlayerFromCommand';
import { StatFilters, filterStats } from '../core/statistics/filterStats'; import { StatFilters, filterStats } from '../core/statistics/filterStats';
import { CommandRegistryRaw } from '../events/interactionCreate'; import { CommandRegistryPromisable } from '../events/interactionCreate';
async function render( async function render(
{ region, id }: ResolvedPlayer, { region, id }: ResolvedPlayer,
@@ -57,48 +57,50 @@ async function render(
); );
} }
export const statsCommand = new Promise<CommandRegistryRaw>(async (resolve) => { export const statsCommand = new Promise<CommandRegistryPromisable>(
const command = await addFilterOptions( async (resolve) => {
new SlashCommandBuilder() const command = await addPeriodicFilterOptions(
.setName('stats') new SlashCommandBuilder()
.setDescription('Regular battles statistics'), .setName('stats')
(option) => option.addStringOption(addUsernameChoices), .setDescription('Regular battles statistics'),
); (option) => option.addStringOption(addUsernameChoices),
);
resolve({ resolve({
inProduction: true, inProduction: true,
inPublic: true, inPublic: true,
command, command,
async handler(interaction) { async handler(interaction) {
const player = await resolvePlayerFromCommand(interaction); const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(player.region, interaction); const period = resolvePeriodFromCommand(player.region, interaction);
const filters = await getFiltersFromCommand(interaction); const filters = await getFiltersFromCommand(interaction);
const path = commandToURL(interaction, { const path = commandToURL(interaction, {
...player, ...player,
...getCustomPeriodParams(interaction), ...getCustomPeriodParams(interaction),
...filters, ...filters,
}); });
return Promise.all([ return Promise.all([
render(player, period, filters), render(player, period, filters),
buttonRefresh(interaction, path), buttonRefresh(interaction, path),
getBlitzStarsLinkButton(player.region, player.id), getBlitzStarsLinkButton(player.region, player.id),
]); ]);
}, },
autocomplete: (interaction) => { autocomplete: (interaction) => {
autocompleteUsername(interaction); autocompleteUsername(interaction);
autocompleteTanks(interaction); autocompleteTanks(interaction);
}, },
async button(interaction) { async button(interaction) {
const player = await resolvePlayerFromButton(interaction); const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction); const period = resolvePeriodFromButton(player.region, interaction);
const filters = getFiltersFromButton(interaction); const filters = getFiltersFromButton(interaction);
return await render(player, period, filters); return await render(player, period, filters);
}, },
}); });
}); },
);
+71
View File
@@ -0,0 +1,71 @@
import { SlashCommandBuilder } from 'discord.js';
import { encyclopediaInfo } from '../core/blitz/encyclopediaInfo';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
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 from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistryPromisable } from '../events/interactionCreate';
import { renderBreakdown } from './breakdown';
export const todayCommand = new Promise<CommandRegistryPromisable>(
async (resolve) => {
const awaitedEncyclopediaInfo = await encyclopediaInfo;
const command = addFilterOptions(
new SlashCommandBuilder()
.setName('today')
.setDescription("Today's played tanks and statistics"),
awaitedEncyclopediaInfo,
).addStringOption(addUsernameChoices);
resolve({
inProduction: true,
inPublic: true,
command,
async handler(interaction) {
const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(
player.region,
interaction,
'today',
);
const filters = await getFiltersFromCommand(interaction);
const path = commandToURL(interaction, {
...player,
...getCustomPeriodParams(interaction, true),
...filters,
});
return [
...(await renderBreakdown(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 renderBreakdown(player, period, filters);
},
});
},
);
@@ -37,7 +37,7 @@ export function WN8Display({ WN8 }: WN8DisplayProps) {
gap: -16, gap: -16,
}} }}
> >
<Glow color={PERCENTILE_COLORS[percentile]} /> <Glow color={color} />
<div <div
style={{ style={{
@@ -71,7 +71,7 @@ export function WN8Display({ WN8 }: WN8DisplayProps) {
</span> </span>
</div> </div>
<Glow color={PERCENTILE_COLORS[percentile]} direction="reverse" /> <Glow color={color} direction="reverse" />
</div> </div>
); );
} }
+15 -7
View File
@@ -5,7 +5,7 @@ import {
GrayColor, GrayColor,
PALETTES, PALETTES,
} from '../../../constants/radixColors'; } from '../../../constants/radixColors';
import { TREE_TYPE_ICONS, TreeTypeEnum } from '../../Tanks'; import { TREE_TYPE_ICONS, TankType, TreeTypeEnum } from '../../Tanks';
import { RowStat } from './RowStat'; import { RowStat } from './RowStat';
export interface RowStatItem { export interface RowStatItem {
@@ -18,10 +18,11 @@ export interface RowStatItem {
interface RowProps { interface RowProps {
type?: 'tank' | 'summary'; type?: 'tank' | 'summary';
naked?: boolean;
title: string; title: string;
minimized?: boolean; minimized?: boolean;
treeType?: TreeTypeEnum; treeType?: TreeTypeEnum;
tankType?: string; tankType?: TankType;
color?: AccentColor | GrayColor; color?: AccentColor | GrayColor;
stats: (RowStatItem | undefined)[]; stats: (RowStatItem | undefined)[];
} }
@@ -33,18 +34,23 @@ export function Row({
minimized, minimized,
tankType, tankType,
treeType, treeType,
naked = false,
type, type,
}: RowProps) { }: RowProps) {
const theme = createColors(PALETTES[`${color}Dark`]); const palette = PALETTES[`${color}Dark`];
const theme = createColors(palette);
return ( return (
<div <div
className="session-tracker-row" className="session-tracker-row"
style={{ style={{
maxWidth: naked ? 600 : undefined,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
borderRadius: 4, borderRadius: 4,
backgroundColor: theme.appBackground2, backgroundColor: naked
? `${theme.appBackground2}c0`
: theme.appBackground2,
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
@@ -55,7 +61,9 @@ export function Row({
justifyContent: 'center', justifyContent: 'center',
padding: 8, padding: 8,
gap: 4, gap: 4,
backgroundColor: theme.componentInteractive, backgroundColor: naked
? `${theme.componentInteractive}c0`
: theme.componentInteractive,
}} }}
> >
{type === 'tank' && tankType !== undefined && ( {type === 'tank' && tankType !== undefined && (
@@ -72,8 +80,8 @@ export function Row({
? treeType === TreeTypeEnum.Collector ? treeType === TreeTypeEnum.Collector
? theme.textLowContrast_blue ? theme.textLowContrast_blue
: treeType === TreeTypeEnum.Premium : treeType === TreeTypeEnum.Premium
? theme.textLowContrast_amber ? theme.textLowContrast_amber
: theme.textHighContrast : theme.textHighContrast
: theme.textLowContrast, : theme.textLowContrast,
fontWeight: 900, fontWeight: 900,
fontSize: 16, fontSize: 16,
+19
View File
@@ -0,0 +1,19 @@
import { asset } from '../core/blitzkrieg/asset';
interface FlagProps {
nation: string;
}
export function Flag({ nation }: FlagProps) {
return (
<div
style={{
width: 22,
height: 22,
overflow: 'hidden',
}}
>
<img src={asset(`flags/circle/${nation}.webp`)} alt={nation} />
</div>
);
}
@@ -1,21 +0,0 @@
import { ReactNode } from 'react';
interface ButtonProps {
children: ReactNode;
}
export function Button({ children }: ButtonProps) {
return (
<button
style={{
padding: 16,
border: 'none',
cursor: 'pointer',
background: 'none',
color: 'inherit',
}}
>
{children}
</button>
);
}
+61 -27
View File
@@ -1,38 +1,72 @@
import { DiscordLogoIcon } from '@radix-ui/react-icons';
import { Flex, Text, Theme } from '@radix-ui/themes';
import Link from 'next/link'; import Link from 'next/link';
import { theme } from '../../stitches.config'; import { theme } from '../../stitches.config';
import { BlitzkriegWormWide } from '../BlitzkriegWormWide'; import { BlitzkriegWormWide } from '../BlitzkriegWormWide';
export default function Navbar() { export default function Navbar() {
return ( return (
<div <Theme radius="none">
style={{ <div
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: theme.colors.textHighContrast,
backgroundColor: theme.colors.appBackground2,
borderBottom: theme.borderStyles.nonInteractive,
height: 48, // TODO: remove this when the icons are implemented
}}
>
{/* <Button>
<HamburgerMenuIcon />
</Button> */}
<Link
href="/"
style={{ style={{
color: 'inherit', position: 'fixed',
display: 'flex', top: 0,
alignItems: 'center', left: 0,
justifyContent: 'center', width: '100%',
zIndex: 1,
padding: '1rem',
background: theme.colors.appBackground1_alpha,
borderBottom: theme.borderStyles.nonInteractive,
backdropFilter: 'blur(4rem)',
boxSizing: 'border-box',
}} }}
> >
<BlitzkriegWormWide /> <Flex justify="center">
</Link> <Flex
{/* <Button> justify="between"
<PersonIcon /> align="center"
</Button> */} style={{ maxWidth: 800, width: '100%', padding: '0 16px' }}
</div> >
<Flex justify="center" align="center" gap="2">
<Link
href="/"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<BlitzkriegWormWide />
</Link>
<Text size="2">❤️</Text>
<Text size="2">
<Link
href="https://tresabhi.github.io/"
style={{ color: 'inherit', textDecoration: 'none' }}
target="_blank"
>
by TrèsAbhi
</Link>
</Text>
</Flex>
<Flex justify="center" align="center" gap="2">
<Link
href="https://discord.gg/nDt7AjGJQH"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<DiscordLogoIcon />
</Link>
</Flex>
</Flex>
</Flex>
</div>
</Theme>
); );
} }
+4 -2
View File
@@ -3,28 +3,30 @@ import { ComponentProps } from 'react';
interface PageWrapperProps extends ComponentProps<typeof Flex> { interface PageWrapperProps extends ComponentProps<typeof Flex> {
color?: ComponentProps<typeof Theme>['accentColor']; color?: ComponentProps<typeof Theme>['accentColor'];
size?: 'regular' | 'wide';
} }
export default function PageWrapper({ export default function PageWrapper({
style, style,
color, color,
size = 'regular',
...props ...props
}: PageWrapperProps) { }: PageWrapperProps) {
return ( return (
<Theme accentColor={color}> <Theme accentColor={color}>
<Flex <Flex
{...props}
direction="column" direction="column"
gap="4" gap="4"
style={{ style={{
width: '100%', width: '100%',
maxWidth: 800, maxWidth: size === 'regular' ? 800 : 960,
margin: 'auto', margin: 'auto',
padding: 16, padding: 16,
boxSizing: 'border-box', boxSizing: 'border-box',
...style, ...style,
}} }}
{...props}
/> />
</Theme> </Theme>
); );
+3 -3
View File
@@ -1,4 +1,5 @@
import { import {
amber,
blue, blue,
green, green,
lime, lime,
@@ -8,7 +9,6 @@ import {
red, red,
slate, slate,
teal, teal,
yellow,
} from '@radix-ui/colors'; } from '@radix-ui/colors';
import { Percentile } from '../constants/percentiles'; import { Percentile } from '../constants/percentiles';
@@ -20,8 +20,8 @@ export const PERCENTILE_COLORS = {
[Percentile.VeryBad]: slate.slate9, [Percentile.VeryBad]: slate.slate9,
[Percentile.Bad]: red.red9, [Percentile.Bad]: red.red9,
[Percentile.BelowAverage]: orange.orange9, [Percentile.BelowAverage]: orange.orange9,
[Percentile.Average]: yellow.yellow9, [Percentile.Average]: amber.amber10,
[Percentile.AboveAverage]: lime.lime9, [Percentile.AboveAverage]: lime.lime8,
[Percentile.Good]: green.green9, [Percentile.Good]: green.green9,
[Percentile.VeryGood]: teal.teal9, [Percentile.VeryGood]: teal.teal9,
[Percentile.Great]: blue.blue9, [Percentile.Great]: blue.blue9,
+20 -4
View File
@@ -2,7 +2,7 @@ import {
TANK_ICONS, TANK_ICONS,
TANK_ICONS_COLLECTOR, TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM, TANK_ICONS_PREMIUM,
} from '../../../core/blitzstars/tankopedia'; } from '../../../core/blitzkrieg/definitions/tanks';
import { theme } from '../../../stitches.config'; import { theme } from '../../../stitches.config';
export enum TreeTypeEnum { export enum TreeTypeEnum {
@@ -11,17 +11,33 @@ export enum TreeTypeEnum {
Collector, Collector,
} }
export type TreeTypeString = 'tech-tree' | 'premium' | 'collector'; export const TANK_TYPES = [
'tank_destroyer',
'light',
'medium',
'heavy',
] as const;
export const TREE_TYPES = ['researchable', 'premium', 'collector'] as const;
export type TankType = (typeof TANK_TYPES)[number];
export type TreeTypeString = (typeof TREE_TYPES)[number];
export const TREE_TYPE_NAMES: Record<TreeTypeString, string> = { export const TREE_TYPE_NAMES: Record<TreeTypeString, string> = {
'tech-tree': 'Tech tree', researchable: 'Tech tree',
premium: 'Premium', premium: 'Premium',
collector: 'Collector', collector: 'Collector',
}; };
export const TREE_TYPE_IMAGES: Record<TreeTypeString, string> = {
researchable: 'https://i.imgur.com/pJxO2XY.png',
premium: 'https://i.imgur.com/mZzSwOU.png',
collector: 'https://i.imgur.com/7A0RsG5.png',
};
export interface ItemProps { export interface ItemProps {
image?: string; image?: string;
tankType?: string; tankType?: TankType;
name: string; name: string;
treeType: TreeTypeEnum; treeType: TreeTypeEnum;
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { Tier } from '../core/blitzstars/tankopedia'; import { Tier } from '../core/blitzkrieg/definitions/tanks';
import { theme } from '../stitches.config'; import { theme } from '../stitches.config';
export type TierWeightsRecord = Partial<Record<Tier, number>>; export type TierWeightsRecord = Partial<Record<Tier, number>>;
+17 -3
View File
@@ -36,6 +36,8 @@ export default function TitleBar({ name, image, description }: TitleBarProps) {
style={{ style={{
display: 'flex', display: 'flex',
gap: 4, gap: 4,
alignItems: 'center',
justifyContent: 'center',
}} }}
> >
<span <span
@@ -51,9 +53,21 @@ export default function TitleBar({ name, image, description }: TitleBarProps) {
</span> </span>
</div> </div>
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}> <div
{description} style={{
</span> display: 'flex',
justifyContent: image ? 'flex-start' : 'center',
}}
>
<span
style={{
color: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{description}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -14,12 +14,12 @@ export const TOOLS: Tool[] = [
{ {
id: 'ratings', id: 'ratings',
title: 'Ratings', title: 'Ratings',
description: 'Current and archived ratings leaderboards', description: 'Ratings gamemode leaderboards',
}, },
{ {
id: 'tankopedia', id: 'tankopedia',
title: 'Tankopedia', title: 'Tankopedia',
description: 'Full Blitz tank encyclopedia', description: 'Blitz tank encyclopedia',
disabled: true, disabled: true,
}, },
{ {
+465
View File
@@ -0,0 +1,465 @@
import { times } from 'lodash';
import { readDVPL } from './readDVPL';
import { readDVPLFile } from './readDVPLFile';
enum KAType {
NONE = 0,
BOOLEAN = 1,
INT32 = 2,
FLOAT = 3,
STRING = 4,
WIDE_STRING = 5,
BYTE_ARRAY = 6,
UINT32 = 7,
KEYED_ARCHIVE = 8,
INT64 = 9,
UINT64 = 10,
VECTOR2 = 11,
VECTOR3 = 12,
VECTOR4 = 13,
MATRIX2 = 14,
MATRIX3 = 15,
MATRIX4 = 16,
COLOR = 17,
FASTNAME = 18,
AABBOX3 = 19,
FILEPATH = 20,
FLOAT64 = 21,
INT8 = 22,
UINT8 = 23,
INT16 = 24,
UINT16 = 25,
ARRAY = 27,
UNCONFIRMED_TRANSFORM = 29,
}
enum VertexType {
VERTEX,
NORMAL,
COLOR,
TEXCOORD0,
TEXCOORD1,
TEXCOORD2,
TEXCOORD3,
TANGENT,
BINORMAL,
HARD_JOINTINDEX,
PIVOT4,
FLEXIBILITY,
ANGLE_SIN_COS,
JOINTINDEX,
JOINTWEIGHT,
CUBETEXCOORD0,
CUBETEXCOORD1,
CUBETEXCOORD2,
CUBETEXCOORD3,
}
interface PolygonGroupRaw {
'##name': 'PolygonGroup';
'#id': Buffer;
cubeTextureCoordCount: number;
indexCount: number;
indexFormat: 0 | 1;
indices: Buffer;
packing: 0 | 1;
primitiveCount: number;
rhi_primitiveType: 1 | 2 | 10;
textureCoordCount: number;
vertexCount: number;
vertexFormat: number;
vertices: Buffer;
}
const VECTOR_SIZES = [
[VertexType.COLOR, VertexType.FLEXIBILITY, VertexType.HARD_JOINTINDEX],
[
VertexType.TEXCOORD0,
VertexType.TEXCOORD1,
VertexType.TEXCOORD2,
VertexType.TEXCOORD3,
VertexType.ANGLE_SIN_COS,
],
[
VertexType.VERTEX,
VertexType.NORMAL,
VertexType.TANGENT,
VertexType.BINORMAL,
VertexType.CUBETEXCOORD0,
VertexType.CUBETEXCOORD1,
VertexType.CUBETEXCOORD2,
VertexType.CUBETEXCOORD3,
],
[VertexType.PIVOT4, VertexType.JOINTINDEX, VertexType.JOINTWEIGHT],
];
export class SCPGStream {
public index = 0;
constructor(public buffer: Buffer) {}
skip(size: number) {
this.index += size;
}
back(size: number) {
this.skip(-size);
}
read(size: number) {
return this.buffer.subarray(this.index, this.index + size);
}
consume(size: number) {
const subarray = this.read(size);
this.skip(size);
return subarray;
}
readRemaining() {
return this.read(Number.POSITIVE_INFINITY);
}
consumeRemaining() {
return this.consume(Number.POSITIVE_INFINITY);
}
consumeByteArray(size: number) {
return this.consume(size);
}
consumeAscii(size: number) {
return this.consume(size).toString('ascii');
}
consumeInt8() {
return this.consume(1).readInt8();
}
consumeInt16() {
return this.consume(2).readInt16LE();
}
consumeInt32() {
return this.consume(4).readInt32LE();
}
consumeInt64() {
return this.consume(8).readBigInt64LE();
}
consumeUInt8() {
return this.consume(1).readUInt8();
}
consumeUInt16() {
return this.consume(2).readUInt16LE();
}
consumeUInt32() {
return this.consume(4).readUInt32LE();
}
consumeUInt64() {
return this.consume(8).readBigUInt64LE();
}
consumeFloat() {
return this.consume(4).readFloatLE();
}
consumeBoolean() {
return this.consume(1).readUInt8() === 1;
}
consumeVectorN(size: number) {
return times(size, () => this.consumeFloat());
}
consumeVector2() {
return this.consumeVectorN(2);
}
consumeVector3() {
return this.consumeVectorN(3);
}
consumeVector4() {
return this.consumeVectorN(4);
}
consumeMatrixN(size: number) {
return times(size, () => this.consumeVectorN(size));
}
consumeMatrix2() {
return this.consumeMatrixN(2);
}
consumeMatrix3() {
return this.consumeMatrixN(3);
}
consumeMatrix4() {
return this.consumeMatrixN(4);
}
consumeSCGHeader() {
return {
name: this.consumeAscii(4),
version: this.consumeUInt32(),
nodeCount: this.consumeUInt32(),
nodeCount2: this.consumeUInt32(),
};
}
consumeSCG() {
const header = this.consumeSCGHeader();
const polygonGroupsRaw: PolygonGroupRaw[] = [];
for (let index = 0; index < header.nodeCount; index++) {
const polygonGroupRaw = this.consumeKA() as PolygonGroupRaw;
polygonGroupsRaw.push(polygonGroupRaw);
}
const polygonGroups = polygonGroupsRaw.map((polygonGroupRaw) => {
const indicesStream = new SCPGStream(polygonGroupRaw.indices);
const indices: number[] = [];
for (let index = 0; index < polygonGroupRaw.indexCount; index++) {
indices.push(
polygonGroupRaw.indexFormat === 0
? indicesStream.consumeUInt16()
: indicesStream.consumeUInt32(),
);
}
const verticesStream = new SCPGStream(polygonGroupRaw.vertices);
const vertices: Partial<Record<VertexType, number[]>>[] = [];
const vertexFormatBuffer = Buffer.alloc(4);
vertexFormatBuffer.writeUInt32LE(polygonGroupRaw.vertexFormat, 0);
const vertexFormat = [...vertexFormatBuffer]
.map((number) => number.toString(2))
.join('')
.split('')
.map((bitString, index) => (bitString === '1' ? index : null))
.filter((type) => type !== null) as VertexType[];
for (let index = 0; index < polygonGroupRaw.vertexCount; index++) {
vertexFormat.forEach((type) => {
const resolved = VECTOR_SIZES.some((types, size) => {
if (types.includes(type)) {
if (!(index in vertices)) vertices[index] = {};
vertices[index][type] = verticesStream.consumeVectorN(size + 1);
return true;
}
});
if (!resolved) {
throw new TypeError(`Unknown vertex type: ${type}`);
}
});
}
return { vertices, indices };
});
return polygonGroups;
}
consumeSC2Header() {
const header = {
name: this.consumeAscii(4),
version: this.consumeUInt32(),
nodeCount: this.consumeUInt32(),
};
const versionTags = this.consumeKA();
const descriptor = this.consumeSC2Descriptor();
return { header, versionTags, descriptor };
}
consumeSC2Descriptor() {
const size = this.consumeUInt32();
const fileType = this.consumeUInt32();
// other felids that we don't care about for some reason lol
this.skip(size - 4);
return { size, fileType };
}
consumeSC2() {
this.consumeSC2Header();
return this.consumeKA();
}
consumeKAValue(
type: KAType = this.consumeUInt8(),
stringTable?: Record<number, string>,
): unknown {
switch (type) {
case KAType.NONE:
return undefined;
case KAType.FILEPATH:
case KAType.STRING: {
if (stringTable) {
const id = this.consumeUInt32();
return stringTable[id];
} else {
const length = this.consumeUInt32();
return this.consumeAscii(length);
}
}
case KAType.BYTE_ARRAY: {
const length = this.consumeUInt32();
return this.consumeByteArray(length);
}
case KAType.FLOAT:
return this.consumeFloat();
case KAType.ARRAY: {
const length = this.consumeUInt32();
const value = [];
for (let index = 0; index < length; index++) {
value.push(this.consumeKAValue(undefined, stringTable));
}
return value;
}
case KAType.KEYED_ARCHIVE: {
this.skip(4); // UInt32 length of the nested archive in bytes
return this.consumeKA(stringTable);
}
case KAType.INT8:
return this.consumeInt8();
case KAType.INT16:
return this.consumeInt16();
case KAType.INT32:
return this.consumeInt32();
case KAType.INT64:
return this.consumeInt64();
case KAType.UINT8:
return this.consumeUInt8();
case KAType.UINT16:
return this.consumeUInt16();
case KAType.UINT32:
return this.consumeUInt32();
case KAType.UINT64:
return this.consumeUInt64();
case KAType.BOOLEAN:
return this.consumeBoolean();
case KAType.VECTOR2:
return this.consumeVector2();
case KAType.VECTOR3:
return this.consumeVector3();
case KAType.VECTOR4:
return this.consumeVector4();
case KAType.COLOR:
return this.consumeVector4(); // as RGBA
case KAType.MATRIX2:
return this.consumeMatrix2();
case KAType.MATRIX3:
return this.consumeMatrix3();
case KAType.MATRIX4:
return this.consumeMatrix4();
case KAType.AABBOX3: {
const minimum = this.consumeVector3();
const maximum = this.consumeVector3();
return { minimum, maximum };
}
case KAType.FASTNAME: {
if (!stringTable) throw new Error('No string table provided');
const index = this.consumeUInt32();
return stringTable[index];
}
case KAType.UNCONFIRMED_TRANSFORM: {
const position = this.consumeVector3();
const scale = this.consumeVector3();
const rotation = this.consumeVector3();
const UNCONFIRMED_scalar = this.consumeFloat();
return {
position,
scale,
rotation,
UNCONFIRMED_scalar,
};
}
default:
throw new TypeError(
`Unhandled KA type: ${type} (${KAType[type] ?? 'unknown type'})`,
);
}
}
consumeKA(stringTable?: Record<number, string>) {
this.skip(2); // "KA"
const version = this.consumeUInt16();
const pairs: Record<string, any> = {};
if (version === 0x0001) {
const count = this.consumeUInt32();
for (let index = 0; index < count; index++) {
const name = this.consumeKAValue(undefined, stringTable) as string;
const value = this.consumeKAValue(undefined, stringTable);
pairs[name] = value;
}
} else if (version === 0x0002) {
const count = this.consumeUInt32();
const strings: string[] = [];
const stringTable: Record<number, string> = {};
for (let index = 0; index < count; index++) {
const length = this.consumeUInt16();
const string = this.consumeAscii(length);
strings.push(string);
}
for (let index = 0; index < count; index++) {
const id = this.consumeUInt32();
stringTable[id] = strings[index];
}
const nodeCount = this.consumeUInt32();
for (let index = 0; index < nodeCount; index++) {
const keyId = this.consumeUInt32();
const value = this.consumeKAValue(undefined, stringTable);
const key = stringTable[keyId];
pairs[key] = value;
}
} else if (version === 0x0102) {
if (!stringTable) throw new Error('No string table provided');
const count = this.consumeUInt32();
for (let index = 0; index < count; index++) {
const keyIndex = this.consumeUInt32();
const key = stringTable[keyIndex];
const valueType = this.consumeUInt8();
if (valueType === KAType.STRING) {
const valueIndex = this.consumeUInt32();
const value = stringTable[valueIndex];
pairs[key] = value;
} else {
const value = this.consumeKAValue(valueType, stringTable);
pairs[key] = value;
}
}
} else if (version === 0xff02) {
// same as 0x0102 but empty and with no count
return {};
} else throw new RangeError(`Unhandled KA version: ${version}`);
return pairs;
}
static fromDVPL(buffer: Buffer) {
return new SCPGStream(readDVPL(buffer));
}
static async fromDVPLFile(file: string) {
return new SCPGStream(await readDVPLFile(file));
}
}
@@ -1,7 +1,7 @@
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import fetchBlitz from './fetchBlitz'; import fetchBlitz from './fetchBlitz';
export interface TankopediaInfo { export interface EncyclopediaInfo {
achievement_sections: Record<string, { name: string; order: number }>; achievement_sections: Record<string, { name: string; order: number }>;
tanks_updated_at: number; tanks_updated_at: number;
languages: Record<string, string>; languages: Record<string, string>;
@@ -10,6 +10,6 @@ export interface TankopediaInfo {
game_version: string; game_version: string;
} }
export const tankopediaInfo = fetchBlitz<TankopediaInfo>( export const encyclopediaInfo = fetchBlitz<EncyclopediaInfo>(
`https://api.wotblitz.com/wotb/encyclopedia/info/?application_id=${WARGAMING_APPLICATION_ID}`, `https://api.wotblitz.com/wotb/encyclopedia/info/?application_id=${WARGAMING_APPLICATION_ID}`,
); );
+8 -1
View File
@@ -4,7 +4,11 @@ import { context } from '../blitzkrieg/context';
import { patientFetch } from '../blitzkrieg/patientFetch'; import { patientFetch } from '../blitzkrieg/patientFetch';
import regionToRegionSubdomain from './regionToRegionSubdomain'; import regionToRegionSubdomain from './regionToRegionSubdomain';
const ratingsInfoCache: Partial<Record<Region, RatingsInfo>> = {};
export default async function getRatingsInfo(region: Region) { export default async function getRatingsInfo(region: Region) {
if (ratingsInfoCache[region]) return ratingsInfoCache[region]!;
const response = await patientFetch( const response = await patientFetch(
context === 'website' context === 'website'
? `/api/${region}/ratings/current/info` ? `/api/${region}/ratings/current/info`
@@ -14,6 +18,9 @@ export default async function getRatingsInfo(region: Region) {
undefined, undefined,
{ cache: 'no-store' }, { cache: 'no-store' },
); );
const data = (await response.json()) as RatingsInfo;
return (await response.json()) as Promise<RatingsInfo>; ratingsInfoCache[region] = data;
return data;
} }
+3 -4
View File
@@ -10,10 +10,9 @@ export default async function getTankStats(region: Region, id: number) {
); );
if (tankStats[id] === null) { if (tankStats[id] === null) {
throw new UserError('No tank stats available', { throw new UserError(
cause: "# No tank stats available\nThis player doesn't have any stats available. This may not be the player you are looking for.",
"This player doesn't have any stats for tanks available. This may not be the player you are looking for.", );
});
} }
return tankStats[id]; return tankStats[id];
+4 -4
View File
@@ -1,11 +1,11 @@
import { TreeTypeEnum } from '../../components/Tanks'; import { TreeTypeEnum } from '../../components/Tanks';
import { tankopedia } from '../blitzstars/tankopedia'; import { tankDefinitions } from '../blitzkrieg/definitions/tanks';
export default async function getTreeType(id: number) { export default async function getTreeType(id: number) {
const entry = (await tankopedia)[id]; const entry = (await tankDefinitions)[id];
if (entry?.is_collectible) return TreeTypeEnum.Collector; if (entry.tree_type === 'collector') return TreeTypeEnum.Collector;
if (entry?.is_premium) return TreeTypeEnum.Premium; if (entry.tree_type === 'premium') return TreeTypeEnum.Premium;
return TreeTypeEnum.TechTree; return TreeTypeEnum.TechTree;
} }
+6
View File
@@ -0,0 +1,6 @@
import getRatingsInfo from './getRatingsInfo';
export async function isOnGoingRatingsSeason() {
const ratingsInfo = await getRatingsInfo('com');
return ratingsInfo.detail === undefined;
}
+6
View File
@@ -0,0 +1,6 @@
import { readDVPLFile } from './readDVPLFile';
export async function readBase64DVPL(file: string) {
const buffer = await readDVPLFile(file);
return buffer.toString('base64');
}
+51
View File
@@ -0,0 +1,51 @@
import { crc32 } from 'crc';
import { decodeBlock } from 'lz4';
/**
* Thanks Maddoxkkm! Modernized heavily.
*
* https://github.com/Maddoxkkm/dvpl_converter/
*/
export function readDVPL(buffer: Buffer) {
const footerBuffer = buffer.subarray(buffer.length - 20, buffer.length);
if (
footerBuffer.toString('utf8', 16, 20) !== 'DVPL' ||
footerBuffer.length !== 20
) {
throw new SyntaxError('Invalid DVPL footer');
}
const footerData = {
oSize: footerBuffer.readUInt32LE(0),
cSize: footerBuffer.readUInt32LE(4),
crc32: footerBuffer.readUInt32LE(8),
type: footerBuffer.readUInt32LE(12),
};
const targetBlock = buffer.subarray(0, buffer.length - 20);
if (targetBlock.length !== footerData.cSize) {
throw new RangeError('DVPL size mismatch');
}
if (crc32(targetBlock) !== footerData.crc32) {
throw new TypeError('DVPL CRC32 mismatch');
}
if (footerData.type === 0) {
if (!(footerData.oSize === footerData.cSize && footerData.type === 0)) {
throw new RangeError('DVPL type and size mismatch');
}
return targetBlock;
} else if (footerData.type === 1 || footerData.type === 2) {
const deDVPLBlock = Buffer.alloc(footerData.oSize);
const DecompressedBlockSize = decodeBlock(targetBlock, deDVPLBlock);
if (DecompressedBlockSize !== footerData.oSize) {
throw new RangeError('Decompressed DVPL size mismatch');
}
return deDVPLBlock;
} else {
throw new SyntaxError('Unknown DVPL format');
}
}
+6
View File
@@ -0,0 +1,6 @@
import { readFile } from 'fs/promises';
import { readDVPL } from './readDVPL';
export async function readDVPLFile(file: string) {
return readDVPL(await readFile(file));
}
+6
View File
@@ -0,0 +1,6 @@
import { readDVPLFile } from './readDVPLFile';
export async function readStringDVPL(file: string) {
const buffer = await readDVPLFile(file);
return buffer.toString();
}
+9
View File
@@ -0,0 +1,9 @@
import { XMLParser } from 'fast-xml-parser';
import { readStringDVPL } from './readStringDVPL';
export async function readXMLDVPL<Type>(file: string) {
const parser = new XMLParser();
const xml = await readStringDVPL(file);
return parser.parse(xml) as Type;
}
+8
View File
@@ -0,0 +1,8 @@
import { parse } from 'yaml';
import { readStringDVPL } from './readStringDVPL';
export async function readYAMLDVPL<Type>(file: string) {
const yaml = await readStringDVPL(file);
return parse(yaml) as Type;
}
+14 -10
View File
@@ -1,30 +1,34 @@
import { go } from 'fuzzysort'; import { go } from 'fuzzysort';
import markdownEscape from 'markdown-escape';
import {
tankDefinitions,
tanksDefinitionsArray,
} from '../blitzkrieg/definitions/tanks';
import { UserError } from '../blitzkrieg/userError'; import { UserError } from '../blitzkrieg/userError';
import { TANKS, tankopedia } from '../blitzstars/tankopedia';
export default async function resolveTankId(tank: string | number) { export default async function resolveTankId(tank: string | number) {
const number = typeof tank === 'string' ? parseInt(tank) : tank; const number = typeof tank === 'string' ? parseInt(tank) : tank;
if (Number.isNaN(number)) { if (Number.isNaN(number)) {
const searchResult = go(`${tank}`, await TANKS, { const searchResult = go(`${tank}`, await tanksDefinitionsArray, {
keys: ['name'], keys: ['name'],
limit: 1, limit: 1,
}); });
if (searchResult.length === 0) { if (searchResult.length === 0) {
throw new UserError('Tank not found', { throw new UserError(
cause: `Could not find tank by the name "${tank}".`, `# Tank not found\nCould not find tank by the name "${markdownEscape(`${tank}`)}".`,
}); );
} else { } else {
return searchResult[0].obj.tank_id; return searchResult[0].obj.id;
} }
} else { } else {
if ((await tankopedia)[number]) { if ((await tankDefinitions)[number]) {
return number; return number;
} else { } else {
throw new UserError('Tank not found', { throw new UserError(
cause: `Could not find tank by the ID "${number}".`, `# Tank not found\nCould not find tank by the ID "${number}". Try typing the name of the tank instead of a number.`,
}); );
} }
} }
} }
-6
View File
@@ -1,6 +0,0 @@
import { tankopedia } from '../blitzstars/tankopedia';
export default async function resolveTankName(tankId: number) {
const tankopediaName = (await tankopedia)[tankId]?.name;
return tankopediaName ? tankopediaName : `Unknown Tank ${tankId}`;
}
+5
View File
@@ -0,0 +1,5 @@
import { NATION_IDS } from '../../constants/nations';
export function toUniqueId(nation: string, id: number) {
return (id << 8) + (NATION_IDS[nation] << 4) + 1;
}
+3
View File
@@ -0,0 +1,3 @@
export function asset(path: string) {
return `https://raw.githubusercontent.com/tresabhi/blitzkrieg-assets/main/${path}`;
}
@@ -0,0 +1,98 @@
import { octokit } from './octokit';
const TIME_PER_BLOB = 2 ** 4 * 1000;
export interface FileChange {
path: string;
content: string;
encoding: 'utf-8' | 'base64';
}
export default async function commitMultipleFiles(
owner: string,
repo: string,
branch: string,
message: string,
changes: FileChange[],
verbose = false,
) {
const latestCommitSha = (
await octokit.git.getRef({
owner,
repo,
ref: `heads/${branch}`,
})
).data.object.sha;
const treeSha = (
await octokit.git.getCommit({
owner,
repo,
commit_sha: latestCommitSha,
})
).data.tree.sha;
const blobs: {
sha: string;
path: string;
mode: '100644';
type: 'blob';
}[] = [];
for (const changeIndexString in changes) {
const changeIndex = parseInt(changeIndexString);
const change = changes[changeIndex];
let done = false;
while (!done) {
try {
const { sha } = (
await octokit.git.createBlob({
owner,
repo,
content: change.content,
encoding: change.encoding,
})
).data;
blobs.push({ sha, path: change.path, mode: '100644', type: 'blob' });
done = true;
} catch (error) {
console.warn(
`Failed blob ${changeIndex + 1} / ${
changes.length
}; retrying in ${TIME_PER_BLOB}ms...`,
);
await new Promise((resolve) => setTimeout(resolve, TIME_PER_BLOB));
}
}
if (verbose) {
console.log(
`Created blob ${changeIndex + 1} / ${changes.length} (${(
(100 * (changeIndex + 1)) /
changes.length
).toFixed(2)}%)`,
);
}
}
const { data: treeData } = await octokit.git.createTree({
owner,
repo,
base_tree: treeSha,
tree: blobs,
});
const { data: newCommitData } = await octokit.git.createCommit({
owner,
repo,
message,
tree: treeData.sha,
parents: [latestCommitSha],
});
await octokit.git.updateRef({
owner,
repo,
ref: `heads/${branch}`,
sha: newCommitData.sha,
});
}
+18
View File
@@ -0,0 +1,18 @@
import { asset } from '../asset';
import { Tier } from './tanks';
export interface BlitzkriegGunDefinition {
id: number;
name: string;
tier: Tier;
pitch: [number, number];
shells: number[];
}
export type BlitzkriegGunDefinitions = Record<number, BlitzkriegGunDefinition>;
export const gunDefinitions = fetch(asset('definitions/guns.json'), {
cache: 'no-cache',
}).then(
async (response) => response.json() as Promise<BlitzkriegGunDefinitions>,
);
+26
View File
@@ -0,0 +1,26 @@
import { asset } from '../asset';
export type ShellType = 'ap' | 'ap_cr' | 'hc' | 'he';
export interface BlitzkriegShellDefinition {
id: number;
name: string;
speed: number;
damage: { armor: number; devices: number };
caliber: number;
normalization: number;
ricochet: number;
type: ShellType;
icon: string;
}
export type BlitzkriegShellDefinitions = Record<
number,
BlitzkriegShellDefinition
>;
export const shellDefinitions = fetch(asset('definitions/shells.json'), {
cache: 'no-cache',
}).then(
async (response) => response.json() as Promise<BlitzkriegShellDefinitions>,
);
+100
View File
@@ -0,0 +1,100 @@
import { deburr } from 'lodash';
import { TankType, TreeTypeString } from '../../../components/Tanks';
import { asset } from '../asset';
export interface BlitzkriegTankDefinition {
id: number;
nation: string;
name: string;
tree_type: TreeTypeString;
tier: Tier;
type: TankType;
testing?: boolean;
turrets: number[];
}
export type BlitzkriegTankDefinitions = Record<
number,
BlitzkriegTankDefinition
>;
export const tankDefinitions = fetch(asset('definitions/tanks.json'), {
cache: 'no-cache',
}).then(
async (response) => response.json() as Promise<BlitzkriegTankDefinitions>,
);
const entries = new Promise<BlitzkriegTankDefinition[]>(async (resolve) => {
resolve(Object.entries(await tankDefinitions).map(([, entry]) => entry));
});
export const tanksDefinitionsArray = new Promise<BlitzkriegTankDefinition[]>(
async (resolve) => {
resolve((await entries).map((entry) => entry));
},
);
export const tankNames = new Promise<string[]>(async (resolve) => {
resolve((await entries).map(({ name }) => name));
});
export const tankNamesDiacritics = tankNames.then((tankNames) =>
Promise.all(
tankNames.map(async (tankName, index) => {
const { id } = (await tanksDefinitionsArray)[index];
const name = tankName ?? `Unknown Tank ${id}`;
const diacriticless = deburr(name);
return {
original: name,
diacriticless,
combined: `${name}${diacriticless}`,
id,
};
}),
),
);
export const TANK_ICONS: Record<TankType, string> = {
tank_destroyer: 'https://i.imgur.com/BIHSEH0.png',
light: 'https://i.imgur.com/CSNha5V.png',
medium: 'https://i.imgur.com/wvf3ltm.png',
heavy: 'https://i.imgur.com/ECeqlZa.png',
};
export const TANK_ICONS_PREMIUM: Record<TankType, string> = {
tank_destroyer: 'https://i.imgur.com/TCu3EdR.png',
light: 'https://i.imgur.com/zdkpTRb.png',
medium: 'https://i.imgur.com/3z7eHX6.png',
heavy: 'https://i.imgur.com/P3vbmyA.png',
};
export const TANK_ICONS_COLLECTOR: Record<TankType, string> = {
tank_destroyer: 'https://i.imgur.com/WTjeirB.png',
light: 'https://i.imgur.com/EwhtKkU.png',
medium: 'https://i.imgur.com/u8YDMBh.png',
heavy: 'https://i.imgur.com/8xRf3nc.png',
};
export const TIERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const;
export type Tier = (typeof TIERS)[number];
export const TIER_ROMAN_NUMERALS: Record<Tier, string> = {
1: 'I',
2: 'II',
3: 'III',
4: 'IV',
5: 'V',
6: 'VI',
7: 'VII',
8: 'VIII',
9: 'IX',
10: 'X',
};
export const NATIONS = tanksDefinitionsArray.then((tanks) => {
const nationsObject: Record<string, true> = {};
tanks.forEach(({ nation }) => {
nationsObject[nation] = true;
});
return Object.keys(nationsObject);
});
@@ -0,0 +1,21 @@
import { asset } from '../asset';
import { Tier } from './tanks';
export interface BlitzkriegTurretDefinition {
id: number;
name: string;
tier: Tier;
yaw: [number, number];
guns: number[];
}
export type BlitzkriegTurretDefinitions = Record<
number,
BlitzkriegTurretDefinition
>;
export const turretDefinitions = fetch(asset('definitions/turrets.json'), {
cache: 'no-cache',
}).then(
async (response) => response.json() as Promise<BlitzkriegTurretDefinitions>,
);
+11 -7
View File
@@ -1,8 +1,11 @@
import { TREE_TYPE_NAMES } from '../../components/Tanks'; import { TREE_TYPE_NAMES } from '../../components/Tanks';
import resolveTankName from '../blitz/resolveTankName'; import { encyclopediaInfo } from '../blitz/encyclopediaInfo';
import { tankopediaInfo } from '../blitz/tankopediaInfo';
import { TIER_ROMAN_NUMERALS, Tier } from '../blitzstars/tankopedia';
import { StatFilters } from '../statistics/filterStats'; import { StatFilters } from '../statistics/filterStats';
import {
TIER_ROMAN_NUMERALS,
Tier,
tankDefinitions,
} from './definitions/tanks';
export async function filtersToDescription({ export async function filtersToDescription({
nation, nation,
@@ -11,13 +14,14 @@ export async function filtersToDescription({
treeType, treeType,
tank, tank,
}: StatFilters) { }: StatFilters) {
const awaitedTankopediaInfo = await tankopediaInfo; const awaitedEncyclopediaInfo = await encyclopediaInfo;
const awaitedTankDefinitions = await tankDefinitions;
const info: string[] = []; const info: string[] = [];
if (tank) return await resolveTankName(tank); if (tank) return awaitedTankDefinitions[tank].name;
if (nation) info.push(awaitedTankopediaInfo.vehicle_nations[nation]); if (nation) info.push(awaitedEncyclopediaInfo.vehicle_nations[nation]);
if (tier) info.push(`Tier ${TIER_ROMAN_NUMERALS[tier as Tier]}`); if (tier) info.push(`Tier ${TIER_ROMAN_NUMERALS[tier as Tier]}`);
if (tankType) info.push(awaitedTankopediaInfo.vehicle_types[tankType]); if (tankType) info.push(awaitedEncyclopediaInfo.vehicle_types[tankType]);
if (treeType) info.push(TREE_TYPE_NAMES[treeType]); if (treeType) info.push(TREE_TYPE_NAMES[treeType]);
return info.length === 0 ? 'No filters' : info.join(', '); return info.length === 0 ? 'No filters' : info.join(', ');
@@ -1,17 +1,28 @@
import getRatingsInfo from '../blitz/getRatingsInfo'; import getRatingsInfo from '../blitz/getRatingsInfo';
interface GitHubTrees {
tree: { path: string; url: string }[];
}
let cachedLatestArchivedSeasonNumber: number | null = null; let cachedLatestArchivedSeasonNumber: number | null = null;
export async function getArchivedLatestSeasonNumber() { export async function getArchivedLatestSeasonNumber() {
if (cachedLatestArchivedSeasonNumber !== null) {
return cachedLatestArchivedSeasonNumber;
}
const ratingsInfo = await getRatingsInfo('com'); const ratingsInfo = await getRatingsInfo('com');
if (ratingsInfo.detail) { if (ratingsInfo.detail) {
const comURL = ( const regionsURL = (
(await fetch( (await fetch(
'https://api.github.com/repos/tresabhi/blitzkrieg-assets/git/trees/main/regions', 'https://api.github.com/repos/tresabhi/blitzkrieg-assets/git/trees/main',
).then((response) => response.json())) as { ).then((response) => response.json())) as GitHubTrees
tree: { path: string; url: string }[]; ).tree.find(({ path }) => path === 'regions')!.url;
} const comURL = (
(await fetch(regionsURL).then((response) =>
response.json(),
)) as GitHubTrees
).tree.find(({ path }) => path === 'com')!.url; ).tree.find(({ path }) => path === 'com')!.url;
const ratingsURL = ( const ratingsURL = (
(await fetch(comURL).then((response) => response.json())) as { (await fetch(comURL).then((response) => response.json())) as {
@@ -20,14 +31,16 @@ export async function getArchivedLatestSeasonNumber() {
).tree[0].url; ).tree[0].url;
const latestSeason = Math.max( const latestSeason = Math.max(
...( ...(
(await fetch(ratingsURL).then((response) => { (await fetch(ratingsURL).then((response) => response.json())) as {
response.json(); tree: { path: string }[];
})) as { tree: { path: string }[] } }
).tree.map(({ path }) => parseInt(path)), ).tree.map(({ path }) => Number(path)),
); );
return latestSeason; cachedLatestArchivedSeasonNumber = latestSeason;
} else { } else {
return ratingsInfo.current_season - 1; cachedLatestArchivedSeasonNumber = ratingsInfo.current_season - 1;
} }
return cachedLatestArchivedSeasonNumber;
} }
+3
View File
@@ -1,4 +1,7 @@
import { throttling } from '@octokit/plugin-throttling';
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
import { secrets } from './secrets'; import { secrets } from './secrets';
Octokit.plugin(throttling);
export const octokit = new Octokit({ auth: secrets.GH_TOKEN }); export const octokit = new Octokit({ auth: secrets.GH_TOKEN });
+5
View File
@@ -0,0 +1,5 @@
import { asset } from './asset';
export function tankIcon(id: number, size: 'big' | 'small' = 'big') {
return asset(`icons/${size}/${id}.webp`);
}
+3 -1
View File
@@ -1,4 +1,6 @@
/** /**
* Discriminator for user caused errors to not report in logs * Discriminator for user caused errors to not report in logs
*/ */
export class UserError extends Error {} export class UserError {
constructor(public message: string) {}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { AllStats } from '../blitz/getAccountInfo'; import { AllStats } from '../blitz/getAccountInfo';
import { context } from '../blitzkrieg/context'; import { context } from '../blitzkrieg/context';
import { tankopedia } from './tankopedia'; import { tankDefinitions } from '../blitzkrieg/definitions/tanks';
export interface SpecialStats { export interface SpecialStats {
winrate: number; winrate: number;
@@ -80,7 +80,7 @@ export interface IndividualTankAverage {
last_update: number; last_update: number;
} }
tankopedia; tankDefinitions;
export type TankAverages = Record<number, IndividualTankAverage>; export type TankAverages = Record<number, IndividualTankAverage>;
-91
View File
@@ -1,91 +0,0 @@
import { deburr } from 'lodash';
import { context } from '../blitzkrieg/context';
export interface TankopediaEntry {
name: string;
nation: string;
is_premium: boolean;
is_collectible: boolean;
tier: Tier;
cost: { price_credit: number; price_gold: number };
images: { preview: string; normal: string };
tank_id: number;
type: string;
description: string;
}
export interface Tankopedia {
[id: number]: TankopediaEntry | undefined;
}
export const tankopedia = fetch(
context === 'website'
? '/api/tankopedia'
: 'https://www.blitzstars.com/bs-tankopedia.json',
)
.then(async (response) => response.json())
.then((wrapperTankopedia) =>
context === 'server'
? (wrapperTankopedia as { data: Tankopedia }).data
: (wrapperTankopedia as Tankopedia),
);
const entries = new Promise<TankopediaEntry[]>(async (resolve) => {
resolve(Object.entries(await tankopedia).map(([, entry]) => entry));
});
export const TANKS = new Promise<TankopediaEntry[]>(async (resolve) => {
resolve((await entries).map((entry) => entry));
});
export const TANK_NAMES = new Promise<string[]>(async (resolve) => {
resolve((await entries).map(({ name }) => name));
});
export const TANK_NAMES_DIACRITICS = TANK_NAMES.then((tankNames) =>
Promise.all(
tankNames.map(async (tankName, index) => {
const id = (await TANKS)[index].tank_id;
const name = tankName ?? `Unknown Tank ${id}`;
const diacriticless = deburr(name);
return {
original: name,
diacriticless,
combined: `${name}${diacriticless}`,
id,
};
}),
),
);
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<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<string, string> = {
'AT-SPG': 'https://i.imgur.com/WTjeirB.png',
lightTank: 'https://i.imgur.com/EwhtKkU.png',
mediumTank: 'https://i.imgur.com/u8YDMBh.png',
heavyTank: 'https://i.imgur.com/8xRf3nc.png',
};
export type Tier = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
export const TIER_ROMAN_NUMERALS: Record<Tier, string> = {
1: 'I',
2: 'II',
3: 'III',
4: 'IV',
5: 'V',
6: 'VI',
7: 'VII',
8: 'VIII',
9: 'IX',
10: 'X',
};
+55 -71
View File
@@ -1,76 +1,60 @@
import { import { SlashCommandBuilder, SlashCommandSubcommandBuilder } from 'discord.js';
SlashCommandBuilder,
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from 'discord.js';
import { TREE_TYPE_NAMES, TreeTypeString } from '../../components/Tanks'; import { TREE_TYPE_NAMES, TreeTypeString } from '../../components/Tanks';
import { tankopediaInfo } from '../blitz/tankopediaInfo'; import { EncyclopediaInfo } from '../blitz/encyclopediaInfo';
import addPeriodSubCommands from './addPeriodSubCommands';
import addTankChoices from './addTankChoices'; import addTankChoices from './addTankChoices';
import addTierChoices from './addTierChoices'; import addTierChoices from './addTierChoices';
export default async function addFilterOptions< export function addFilterOptions<
OptionType extends SlashCommandBuilder | SlashCommandSubcommandGroupBuilder, Option extends SlashCommandBuilder | SlashCommandSubcommandBuilder,
>( >(option: Option, encyclopediaInfo: EncyclopediaInfo) {
option: OptionType, return option
extra: ( .addStringOption((option) =>
option: SlashCommandSubcommandBuilder, option
) => SlashCommandSubcommandBuilder = (option) => option, .setName('nation')
): Promise<OptionType> { .setDescription('Nation')
const awaitedTankopediaInfo = await tankopediaInfo; .addChoices(
...Object.entries(encyclopediaInfo.vehicle_nations).map(
addPeriodSubCommands(option, (option) => { ([type, name]) => ({ name, value: type }),
extra(option); ),
)
return option .setRequired(false),
.addStringOption((option) => )
option .addStringOption((option) => addTierChoices(option).setRequired(false))
.setName('nation') .addStringOption((option) =>
.setDescription('Nation') option
.addChoices( .setName('tank-type')
...Object.entries(awaitedTankopediaInfo.vehicle_nations).map( .setDescription('Tank type')
([type, name]) => ({ name, value: type }), .addChoices(
), ...Object.entries(encyclopediaInfo.vehicle_types).map(
) ([type, name]) => ({
.setRequired(false), name: name.replace(' Tank', ''),
) value: type,
.addStringOption((option) => addTierChoices(option).setRequired(false)) }),
.addStringOption((option) => ),
option )
.setName('tank-type') .setRequired(false),
.setDescription('Tank type') )
.addChoices( .addStringOption((option) =>
...Object.entries(awaitedTankopediaInfo.vehicle_types).map( option
([type, name]) => ({ .setName('tree-type')
name: name.replace(' Tank', ''), .setDescription('Tech tree type')
value: type, .addChoices(
}), {
), name: TREE_TYPE_NAMES['researchable'],
) value: 'researchable' satisfies TreeTypeString,
.setRequired(false), },
) {
.addStringOption((option) => name: TREE_TYPE_NAMES.premium,
option value: 'premium' satisfies TreeTypeString,
.setName('tree-type') },
.setDescription('Tech tree type') {
.addChoices( name: TREE_TYPE_NAMES.collector,
{ value: 'collector' satisfies TreeTypeString,
name: TREE_TYPE_NAMES['tech-tree'], },
value: 'tech-tree' satisfies TreeTypeString, )
}, .setRequired(false),
{ )
name: TREE_TYPE_NAMES.premium, .addStringOption((option) =>
value: 'premium' satisfies TreeTypeString, addTankChoices(option).setRequired(false),
}, ) as Option;
{
name: TREE_TYPE_NAMES.collector,
value: 'collector' satisfies TreeTypeString,
},
)
.setRequired(false),
)
.addStringOption((option) => addTankChoices(option).setRequired(false));
});
return option;
} }
@@ -0,0 +1,25 @@
import {
SlashCommandBuilder,
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from 'discord.js';
import { encyclopediaInfo } from '../blitz/encyclopediaInfo';
import { addFilterOptions } from './addFilterOptions';
import addPeriodSubCommands from './addPeriodSubCommands';
export default async function addPeriodicFilterOptions<
OptionType extends SlashCommandBuilder | SlashCommandSubcommandGroupBuilder,
>(
option: OptionType,
extra: (
option: SlashCommandSubcommandBuilder,
) => SlashCommandSubcommandBuilder = (option) => option,
): Promise<OptionType> {
const awaitedEncyclopediaInfo = await encyclopediaInfo;
addPeriodSubCommands(option, (option) => {
return addFilterOptions(extra(option), awaitedEncyclopediaInfo);
});
return option;
}
+2 -2
View File
@@ -4,7 +4,7 @@ import {
CacheType, CacheType,
} from 'discord.js'; } from 'discord.js';
import { go } from 'fuzzysort'; import { go } from 'fuzzysort';
import { TANK_NAMES_DIACRITICS } from '../blitzstars/tankopedia'; import { tankNamesDiacritics } from '../blitzkrieg/definitions/tanks';
import { import {
DISCORD_CHOICES_MAX_NAME_SIZE, DISCORD_CHOICES_MAX_NAME_SIZE,
OVERFLOW_SUFFIX, OVERFLOW_SUFFIX,
@@ -19,7 +19,7 @@ export default async function autocompleteTanks(
await interaction.respond( await interaction.respond(
focusedOption.value focusedOption.value
? await Promise.all( ? await Promise.all(
go(focusedOption.value, await TANK_NAMES_DIACRITICS, { go(focusedOption.value, await tankNamesDiacritics, {
keys: ['combined'], keys: ['combined'],
limit: 10, limit: 10,
}).map(async (item) => { }).map(async (item) => {
+6 -1
View File
@@ -3,12 +3,17 @@ import { PeriodType } from './addPeriodSubCommands';
export function getCustomPeriodParams( export function getCustomPeriodParams(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
forcedNonCustomPeriod = false,
) { ) {
if ((interaction.options.getSubcommand() as PeriodType) === 'custom') if (
!forcedNonCustomPeriod &&
(interaction.options.getSubcommand() as PeriodType) === 'custom'
) {
return { return {
start: interaction.options.getInteger('start', true), start: interaction.options.getInteger('start', true),
end: interaction.options.getInteger('end', true), end: interaction.options.getInteger('end', true),
}; };
}
return undefined; return undefined;
} }
+4 -3
View File
@@ -1,4 +1,5 @@
import { ChatInputCommandInteraction } from 'discord.js'; import { ChatInputCommandInteraction } from 'discord.js';
import markdownEscape from 'markdown-escape';
import { Region } from '../../constants/regions'; import { Region } from '../../constants/regions';
import searchClansAcrossRegions from '../blitz/searchClansAcrossRegions'; import searchClansAcrossRegions from '../blitz/searchClansAcrossRegions';
import { UserError } from '../blitzkrieg/userError'; import { UserError } from '../blitzkrieg/userError';
@@ -18,9 +19,9 @@ export default async function resolveClanFromCommand(
if (accounts[0]) { if (accounts[0]) {
return { region: accounts[0].region, id: accounts[0].clan_id }; return { region: accounts[0].region, id: accounts[0].clan_id };
} else { } else {
throw new UserError('Could not find clan', { throw new UserError(
cause: `I couldn't find clan \`${clan}\`. Try selecting a username from the search result.`, `# Could not find clan\nCouldn't find "${markdownEscape(clan)}". Try selecting a clan from the search result.`,
}); );
} }
} }
} }
+4 -1
View File
@@ -20,11 +20,14 @@ export interface ResolvedPeriod {
export default function resolvePeriodFromCommand( export default function resolvePeriodFromCommand(
region: Region, region: Region,
interaction: ChatInputCommandInteraction<CacheType>, interaction: ChatInputCommandInteraction<CacheType>,
forcedPeriod?: PeriodType,
) { ) {
let name: string; let name: string;
let start: number; let start: number;
let end: number; let end: number;
const periodSubcommand = interaction.options.getSubcommand() as PeriodType; const periodSubcommand = forcedPeriod
? forcedPeriod
: (interaction.options.getSubcommand() as PeriodType);
if (periodSubcommand === 'custom') { if (periodSubcommand === 'custom') {
const startOption = interaction.options.getInteger('start', true); const startOption = interaction.options.getInteger('start', true);
+6 -6
View File
@@ -34,11 +34,11 @@ export default async function resolvePlayerFromCommand(
id: accounts[0].account_id, id: accounts[0].account_id,
} satisfies ResolvedPlayer; } satisfies ResolvedPlayer;
} else { } else {
throw new UserError('Could not find user', { throw new UserError(
cause: `I couldn't find user "${markdownEscape( `# Could not find user\nI couldn't find user "${markdownEscape(
commandUsername, commandUsername,
)}". Try picking an user from the search result, typing in a valid username, or using the \`/link\` command.`, )}". Try picking an user from the search result, typing in a valid username, or using the \`/link\` command.`,
}); );
} }
} }
} else { } else {
@@ -47,9 +47,9 @@ export default async function resolvePlayerFromCommand(
if (account) { if (account) {
return { region: account.region, id: account.blitz }; return { region: account.region, id: account.blitz };
} else { } else {
throw new UserError('Use the `/link` command', { throw new UserError(
cause: 'Link your Blitz and Discord accounts to get started.', "# I don't know who you are\nUse the `/link` command to get started.",
}); );
} }
} }
} }
+9 -5
View File
@@ -4,13 +4,17 @@ import {
emptyIndividualTankStats, emptyIndividualTankStats,
} from '../../types/tanksStats'; } from '../../types/tanksStats';
export function deltaTankStats(a: NormalizedTankStats, b: NormalizedTankStats) { export function deltaTankStats(
a: NormalizedTankStats,
b: NormalizedTankStats,
time?: number,
) {
const bTanks = Object.keys(b); const bTanks = Object.keys(b);
const changedTanks = bTanks.filter((id) => { const changedTanks = bTanks.filter((id) => {
return ( return time === undefined
a[id as unknown as number]?.all.battles !== ? a[id as unknown as number]?.all.battles !==
b[id as unknown as number].all.battles b[id as unknown as number].all.battles
); : b[id as unknown as number].last_battle_time > time;
}); });
const diff = changedTanks.map((id) => { const diff = changedTanks.map((id) => {
const aTank = a[id as unknown as number] ?? emptyIndividualTankStats; const aTank = a[id as unknown as number] ?? emptyIndividualTankStats;
+13 -16
View File
@@ -1,7 +1,7 @@
import { TreeTypeString } from '../../components/Tanks'; import { TreeTypeString } from '../../components/Tanks';
import { tankDefinitions } from '../blitzkrieg/definitions/tanks';
import { DiffedTankStats } from '../blitzstars/getStatsInPeriod'; import { DiffedTankStats } from '../blitzstars/getStatsInPeriod';
import { tankAverages } from '../blitzstars/tankAverages'; import { tankAverages } from '../blitzstars/tankAverages';
import { tankopedia } from '../blitzstars/tankopedia';
import calculateWN8 from './calculateWN8'; import calculateWN8 from './calculateWN8';
import sumStats from './sumStats'; import sumStats from './sumStats';
@@ -20,10 +20,10 @@ export async function filterStats(
// tank, if provided, takes priority over all other filters // tank, if provided, takes priority over all other filters
if (filters.tank) filters = { tank: filters.tank }; if (filters.tank) filters = { tank: filters.tank };
const awaitedTankopedia = await tankopedia; const awaitedTankDefinitions = await tankDefinitions;
const awaitedTankAverages = await tankAverages; const awaitedTankAverages = await tankAverages;
const filteredOrder = order.filter((id) => { const filteredOrder = order.filter((id) => {
const entry = awaitedTankopedia[id]; const entry = awaitedTankDefinitions[id];
return ( return (
entry && entry &&
@@ -31,14 +31,11 @@ export async function filterStats(
(filters.tier === undefined || entry.tier === filters.tier) && (filters.tier === undefined || entry.tier === filters.tier) &&
(filters.tankType === undefined || entry.type === filters.tankType) && (filters.tankType === undefined || entry.type === filters.tankType) &&
(filters.treeType === undefined || (filters.treeType === undefined ||
(filters.treeType === 'collector' && entry.is_collectible) || (filters.treeType === 'collector' && entry.tree_type === 'collector') ||
(filters.treeType === 'premium' && (filters.treeType === 'premium' && entry.tree_type === 'premium') ||
entry.is_premium && (filters.treeType === 'researchable' &&
!entry.is_collectible) || entry.tree_type === 'researchable')) &&
(filters.treeType === 'tech-tree' && (filters.tank === undefined || entry.id === filters.tank)
!entry.is_collectible &&
!entry.is_premium)) &&
(filters.tank === undefined || entry.tank_id === filters.tank)
); );
}); });
const stats = sumStats(filteredOrder.map((id) => diff[id])); const stats = sumStats(filteredOrder.map((id) => diff[id]));
@@ -47,9 +44,9 @@ export async function filterStats(
awaitedTankAverages[id] ? accumulator + diff[id].battles : accumulator, awaitedTankAverages[id] ? accumulator + diff[id].battles : accumulator,
0, 0,
); );
const battlesOfTanksWithTankopediaEntry = filteredOrder.reduce<number>( const battlesOfTanksWithTankDefinition = filteredOrder.reduce<number>(
(accumulator, id) => (accumulator, id) =>
awaitedTankopedia[id] ? accumulator + diff[id].battles : accumulator, awaitedTankDefinitions[id] ? accumulator + diff[id].battles : accumulator,
0, 0,
); );
const supplementary = { const supplementary = {
@@ -66,11 +63,11 @@ export async function filterStats(
tier: tier:
filteredOrder.reduce<number>( filteredOrder.reduce<number>(
(accumulator, id) => (accumulator, id) =>
awaitedTankopedia[id] awaitedTankDefinitions[id]
? accumulator + awaitedTankopedia[id]!.tier * diff[id].battles ? accumulator + awaitedTankDefinitions[id]!.tier * diff[id].battles
: accumulator, : accumulator,
0, 0,
) / battlesOfTanksWithTankopediaEntry, ) / battlesOfTanksWithTankDefinition,
}; };
return { stats, supplementary, filteredOrder }; return { stats, supplementary, filteredOrder };
+4 -4
View File
@@ -1,16 +1,16 @@
import { TierWeightsRecord } from '../../components/TierWeights'; import { TierWeightsRecord } from '../../components/TierWeights';
import { AllStats } from '../blitz/getAccountInfo'; import { AllStats } from '../blitz/getAccountInfo';
import { tankopedia } from '../blitzstars/tankopedia'; import { tankDefinitions } from '../blitzkrieg/definitions/tanks';
export async function getTierWeights( export async function getTierWeights(
stats: Record<number, AllStats>, stats: Record<number, AllStats>,
order: number[] = Object.keys(stats).map(parseInt), order: number[] = Object.keys(stats).map(parseInt),
) { ) {
const awaitedTankopedia = await tankopedia; const awaitedTankDefinitions = await tankDefinitions;
const tierWeights: TierWeightsRecord = {}; const tierWeights: TierWeightsRecord = {};
order.map((id) => { order.forEach((id) => {
const entry = awaitedTankopedia[id]; const entry = awaitedTankDefinitions[id];
if (!entry) return; if (!entry) return;
if (!tierWeights[entry.tier]) tierWeights[entry.tier] = 0; if (!tierWeights[entry.tier]) tierWeights[entry.tier] = 0;
@@ -11,7 +11,6 @@ import { commands } from '..';
import discord from '../../../../discord.json' assert { type: 'json' }; import discord from '../../../../discord.json' assert { type: 'json' };
import { UserError } from '../../../core/blitzkrieg/userError'; import { UserError } from '../../../core/blitzkrieg/userError';
import buttonLink from '../../../core/discord/buttonLink'; import buttonLink from '../../../core/discord/buttonLink';
import embedNegative from '../../../core/discord/embedNegative';
import embedWarning from '../../../core/discord/embedWarning'; import embedWarning from '../../../core/discord/embedWarning';
import normalizeInteractionReturnable from '../../../core/discord/normalizeInteractionReturnable'; import normalizeInteractionReturnable from '../../../core/discord/normalizeInteractionReturnable';
import { psa } from '../../../core/discord/psa'; import { psa } from '../../../core/discord/psa';
@@ -19,7 +18,8 @@ import { psa } from '../../../core/discord/psa';
export default async function handleChatInputCommand( export default async function handleChatInputCommand(
interaction: ChatInputCommandInteraction<CacheType>, interaction: ChatInputCommandInteraction<CacheType>,
) { ) {
const registry = (await commands)[interaction.commandName]; const awaitedCommands = await commands;
const registry = awaitedCommands[interaction.commandName];
await interaction.deferReply(); await interaction.deferReply();
@@ -118,25 +118,15 @@ export default async function handleChatInputCommand(
if (error instanceof UserError) { if (error instanceof UserError) {
interaction.editReply({ interaction.editReply({
embeds: [ content: error.message,
embedNegative(
error.message,
(error.cause as string | undefined) ??
'No further information is available.',
),
],
components, components,
}); });
} else { } else {
console.error(interaction.commandName, error); console.error(interaction.commandName, error);
interaction.editReply({ interaction.editReply({
embeds: [ content:
embedNegative( "# Blitzkrieg ran into an error!\nWe're so sorry about this. Feel free to [join the official Discord server](https://discord.gg/nDt7AjGJQH) to get help.",
'Blitzkrieg ran into an error!',
"We're so sorry about this. Feel free to [join the official Discord server](https://discord.gg/nDt7AjGJQH) to get help.",
),
],
components, components,
}); });
} }
+32 -23
View File
@@ -23,6 +23,7 @@ import { fullStatsCommand } from '../../commands/fullStats';
import { inactiveCommand } from '../../commands/inactive'; import { inactiveCommand } from '../../commands/inactive';
import { verifyCommand } from '../../commands/link'; import { verifyCommand } from '../../commands/link';
import { ownedTanksCommand } from '../../commands/ownedTanks'; import { ownedTanksCommand } from '../../commands/ownedTanks';
import { permissionsCommand } from '../../commands/permissions';
import { pingCommand } from '../../commands/ping'; import { pingCommand } from '../../commands/ping';
import { playerAchievementsCommand } from '../../commands/playerAchievements'; import { playerAchievementsCommand } from '../../commands/playerAchievements';
import { playerInfoCommand } from '../../commands/playerInfo'; import { playerInfoCommand } from '../../commands/playerInfo';
@@ -31,6 +32,7 @@ import { searchClansCommand } from '../../commands/searchClans';
import { searchPlayersCommand } from '../../commands/searchPlayers'; import { searchPlayersCommand } from '../../commands/searchPlayers';
import { searchTanksCommand } from '../../commands/searchTanks'; import { searchTanksCommand } from '../../commands/searchTanks';
import { statsCommand } from '../../commands/stats'; import { statsCommand } from '../../commands/stats';
import { todayCommand } from '../../commands/today';
import getClientId from '../../core/blitzkrieg/getClientId'; import getClientId from '../../core/blitzkrieg/getClientId';
import isDev from '../../core/blitzkrieg/isDev'; import isDev from '../../core/blitzkrieg/isDev';
import { secrets } from '../../core/blitzkrieg/secrets'; import { secrets } from '../../core/blitzkrieg/secrets';
@@ -52,38 +54,44 @@ export type InteractionReturnable =
| InteractionIterableReturnable | InteractionIterableReturnable
| Promise<InteractionIterableReturnable>; | Promise<InteractionIterableReturnable>;
export interface Registry { type CommandRegistryBase = {
inProduction: boolean;
}
export type CommandRegistryRaw = Registry & {
inPublic: boolean;
inPreview?: boolean;
command: command:
| SlashCommandBuilder | SlashCommandBuilder
| SlashCommandSubcommandsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder
| Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>; | Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
};
type CommandRegistryDefinitionBase = {
inProduction: boolean;
inPublic: boolean;
inPreview?: boolean;
autocomplete?: (interaction: AutocompleteInteraction<CacheType>) => void; autocomplete?: (interaction: AutocompleteInteraction<CacheType>) => void;
button?: (interaction: ButtonInteraction<CacheType>) => InteractionReturnable; button?: (interaction: ButtonInteraction<CacheType>) => InteractionReturnable;
} & ( };
| { type CommandRegistryDefinitionHandlesInteraction = {
handlesInteraction: true; handlesInteraction: true;
handler: (interaction: ChatInputCommandInteraction<CacheType>) => void; handler: (interaction: ChatInputCommandInteraction<CacheType>) => void;
} };
| { type CommandRegistryDefinitionDoesHandleInteraction = {
handlesInteraction?: false; handlesInteraction?: false;
handler: ( handler: (
interaction: ChatInputCommandInteraction<CacheType>, interaction: ChatInputCommandInteraction<CacheType>,
) => InteractionReturnable; ) => InteractionReturnable;
} };
); export type CommandRegistryPromisable = CommandRegistryBase &
export type CommandRegistry = CommandRegistryRaw | Promise<CommandRegistryRaw>; (CommandRegistryDefinitionBase &
(
| CommandRegistryDefinitionHandlesInteraction
| CommandRegistryDefinitionDoesHandleInteraction
));
export type CommandRegistry =
| CommandRegistryPromisable
| Promise<CommandRegistryPromisable>;
const rest = new REST().setToken(secrets.DISCORD_TOKEN); const rest = new REST().setToken(secrets.DISCORD_TOKEN);
export const COMMANDS_RAW: CommandRegistry[] = [ export const COMMANDS_RAW: CommandRegistry[] = [
permissionsCommand,
debugCommand, debugCommand,
eligibleCommand, eligibleCommand,
aboutCommand, aboutCommand,
@@ -101,10 +109,11 @@ export const COMMANDS_RAW: CommandRegistry[] = [
evolutionCommand, evolutionCommand,
statsCommand, statsCommand,
ratingsCommand, ratingsCommand,
todayCommand,
]; ];
export const commands = Promise.all(COMMANDS_RAW).then((rawCommands) => export const commands = Promise.all(COMMANDS_RAW).then((rawCommands) =>
rawCommands.reduce<Record<string, CommandRegistryRaw>>( rawCommands.reduce<Record<string, CommandRegistryPromisable>>(
(accumulator, registry) => { (accumulator, registry) => {
if (isDev()) registry.command.setDefaultMemberPermissions(0); if (isDev()) registry.command.setDefaultMemberPermissions(0);
@@ -123,7 +132,7 @@ export const publicCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] =
[]; [];
commands.then((awaitedCommands) => { commands.then((awaitedCommands) => {
Object.entries(awaitedCommands).forEach(([, registry]) => { Object.values(awaitedCommands).forEach((registry) => {
const json = registry.command.toJSON(); const json = registry.command.toJSON();
if (registry.inPublic) { if (registry.inPublic) {
+2
View File
@@ -4,6 +4,7 @@ import {
orangeDark, orangeDark,
slate, slate,
slateDark, slateDark,
slateDarkA,
tealDark, tealDark,
} from '@radix-ui/colors'; } from '@radix-ui/colors';
import { createBorderStyles, createColors, createSpaces } from 'bepaint'; import { createBorderStyles, createColors, createSpaces } from 'bepaint';
@@ -14,6 +15,7 @@ export const theme = {
colors: { colors: {
...createColors(slateDark), ...createColors(slateDark),
...createColors(slateDarkA, 'alpha'),
...createColors(slate, 'light'), ...createColors(slate, 'light'),
...createColors(amberDark, 'amber'), ...createColors(amberDark, 'amber'),
+18 -20
View File
@@ -1,6 +1,6 @@
import { produce } from 'immer'; import { produce } from 'immer';
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { CustomColumnDisplay } from '../app/tools/session/components/CustomColumn'; import { CustomColumnDisplay } from '../app/tools/session/components/CustomColumn';
import { AccentColor, GrayColor } from '../constants/radixColors'; import { AccentColor, GrayColor } from '../constants/radixColors';
import { Region } from '../constants/regions'; import { Region } from '../constants/regions';
@@ -66,26 +66,24 @@ export function resetSession() {
} }
export const useSession = create<Session>()( export const useSession = create<Session>()(
devtools( persist(
persist( (set) => ({
(set) => ({ title: 'Untitled session',
title: 'Untitled session', isTracking: false,
isTracking: false, resetPrompt: true,
resetPrompt: true, showTotal: true,
showTotal: true, showCareer: true,
showCareer: true, embedPrompt: true,
embedPrompt: true,
customColumns: [ customColumns: [
{ display: 'battles', showDelta: false }, { display: 'battles', showDelta: false },
{ display: 'winrate', showDelta: true }, { display: 'winrate', showDelta: true },
{ display: 'wn8', showDelta: false }, { display: 'wn8', showDelta: false },
{ display: 'damage', showDelta: true }, { display: 'damage', showDelta: true },
], ],
color: 'slate', color: 'slate',
}), }),
{ name: 'session' }, { name: 'session' },
),
), ),
); );
+40
View File
@@ -0,0 +1,40 @@
import { produce } from 'immer';
import { create } from 'zustand';
import { TankType, TreeTypeString } from '../components/Tanks';
import { Tier } from '../core/blitzkrieg/definitions/tanks';
export type TankopediaSortBy = 'tier' | 'name';
export type TankopediaSortDirection = 'ascending' | 'descending';
export type TankopediaTestTankDisplay = 'include' | 'exclude' | 'only';
type Tankopedia = {
sort: {
by: TankopediaSortBy;
direction: TankopediaSortDirection;
};
filters: {
tiers: Tier[];
types: TankType[];
treeTypes: TreeTypeString[];
nations: string[];
test: TankopediaTestTankDisplay;
};
};
export const useTankopedia = create<Tankopedia>()(() => ({
sort: {
by: 'tier',
direction: 'descending',
},
filters: {
tiers: [],
types: [],
treeTypes: [],
nations: [],
test: 'include',
},
}));
export default function mutateTankopedia(recipe: (draft: Tankopedia) => void) {
useTankopedia.setState(produce(recipe));
}
+14 -3
View File
@@ -10,7 +10,11 @@
"noEmit": true, "noEmit": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"jsx": "preserve", "jsx": "preserve",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"incremental": true, "incremental": true,
"isolatedModules": true, "isolatedModules": true,
@@ -20,6 +24,13 @@
} }
] ]
}, },
"include": ["src/**/*", ".next/types/**/*.ts", "dist/website/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "src/**/*",
"scripts/**/*",
"dist/website/types/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
} }
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more