Compare commits

...
102 Commits
Author SHA1 Message Date
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 382a23b661 Merge pull request #58 from tresabhi/1.15.8 2023-12-13 12:03:16 -06:00
TrèsAbhi 98107291f5 feat: changelogs 2023-12-13 12:03:02 -06:00
TrèsAbhi c6779a714b feat: custom titles 2023-12-13 11:59:37 -06:00
TrèsAbhi ef8f0fd72d feat: color previews 2023-12-13 11:46:36 -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 b42df81b48 feat: remove running bot instructions 2023-12-10 12:03:34 -06:00
TrèsAbhi 536fed5448 feat: newer website image 2023-12-10 12:03:02 -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
92 changed files with 6149 additions and 1869 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:
+23
View File
@@ -1,40 +1,60 @@
{ {
"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",
"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 +62,7 @@
"tankopedia", "tankopedia",
"tankopedias", "tankopedias",
"tankstats", "tankstats",
"TEXCOORD",
"tresabhi", "tresabhi",
"Unicum", "Unicum",
"Unregistering", "Unregistering",
@@ -52,6 +73,8 @@
"webp", "webp",
"winrate", "winrate",
"wotblitz", "wotblitz",
"XMLDVPL",
"YAMLDVPL",
"zustand" "zustand"
], ],
"deno.enable": false, "deno.enable": false,
+2 -23
View File
@@ -14,31 +14,10 @@ Go to [application directory for Blitzkrieg](https://discord.com/application-dir
### https://blitz-krieg.vercel.app/ ### https://blitz-krieg.vercel.app/
![Blitzkrieg website](https://i.imgur.com/QOXUP63.png) ![Blitzkrieg website](https://i.imgur.com/eRIZe5P.png)
## Visit the documentation website ## Visit the documentation website
### 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)
## Run the bot
### Install dependencies
```bash
yarn
```
### Create `.env`
```env
GH_TOKEN={YOUR_GITHUB_TOKEN_HERE}
DISCORD_TOKEN={YOUR_DISCORD_TOKE_HERE}
```
### Run the command
```bash
yarn dev:bot
```
+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
+9
View File
@@ -0,0 +1,9 @@
# Blitzkrieg 1.15.8
A request fulfilled \:\)
## Features
- Added custom titles in session tracker
- Change the title through Options > Edit title...
- Colors are shown when selecting a theme color for the session tracker
+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
+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.7", "version": "1.15.10",
"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);
}
+19 -21
View File
@@ -6,11 +6,11 @@ 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';
@@ -30,7 +30,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;
@@ -88,7 +88,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(
@@ -119,7 +119,7 @@ export default function SessionPage() {
).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(
@@ -209,14 +209,7 @@ export default function SessionPage() {
<Breakdown.Row <Breakdown.Row
color={session.color} color={session.color}
minimized={!session.showCareer} minimized={!session.showCareer}
title={`Since ${since.toLocaleString(undefined, { title={session.title}
hour: 'numeric',
minute: 'numeric',
hour12: true,
month: 'short',
year: 'numeric',
day: 'numeric',
})}`}
stats={session.customColumns.map((customColumn) => { stats={session.customColumns.map((customColumn) => {
const rowStat = totalRowStats[customColumn.display]; const rowStat = totalRowStats[customColumn.display];
@@ -238,7 +231,8 @@ 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(
({ stats, tankDefinitions, career, careerWN8, currentWN8 }) => {
const rowStats: Record< const rowStats: Record<
CustomColumnDisplay, CustomColumnDisplay,
Breakdown.RowStatItem | undefined Breakdown.RowStatItem | undefined
@@ -297,13 +291,16 @@ export default function SessionPage() {
color={session.color} color={session.color}
key={stats.tank_id} key={stats.tank_id}
minimized={!session.showCareer} minimized={!session.showCareer}
title={tankopedia?.name ?? `Unknown tank ${stats.tank_id}`} title={
tankDefinitions?.name ?? `Unknown tank ${stats.tank_id}`
}
type="tank" type="tank"
tankType={tankopedia?.type} tankType={tankDefinitions?.type}
treeType={(() => { treeType={(() => {
if (tankopedia?.is_collectible) if (tankDefinitions?.tree_type === 'collector')
return TreeTypeEnum.Collector; return TreeTypeEnum.Collector;
if (tankopedia?.is_premium) return TreeTypeEnum.Premium; if (tankDefinitions?.tree_type === 'premium')
return TreeTypeEnum.Premium;
})()} })()}
stats={session.customColumns.map((customColumn) => { stats={session.customColumns.map((customColumn) => {
const rowStat = rowStats[customColumn.display]; const rowStat = rowStats[customColumn.display];
@@ -321,7 +318,8 @@ export default function SessionPage() {
})} })}
/> />
); );
})} },
)}
</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(
+67 -4
View File
@@ -1,9 +1,20 @@
import { ContextMenu, DropdownMenu } from '@radix-ui/themes'; import {
Button,
ContextMenu,
Dialog,
DropdownMenu,
Flex,
Heading,
TextField,
} from '@radix-ui/themes';
import { createColors } from 'bepaint';
import { useRef, useState } from 'react';
import { import {
ACCENT_COLORS, ACCENT_COLORS,
AccentColor, AccentColor,
GRAY_COLORS, GRAY_COLORS,
GrayColor, GrayColor,
PALETTES,
} from '../../../../constants/radixColors'; } from '../../../../constants/radixColors';
import { useSession } from '../../../../stores/session'; import { useSession } from '../../../../stores/session';
import { CustomColumn } from './CustomColumn'; import { CustomColumn } from './CustomColumn';
@@ -15,8 +26,45 @@ interface ContextMenuProps {
export function Menu({ Builder, reset }: ContextMenuProps) { export function Menu({ Builder, reset }: ContextMenuProps) {
const session = useSession(); const session = useSession();
const [showTitleEditor, setShowTitleEditor] = useState(false);
const titleInput = useRef<HTMLInputElement>(null);
return ( return (
<>
<Dialog.Root open={showTitleEditor} onOpenChange={setShowTitleEditor}>
<Dialog.Content>
<Flex direction="column" gap="4">
<Heading size="4">Edit title</Heading>
<Flex gap="2" style={{ width: '100%' }}>
<TextField.Root style={{ flex: 1 }}>
<TextField.Input
ref={titleInput}
defaultValue={session.title}
placeholder="Enter title..."
onKeyDown={(event) => {
if (event.key === 'Enter' && titleInput.current) {
setShowTitleEditor(false);
useSession.setState({ title: titleInput.current!.value });
}
}}
/>
</TextField.Root>
<Dialog.Close>
<Button
onClick={() =>
useSession.setState({ title: titleInput.current!.value })
}
>
Set
</Button>
</Dialog.Close>
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
<Builder.Content> <Builder.Content>
<Builder.Sub> <Builder.Sub>
<Builder.SubTrigger>Accent color</Builder.SubTrigger> <Builder.SubTrigger>Accent color</Builder.SubTrigger>
@@ -29,9 +77,20 @@ export function Menu({ Builder, reset }: ContextMenuProps) {
} }
> >
{[...ACCENT_COLORS, ...GRAY_COLORS].map((color) => ( {[...ACCENT_COLORS, ...GRAY_COLORS].map((color) => (
<Builder.RadioItem value={color}>{`${color <Builder.RadioItem value={color} key={color}>
.charAt(0) <Flex gap="2" align="center">
.toUpperCase()}${color.slice(1)}`}</Builder.RadioItem> <div
style={{
backgroundColor: createColors(PALETTES[color])
.solidBackground,
width: 16,
height: 16,
borderRadius: '100%',
}}
/>
{`${color.charAt(0).toUpperCase()}${color.slice(1)}`}
</Flex>
</Builder.RadioItem>
))} ))}
</Builder.RadioGroup> </Builder.RadioGroup>
</Builder.SubContent> </Builder.SubContent>
@@ -39,6 +98,9 @@ export function Menu({ Builder, reset }: ContextMenuProps) {
<Builder.Separator /> <Builder.Separator />
<Builder.Item onClick={() => setShowTitleEditor(true)}>
Edit title...
</Builder.Item>
<CustomColumn Builder={Builder} column={0} /> <CustomColumn Builder={Builder} column={0} />
<CustomColumn Builder={Builder} column={1} /> <CustomColumn Builder={Builder} column={1} />
<CustomColumn Builder={Builder} column={2} /> <CustomColumn Builder={Builder} column={2} />
@@ -69,5 +131,6 @@ export function Menu({ Builder, reset }: ContextMenuProps) {
Reset Reset
</Builder.Item> </Builder.Item>
</Builder.Content> </Builder.Content>
</>
); );
} }
+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>
);
}
+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'),
+15 -29
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,18 +26,12 @@ 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);
if (serverAndIdPattern.test(username)) {
const [region, idString] = username.split('/');
const id = parseInt(idString);
const discordId = parseInt(interaction.user.id); const discordId = parseInt(interaction.user.id);
const accountInfo = await getAccountInfo(region as Region, id); const accountInfo = await getAccountInfo(region, id);
const clanAccountInfo = await getClanAccountInfo(region as Region, id, [ const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
'clan',
]);
await linkBlitzAndDiscord(discordId, region as Region, id); await linkBlitzAndDiscord(discordId, region, id);
if (interaction.guildId === discord.sklld_guild_id) { if (interaction.guildId === discord.sklld_guild_id) {
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) { if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
@@ -59,7 +49,7 @@ export const verifyCommand: CommandRegistry = {
); );
} }
return ( return [
<Wrapper fat> <Wrapper fat>
<div <div
style={{ style={{
@@ -198,7 +188,7 @@ export const verifyCommand: CommandRegistry = {
{clanAccountInfo?.clan {clanAccountInfo?.clan
? `[${clanAccountInfo?.clan?.tag}] - ` ? `[${clanAccountInfo?.clan?.tag}] - `
: ''} : ''}
{REGION_NAMES_SHORT[region as Region]} {REGION_NAMES[region]}
</span> </span>
</div> </div>
</div> </div>
@@ -206,17 +196,13 @@ export const verifyCommand: CommandRegistry = {
<Glow color={teal.teal9} rotation={-90} /> <Glow color={teal.teal9} rotation={-90} />
</div> </div>
</Wrapper> </Wrapper>,
);
} else { embedInfo(
return [ 'Incorrect account?',
embedNegative( 'Use the command again and select a username from the search results.',
'Please select from the options',
'Follow the procedure like the video above. The search results may take a while to show up.',
), ),
new AttachmentBuilder('https://i.imgur.com/2p6GFgC.gif'),
]; ];
}
}, },
autocomplete: autocompleteUsername, 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)}
/> />
)), )),
)} )}
+72 -100
View File
@@ -2,7 +2,6 @@ import {
APIApplicationCommandOptionChoice, APIApplicationCommandOptionChoice,
SlashCommandBuilder, SlashCommandBuilder,
SlashCommandSubcommandBuilder, SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from 'discord.js'; } from 'discord.js';
import { range } from 'lodash'; import { range } from 'lodash';
import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard'; import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard';
@@ -17,17 +16,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 +115,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.",
) { );
export const ratingsCommand = new Promise<CommandRegistryPromisable>(
async (resolve) => {
const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
const onGoingSeason = await isOnGoingRatingsSeason();
const seasonNumbers = range(
FIRST_ARCHIVED_RATINGS_SEASON,
latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1,
);
function addSubcommands(option: SlashCommandSubcommandBuilder) {
return option return option
.addSubcommand((option) =>
addOptions(
option
.setName('neighbors')
.setDescription('Your position and neighbors'),
)
.addStringOption(addUsernameChoices)
.addIntegerOption((option) => .addIntegerOption((option) =>
option option
.setName('limit') .setName('limit')
@@ -135,10 +138,42 @@ function addSubcommands(
.setRequired(false) .setRequired(false)
.setMinValue(5) .setMinValue(5)
.setMaxValue(30), .setMaxValue(30),
)
.addStringOption((option) =>
option
.setName('season')
.setDescription('Season number')
.setRequired(false)
.addChoices(
...seasonNumbers
.map(
(number, index) =>
({
name: `${number}${index === seasonNumbers.length - 1 && onGoingSeason ? ' (current)' : ''}`,
value: `${number}`,
}) satisfies APIApplicationCommandOptionChoice<string>,
)
.reverse(),
),
);
}
const command = new SlashCommandBuilder()
.setName('ratings')
.setDescription('Ratings battles statistics')
.addSubcommand((option) =>
addSubcommands(
option
.setName('neighbors')
.setDescription('Shows neighboring players on the leaderboard'),
), ),
) )
.addSubcommand((option) => .addSubcommand((option) =>
addOptions(option.setName('league').setDescription('Top league players')) addSubcommands(
option
.setName('league')
.setDescription('Shows top players from a league')
.addStringOption(addRegionChoices)
.addStringOption((option) => .addStringOption((option) =>
option option
.setName('league') .setName('league')
@@ -153,59 +188,7 @@ function addSubcommands(
), ),
) )
.setRequired(true), .setRequired(true),
)
.addStringOption(addRegionChoices)
.addIntegerOption((option) =>
option
.setName('limit')
.setDescription('How many rows to display (default: 10)')
.setRequired(false)
.setMinValue(5)
.setMaxValue(30),
), ),
);
}
const noOngoingSeason = embedNegative(
'No ongoing season',
"Wargaming didn't provide any data for this season.",
);
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()
.setName('ratings')
.setDescription('Ratings battles statistics')
.addSubcommandGroup((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(
option
.setName('current')
.setDescription('The current running Ratings season'),
), ),
); );
@@ -224,18 +207,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,16 +233,14 @@ 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(
async (result) =>
result.slice(0, limit).map( result.slice(0, limit).map(
(player) => (player) =>
({ ({
@@ -298,15 +280,14 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
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,
@@ -326,8 +307,7 @@ 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!);
@@ -335,13 +315,9 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
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) =>
({ ({
@@ -361,11 +337,8 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
if (playerIndex === -1) { if (playerIndex === -1) {
throw new UserError( throw new UserError(
`${accountInfo.nickname} didn't player ratings in season ${season}`, `${accountInfo.nickname} didn't play ratings in season ${season}`,
{
cause:
'This player did not participate in this season or did not get past calibration.', 'This player did not participate in this season or did not get past calibration.',
},
); );
} }
@@ -384,21 +357,20 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
id: player.id, id: player.id,
score: player.score, score: player.score,
position: playerIndex + index + 1, position: playerIndex + index + 1,
clan: clanData[player.id]!.clan?.tag, clan: clanData[index]?.clan?.tag,
nickname: clanData[player.id]!.account_name, nickname: clanData[index]
? clanData[index]!.account_name
: `Deleted ${player.id}`,
})); }));
}, },
); );
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!);
if (clan && neighbors) titleNameDiscriminator = `[${clan.tag}]`;
players = neighbors; players = neighbors;
titleImage = clan titleImage = clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png` ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
+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);
+7 -5
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,8 +57,9 @@ async function render(
); );
} }
export const statsCommand = new Promise<CommandRegistryRaw>(async (resolve) => { export const statsCommand = new Promise<CommandRegistryPromisable>(
const command = await addFilterOptions( async (resolve) => {
const command = await addPeriodicFilterOptions(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('stats') .setName('stats')
.setDescription('Regular battles statistics'), .setDescription('Regular battles statistics'),
@@ -101,4 +102,5 @@ export const statsCommand = new Promise<CommandRegistryRaw>(async (resolve) => {
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>
); );
} }
+2 -2
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 {
@@ -21,7 +21,7 @@ interface RowProps {
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)[];
} }
+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>
);
}
+46 -12
View File
@@ -1,24 +1,33 @@
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 (
<Theme radius="none">
<div <div
style={{ style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%', width: '100%',
display: 'flex', zIndex: 1,
alignItems: 'center', padding: '1rem',
justifyContent: 'center', background: theme.colors.appBackground1_alpha,
color: theme.colors.textHighContrast,
backgroundColor: theme.colors.appBackground2,
borderBottom: theme.borderStyles.nonInteractive, borderBottom: theme.borderStyles.nonInteractive,
height: 48, // TODO: remove this when the icons are implemented backdropFilter: 'blur(4rem)',
boxSizing: 'border-box',
}} }}
> >
{/* <Button> <Flex justify="center">
<HamburgerMenuIcon /> <Flex
</Button> */} justify="between"
align="center"
style={{ maxWidth: 800, width: '100%', padding: '0 16px' }}
>
<Flex justify="center" align="center" gap="2">
<Link <Link
href="/" href="/"
style={{ style={{
@@ -30,9 +39,34 @@ export default function Navbar() {
> >
<BlitzkriegWormWide /> <BlitzkriegWormWide />
</Link> </Link>
{/* <Button> <Text size="2">❤️</Text>
<PersonIcon /> <Text size="2">
</Button> */} <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> </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>>;
+15 -1
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,11 +53,23 @@ export default function TitleBar({ name, image, description }: TitleBarProps) {
</span> </span>
</div> </div>
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}> <div
style={{
display: 'flex',
justifyContent: image ? 'flex-start' : 'center',
}}
>
<span
style={{
color: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{description} {description}
</span> </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;
} }
+4 -4
View File
@@ -10,10 +10,10 @@ 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',
"This player doesn't have any stats for tanks available. This may not be the player you are looking for.", "This player doesn't have any stats 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;
}
+15 -10
View File
@@ -1,30 +1,35 @@
import { go } from 'fuzzysort'; import { go } from 'fuzzysort';
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',
}); `Could not find tank by the name "${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',
}); `Could 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`);
}
+6 -1
View File
@@ -1,4 +1,9 @@
/** /**
* 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,
public cause: 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',
};
+12 -28
View File
@@ -1,34 +1,19 @@
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,
extra: (
option: SlashCommandSubcommandBuilder,
) => SlashCommandSubcommandBuilder = (option) => option,
): Promise<OptionType> {
const awaitedTankopediaInfo = await tankopediaInfo;
addPeriodSubCommands(option, (option) => {
extra(option);
return option return option
.addStringOption((option) => .addStringOption((option) =>
option option
.setName('nation') .setName('nation')
.setDescription('Nation') .setDescription('Nation')
.addChoices( .addChoices(
...Object.entries(awaitedTankopediaInfo.vehicle_nations).map( ...Object.entries(encyclopediaInfo.vehicle_nations).map(
([type, name]) => ({ name, value: type }), ([type, name]) => ({ name, value: type }),
), ),
) )
@@ -40,7 +25,7 @@ export default async function addFilterOptions<
.setName('tank-type') .setName('tank-type')
.setDescription('Tank type') .setDescription('Tank type')
.addChoices( .addChoices(
...Object.entries(awaitedTankopediaInfo.vehicle_types).map( ...Object.entries(encyclopediaInfo.vehicle_types).map(
([type, name]) => ({ ([type, name]) => ({
name: name.replace(' Tank', ''), name: name.replace(' Tank', ''),
value: type, value: type,
@@ -55,8 +40,8 @@ export default async function addFilterOptions<
.setDescription('Tech tree type') .setDescription('Tech tree type')
.addChoices( .addChoices(
{ {
name: TREE_TYPE_NAMES['tech-tree'], name: TREE_TYPE_NAMES['researchable'],
value: 'tech-tree' satisfies TreeTypeString, value: 'researchable' satisfies TreeTypeString,
}, },
{ {
name: TREE_TYPE_NAMES.premium, name: TREE_TYPE_NAMES.premium,
@@ -69,8 +54,7 @@ export default async function addFilterOptions<
) )
.setRequired(false), .setRequired(false),
) )
.addStringOption((option) => addTankChoices(option).setRequired(false)); .addStringOption((option) =>
}); addTankChoices(option).setRequired(false),
) as Option;
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 (
!forcedNonCustomPeriod &&
(interaction.options.getSubcommand() as PeriodType) === 'custom'
) { ) {
if ((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
@@ -18,9 +18,10 @@ 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',
}); `\`${clan}\` was not found. 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);
+8 -6
View File
@@ -34,11 +34,12 @@ 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',
`I 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 +48,10 @@ 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.', "You're account isn't linked with Blitzkrieg yet",
}); 'Use the `/link` command to get started.',
);
} }
} }
} }
+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;
@@ -19,7 +19,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();
+24 -17
View File
@@ -31,6 +31,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,34 +53,39 @@ 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);
@@ -101,10 +107,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 +130,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'),
+3 -3
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';
@@ -22,6 +22,7 @@ type Session = (
time: number; time: number;
} }
) & { ) & {
title: string;
resetPrompt: boolean; resetPrompt: boolean;
embedPrompt: boolean; embedPrompt: boolean;
customColumns: { customColumns: {
@@ -65,9 +66,9 @@ export function resetSession() {
} }
export const useSession = create<Session>()( export const useSession = create<Session>()(
devtools(
persist( persist(
(set) => ({ (set) => ({
title: 'Untitled session',
isTracking: false, isTracking: false,
resetPrompt: true, resetPrompt: true,
showTotal: true, showTotal: true,
@@ -84,7 +85,6 @@ export const useSession = create<Session>()(
}), }),
{ name: 'session' }, { name: 'session' },
), ),
),
); );
export default function mutateSession(recipe: (draft: Session) => void) { export default function mutateSession(recipe: (draft: Session) => void) {
+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"
]
} }
+2843 -1176
View File
File diff suppressed because it is too large. Load diff